Exemple #1
0
class UnitsTest(parameterized.TestCase, absltest.TestCase):
    def setUp(self):
        super().setUp()
        self._resolver = units.UnitResolver()

    @parameterized.named_parameters(
        ('capitalized', '15.0 ML',
         reaction_pb2.Volume(value=15.0,
                             units=reaction_pb2.Volume.MILLILITER)),
        ('integer', '24 H',
         reaction_pb2.Time(value=24, units=reaction_pb2.Time.HOUR)),
        ('no space', '32.1g',
         reaction_pb2.Mass(value=32.1, units=reaction_pb2.Mass.GRAM)),
        ('extra space', '   32.1      \t   g  ',
         reaction_pb2.Mass(value=32.1, units=reaction_pb2.Mass.GRAM)),
        ('lengths', ' 10 meter',
         reaction_pb2.Length(value=10, units=reaction_pb2.Length.METER)),
    )
    def test_resolve(self, string, expected):
        self.assertEqual(self._resolver.resolve(string), expected)

    @parameterized.named_parameters(
        ('bad units', '1.21 GW', 'unrecognized units'),
        ('multiple matches', '15.0 ML 20.0 L',
         'string does not contain a value with units'),
        ('extra period', '15.0. ML',
         'string does not contain a value with units'),
        ('ambiguous units', '5.2 m', 'ambiguous'),
    )
    def test_resolve_should_fail(self, string, expected_error):
        with self.assertRaisesRegex((KeyError, ValueError), expected_error):
            self._resolver.resolve(string)
Exemple #2
0
    def test_input_resolve(self):
        roundtrip_smi = lambda smi: Chem.MolToSmiles(Chem.MolFromSmiles(smi))
        string = '10 g of THF'
        reaction_input = resolvers.resolve_input(string)
        self.assertEqual(len(reaction_input.components), 1)
        self.assertEqual(reaction_input.components[0].amount.mass,
                         reaction_pb2.Mass(value=10, units='GRAM'))
        self.assertEqual(
            reaction_input.components[0].identifiers[0],
            reaction_pb2.CompoundIdentifier(type='NAME', value='THF'))
        self.assertEqual(reaction_pb2.CompoundIdentifier.SMILES,
                         reaction_input.components[0].identifiers[1].type)
        self.assertEqual(
            roundtrip_smi(reaction_input.components[0].identifiers[1].value),
            roundtrip_smi('C1COCC1'))

        string = '100 mL of 5.0uM sodium hydroxide in water'
        reaction_input = resolvers.resolve_input(string)
        self.assertEqual(len(reaction_input.components), 2)
        self.assertEqual(reaction_input.components[0].amount.moles,
                         reaction_pb2.Moles(value=500, units='NANOMOLE'))
        self.assertEqual(
            reaction_input.components[0].identifiers[0],
            reaction_pb2.CompoundIdentifier(type='NAME',
                                            value='sodium hydroxide'))
        self.assertEqual(reaction_pb2.CompoundIdentifier.SMILES,
                         reaction_input.components[0].identifiers[1].type)
        self.assertEqual(
            roundtrip_smi(reaction_input.components[0].identifiers[1].value),
            roundtrip_smi('[Na+].[OH-]'))
        self.assertEqual(reaction_input.components[1].amount.volume,
                         reaction_pb2.Volume(value=100, units='MILLILITER'))
        self.assertEqual(
            reaction_input.components[1].amount.volume_includes_solutes, True)
        self.assertEqual(
            reaction_input.components[1].identifiers[0],
            reaction_pb2.CompoundIdentifier(type='NAME', value='water'))
        self.assertEqual(reaction_pb2.CompoundIdentifier.SMILES,
                         reaction_input.components[1].identifiers[1].type)
        self.assertEqual(
            roundtrip_smi(reaction_input.components[1].identifiers[1].value),
            roundtrip_smi('O'))
