smiles-rs 0.2.1

A SMILES parser producing molecular graphs, with no_std support
Documentation
use alloc::{string::ToString, vec::Vec};

use geometric_traits::traits::SparseValuedMatrixRef;

use super::super::{
    Smiles, remap_parsed_stereo_neighbors_row,
    support::{assert_canonicalization_invariants, permute_smiles, same_canonicalization_state},
};
use crate::{parser::smiles_parser::parse_wildcard_smiles, smiles::StereoNeighbor};

#[test]
fn exact_canonicalize_is_idempotent_for_disconnected_cases() {
    for source in ["CC.O", "N[C@H](F)C.O", "c1ccccc1.CC", "CC.CN.O"] {
        let once = Smiles::from_str(source).unwrap().exact_canonicalize();
        let twice = once.exact_canonicalize();
        same_canonicalization_state(&once, &twice);
    }
}

#[test]
fn exact_canonical_labeling_rooted_always_ranks_the_root_first() {
    let smiles = Smiles::from_str("C1CC1").unwrap();
    for root in 0..smiles.nodes().len() {
        let labeling = smiles.exact_canonical_labeling_rooted(root);
        assert_eq!(
            labeling.new_index_of_old_node()[root],
            0,
            "root {root} must be individualized to canonical rank 0"
        );
    }
}

#[test]
fn exact_canonicalize_converges_identical_disconnected_components() {
    let left = Smiles::from_str("CC.CC.O").unwrap().exact_canonicalize();
    let right = Smiles::from_str("O.CC.CC").unwrap().exact_canonicalize();

    same_canonicalization_state(&left, &right);
}

#[test]
#[should_panic(expected = "radical_electrons differs")]
fn same_canonicalization_state_rejects_graphs_differing_only_in_radicals() {
    let methyl = Smiles::from_str("[CH3]").unwrap();
    let without_radical: Smiles = Smiles::from_bond_matrix_parts_with_sidecars(
        methyl.atom_nodes.clone(),
        methyl.bond_matrix.clone(),
        methyl.parsed_stereo_neighbors.clone(),
        methyl.implicit_hydrogen_cache.clone(),
        Some(vec![0]),
        None,
    );

    same_canonicalization_state(&methyl, &without_radical);
}

#[test]
fn canonicalize_converges_permuted_symmetric_cage_graph() {
    let original = Smiles::from_str("C12C3C4C1C5C2C3C45").unwrap();
    let permuted = permute_smiles(&original, &[7, 3, 0, 5, 2, 6, 1, 4]);

    assert_eq!(original.canonicalize(), permuted.canonicalize());
}

#[test]
fn canonicalize_converges_permuted_bridged_graph() {
    let original = Smiles::from_str("C1CC2CCC1C2").unwrap();
    let permuted = permute_smiles(&original, &[6, 2, 4, 0, 5, 1, 3]);

    assert_eq!(original.canonicalize(), permuted.canonicalize());
}

#[test]
fn canonicalize_converges_permuted_fused_graph() {
    let original = Smiles::from_str("c1cccc2ccccc12").unwrap();
    let permuted = permute_smiles(&original, &[9, 4, 0, 7, 2, 8, 1, 5, 3, 6]);

    assert_eq!(original.canonicalize(), permuted.canonicalize());
}

#[test]
fn canonicalize_remaps_parsed_stereo_neighbors() {
    let smiles = Smiles::from_str("O[C@H](F)C").unwrap();
    let stereo_normalized = smiles.canonicalization_normal_form().stereo_normal_form();
    let labeling = stereo_normalized.exact_canonical_labeling();
    let canonicalized = smiles.canonicalize();

    let expected: Vec<Vec<StereoNeighbor>> = labeling
        .order()
        .iter()
        .copied()
        .map(|old_node| {
            remap_parsed_stereo_neighbors_row(
                &stereo_normalized,
                old_node,
                labeling.new_index_of_old_node(),
            )
        })
        .collect();

    let actual = (0..canonicalized.nodes().len())
        .map(|node_id| canonicalized.parsed_stereo_neighbors_row(node_id).to_vec())
        .collect::<Vec<_>>();

    assert_eq!(actual, expected);
}

#[test]
fn canonicalize_remaps_implicit_hydrogen_cache_and_clears_provenance() {
    let aromaticized = Smiles::from_str("C1=CC=CC=C1")
        .unwrap()
        .perceive_aromaticity()
        .unwrap()
        .into_aromaticized();
    let labeling = aromaticized.canonical_labeling();
    let expected_cache = aromaticized
        .implicit_hydrogen_counts()
        .iter()
        .copied()
        .enumerate()
        .map(|(old_node, count)| (labeling.new_index_of_old_node()[old_node], count))
        .collect::<Vec<_>>();

    let canonicalized = aromaticized.canonicalize();
    let actual_cache =
        canonicalized.implicit_hydrogen_counts().iter().copied().enumerate().collect::<Vec<_>>();

    let mut expected_cache = expected_cache;
    expected_cache.sort_unstable_by_key(|&(new_node, _)| new_node);

    assert_eq!(actual_cache, expected_cache);
    assert!(canonicalized.kekulization_source.is_none());
    assert!(
        canonicalized
            .bond_matrix()
            .sparse_entries()
            .all(|((row, column), entry)| row >= column || entry.ring_num().is_none())
    );
    assert_canonicalization_invariants(&aromaticized);
}

#[test]
fn canonicalization_spelling_normal_form_keeps_aromatic_wildcards_bracketed() {
    let aromaticized = parse_wildcard_smiles("******#8OOO*c8")
        .unwrap()
        .perceive_aromaticity()
        .unwrap()
        .into_aromaticized();

    let rewritten = aromaticized.canonicalization_spelling_normal_form();

    assert_eq!(rewritten.aromaticity_assignment(), aromaticized.aromaticity_assignment());
    assert!(rewritten.to_string().contains("[*]"));
}

#[test]
fn canonicalize_converges_permuted_graph_with_sidecars() {
    let original = Smiles::from_str("C1=CC=CC=C1[C@H](F)O")
        .unwrap()
        .perceive_aromaticity()
        .unwrap()
        .into_aromaticized();
    let permuted = permute_smiles(&original, &[8, 3, 6, 1, 7, 0, 4, 2, 5]);

    assert_eq!(original.canonicalize(), permuted.canonicalize());
}