import pytest from rdkit import Chem from macro_lactone_toolkit import ( AmbiguousMacrolactoneError, MacroLactoneAnalyzer, MacrolactoneDetectionError, MacrolactoneFragmenter, ) from .helpers import ( build_ambiguous_smiles, build_macrolactone, build_non_standard_ring_atom_macrolactone, build_overlapping_candidate_macrolactone, ) @pytest.mark.parametrize("ring_size", [12, 14, 16, 20]) def test_analyzer_detects_supported_ring_sizes(ring_size: int): built = build_macrolactone(ring_size) analyzer = MacroLactoneAnalyzer() assert analyzer.get_valid_ring_sizes(built.smiles) == [ring_size] def test_analyzer_rejects_non_lactone_macrocycle(): analyzer = MacroLactoneAnalyzer() assert analyzer.get_valid_ring_sizes("C1CCCCCCCCCCC1") == [] @pytest.mark.parametrize("ring_size", [12, 14, 16, 20]) def test_analyzer_classifies_supported_ring_sizes(ring_size: int): built = build_macrolactone(ring_size) analyzer = MacroLactoneAnalyzer() result = analyzer.classify_macrocycle(built.smiles) assert result.classification == "standard_macrolactone" assert result.ring_size == ring_size assert result.primary_reason_code is None assert result.primary_reason_message is None assert result.all_reason_codes == [] assert result.all_reason_messages == [] assert result.candidate_ring_sizes == [ring_size] def test_analyzer_classifies_ring_heteroatom_as_non_standard(): built = build_non_standard_ring_atom_macrolactone() analyzer = MacroLactoneAnalyzer() result = analyzer.classify_macrocycle(built.smiles) assert result.classification == "non_standard_macrocycle" assert result.ring_size == 16 assert result.primary_reason_code == "contains_non_carbon_ring_atoms_outside_positions_1_2" assert result.primary_reason_message == "Ring positions 3..N contain non-carbon atoms." assert result.all_reason_codes == ["contains_non_carbon_ring_atoms_outside_positions_1_2"] assert result.candidate_ring_sizes == [16] def test_analyzer_classifies_overlapping_candidates_as_non_standard(): built = build_overlapping_candidate_macrolactone() analyzer = MacroLactoneAnalyzer() result = analyzer.classify_macrocycle(built.smiles) assert result.classification == "non_standard_macrocycle" assert result.ring_size == 12 assert result.primary_reason_code == "multiple_overlapping_macrocycle_candidates" assert result.primary_reason_message == "Overlapping macrolactone candidate rings were detected." assert result.all_reason_codes == ["multiple_overlapping_macrocycle_candidates"] assert result.candidate_ring_sizes == [12] def test_analyzer_classifies_non_lactone_macrocycle(): analyzer = MacroLactoneAnalyzer() result = analyzer.classify_macrocycle("C1CCCCCCCCCCC1") assert result.classification == "not_macrolactone" assert result.ring_size is None assert result.primary_reason_code == "no_lactone_ring_in_12_to_20_range" assert result.primary_reason_message == "No 12-20 membered lactone ring was detected." assert result.all_reason_codes == ["no_lactone_ring_in_12_to_20_range"] assert result.candidate_ring_sizes == [] def test_analyzer_explicit_ring_size_miss_returns_requested_ring_not_found(): built = build_macrolactone(12) analyzer = MacroLactoneAnalyzer() result = analyzer.classify_macrocycle(built.smiles, ring_size=16) assert result.classification == "not_macrolactone" assert result.ring_size is None assert result.primary_reason_code == "requested_ring_size_not_found" assert result.primary_reason_message == "The requested ring size was not detected as a lactone ring." assert result.all_reason_codes == ["requested_ring_size_not_found"] assert result.candidate_ring_sizes == [] def test_fragmenter_auto_numbers_ring_with_expected_positions(): built = build_macrolactone(16, {5: "methyl"}) result = MacrolactoneFragmenter().number_molecule(built.mol) assert result.ring_size == 16 assert result.position_to_atom == built.position_to_atom assert set(result.position_to_atom) == set(range(1, 17)) assert result.atom_to_position == { atom_idx: position for position, atom_idx in built.position_to_atom.items() } carbonyl_atom = built.mol.GetAtomWithIdx(result.position_to_atom[1]) assert carbonyl_atom.GetSymbol() == "C" assert any( bond.GetBondType() == Chem.BondType.DOUBLE and bond.GetOtherAtom(carbonyl_atom).GetSymbol() == "O" for bond in carbonyl_atom.GetBonds() ) ester_oxygen = built.mol.GetAtomWithIdx(result.position_to_atom[2]) assert ester_oxygen.GetSymbol() == "O" def test_fragmenter_requires_explicit_ring_size_for_ambiguous_molecule(): ambiguous_smiles = build_ambiguous_smiles() with pytest.raises(AmbiguousMacrolactoneError): MacrolactoneFragmenter().number_molecule(ambiguous_smiles) def test_fragmenter_raises_for_missing_macrolactone(): with pytest.raises( MacrolactoneDetectionError, match="classification=not_macrolactone primary_reason_code=no_lactone_ring_in_12_to_20_range", ): MacrolactoneFragmenter().number_molecule("CCO") def test_fragmenter_rejects_non_standard_macrocycle_with_reason_code(): built = build_non_standard_ring_atom_macrolactone() with pytest.raises( MacrolactoneDetectionError, match="classification=non_standard_macrocycle " "primary_reason_code=contains_non_carbon_ring_atoms_outside_positions_1_2", ): MacrolactoneFragmenter().number_molecule(built.smiles) def test_fragmenter_rejects_non_standard_macrocycle_during_fragmentation(): built = build_overlapping_candidate_macrolactone() with pytest.raises( MacrolactoneDetectionError, match="classification=non_standard_macrocycle " "primary_reason_code=multiple_overlapping_macrocycle_candidates", ): MacrolactoneFragmenter().fragment_molecule(built.smiles) def test_explicit_ring_size_selects_requested_ring(): built = build_macrolactone(14) result = MacrolactoneFragmenter(ring_size=14).number_molecule(built.smiles) assert result.ring_size == 14 def test_explicit_ring_size_rejects_wrong_ring(): built = build_macrolactone(12) with pytest.raises(MacrolactoneDetectionError): MacrolactoneFragmenter(ring_size=16).number_molecule(built.smiles)