Exemple #3
0
class ValidationsTest(parameterized.TestCase, absltest.TestCase):
    def setUp(self):
        super().setUp()
        # Redirect warning messages to stdout so they can be filtered from the
        # other test output.
        self._showwarning = warnings.showwarning

        # pylint: disable=too-many-arguments
        def _showwarning(message,
                         category,
                         filename,
                         lineno,
                         file=None,
                         line=None):
            del file  # Unused.
            self._showwarning(message=message,
                              category=category,
                              filename=filename,
                              lineno=lineno,
                              file=sys.stdout,
                              line=line)

        # pylint: enable=too-many-arguments
        warnings.showwarning = _showwarning

    def tearDown(self):
        super().tearDown()
        # Restore the original showwarning.
        warnings.showwarning = self._showwarning

    def _run_validation(self, message, **kwargs):
        original = type(message)()
        original.CopyFrom(message)
        output = validations.validate_message(message, **kwargs)
        # Verify that `message` is unchanged by the validation process.
        self.assertEqual(original, message)
        return output

    @parameterized.named_parameters(
        ('volume',
         reaction_pb2.Volume(value=15.0,
                             units=reaction_pb2.Volume.MILLILITER)),
        ('time', reaction_pb2.Time(value=24, units=reaction_pb2.Time.HOUR)),
        ('mass', reaction_pb2.Mass(value=32.1, units=reaction_pb2.Mass.GRAM)),
    )
    def test_units(self, message):
        self.assertEmpty(self._run_validation(message))

    @parameterized.named_parameters(
        ('neg volume',
         reaction_pb2.Volume(
             value=-15.0,
             units=reaction_pb2.Volume.MILLILITER), 'non-negative'),
        ('neg time', reaction_pb2.Time(
            value=-24, units=reaction_pb2.Time.HOUR), 'non-negative'),
        ('neg mass',
         reaction_pb2.Mass(value=-32.1,
                           units=reaction_pb2.Mass.GRAM), 'non-negative'),
        ('no units', reaction_pb2.FlowRate(value=5), 'units'),
        ('percentage out of range', reaction_pb2.Percentage(value=200),
         'between'),
        ('low temperature', reaction_pb2.Temperature(
            value=-5, units='KELVIN'), 'between'),
        ('low temperature 2',
         reaction_pb2.Temperature(value=-500, units='CELSIUS'), 'between'),
    )
    def test_units_should_fail(self, message, expected_error):
        with self.assertRaisesRegex(validations.ValidationError,
                                    expected_error):
            self._run_validation(message)

    def test_orcid(self):
        message = reaction_pb2.Person(orcid='0000-0001-2345-678X')
        self.assertEmpty(self._run_validation(message))

    def test_orcid_should_fail(self):
        message = reaction_pb2.Person(orcid='abcd-0001-2345-678X')
        with self.assertRaisesRegex(validations.ValidationError, 'Invalid'):
            self._run_validation(message)

    def test_reaction(self):
        message = reaction_pb2.Reaction()
        with self.assertRaisesRegex(validations.ValidationError,
                                    'reaction input'):
            self._run_validation(message)

    def test_reaction_recursive(self):
        message = reaction_pb2.Reaction()
        # Reactions must have at least one input
        with self.assertRaisesRegex(validations.ValidationError,
                                    'reaction input'):
            self._run_validation(message, recurse=False)
        dummy_input = message.inputs['dummy_input']
        # Reactions must have at least one outcome
        with self.assertRaisesRegex(validations.ValidationError,
                                    'reaction outcome'):
            self._run_validation(message, recurse=False)
        outcome = message.outcomes.add()
        self.assertEmpty(self._run_validation(message, recurse=False))
        # Inputs must have at least one component
        with self.assertRaisesRegex(validations.ValidationError, 'component'):
            self._run_validation(message)
        dummy_component = dummy_input.components.add()
        # Components must have at least one identifier
        with self.assertRaisesRegex(validations.ValidationError, 'identifier'):
            self._run_validation(message)
        dummy_component.identifiers.add(type='CUSTOM')
        # Custom identifiers must have details specified
        with self.assertRaisesRegex(validations.ValidationError, 'details'):
            self._run_validation(message)
        dummy_component.identifiers[0].details = 'custom_identifier'
        dummy_component.identifiers[0].value = 'custom_value'
        # Components of reaction inputs must have a defined amount
        with self.assertRaisesRegex(validations.ValidationError,
                                    'require an amount'):
            self._run_validation(message)
        dummy_component.mass.value = 1
        dummy_component.mass.units = reaction_pb2.Mass.GRAM
        # Reactions must have defined products or conversion
        with self.assertRaisesRegex(validations.ValidationError,
                                    'products or conversion'):
            self._run_validation(message)
        outcome.conversion.value = 75
        # If converseions are defined, must have limiting reagent flag
        with self.assertRaisesRegex(validations.ValidationError,
                                    'is_limiting'):
            self._run_validation(message)
        dummy_component.is_limiting = True
        self.assertEmpty(self._run_validation(message))

        # If an analysis uses an internal standard, a component must have
        # an INTERNAL_STANDARD reaction role
        outcome.analyses['dummy_analysis'].CopyFrom(
            reaction_pb2.ReactionAnalysis(type='CUSTOM',
                                          details='test',
                                          uses_internal_standard=True))
        with self.assertRaisesRegex(validations.ValidationError,
                                    'INTERNAL_STANDARD'):
            self._run_validation(message)
        # Assigning internal standard role to input should resolve the error
        message_input_istd = reaction_pb2.Reaction()
        message_input_istd.CopyFrom(message)
        message_input_istd.inputs['dummy_input'].components[
            0].reaction_role = (
                reaction_pb2.Compound.ReactionRole.INTERNAL_STANDARD)
        self.assertEmpty(self._run_validation(message_input_istd))
        # Assigning internal standard role to workup should resolve the error
        message_workup_istd = reaction_pb2.Reaction()
        message_workup_istd.CopyFrom(message)
        workup = message_workup_istd.workup.add(type='CUSTOM', details='test')
        istd = workup.input.components.add()
        istd.identifiers.add(type='SMILES', value='CCO')
        istd.mass.value = 1
        istd.mass.units = reaction_pb2.Mass.GRAM
        istd.reaction_role = istd.ReactionRole.INTERNAL_STANDARD
        self.assertEmpty(self._run_validation(message_workup_istd))

    def test_reaction_recursive_noraise_on_error(self):
        message = reaction_pb2.Reaction()
        message.inputs['dummy_input'].components.add()
        errors = self._run_validation(message, raise_on_error=False)
        expected = [
            'Compounds must have at least one identifier',
            "Reaction input's components require an amount",
            'Reactions should have at least 1 reaction outcome',
        ]
        self.assertEqual(errors, expected)

    def test_datetimes(self):
        message = reaction_pb2.ReactionProvenance()
        message.experiment_start.value = '11 am'
        message.record_created.time.value = '10 am'
        with self.assertRaisesRegex(validations.ValidationError, 'after'):
            self._run_validation(message)
        message.record_created.time.value = '11:15 am'
        self.assertEmpty(self._run_validation(message))

    def test_reaction_id(self):
        message = reaction_pb2.Reaction()
        _ = message.inputs['test']
        message.outcomes.add()
        message.reaction_id = 'ord-c0bbd41f095a44a78b6221135961d809'
        self.assertEmpty(self._run_validation(message, recurse=False))

    @parameterized.named_parameters(
        ('too short', 'ord-c0bbd41f095a4'),
        ('too long', 'ord-c0bbd41f095a4c0bbd41f095a4c0bbd41f095a4'),
        ('bad prefix', 'foo-c0bbd41f095a44a78b6221135961d809'),
        ('bad capitalization', 'ord-C0BBD41F095A44A78B6221135961D809'),
        ('bad characters', 'ord-h0bbd41f095a44a78b6221135961d809'),
        ('bad characters 2', 'ord-notARealId'),
    )
    def test_bad_reaction_id(self, reaction_id):
        message = reaction_pb2.Reaction(reaction_id=reaction_id)
        _ = message.inputs['test']
        message.outcomes.add()
        with self.assertRaisesRegex(validations.ValidationError, 'malformed'):
            self._run_validation(message, recurse=False)

    def test_data(self):
        message = reaction_pb2.Data()
        with self.assertRaisesRegex(validations.ValidationError,
                                    'requires one of'):
            self._run_validation(message)
        message.bytes_value = b'test data'
        with self.assertRaisesRegex(validations.ValidationError,
                                    'format is required'):
            self._run_validation(message)
        message.string_value = 'test data'
        self.assertEmpty(self._run_validation(message))

    def test_dataset_bad_reaction_id(self):
        message = dataset_pb2.Dataset(reaction_ids=['foo'])
        with self.assertRaisesRegex(validations.ValidationError, 'malformed'):
            self._run_validation(message)

    def test_dataset_records_and_ids(self):
        message = dataset_pb2.Dataset(
            reactions=[reaction_pb2.Reaction()],
            reaction_ids=['ord-c0bbd41f095a44a78b6221135961d809'])
        with self.assertRaisesRegex(validations.ValidationError, 'not both'):
            self._run_validation(message, recurse=False)

    def test_dataset_bad_id(self):
        message = dataset_pb2.Dataset(reactions=[reaction_pb2.Reaction()],
                                      dataset_id='foo')
        with self.assertRaisesRegex(validations.ValidationError, 'malformed'):
            self._run_validation(message, recurse=False)

    def test_dataset_example(self):
        message = dataset_pb2.DatasetExample()
        with self.assertRaisesRegex(validations.ValidationError,
                                    'description is required'):
            self._run_validation(message)
        message.description = 'test example'
        with self.assertRaisesRegex(validations.ValidationError,
                                    'url is required'):
            self._run_validation(message)
        message.url = 'example.com'
        with self.assertRaisesRegex(validations.ValidationError,
                                    'created is required'):
            self._run_validation(message)
        message.created.time.value = '11 am'
        self.assertEmpty(self._run_validation(message))
Exemple #4
0
class BuildCompoundTest(parameterized.TestCase, absltest.TestCase):

    def test_smiles_and_name(self):
        compound = message_helpers.build_compound(smiles='c1ccccc1',
                                                  name='benzene')
        expected = reaction_pb2.Compound(identifiers=[
            reaction_pb2.CompoundIdentifier(value='c1ccccc1', type='SMILES'),
            reaction_pb2.CompoundIdentifier(value='benzene', type='NAME')
        ])
        self.assertEqual(compound, expected)

    @parameterized.named_parameters(
        ('mass', '1.2 g', reaction_pb2.Mass(value=1.2, units='GRAM')),
        ('moles', '3.4 mol', reaction_pb2.Moles(value=3.4, units='MOLE')),
        ('volume', '5.6 mL', reaction_pb2.Volume(value=5.6, units='MILLILITER'))
    )
    def test_amount(self, amount, expected):
        compound = message_helpers.build_compound(amount=amount)
        self.assertEqual(
            getattr(compound.amount, compound.amount.WhichOneof('kind')),
            expected)

    @parameterized.named_parameters(('missing_units', '1.2'),
                                    ('negative_mass', '-3.4 g'))
    def test_bad_amount(self, amount):
        with self.assertRaises((KeyError, ValueError)):
            message_helpers.build_compound(amount=amount)

    def test_role(self):
        compound = message_helpers.build_compound(role='solvent')
        self.assertEqual(compound.reaction_role,
                         reaction_pb2.ReactionRole.SOLVENT)

    def test_bad_role(self):
        with self.assertRaisesRegex(KeyError, 'not a supported type'):
            message_helpers.build_compound(role='flavorant')

    def test_is_limiting(self):
        self.assertTrue(
            message_helpers.build_compound(is_limiting=True).is_limiting)
        self.assertFalse(
            message_helpers.build_compound(is_limiting=False).is_limiting)
        self.assertFalse(
            message_helpers.build_compound().HasField('is_limiting'))

    @parameterized.named_parameters(
        ('prep_without_details', 'dried', None,
         reaction_pb2.CompoundPreparation(type='DRIED')),
        ('prep_with_details', 'dried', 'in the fire of the sun',
         reaction_pb2.CompoundPreparation(type='DRIED',
                                          details='in the fire of the sun')),
        ('custom_prep_with_details', 'custom', 'threw it on the ground',
         reaction_pb2.CompoundPreparation(type='CUSTOM',
                                          details='threw it on the ground')))
    def test_prep(self, prep, details, expected):
        compound = message_helpers.build_compound(prep=prep,
                                                  prep_details=details)
        self.assertEqual(compound.preparations[0], expected)

    def test_bad_prep(self):
        with self.assertRaisesRegex(KeyError, 'not a supported type'):
            message_helpers.build_compound(prep='shaken')

    def test_prep_details_without_prep(self):
        with self.assertRaisesRegex(ValueError, 'prep must be provided'):
            message_helpers.build_compound(prep_details='rinsed gently')

    def test_custom_prep_without_details(self):
        with self.assertRaisesRegex(ValueError,
                                    'prep_details must be provided'):
            message_helpers.build_compound(prep='custom')

    def test_vendor(self):
        self.assertEqual(
            message_helpers.build_compound(vendor='Sally').source.vendor,
            'Sally')
 def setUp(self):
     super().setUp()
     self.test_subdirectory = tempfile.mkdtemp(dir=flags.FLAGS.test_tmpdir)
     template_string = """
     inputs {
         key: "test"
         value {
             components {
                 identifiers {
                     type: SMILES
                     value: "$input_smiles$"
                 }
                 amount {
                     mass {
                         value: $input_mass$
                         units: GRAM
                     }
                 }
             }
         }
     }
     outcomes {
         analyses {
             key: "my_analysis"
             value {
                 type: WEIGHT
             }
         }
         products {
             identifiers {
                 type: SMILES
                 value: "$product_smiles$"
             }
             measurements {
                 analysis_key: "my_analysis"
                 type: YIELD
                 percentage {
                     value: $product_yield$
                 }
             }
         }
     }
     """
     self.template = os.path.join(self.test_subdirectory, 'template.pbtxt')
     with open(self.template, 'w') as f:
         f.write(template_string)
     data = pd.DataFrame({
         'input_smiles': ['C', 'CC', 'CCC'],
         'input_mass': [1.2, 3.4, 5.6],
         'product_smiles': ['CO', 'CCO', 'CCCO'],
         'product_yield': [7.8, 9.0, 8.7],
     })
     self.spreadsheet = os.path.join(self.test_subdirectory,
                                     'spreadsheet.csv')
     data.to_csv(self.spreadsheet, index=False)
     self.expected = dataset_pb2.Dataset()
     reaction1 = self.expected.reactions.add()
     reaction1_compound1 = reaction1.inputs['test'].components.add()
     reaction1_compound1.identifiers.add(value='C', type='SMILES')
     reaction1_compound1.amount.mass.CopyFrom(
         reaction_pb2.Mass(value=1.2, units='GRAM'))
     reaction1_product1 = reaction1.outcomes.add().products.add()
     reaction1_product1.identifiers.add(value='CO', type='SMILES')
     reaction1_product1.measurements.add(analysis_key='my_analysis',
                                         type='YIELD',
                                         percentage=dict(value=7.8))
     reaction1.outcomes[0].analyses['my_analysis'].type = (
         reaction_pb2.Analysis.WEIGHT)
     reaction2 = self.expected.reactions.add()
     reaction2_compound1 = reaction2.inputs['test'].components.add()
     reaction2_compound1.identifiers.add(value='CC', type='SMILES')
     reaction2_compound1.amount.mass.CopyFrom(
         reaction_pb2.Mass(value=3.4, units='GRAM'))
     reaction2_product1 = reaction2.outcomes.add().products.add()
     reaction2_product1.identifiers.add(value='CCO', type='SMILES')
     reaction2_product1.measurements.add(analysis_key='my_analysis',
                                         type='YIELD',
                                         percentage=dict(value=9.0))
     reaction2.outcomes[0].analyses['my_analysis'].type = (
         reaction_pb2.Analysis.WEIGHT)
     reaction3 = self.expected.reactions.add()
     reaction3_compound1 = reaction3.inputs['test'].components.add()
     reaction3_compound1.identifiers.add(value='CCC', type='SMILES')
     reaction3_compound1.amount.mass.CopyFrom(
         reaction_pb2.Mass(value=5.6, units='GRAM'))
     reaction3_product1 = reaction3.outcomes.add().products.add()
     reaction3_product1.identifiers.add(value='CCCO', type='SMILES')
     reaction3_product1.measurements.add(analysis_key='my_analysis',
                                         type='YIELD',
                                         percentage=dict(value=8.7))
     reaction3.outcomes[0].analyses['my_analysis'].type = (
         reaction_pb2.Analysis.WEIGHT)
class ValidationsTest(parameterized.TestCase, absltest.TestCase):
    @parameterized.named_parameters(
        ('volume',
         reaction_pb2.Volume(value=15.0,
                             units=reaction_pb2.Volume.MILLILITER)),
        ('time', reaction_pb2.Time(value=24, units=reaction_pb2.Time.HOUR)),
        ('mass', reaction_pb2.Mass(value=32.1, units=reaction_pb2.Mass.GRAM)),
    )
    def test_units(self, message):
        self.assertEmpty(validations.validate_message(message))

    @parameterized.named_parameters(
        ('neg volume',
         reaction_pb2.Volume(
             value=-15.0,
             units=reaction_pb2.Volume.MILLILITER), 'non-negative'),
        ('neg time', reaction_pb2.Time(
            value=-24, units=reaction_pb2.Time.HOUR), 'non-negative'),
        ('neg mass',
         reaction_pb2.Mass(value=-32.1,
                           units=reaction_pb2.Mass.GRAM), 'non-negative'),
        ('no units', reaction_pb2.FlowRate(value=5), 'units'),
        ('percentage out of range', reaction_pb2.Percentage(value=200),
         'between'),
        ('low temperature', reaction_pb2.Temperature(
            value=-5, units='KELVIN'), 'between'),
        ('low temperature 2',
         reaction_pb2.Temperature(value=-500, units='CELSIUS'), 'between'),
    )
    def test_units_should_fail(self, message, expected_error):
        with self.assertRaisesRegex(validations.ValidationError,
                                    expected_error):
            validations.validate_message(message)

    def test_orcid(self):
        message = reaction_pb2.Person(orcid='0000-0001-2345-678X')
        self.assertEmpty(validations.validate_message(message))

    def test_orcid_should_fail(self):
        message = reaction_pb2.Person(orcid='abcd-0001-2345-678X')
        with self.assertRaisesRegex(validations.ValidationError, 'Invalid'):
            validations.validate_message(message)

    def test_reaction(self):
        message = reaction_pb2.Reaction()
        with self.assertRaisesRegex(validations.ValidationError,
                                    'reaction input'):
            validations.validate_message(message)

    def test_reaction_recursive(self):
        message = reaction_pb2.Reaction()
        # Reactions must have at least one input
        with self.assertRaisesRegex(validations.ValidationError,
                                    'reaction input'):
            validations.validate_message(message, recurse=False)
        dummy_input = message.inputs['dummy_input']
        # Reactions must have at least one outcome
        with self.assertRaisesRegex(validations.ValidationError,
                                    'reaction outcome'):
            validations.validate_message(message, recurse=False)
        outcome = message.outcomes.add()
        self.assertEmpty(validations.validate_message(message, recurse=False))
        # Inputs must have at least one component
        with self.assertRaisesRegex(validations.ValidationError, 'component'):
            validations.validate_message(message)
        dummy_component = dummy_input.components.add()
        # Components must have at least one identifier
        with self.assertRaisesRegex(validations.ValidationError, 'identifier'):
            validations.validate_message(message)
        dummy_component.identifiers.add(type='CUSTOM')
        # Custom identifiers must have details specified
        with self.assertRaisesRegex(validations.ValidationError, 'details'):
            validations.validate_message(message)
        dummy_component.identifiers[0].details = 'custom_identifier'
        dummy_component.identifiers[0].value = 'custom_value'
        # Components of reaction inputs must have a defined amount
        with self.assertRaisesRegex(validations.ValidationError,
                                    'require an amount'):
            validations.validate_message(message)
        dummy_component.mass.value = 1
        dummy_component.mass.units = reaction_pb2.Mass.GRAM
        # Reactions must have defined products or conversion
        with self.assertRaisesRegex(validations.ValidationError,
                                    'products or conversion'):
            validations.validate_message(message)
        outcome.conversion.value = 75
        # If converseions are defined, must have limiting reagent flag
        with self.assertRaisesRegex(validations.ValidationError,
                                    'is_limiting'):
            validations.validate_message(message)
        dummy_component.is_limiting = True
        self.assertEmpty(validations.validate_message(message))

        # If an analysis uses an internal standard, a component must have
        # an INTERNAL_STANDARD reaction role
        outcome.analyses['dummy_analysis'].uses_internal_standard = True
        with self.assertRaisesRegex(validations.ValidationError,
                                    'INTERNAL_STANDARD'):
            validations.validate_message(message)
        # Assigning internal standard role to input should resolve the error
        message_input_istd = reaction_pb2.Reaction()
        message_input_istd.CopyFrom(message)
        message_input_istd.inputs['dummy_input'].components[
            0].reaction_role = (
                reaction_pb2.Compound.ReactionRole.INTERNAL_STANDARD)
        self.assertEmpty(validations.validate_message(message_input_istd))
        # Assigning internal standard role to workup should resolve the error
        message_workup_istd = reaction_pb2.Reaction()
        message_workup_istd.CopyFrom(message)
        workup = message_workup_istd.workup.add()
        istd = workup.components.add()
        istd.identifiers.add(type='SMILES', value='CCO')
        istd.mass.value = 1
        istd.mass.units = reaction_pb2.Mass.GRAM
        istd.reaction_role = istd.ReactionRole.INTERNAL_STANDARD
        self.assertEmpty(validations.validate_message(message_workup_istd))

    def test_reaction_recursive_noraise_on_error(self):
        message = reaction_pb2.Reaction()
        message.inputs['dummy_input'].components.add()
        errors = validations.validate_message(message, raise_on_error=False)
        expected = [
            'Compounds must have at least one identifier',
            "Reaction input's components require an amount",
            'Reactions should have at least 1 reaction outcome',
        ]
        self.assertEqual(errors, expected)

    def test_datetimes(self):
        message = reaction_pb2.ReactionProvenance()
        message.experiment_start.value = '11 am'
        message.record_created.time.value = '10 am'
        with self.assertRaisesRegex(validations.ValidationError, 'after'):
            validations.validate_message(message)
        message.record_created.time.value = '11:15 am'
        self.assertEmpty(validations.validate_message(message))

    def test_record_id(self):
        message = reaction_pb2.ReactionProvenance()
        message.record_created.time.value = '10 am'
        message.record_id = 'ord-c0bbd41f095a44a78b6221135961d809'
        self.assertEmpty(validations.validate_message(message))

    @parameterized.named_parameters(
        ('too short', 'ord-c0bbd41f095a4'),
        ('too long', 'ord-c0bbd41f095a4c0bbd41f095a4c0bbd41f095a4'),
        ('bad prefix', 'foo-c0bbd41f095a44a78b6221135961d809'),
        ('bad capitalization', 'ord-C0BBD41F095A44A78B6221135961D809'),
        ('bad characters', 'ord-h0bbd41f095a44a78b6221135961d809'),
        ('bad characters 2', 'ord-notARealId'),
    )
    def test_bad_record_id(self, record_id):
        message = reaction_pb2.ReactionProvenance()
        message.record_created.time.value = '10 am'
        message.record_id = record_id
        with self.assertRaisesRegex(validations.ValidationError, 'malformed'):
            validations.validate_message(message)

    def test_compound_name_resolver(self):
        message = reaction_pb2.Compound()
        identifier = message.identifiers.add()
        identifier.type = identifier.NAME
        identifier.value = 'aspirin'
        validations.validate_message(message)  # Message is modified in place.
        self.assertEqual(
            message.identifiers[1],
            reaction_pb2.CompoundIdentifier(
                type='SMILES',
                value='CC(=O)OC1=CC=CC=C1C(=O)O',
                details='NAME resolved by PubChem'))

    @absltest.skipIf(Chem is None, 'no rdkit')
    def test_compound_rdkit_binary(self):
        mol = Chem.MolFromSmiles('CC(=O)OC1=CC=CC=C1C(=O)O')
        message = reaction_pb2.Compound()
        identifier = message.identifiers.add()
        identifier.type = identifier.SMILES
        identifier.value = Chem.MolToSmiles(mol)
        validations.validate_message(message)  # Message is modified in place.
        self.assertEqual(
            message.identifiers[1],
            reaction_pb2.CompoundIdentifier(type='RDKIT_BINARY',
                                            bytes_value=mol.ToBinary()))

    def test_data(self):
        message = reaction_pb2.Data()
        with self.assertRaisesRegex(validations.ValidationError,
                                    'requires one of'):
            validations.validate_message(message)
        message.bytes_value = b'test data'
        with self.assertRaisesRegex(validations.ValidationError,
                                    'format is required'):
            validations.validate_message(message)
        message.value = 'test data'
        self.assertEmpty(validations.validate_message(message))