use super::*;
fn parse(s: &str) -> MolHandle {
MolHandle {
inner: std::rc::Rc::new(chematic_smiles::parse(s).unwrap()),
}
}
#[test]
fn parse_benzene_atom_count() {
assert_eq!(parse("c1ccccc1").atom_count(), 6);
}
#[test]
fn logd_at_ph7_benzene() {
let h = parse("c1ccccc1");
let logd = h.logd_at_ph(7.0);
assert!(logd.is_finite(), "logD should be finite, got {logd}");
}
#[test]
fn logd_profile_json_has_ph_fields() {
let h = parse("c1ccccc1");
let json = h.logd_profile_json(0.0, 14.0, 15);
assert!(
json.starts_with('[') && json.ends_with(']'),
"should be JSON array: {json}"
);
assert!(json.contains(r#""ph":"#), "should contain ph field: {json}");
assert!(
json.contains(r#""logd":"#),
"should contain logd field: {json}"
);
}
#[test]
fn isotope_distribution_json_nonempty() {
let h = parse("C");
let json = h.isotope_distribution_json(0.1);
assert!(json.starts_with('['), "should be JSON array: {json}");
assert!(
json.contains(r#""mass":"#),
"should contain mass entries: {json}"
);
}
#[test]
fn randic_index_ethane() {
let h = parse("CC");
let r = h.randic_index();
assert!((r - 1.0).abs() < 1e-6, "ethane Randic index = 1.0, got {r}");
}
#[test]
fn zagreb_m1_ethane() {
let h = parse("CC");
let z = h.zagreb_index_m1();
assert_eq!(z, 2, "ethane Zagreb M1 = 2, got {z}");
}
#[test]
fn iupac_name_benzene() {
assert_eq!(parse("c1ccccc1").iupac_name(), "benzene");
}
#[test]
fn iupac_name_ethanol() {
assert_eq!(parse("CCO").iupac_name(), "ethanol");
}
#[test]
fn iupac_name_branched_acid() {
assert_eq!(parse("CC(C)C(=O)O").iupac_name(), "2-methylpropanoic acid");
}
#[test]
fn iupac_name_unsupported_returns_empty() {
let h = parse("C.C");
assert!(
h.iupac_name().is_empty(),
"disconnected should return empty"
);
}
#[test]
fn assign_cip_json_no_centers() {
let json = parse("CC").assign_cip_json();
assert_eq!(json, r#"{"centers":[]}"#);
}
#[test]
fn assign_cip_json_chiral_center() {
let json = parse("N[C@@H](C)C(=O)O").assign_cip_json();
assert!(
json.contains("\"code\":"),
"should contain code field: {json}"
);
assert!(
json.contains("\"atom\":"),
"should contain atom field: {json}"
);
}
#[test]
fn canonical_smiles_benzene() {
let mol = parse("c1ccccc1");
let cs = mol.canonical_smiles();
assert!(!cs.is_empty());
}
#[test]
fn parse_cxsmiles_json_preserves_metadata() {
let json = parse_cxsmiles_json("C~O |$C1;O2$,atomProp:1.role.acceptor,^2:0,Z:0|").unwrap();
assert!(json.contains(r#""atomLabels":["C1","O2"]"#), "{json}");
assert!(json.contains(r#""key":"role""#), "{json}");
assert!(json.contains(r#""atomRadicals":[2,null]"#), "{json}");
assert!(json.contains(r#""zeroBonds":[0]"#), "{json}");
}
#[test]
fn parse_cxsmarts_json_preserves_metadata() {
let json = parse_cxsmarts_json("[#6]~[#8] |$C1;O2$,atomProp:1.role.acceptor,^2:0|").unwrap();
assert!(json.contains(r#""atomCount":2"#), "{json}");
assert!(json.contains(r#""atomLabels":["C1","O2"]"#), "{json}");
assert!(json.contains(r#""key":"role""#), "{json}");
assert!(json.contains(r#""atomRadicals":[2,null]"#), "{json}");
}
#[test]
fn molecular_weight_aspirin() {
let mw = parse("CC(=O)Oc1ccccc1C(=O)O").molecular_weight();
assert!((mw - 180.16).abs() < 1.0);
}
#[test]
fn lipinski_aspirin() {
assert!(parse("CC(=O)Oc1ccccc1C(=O)O").lipinski_passes());
}
#[test]
fn tanimoto_same_mol() {
let a = parse("c1ccccc1");
let b = parse("c1ccccc1");
let sim = tanimoto_ecfp4(&a, &b);
assert!((sim - 1.0).abs() < 1e-6);
}
#[test]
fn tanimoto_different() {
let a = parse("c1ccccc1");
let b = parse("CC(=O)Oc1ccccc1C(=O)O");
assert!(tanimoto_ecfp4(&a, &b) < 1.0);
}
#[test]
fn heavy_atom_count_ethanol() {
assert_eq!(parse("CCO").heavy_atom_count(), 3);
}
#[test]
fn logp_crippen_aspirin_range() {
let lp = parse("CC(=O)Oc1ccccc1C(=O)O").logp_crippen();
assert!(lp > 0.5 && lp < 3.5, "aspirin LogP = {lp:.3}");
}
#[test]
fn fsp3_benzene_zero() {
assert_eq!(parse("c1ccccc1").fsp3(), 0.0, "benzene Fsp3 = 0");
}
#[test]
fn fsp3_cyclohexane_one() {
assert_eq!(parse("C1CCCCC1").fsp3(), 1.0, "cyclohexane Fsp3 = 1");
}
#[test]
fn aromatic_ring_count_benzene() {
assert_eq!(parse("c1ccccc1").aromatic_ring_count(), 1);
}
#[test]
fn qed_aspirin_range() {
let q = parse("CC(=O)Oc1ccccc1C(=O)O").qed();
assert!(q > 0.0 && q <= 1.0, "aspirin QED = {q:.3}");
}
#[test]
fn exact_mass_aspirin() {
let em = parse("CC(=O)Oc1ccccc1C(=O)O").exact_mass();
assert!((em - 180.042).abs() < 0.01, "aspirin exact mass = {em:.4}");
}
#[test]
fn rotatable_bond_count_aspirin() {
let rb = parse("CC(=O)Oc1ccccc1C(=O)O").rotatable_bond_count();
assert!((2..=5).contains(&rb), "aspirin rotbonds = {rb}");
}
#[test]
fn tanimoto_atom_pair_same_mol() {
let a = parse("c1ccccc1");
let b = parse("c1ccccc1");
assert!((tanimoto_atom_pair(&a, &b) - 1.0).abs() < 1e-6);
}
#[test]
fn tanimoto_torsion_same_mol() {
let a = parse("CCCC");
let b = parse("CCCC");
assert!((tanimoto_torsion(&a, &b) - 1.0).abs() < 1e-6);
}
#[test]
fn brics_fragment_count_benzene() {
assert_eq!(brics_fragment_count(&parse("c1ccccc1")), 1);
}
#[test]
fn brics_fragment_count_aspirin() {
assert!(brics_fragment_count(&parse("CC(=O)Oc1ccccc1C(=O)O")) >= 2);
}
#[test]
fn wiener_index_ethane() {
assert_eq!(parse("CC").wiener_index(), 1.0);
}
#[test]
fn kappa1_propane_range() {
let k = parse("CCC").kappa1();
assert!(k > 0.0, "kappa1 should be positive");
}
#[test]
fn chi0_benzene_positive() {
assert!(parse("c1ccccc1").chi0() > 0.0);
}
#[test]
fn labute_asa_aspirin_range() {
let asa = parse("CC(=O)Oc1ccccc1C(=O)O").labute_asa();
assert!(asa > 50.0 && asa < 200.0, "aspirin LabuteASA = {asa:.2}");
}
#[test]
fn bertz_ct_benzene_positive() {
assert!(parse("c1ccccc1").bertz_ct() > 0.0);
}
#[test]
fn morgan_fp_counts_json_benzene() {
let json = parse("c1ccccc1").morgan_fp_counts_json(2);
assert!(json.starts_with('{') && json.ends_with('}'));
}
#[test]
fn add_remove_hydrogens_roundtrip() {
let mol = parse("CC");
let with_h = add_hydrogens(&mol);
assert!(
with_h.atom_count() > mol.atom_count(),
"H atoms should be added"
);
let back = remove_hydrogens(&with_h);
assert_eq!(back.atom_count(), mol.atom_count());
}
#[test]
fn depict_svg_grid_two_mols() {
let svg = depict_svg_grid("CC\nCCC", 2);
assert!(svg.contains("<svg"), "expected SVG output");
}
#[test]
fn depict_svg_grid_invalid_smiles_skipped() {
let svg = depict_svg_grid("CC\nNOT_A_SMILES\nCCC", 2);
assert!(
svg.contains("<svg"),
"invalid SMILES should be silently skipped"
);
}
#[test]
fn run_reactants_esterification() {
let result = run_reactants(
"[C:1](=O)[OH:2].[O:3][C:4]>>[C:1](=O)[O:3][C:4]",
"CC(=O)O|CCO",
);
assert!(result.is_ok(), "run_reactants should succeed");
let json = result.unwrap();
assert!(json.contains('['), "expected JSON array");
}
#[test]
fn is_valid_smiles_valid() {
assert!(is_valid_smiles("CCO"), "ethanol is valid");
assert!(is_valid_smiles("c1ccccc1"), "benzene is valid");
assert!(is_valid_smiles("O"), "water is valid");
assert!(is_valid_smiles("C"), "methane is valid");
}
#[test]
fn is_valid_smiles_invalid() {
assert!(!is_valid_smiles(""), "empty string is invalid");
assert!(
!is_valid_smiles("[NOSUCHELEMENT]"),
"unknown bracket atom is invalid"
);
}
#[test]
fn depict_svg_opts_transparent_background() {
let h = parse("CCO");
let mut opts = DepictOptions::new();
opts.set_background("transparent".to_string());
let svg = h.depict_svg_opts(&opts);
assert!(svg.contains("<svg"), "must produce SVG");
assert!(
!svg.contains("fill=\"transparent\""),
"no bg rect for transparent"
);
}
#[test]
fn depict_svg_opts_custom_size() {
let h = parse("CCO");
let mut opts = DepictOptions::new();
opts.set_width(300);
opts.set_height(200);
let svg = h.depict_svg_opts(&opts);
assert!(svg.contains("width=\"300\""), "SVG width should be 300");
assert!(svg.contains("height=\"200\""), "SVG height should be 200");
}
#[test]
fn depict_svg_opts_dark_theme() {
let h = parse("CC");
let mut opts = DepictOptions::new();
opts.set_dark(true);
opts.set_background("#0f172a".to_string());
let svg = h.depict_svg_opts(&opts);
assert!(
svg.contains("stroke=\"white\""),
"dark theme bonds should be white"
);
}
#[test]
fn depict_svg_single_atom_water_shows_h2o() {
let svg = parse("O").depict_svg();
assert!(svg.contains("H2O"), "water 'O' should render as H2O");
}
#[test]
fn depict_svg_single_atom_methane_shows_ch4() {
let svg = parse("C").depict_svg();
assert!(svg.contains("CH4"), "methane 'C' should render as CH4");
}
#[test]
fn depict_svg_disconnected_nacl() {
let svg = parse("[Na+].[Cl-]").depict_svg();
assert!(
svg.contains("Na"),
"Na should appear in disconnected SMILES SVG"
);
assert!(
svg.contains("Cl"),
"Cl should appear in disconnected SMILES SVG"
);
assert!(!svg.is_empty());
}
#[test]
fn depict_svg_disconnected_water_dimer() {
let svg = parse("O.O").depict_svg();
assert!(
svg.matches("H2O").count() >= 2,
"both water O atoms should render as H2O"
);
assert!(!svg.is_empty());
}
#[test]
fn depict_svg_opts_atom_ids_contains_data_attrs() {
let h = parse("CC(=O)O"); let mut opts = DepictOptions::new();
opts.set_atom_ids(true);
let svg = h.depict_svg_opts(&opts);
assert!(
svg.contains("data-atom-idx="),
"atom_ids should add data-atom-idx"
);
assert!(
svg.contains("data-element="),
"atom_ids should add data-element"
);
assert!(
svg.contains("data-charge="),
"atom_ids should add data-charge"
);
assert_eq!(
svg.matches("data-atom-idx=").count(),
4,
"all atoms should have data-atom-idx"
);
}
#[test]
fn depict_svg_opts_atom_ids_false_no_data_attrs() {
let h = parse("CC(=O)O");
let svg = h.depict_svg_opts(&DepictOptions::new());
assert!(
!svg.contains("data-atom-idx="),
"default opts should not have data-atom-idx"
);
}
#[test]
fn depict_svg_opts_atom_ids_charge_correct() {
let h = parse("[NH4+]");
let mut opts = DepictOptions::new();
opts.set_atom_ids(true);
let svg = h.depict_svg_opts(&opts);
assert!(
svg.contains("data-charge=\"1\""),
"NH4+ should have charge=1"
);
}
#[test]
fn depict_svg_opts_show_atom_indices() {
let h = parse("c1ccccc1"); let mut opts = DepictOptions::new();
opts.set_show_atom_indices(true);
let svg = h.depict_svg_opts(&opts);
assert!(svg.contains(">0<"), "index 0 should appear");
assert!(svg.contains(">5<"), "index 5 should appear");
}
#[test]
fn depict_svg_opts_show_atom_indices_false_no_indices() {
let h = parse("CCO");
let svg = h.depict_svg_opts(&DepictOptions::new());
assert!(
!svg.contains("fill=\"#8b92a9\""),
"default should not show grey index labels"
);
}
#[test]
fn depict_svg_opts_kekulize_removes_aromatic_bonds() {
let h = parse("c1ccccc1"); let mut opts = DepictOptions::new();
opts.set_kekulize(true);
let svg = h.depict_svg_opts(&opts);
assert!(!svg.is_empty());
assert!(
svg.contains("<line"),
"kekulé benzene should have line elements"
);
assert!(
!svg.contains("stroke-dasharray"),
"kekulé benzene must not use aromatic dashed style"
); }
#[test]
fn depict_svg_opts_kekulize_false_uses_aromatic() {
let h = parse("c1ccccc1");
let svg = h.depict_svg_opts(&DepictOptions::new());
assert!(
svg.contains("stroke-dasharray"),
"default benzene should use aromatic dashed style"
);
}
#[test]
fn smarts_match_benzene_ring_returns_json() {
let mol = parse("c1ccccc1");
let result = smarts_match_atoms("c1ccccc1", &mol);
assert!(result.is_ok(), "valid SMARTS should not error");
let json = result.unwrap();
assert!(
!json.is_empty() && json != "[]",
"benzene ring SMARTS should find a match"
);
assert!(json.starts_with("[["), "result should be array of arrays");
}
#[test]
fn smarts_match_no_match_returns_empty_array() {
let mol = parse("CC"); let result = smarts_match_atoms("c1ccccc1", &mol);
assert!(
result.is_ok(),
"valid SMARTS on non-matching mol should not error"
);
assert_eq!(
result.unwrap(),
"[]",
"no match should return empty JSON array"
);
}
#[test]
fn generate_3d_pdb_benzene_has_6_hetatm_lines() {
let mol = parse("c1ccccc1");
let pdb = generate_3d_pdb(&mol);
let hetatm_count = pdb.lines().filter(|l| l.starts_with("HETATM")).count();
assert_eq!(hetatm_count, 6, "benzene should produce 6 HETATM lines");
}
#[test]
fn generate_3d_pdb_aspirin_no_nan() {
let mol = parse("CC(=O)Oc1ccccc1C(=O)O");
let pdb = generate_3d_pdb(&mol);
assert!(
!pdb.contains("nan") && !pdb.contains("inf"),
"PDB must have no NaN/Inf coords"
);
assert!(pdb.contains("HETATM"), "must produce HETATM records");
}
#[test]
fn smarts_parse_invalid_is_err() {
assert!(
chematic_smarts::parse_smarts("[invalid").is_err(),
"invalid SMARTS should return Err from parse_smarts"
);
}
const ETHANE_MOL_BLOCK: &str = "\
ethane
chematic
2 1 0 0 0 0 0 0 0 0 0 V2000
0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
1.5000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
1 2 1 0
M END
";
#[test]
fn mol_from_sdf_block_ethane_atom_count() {
let h = mol_from_sdf_block(ETHANE_MOL_BLOCK).expect("ethane parse");
assert_eq!(h.atom_count(), 2);
}
#[test]
fn sdf_to_smiles_json_two_records() {
let sdf = format!("{ETHANE_MOL_BLOCK}$$$$\n{ETHANE_MOL_BLOCK}$$$$\n");
let json = sdf_to_smiles_json(&sdf);
assert!(json.starts_with('[') && json.ends_with(']'));
let count = json.matches("CC").count();
assert_eq!(count, 2, "expected 2 ethane SMILES in JSON, got: {json}");
}
#[test]
fn estate_indices_json_acetic_acid_nonempty() {
let h = parse("CC(=O)O");
let json = estate_indices_json(&h);
assert!(json.starts_with('[') && json.ends_with(']'));
assert!(!json.is_empty() && json != "[]");
}
#[test]
fn tanimoto_topo_path_same_mol_is_one() {
let a = parse("c1ccccc1");
let b = parse("c1ccccc1");
assert!((tanimoto_topo_path(&a, &b) - 1.0).abs() < 1e-9);
}
#[test]
fn tanimoto_topo_path_different_mols_lt_one() {
let a = parse("c1ccccc1");
let b = parse("CC(=O)Oc1ccccc1C(=O)O");
assert!(tanimoto_topo_path(&a, &b) < 1.0);
}
#[test]
fn sum_estate_aspirin_positive() {
let h = parse("CC(=O)Oc1ccccc1C(=O)O");
assert!(h.sum_estate() > 0.0);
}
#[test]
fn max_min_estate_ordering() {
let h = parse("CC(=O)O");
assert!(h.max_estate() >= h.min_estate());
}
#[test]
fn depict_reaction_svg_esterification() {
let svg = depict_reaction_svg("CC(=O)O.CCO>>CC(=O)OCC.O").unwrap();
assert!(svg.contains("→"), "must contain arrow character");
assert!(svg.contains("<svg"), "must be valid SVG");
}
#[test]
fn depict_reaction_svg_single_step() {
let svg = depict_reaction_svg("C>>CC").unwrap();
assert!(svg.contains("→"));
assert!(svg.contains("<svg"));
}
#[test]
fn rxn_parse_missing_arrow_is_err() {
assert!(chematic_rxn::parse_reaction("not_a_reaction").is_err());
}
#[test]
fn identify_functional_groups_pyridine_has_n() {
let h = parse("c1ccncc1");
let json = identify_functional_groups(&h);
assert!(
json.contains('N'),
"pyridine FG JSON should contain N: {json}"
);
assert!(json.starts_with('['), "should be JSON array");
}
#[test]
fn identify_functional_groups_hexane_empty() {
let h = parse("CCCCCC");
let json = identify_functional_groups(&h);
assert_eq!(json, "[]", "hexane should have no functional groups");
}
#[test]
fn gasteiger_charges_json_oxygen_negative() {
let h = parse("CC(=O)O"); let json = gasteiger_charges_json(&h);
assert!(json.starts_with('[') && json.ends_with(']'));
let has_negative = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.any(|s| s.trim().parse::<f64>().map(|v| v < 0.0).unwrap_or(false));
assert!(
has_negative,
"acetic acid should have at least one negative charge: {json}"
);
}
#[test]
fn test_mmff94_charges_json_length() {
let h = parse("CC(=O)O");
let json = mmff94_charges_json(&h);
assert!(json.starts_with('[') && json.ends_with(']'));
let count = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.count();
assert_eq!(count, 4, "acetic acid should have 4 charges, got {count}");
}
#[test]
fn test_mmff94_charges_json_acetate_negative() {
let h = parse("CC(=O)[O-]");
let json = mmff94_charges_json(&h);
let total: f64 = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.filter_map(|s| s.trim().parse::<f64>().ok())
.sum();
assert!(
(total + 1.0).abs() < 0.01,
"acetate total charge should be -1, got {total}"
);
}
#[test]
fn test_mhfp_hashes_json_format() {
let h = parse("c1ccccc1");
let json = mhfp_hashes_json(&h);
assert!(
json.contains("\"num_hashes\":128"),
"should have num_hashes:128"
);
assert!(json.contains("\"hashes\":"), "should have hashes key");
}
#[test]
fn test_tanimoto_mhfp_smiles_self() {
let result = tanimoto_mhfp_smiles("c1ccccc1", "c1ccccc1").unwrap();
assert!(
(result - 1.0).abs() < 1e-9,
"self-similarity should be 1.0, got {result}"
);
}
#[test]
fn test_mhfp_lsh_handle_query() {
let mut idx = MhfpLshHandle::new(128);
idx.add_smiles("c1ccccc1").unwrap(); idx.add_smiles("Cc1ccccc1").unwrap(); idx.add_smiles("CC").unwrap();
let json = idx.query_json("c1ccccc1", 0.99).unwrap();
assert!(
json.contains("\"index\":0"),
"benzene should find itself: {json}"
);
assert_eq!(idx.len(), 3);
}
#[test]
fn to_mol_block_has_nonzero_coords() {
let h = parse("c1ccccc1"); let block = to_mol_block(&h);
let nonzero = block.lines().skip(4).take(6).any(|line| {
if line.len() < 30 {
return false;
}
let x: f64 = line[0..10].trim().parse().unwrap_or(0.0);
let y: f64 = line[10..20].trim().parse().unwrap_or(0.0);
x.abs() > 0.01 || y.abs() > 0.01
});
assert!(
nonzero,
"to_mol_block should write real 2D coordinates, got:\n{block}"
);
}
#[test]
fn sa_score_range() {
let h = parse("CC(=O)Oc1ccccc1C(=O)O"); let score = sa_score(&h);
assert!(
(1.0..=10.0).contains(&score),
"SA score out of [1,10]: {score:.2}"
);
}
#[test]
fn mol_with_atom_charge_changes_charge() {
let mol = parse("N"); let mol2 = mol_with_atom_charge(&mol, 0, 1).unwrap();
let atom = mol2.inner.atom(chematic_core::AtomIdx(0));
assert_eq!(atom.charge, 1, "charge should be 1");
}
#[test]
fn mol_with_atom_element_changes_element() {
let mol = parse("CC");
let mol2 = mol_with_atom_element(&mol, 0, "N").unwrap();
let atom = mol2.inner.atom(chematic_core::AtomIdx(0));
assert_eq!(atom.element.symbol(), "N", "element should be N");
}
#[test]
fn mol_block_coords_json_ethanol() {
let mol_block = "\nethanol\n\n 3 2 0 0 0 0 0 0 0 0 0 V2000\n 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 1.5000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 3.0000 0.0000 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 1 2 1 0\n 2 3 1 0\nM END\n";
let json = mol_block_coords_json(mol_block).unwrap();
assert!(
json.contains("[0.0000,0.0000]"),
"first atom at origin: {json}"
);
assert!(
json.contains("[1.5000,0.0000]"),
"second atom at x=1.5: {json}"
);
}
#[test]
fn cdxml_to_smiles_json_two_fragments() {
let cdxml = r#"<?xml version="1.0"?>
<CDXML>
<fragment>
<n id="1" Element="6" p="0 0"/>
<n id="2" Element="6" p="10 0"/>
<b B="1" E="2" Order="1"/>
</fragment>
<fragment>
<n id="3" Element="8" p="30 0"/>
<n id="4" Element="6" p="40 0"/>
<b B="3" E="4" Order="1"/>
</fragment>
</CDXML>"#;
let json = cdxml_to_smiles_json(cdxml).unwrap();
let count = json.matches(',').count() + 1;
assert_eq!(count, 2, "should have 2 fragments: {json}");
}
#[test]
fn cdxml_wedge_bond_becomes_up() {
let cdxml = r#"<?xml version="1.0"?>
<CDXML>
<fragment>
<n id="1" Element="6" p="0 0"/>
<n id="2" Element="6" p="10 0"/>
<b B="1" E="2" Order="1" Display="WedgeBegin"/>
</fragment>
</CDXML>"#;
let (mol, _) = chematic_mol::parse_cdxml(cdxml).unwrap();
let bond = mol.bond(chematic_core::BondIdx(0));
assert_eq!(bond.order, chematic_core::BondOrder::Up, "WedgeBegin → Up");
}
#[test]
fn depict_data_with_coords_uses_provided_coords() {
let mol = parse("CC");
let json = depict_data_with_coords_json(&mol, "[[100.0,200.0],[300.0,400.0]]");
assert!(json.contains("100."), "x coord should be 100: {json}");
assert!(json.contains("200."), "y coord should be 200: {json}");
}
#[test]
fn mol_with_atom_added_increases_count() {
let mol = parse("CC");
let mol2 = mol_with_atom_added(&mol, "O").unwrap();
assert_eq!(mol2.atom_count(), mol.atom_count() + 1);
}
#[test]
fn mol_with_bond_added_increases_count() {
let mol = parse("CC.O"); let mol2 = mol_with_bond_added(&mol, 1, 2, 1).unwrap();
assert_eq!(mol2.bond_count(), mol.bond_count() + 1);
}
#[test]
fn mol_with_atom_removed_decreases_count() {
let mol = parse("CCO");
let mol2 = mol_with_atom_removed(&mol, 2).unwrap();
assert_eq!(mol2.atom_count(), mol.atom_count() - 1);
assert_eq!(mol2.bond_count(), mol.bond_count() - 1);
}
#[test]
fn mol_with_bond_removed_decreases_count() {
let mol = parse("CC");
let mol2 = mol_with_bond_removed(&mol, 0).unwrap();
assert_eq!(mol2.atom_count(), 2, "atoms preserved");
assert_eq!(mol2.bond_count(), 0, "bond removed");
}
#[test]
fn mol_next_atom_idx_equals_atom_count() {
let mol = parse("CCO");
assert_eq!(mol_next_atom_idx(&mol), 3);
}
#[test]
fn smiles_array_to_sdf_contains_dollar_delimiters() {
let sdf = smiles_array_to_sdf(r#"["CC","CCO"]"#).unwrap();
assert_eq!(sdf.matches("$$$$").count(), 2, "2 delimiters: {sdf}");
}
#[test]
fn to_mol_v3000_block_contains_v3000() {
let mol = parse("CC");
let v3k = to_mol_v3000_block(&mol);
assert!(v3k.contains("V3000"), "should contain V3000 tag");
assert!(v3k.contains("M V30 BEGIN ATOM"), "should have atom block");
}
#[test]
fn depict_data_json_benzene_atoms_bonds() {
let mol = parse("c1ccccc1");
let json = depict_data_json(&mol);
assert!(json.contains("\"atoms\":"), "should have atoms key");
assert!(json.contains("\"bonds\":"), "should have bonds key");
assert!(json.contains("\"element\":\"C\""), "benzene has C atoms");
}
#[test]
fn depict_data_json_pyridine_nitrogen_has_label() {
let mol = parse("c1ccncc1");
let json = depict_data_json(&mol);
assert!(
json.contains("\"label\":\"N\""),
"pyridine N should have label: {json}"
);
}
#[test]
fn cpk_color_nitrogen_is_blue() {
assert_eq!(cpk_color("N"), "#3050F8", "N is blue in CPK");
}
#[test]
fn cpk_color_carbon_is_black() {
assert_eq!(cpk_color("C"), "#000000", "C is black in CPK");
}
#[test]
fn mol_from_cml_ethanol() {
let cml = r#"<molecule>
<atomArray>
<atom id="a1" elementType="C" x2="0.0" y2="0.0"/>
<atom id="a2" elementType="C" x2="1.5" y2="0.0"/>
<atom id="a3" elementType="O" x2="3.0" y2="0.0"/>
</atomArray>
<bondArray>
<bond atomRefs2="a1 a2" order="1"/>
<bond atomRefs2="a2 a3" order="1"/>
</bondArray>
</molecule>"#;
let mol = mol_from_cml(cml).unwrap();
assert_eq!(mol.atom_count(), 3, "ethanol: 3 heavy atoms");
assert_eq!(mol.bond_count(), 2, "ethanol: 2 bonds");
}
#[test]
fn to_cml_contains_molecule_tag() {
let mol = parse("CC(=O)O");
let cml = to_cml(&mol);
assert!(cml.contains("<molecule"), "should contain <molecule");
assert!(cml.contains("elementType="), "should contain elementType");
assert!(cml.contains("atomRefs2="), "should contain atomRefs2");
}
#[test]
fn to_cml_roundtrip_atom_count() {
let mol = parse("CC(=O)O");
let cml = to_cml(&mol);
let mol2 = mol_from_cml(&cml).unwrap();
assert_eq!(
mol.atom_count(),
mol2.atom_count(),
"CML round-trip preserves atom count"
);
assert_eq!(
mol.bond_count(),
mol2.bond_count(),
"CML round-trip preserves bond count"
);
}
#[test]
fn mol_from_cdxml_ethanol() {
let cdxml = r#"<?xml version="1.0"?>
<CDXML>
<fragment>
<n id="1" p="10.0 20.0" Element="6"/>
<n id="2" p="25.0 20.0" Element="6"/>
<n id="3" p="40.0 20.0" Element="8"/>
<b B="1" E="2" Order="1"/>
<b B="2" E="3" Order="1"/>
</fragment>
</CDXML>"#;
let mol = mol_from_cdxml(cdxml).unwrap();
assert_eq!(mol.atom_count(), 3, "CDXML ethanol: 3 heavy atoms");
assert_eq!(mol.bond_count(), 2, "CDXML ethanol: 2 bonds");
}
#[test]
fn mol_from_cml_unknown_element_returns_err() {
let cml = r#"<molecule><atomArray>
<atom id="a1" elementType="Xx"/>
</atomArray></molecule>"#;
let result = chematic_mol::parse_cml(cml);
assert!(result.is_err(), "unknown element should return Err");
}
#[test]
fn mmp_pairs_json_ethylbenzene_propylbenzene() {
let smiles_json = r#"["CCc1ccccc1","CCCc1ccccc1"]"#;
let result = mmp_pairs_json(smiles_json).unwrap();
assert!(
result.contains("\"core\":"),
"should have core key: {result}"
);
assert!(
result.contains("c1c(cccc1)[*]"),
"core should be benzene: {result}"
);
assert!(
result.contains("\"fragment_a\":"),
"should have fragment_a: {result}"
);
assert!(
result.contains("\"fragment_b\":"),
"should have fragment_b: {result}"
);
}
#[test]
fn mmp_pairs_json_no_pairs_for_single_molecule() {
let smiles_json = r#"["CCc1ccccc1"]"#;
let result = mmp_pairs_json(smiles_json).unwrap();
assert_eq!(result, "[]", "single molecule should have no pairs");
}
#[test]
fn mmp_pairs_json_three_molecules() {
let smiles_json = r#"["CCc1ccccc1","CCCc1ccccc1","CCCCc1ccccc1"]"#;
let result = mmp_pairs_json(smiles_json).unwrap();
assert!(
result.contains("\"mol_a\":"),
"should have mol_a key: {result}"
);
assert!(
result.contains("\"core\":"),
"should have core key: {result}"
);
let pair_count = result.matches("\"mol_a\":").count();
assert!(
pair_count >= 1,
"should have >= 1 pair, got {pair_count}: {result}"
);
}
#[test]
fn conformer_handle_new_and_add() {
let ens = ConformerHandle::new("CCCC").unwrap(); assert_eq!(ens.conformer_count(), 0, "new ensemble has 0 conformers");
}
#[test]
fn conformer_handle_add_generated() {
let mut ens = ConformerHandle::new("CCCC").unwrap();
let idx = ens.add_generated_conformer();
assert_eq!(idx, 0, "first conformer has index 0");
assert_eq!(ens.conformer_count(), 1);
}
#[test]
fn conformer_handle_add_minimized() {
let mut ens = ConformerHandle::new("CCCC").unwrap();
let idx = ens.add_minimized_conformer();
assert_eq!(idx, 0);
assert_eq!(ens.conformer_count(), 1);
}
#[test]
fn conformer_handle_get_pdb_returns_hetatm() {
let mut ens = ConformerHandle::new("CC").unwrap();
ens.add_generated_conformer();
let pdb = ens.get_conformer_pdb(0).expect("conformer 0 exists");
assert!(pdb.contains("HETATM"), "PDB should contain HETATM records");
}
#[test]
fn conformer_handle_rmsd_same_conformer() {
let mut ens = ConformerHandle::new("CCCC").unwrap();
ens.add_generated_conformer();
ens.add_generated_conformer();
let rmsd = ens.conformer_rmsd(0, 0);
assert!(rmsd.is_finite(), "RMSD should be finite");
assert!(rmsd >= 0.0, "RMSD must be non-negative");
}
#[test]
fn conformer_handle_rmsd_out_of_range_is_nan() {
let mut ens = ConformerHandle::new("CC").unwrap();
ens.add_generated_conformer();
assert!(ens.conformer_rmsd(0, 99).is_nan(), "out-of-range gives NaN");
}
#[test]
fn conformer_handle_remove_conformer() {
let mut ens = ConformerHandle::new("CC").unwrap();
ens.add_generated_conformer();
assert!(ens.remove_conformer(0));
assert_eq!(ens.conformer_count(), 0);
assert!(!ens.remove_conformer(0), "already removed");
}
#[test]
fn conformer_handle_mol_returns_correct_atom_count() {
let ens = ConformerHandle::new("CC(=O)O").unwrap(); let mol = ens.mol();
assert_eq!(mol.atom_count(), 4);
}
#[test]
fn rgroup_toluene_ethylbenzene_para_substituted() {
let smiles_json = r#"["Cc1ccccc1","CCc1ccccc1"]"#;
let json = rgroup_decompose_json(smiles_json, "c1ccc(*)cc1").unwrap();
assert!(
json.contains("\"matched\":true"),
"both should match: {json}"
);
assert!(json.contains("\"r1\":"), "should have r1 key: {json}");
}
#[test]
fn rgroup_no_match_returns_false() {
let smiles_json = r#"["c1ccccc1"]"#;
let json = rgroup_decompose_json(smiles_json, "c1ccc(*)cc1").unwrap();
assert!(
json.contains("\"matched\":false"),
"unsubstituted benzene should not match: {json}"
);
}
#[test]
fn rgroup_two_attachment_points() {
let smiles_json = r#"["Cc1cccc(C)c1"]"#;
let json = rgroup_decompose_json(smiles_json, "c1cc(*)cc(*)c1").unwrap();
assert!(
json.contains("\"matched\":true"),
"m-xylene should match: {json}"
);
assert!(json.contains("\"r1\":"), "should have r1: {json}");
assert!(json.contains("\"r2\":"), "should have r2: {json}");
}
#[test]
fn rgroup_invalid_smarts_returns_err() {
let result = chematic_smarts::parse_smarts("~~~invalid~~~");
assert!(result.is_err(), "invalid SMARTS should not parse");
}
#[test]
fn fcfp4_bitvec_length_256() {
assert_eq!(fcfp4_bitvec(&parse("c1ccccc1")).len(), 256);
}
#[test]
fn fcfp6_bitvec_length_256() {
assert_eq!(fcfp6_bitvec(&parse("c1ccccc1")).len(), 256);
}
#[test]
fn dice_ecfp6_identical_is_one() {
let mol = parse("c1ccccc1");
assert!((dice_ecfp6(&mol, &mol) - 1.0).abs() < 1e-9);
}
#[test]
fn write_smiles_contains_same_atoms() {
let mol = parse("CC(=O)O");
let smi = write_smiles(&mol);
let mol2 = parse_smiles(&smi).unwrap();
assert_eq!(mol.atom_count(), mol2.atom_count());
}
#[test]
fn normalize_reaction_smiles_roundtrip() {
let result = chematic_rxn::parse_reaction("CC>>CO");
assert!(result.is_ok(), "simple reaction should parse");
let rxn = result.unwrap();
let out = chematic_rxn::write_reaction(&rxn);
assert!(
out.contains(">>"),
"written reaction should contain >>: {out}"
);
}
#[test]
fn brics_fragments_json_aspirin() {
let mol = parse("CC(=O)Oc1ccccc1C(=O)O"); let json = brics_fragments_json(&mol);
let count_from_json = json
.split('"')
.filter(|s| s.contains('C') || s.contains('c'))
.count();
assert!(
json.starts_with('[') && json.ends_with(']'),
"not a JSON array: {json}"
);
assert_eq!(
count_from_json,
brics_fragment_count(&mol),
"fragment count mismatch: json={json}"
);
}
#[test]
fn brics_fragments_json_benzene_self() {
let mol = parse("c1ccccc1");
let json = brics_fragments_json(&mol);
assert!(json.starts_with('[') && json.ends_with(']'));
assert_eq!(
brics_fragment_count(&mol),
1,
"benzene has 1 fragment (itself): {json}"
);
}
#[test]
fn atom_pair_bitvec_length_256() {
let mol = parse("c1ccccc1");
assert_eq!(atom_pair_bitvec(&mol).len(), 256);
}
#[test]
fn torsion_bitvec_length_256() {
let mol = parse("CCCCC");
assert_eq!(torsion_bitvec(&mol).len(), 256);
}
#[test]
fn tanimoto_fcfp6_identical_is_one() {
let mol = parse("c1ccccc1");
assert!((tanimoto_fcfp6(&mol, &mol) - 1.0).abs() < 1e-9);
}
#[test]
fn sdf_from_records_json_round_trip() {
let smiles_json = r#"["CC"]"#;
let names_json = r#"["ethane"]"#;
let props_json = r#"["MW\t30.07\nSource\ttest"]"#;
let sdf = sdf_from_records_json(smiles_json, names_json, props_json).unwrap();
assert!(sdf.contains("ethane"), "name missing: {sdf}");
assert!(sdf.contains("> <MW>"), "MW field missing: {sdf}");
assert!(sdf.contains("30.07"), "MW value missing: {sdf}");
assert!(sdf.contains("$$$$"), "SDF delimiter missing: {sdf}");
}
#[test]
fn sdf_from_records_json_no_props() {
let sdf = sdf_from_records_json(r#"["C"]"#, r#"["methane"]"#, r#"[""]"#).unwrap();
assert!(sdf.contains("methane"));
assert!(sdf.contains("$$$$"));
}
#[test]
fn sdf_from_records_json_length_mismatch_returns_err() {
let smiles = parse_smiles_json_array(r#"["CC","CCC"]"#).unwrap();
let names = parse_smiles_json_array(r#"["ethane"]"#).unwrap();
assert_ne!(smiles.len(), names.len(), "lengths should differ");
}
#[test]
fn parse_smiles_json_array_handles_escaped_strings() {
let smiles = parse_smiles_json_array(r#"["C\\C=C\\O","CC"]"#).unwrap();
assert_eq!(smiles[0], r#"C\C=C\O"#);
assert_eq!(smiles[1], "CC");
}
#[test]
fn mol_from_xyz_roundtrip_atom_count() {
let xyz = "2\nethane\nC 0.0 0.0 0.0\nC 1.5 0.0 0.0\n";
let result = chematic_3d::parse_xyz(xyz);
assert!(result.is_ok(), "valid XYZ should parse");
let (mol, _) = result.unwrap();
assert_eq!(mol.atom_count(), 2);
}
#[test]
fn to_xyz_contains_atom_lines() {
let mol = parse("CC"); let xyz = to_xyz(&mol);
assert!(xyz.contains('C'), "XYZ should contain C atom lines");
let count: usize = xyz.lines().next().unwrap().trim().parse().unwrap();
assert_eq!(count, mol.atom_count(), "XYZ header atom count matches mol");
}
#[test]
fn mol_from_pdb_returns_handle() {
let h = mol_from_pdb("");
assert_eq!(h.atom_count(), 0);
}
#[test]
fn logp_per_atom_json_length() {
let mol = parse("CC(=O)O"); let json = logp_per_atom_json(&mol);
let count = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.count();
assert_eq!(
count,
mol.atom_count(),
"per-atom logP array length mismatch"
);
}
#[test]
fn mr_per_atom_json_length() {
let mol = parse("CC(=O)O");
let json = mr_per_atom_json(&mol);
let count = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.count();
assert_eq!(count, mol.atom_count());
}
#[test]
fn labute_asa_per_atom_json_no_nan() {
let mol = parse("c1ccccc1");
let json = labute_asa_per_atom_json(&mol);
assert!(!json.contains("NaN"), "no NaN in asa per-atom: {json}");
}
#[test]
fn sssr_rings_json_benzene_one_ring() {
let mol = parse("c1ccccc1");
let json = sssr_rings_json(&mol);
assert!(
json.starts_with("[[") && json.ends_with("]]"),
"expected one ring: {json}"
);
let ring: Vec<usize> = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.filter_map(|s| s.trim_matches(|c| c == '[' || c == ']').parse().ok())
.collect();
assert_eq!(ring.len(), 6, "benzene ring has 6 atoms");
}
#[test]
fn sssr_rings_json_naphthalene_two_rings() {
let mol = parse("c1ccc2ccccc2c1"); let json = sssr_rings_json(&mol);
let ring_count = json.matches("],[").count() + 1;
assert_eq!(ring_count, 2, "naphthalene has 2 rings: {json}");
}
#[test]
fn ecfp_bitvec_custom_256_length() {
let mol = parse("c1ccccc1");
let bv = ecfp_bitvec_custom(&mol, 2, 256, false);
assert_eq!(bv.len(), 32, "256-bit FP = 32 bytes");
}
#[test]
fn ecfp_bitvec_custom_identical_tanimoto() {
let mol = parse("c1ccccc1");
let a = ecfp_bitvec_custom(&mol, 2, 512, false);
let b = ecfp_bitvec_custom(&mol, 2, 512, false);
assert_eq!(a, b, "same mol same FP");
}
#[test]
fn ecfp4_bitvec_with_chirality_l_vs_d_alanine_different() {
let l_ala = parse("C[C@H](N)C(=O)O");
let d_ala = parse("C[C@@H](N)C(=O)O");
let l_fp = ecfp4_bitvec_with_chirality(&l_ala, true);
let d_fp = ecfp4_bitvec_with_chirality(&d_ala, true);
assert_ne!(
l_fp, d_fp,
"L-alanine and D-alanine should have different ECFP4 with use_chirality=true"
);
}
#[test]
fn ecfp4_bitvec_with_chirality_l_vs_d_alanine_same_without_chirality() {
let l_ala = parse("C[C@H](N)C(=O)O");
let d_ala = parse("C[C@@H](N)C(=O)O");
let l_fp = ecfp4_bitvec_with_chirality(&l_ala, false);
let d_fp = ecfp4_bitvec_with_chirality(&d_ala, false);
assert_eq!(
l_fp, d_fp,
"L-alanine and D-alanine should have identical ECFP4 with use_chirality=false"
);
}
#[test]
fn ecfp6_bitvec_with_chirality_l_vs_d_alanine_different() {
let l_ala = parse("C[C@H](N)C(=O)O");
let d_ala = parse("C[C@@H](N)C(=O)O");
let l_fp = ecfp6_bitvec_with_chirality(&l_ala, true);
let d_fp = ecfp6_bitvec_with_chirality(&d_ala, true);
assert_ne!(
l_fp, d_fp,
"L-alanine and D-alanine should have different ECFP6 with use_chirality=true"
);
}
#[test]
fn smarts_match_atoms_with_chirality_chiral_atom_matches_only_with_flag() {
let l_ala = parse("C[C@H](N)C(=O)O");
let d_ala = parse("C[C@@H](N)C(=O)O");
let l_matches_with = smarts_match_atoms_with_chirality("[C@H]", &l_ala, true).unwrap();
let l_matches_without = smarts_match_atoms_with_chirality("[C@H]", &l_ala, false).unwrap();
let d_matches_with = smarts_match_atoms_with_chirality("[C@H]", &d_ala, true).unwrap();
let d_matches_without = smarts_match_atoms_with_chirality("[C@H]", &d_ala, false).unwrap();
assert!(
!l_matches_with.contains("[]"),
"L-ala should match [C@H] with use_chirality=true"
);
assert!(
d_matches_with.contains("[]"),
"D-ala should NOT match [C@H] with use_chirality=true"
);
assert!(
!l_matches_without.contains("[]"),
"L-ala should match [C@H] with use_chirality=false"
);
assert!(
!d_matches_without.contains("[]"),
"D-ala should match [C@H] with use_chirality=false (chirality ignored)"
);
}
#[test]
fn enumerate_stereo_isomers_one_center_gives_two() {
let mol = parse("C(F)(Cl)Br");
let result = enumerate_stereo_isomers_json(&mol);
assert!(result.is_ok(), "should return Ok for valid mol");
let json = result.unwrap();
let count = json.matches("\"smiles\"").count();
assert_eq!(count, 2, "expected 2 stereoisomers: {json}");
}
#[test]
fn enumerate_stereo_isomers_already_specified_gives_one() {
let mol = parse("C[C@H](F)Cl");
let result = enumerate_stereo_isomers_json(&mol);
assert!(result.is_ok());
let json = result.unwrap();
let count = json.matches("\"smiles\"").count();
assert_eq!(count, 1, "fully specified mol should give 1 isomer: {json}");
}
#[test]
fn enumerate_stereo_isomers_no_center_gives_one() {
let mol = parse("CC");
let result = enumerate_stereo_isomers_json(&mol);
assert!(result.is_ok());
let json = result.unwrap();
assert!(json.starts_with('[') && json.ends_with(']'));
}
#[test]
fn mol_from_v3000_block_parses_atom_count() {
assert!(
chematic_mol::parse_mol_v3000(
"\n \n\n 0 0 0 0 0 0 0 0999 V3000\nM V30 BEGIN CTAB\n\
M V30 COUNTS 2 1 0 0 0\nM V30 BEGIN ATOM\n\
M V30 1 C 0 0 0 0\nM V30 2 C 1.5 0 0 0\n\
M V30 END ATOM\nM V30 BEGIN BOND\n\
M V30 1 1 1 2\nM V30 END BOND\n\
M V30 END CTAB\nM END"
)
.is_ok()
);
}
#[test]
fn generate_3d_minimized_pdb_nonzero_coords() {
let mol = parse("CCCC"); let pdb = generate_3d_minimized_pdb(&mol);
assert!(pdb.contains("HETATM"), "expected HETATM records");
let has_nonzero = pdb.lines().filter(|l| l.starts_with("HETATM")).any(|l| {
if l.len() < 54 {
return false;
}
let x: f64 = l[30..38].trim().parse().unwrap_or(0.0);
let y: f64 = l[38..46].trim().parse().unwrap_or(0.0);
x.abs() > 0.01 || y.abs() > 0.01
});
assert!(
has_nonzero,
"minimized PDB should have non-zero coords:\n{pdb}"
);
}
#[test]
fn sdf_to_records_json_parses_properties() {
let sdf = concat!(
"aspirin\n",
" chematic\n",
"\n",
" 2 1 0 0 0 0 0 0 0 0 0 V2000\n",
" 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n",
" 1.5000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n",
" 1 2 1 0\n",
"M END\n",
"> <MW>\n",
"180.2\n",
"\n",
"> <Source>\n",
"ChEMBL\n",
"\n",
"$$$$\n",
);
let json = sdf_to_records_json(sdf);
assert!(
json.contains("\"name\":\"aspirin\""),
"name missing: {json}"
);
assert!(json.contains("\"MW\":\"180.2\""), "MW missing: {json}");
assert!(
json.contains("\"Source\":\"ChEMBL\""),
"Source missing: {json}"
);
}
#[test]
fn sdf_to_records_json_escapes_special_chars() {
let sdf = concat!(
"mol\n",
" chematic\n",
"\n",
" 1 0 0 0 0 0 0 0 0 0 0 V2000\n",
" 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n",
"M END\n",
"> <Notes>\n",
"line1\n",
"line2\n",
"\n",
"$$$$\n",
);
let json = sdf_to_records_json(sdf);
assert!(
json.contains("\\n"),
"newline in multi-line value should be escaped: {json}"
);
}
#[test]
fn depict_svg_grid_highlighted_no_smarts_returns_grid() {
let smiles = "c1ccccc1\nCCO";
let svg = depict_svg_grid_highlighted(smiles, 2, "");
assert!(svg.contains("mol-0"), "expected mol-0: {svg}");
assert!(svg.contains("mol-1"), "expected mol-1: {svg}");
}
#[test]
fn depict_svg_grid_highlighted_with_smarts_adds_circles() {
let smiles = "c1ccccc1\nc1ccncc1"; let svg = depict_svg_grid_highlighted(smiles, 2, "c1ccccn1"); assert!(svg.contains("<circle"), "expected highlight circles: {svg}");
}
#[test]
fn pains_matches_json_empty_for_clean_mol() {
let mol = parse("c1ccccc1"); let json = pains_matches_json(&mol);
assert_eq!(json, "[]");
}
#[test]
fn pains_matches_json_returns_alert_names() {
let mol = parse("O=C1CSC(=S)N1"); let json = pains_matches_json(&mol);
assert!(json.starts_with('[') && json.ends_with(']'));
let _ = json; }
#[test]
fn cip_assignments_json_aspirin_no_stereo() {
let mol = parse("CC(=O)Oc1ccccc1C(=O)O"); let json = cip_assignments_json(&mol);
assert_eq!(json, "[]");
}
#[test]
fn cip_assignments_json_chiral_center() {
let mol = parse("[C@@H](F)(Cl)Br"); let json = cip_assignments_json(&mol);
assert!(json.contains("cipCode"), "expected cipCode in: {json}");
assert!(json.contains('R') || json.contains('S'));
}
#[test]
fn ecfp6_bitvec_length_256() {
let mol = parse("c1ccccc1");
assert_eq!(ecfp6_bitvec(&mol).len(), 256);
}
#[test]
fn tanimoto_ecfp6_identical_is_one() {
let mol = parse("c1ccccc1");
assert!((tanimoto_ecfp6(&mol, &mol) - 1.0).abs() < 1e-9);
}
#[test]
fn dice_ecfp4_identical_is_one() {
let mol = parse("c1ccccc1");
assert!((dice_ecfp4(&mol, &mol) - 1.0).abs() < 1e-9);
}
#[test]
fn dice_maccs_identical_is_one() {
let mol = parse("c1ccccc1");
assert!((dice_maccs(&mol, &mol) - 1.0).abs() < 1e-9);
}
#[test]
fn num_aliphatic_rings_cyclohexane() {
let mol = parse("C1CCCCC1"); assert_eq!(mol.num_aliphatic_rings(), 1);
assert_eq!(mol.num_saturated_rings(), 1);
assert_eq!(mol.aromatic_ring_count(), 0);
}
#[test]
fn num_unspecified_stereocenters_unspec() {
let mol = parse("C(F)(Cl)Br"); assert_eq!(mol.num_unspecified_stereocenters(), 1);
}
#[test]
fn shape_descriptors_json_benzene_keys() {
let mol = parse("c1ccccc1");
let json = shape_descriptors_json(&mol);
assert!(json.contains("\"pmi1\""), "missing pmi1: {json}");
assert!(json.contains("\"npr1\""), "missing npr1: {json}");
assert!(
json.contains("\"asphericity\""),
"missing asphericity: {json}"
);
}
#[test]
fn shape_descriptors_json_single_atom_no_nan() {
let mol = parse("[Na+]"); let json = shape_descriptors_json(&mol);
assert!(!json.contains("NaN"), "NaN in output: {json}");
assert!(!json.contains("inf"), "inf in output: {json}");
}
#[test]
fn maxmin_picks_returns_correct_count() {
let json = r#"["CC","c1ccccc1","CCO","CCCC","c1cccnc1"]"#;
let result = maxmin_picks_ecfp4_json(json, 3).unwrap();
let count = result
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.count();
assert_eq!(count, 3, "expected 3 picks: {result}");
}
#[test]
fn smiles_parse_fails_for_unclosed_ring() {
assert!(
chematic_smiles::parse("C1CC").is_err(),
"unclosed ring should fail"
);
}
#[test]
fn butina_cluster_all_similar_one_cluster() {
let json = r#"["c1ccccc1","c1ccccc1","c1ccccc1"]"#;
let result = butina_cluster_ecfp4_json(json, 0.0).unwrap();
assert!(result.starts_with('[') && result.ends_with(']'));
}
#[test]
fn mcs_smiles_two_acetyl_compounds() {
let json = r#"["CC(=O)O","CC(=O)N"]"#; let result = mcs_smiles_json(json).unwrap();
assert_ne!(result, "null", "expected a non-null MCS");
assert!(!result.is_empty());
}
#[test]
fn mcs_smiles_no_overlap_returns_null() {
let json = r#"["[Na+]","[Cl-]"]"#; let result = mcs_smiles_json(json).unwrap();
assert!(result == "null" || !result.is_empty());
}
#[test]
fn mcs_with_ring_config_quinoline_series_complete_rings() {
let json = r#"["c1ccc2nc(CC)ccc2c1","c1ccc2nc(CO)ccc2c1","c1ccc2nc(CN)ccc2c1"]"#;
let result = mcs_smiles_json_with_ring_config(json, true, true).unwrap();
assert_ne!(result, "null");
let mol = chematic_smiles::parse(&result).expect("valid SMILES");
assert!(
mol.atom_count() >= 10,
"expected at least the quinoline scaffold (10 atoms), got {}",
mol.atom_count()
);
}
#[test]
fn mcs_with_ring_config_benzene_toluene() {
let json = r#"["c1ccccc1","Cc1ccccc1"]"#;
let result = mcs_smiles_json_with_ring_config(json, false, true).unwrap();
assert_ne!(result, "null");
let mol = chematic_smiles::parse(&result).expect("valid SMILES");
assert_eq!(
mol.atom_count(),
6,
"expected full benzene ring (6 atoms), got {}",
mol.atom_count()
);
}
#[test]
fn mcs_smiles_single_input_returns_err() {
let json = r#"["CC"]"#;
let smiles_list: Vec<String> = json
.split('"')
.enumerate()
.filter(|(i, _)| i % 2 == 1)
.map(|(_, s)| s.to_string())
.collect();
assert_eq!(smiles_list.len(), 1, "helper extracted wrong count");
assert!(smiles_list.len() < 2, "should be fewer than 2 inputs");
}
#[test]
fn murcko_scaffold_benzene_ring() {
let mol = parse("c1ccccc1CC(=O)O"); let scaffold = murcko_scaffold(&mol);
let smi = scaffold.canonical_smiles();
assert!(!smi.is_empty(), "murcko_scaffold returned empty SMILES");
}
#[test]
fn canonical_tautomer_returns_handle() {
let mol = parse("Oc1cccc2ccccc12"); let t = canonical_tautomer(&mol);
assert!(t.atom_count() > 0);
}
#[test]
fn enumerate_tautomers_json_is_array() {
let mol = parse("Oc1cccc2ccccc12");
let json = enumerate_tautomers_json(&mol);
assert!(json.starts_with('[') && json.ends_with(']'));
assert!(json.len() > 2, "expected at least one tautomer");
}
#[test]
fn largest_fragment_strips_salt() {
let mol = parse("CC(=O)[O-].[Na+]");
let frag = largest_fragment(&mol);
assert!(frag.atom_count() > 1, "expected the larger fragment");
assert!(
frag.atom_count() < mol.atom_count(),
"fragment should be smaller than the salt"
);
}
#[test]
fn neutralize_charges_removes_charges() {
let mol = parse("CC(=O)[O-]");
let neutral = neutralize_charges(&mol);
assert_eq!(neutral.formal_charge_sum(), 0);
}
#[test]
fn standardize_smiles_report_json_includes_audit_report() {
let json = standardize_smiles_report_json("CC.CCC", true, false, false, false);
assert!(json.contains("\"smiles\""), "missing smiles: {json}");
assert!(json.contains("\"report\""), "missing report: {json}");
assert!(
json.contains("\"status\":\"Modified\""),
"missing status: {json}"
);
assert!(
json.contains("\"step\":\"LargestFragment\""),
"missing largest-fragment step: {json}"
);
}
#[test]
fn maccs_bitvec_length_21() {
let mol = parse("c1ccccc1");
let bv = maccs_bitvec(&mol);
assert_eq!(bv.len(), 21, "MACCS 166 bits should fit in 21 bytes");
}
#[test]
fn tanimoto_maccs_identical_is_one() {
let mol = parse("c1ccccc1");
assert!((tanimoto_maccs(&mol, &mol) - 1.0).abs() < 1e-9);
}
#[test]
fn get_descriptors_json_keys() {
let mol = parse("CC(=O)Oc1ccccc1C(=O)O"); let json = get_descriptors_json(&mol);
assert!(json.contains("\"mw\""), "missing mw: {json}");
assert!(json.contains("\"tpsa\""), "missing tpsa: {json}");
assert!(json.contains("\"qed\""), "missing qed: {json}");
}
#[test]
fn slogp_vsa_json_length_12() {
let h = parse("c1ccccc1");
let json = slogp_vsa_json(&h);
let count = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.count();
assert_eq!(count, 12, "SlogP_VSA should have 12 values");
}
#[test]
fn smr_vsa_json_length_10() {
let h = parse("c1ccccc1");
let json = smr_vsa_json(&h);
let count = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.count();
assert_eq!(count, 10, "SMR_VSA should have 10 values");
}
#[test]
fn peoe_vsa_json_length_14() {
let h = parse("c1ccccc1");
let json = peoe_vsa_json(&h);
let count = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.count();
assert_eq!(count, 14, "PEOE_VSA should have 14 values");
}
#[test]
fn test_pka_acid_value_acetic_acid() {
let h = parse("CC(=O)O");
let pka = h.pka_acid_value();
assert!(pka.is_finite(), "acetic acid pKa should be finite");
assert!(
(pka - 4.0).abs() < 1.0,
"acetic acid pKa ~4.0, got {pka:.2}"
);
}
#[test]
fn test_pka_base_value_aniline() {
let h = parse("Nc1ccccc1");
let pka = h.pka_base_value();
assert!(pka.is_finite(), "aniline pKa_base should be finite");
assert!(
(pka - 4.6).abs() < 1.0,
"aniline pKa_base ~4.6, got {pka:.2}"
);
}
#[test]
fn test_pka_acid_value_benzene_nan() {
let h = parse("c1ccccc1");
let pka = h.pka_acid_value();
assert!(pka.is_nan(), "benzene has no acidic site → NaN");
}
#[test]
fn test_pka_base_value_benzene_nan() {
let h = parse("c1ccccc1");
let pka = h.pka_base_value();
assert!(pka.is_nan(), "benzene has no basic site → NaN");
}
#[test]
fn test_bbb_score_benzene_positive() {
let h = parse("c1ccccc1");
let score = h.bbb_score();
assert!(
score > -1.0,
"benzene should be CNS penetrant (logBB > -1), got {score:.3}"
);
}
#[test]
fn test_bbb_passes_benzene() {
let h = parse("c1ccccc1");
assert!(
h.bbb_passes(),
"benzene (TPSA=0, MW=78) should pass BBB rules"
);
}
#[test]
fn test_caco2_hexane_high() {
let h = parse("CCCCCC");
let c = h.caco2_permeability();
assert!(
c > -5.5,
"hexane should have high Caco-2 permeability, got {c:.3}"
);
}
#[test]
fn test_herg_risk_range() {
let h = parse("c1ccccc1");
let r = h.herg_risk_score();
assert!(
(0.0..=1.0).contains(&r),
"hERG risk must be in [0,1], got {r}"
);
}
#[test]
fn test_cyp3a4_risk_range() {
let h = parse("c1ccccc1");
let r = h.cyp3a4_inhibition_risk();
assert!(
(0.0..=1.0).contains(&r),
"CYP3A4 risk must be in [0,1], got {r}"
);
}
#[test]
fn test_predict_pka_json_acetic_acid() {
let json = predict_pka_json("CC(=O)O");
assert!(
json.starts_with('[') && json.ends_with(']'),
"should be JSON array"
);
assert!(
json.contains("\"type\":\"acid\""),
"should contain acid site"
);
assert!(!json.contains("\"error\""), "should not contain error");
}
#[test]
fn test_predict_pka_json_benzene_empty() {
let json = predict_pka_json("c1ccccc1");
assert_eq!(json, "[]", "benzene has no ionizable sites");
}
#[test]
fn test_predict_pka_json_invalid_smiles() {
let json = predict_pka_json("C1CC");
assert!(
json.contains("\"error\""),
"invalid SMILES should return error JSON"
);
}
#[test]
fn test_predict_pka_json_glycine_both() {
let json = predict_pka_json("NCC(=O)O");
assert!(
json.contains("\"type\":\"acid\""),
"glycine should have acid site"
);
assert!(
json.contains("\"type\":\"base\""),
"glycine should have base site"
);
}
#[test]
fn test_admet_profile_json_aspirin_valid() {
let json = admet_profile_json("CC(=O)Oc1ccccc1C(=O)O");
assert!(
!json.contains("\"error\""),
"aspirin should parse without error"
);
assert!(
json.contains("\"bbb_score\""),
"should have bbb_score field"
);
assert!(json.contains("\"caco2\""), "should have caco2 field");
assert!(
json.contains("\"herg_risk\""),
"should have herg_risk field"
);
assert!(json.contains("\"pka_acid\""), "should have pka_acid field");
assert!(
json.contains("\"bbb_passes\":true"),
"aspirin should pass BBB"
);
}
#[test]
fn test_admet_profile_json_benzene_null_pka() {
let json = admet_profile_json("c1ccccc1");
assert!(
json.contains("\"pka_acid\":null"),
"benzene pka_acid should be null"
);
assert!(
json.contains("\"pka_base\":null"),
"benzene pka_base should be null"
);
}
#[test]
fn test_admet_profile_json_invalid_smiles() {
let json = admet_profile_json("C1CC");
assert!(
json.contains("\"error\""),
"invalid SMILES should return error JSON"
);
}
#[test]
fn test_get_descriptors_json_has_admet_fields() {
let h = parse("c1ccccc1");
let json = get_descriptors_json(&h);
assert!(json.contains("\"bbbScore\""), "should have bbbScore field");
assert!(json.contains("\"caco2\""), "should have caco2 field");
assert!(json.contains("\"hergRisk\""), "should have hergRisk field");
assert!(
json.contains("\"cyp3a4Risk\""),
"should have cyp3a4Risk field"
);
assert!(json.contains("\"pkaAcid\""), "should have pkaAcid field");
assert!(json.contains("\"pkaBase\""), "should have pkaBase field");
}
#[test]
fn test_get_descriptors_json_benzene_null_pka() {
let h = parse("c1ccccc1");
let json = get_descriptors_json(&h);
assert!(
json.contains("\"pkaAcid\":null"),
"benzene pkaAcid should be null"
);
assert!(
json.contains("\"pkaBase\":null"),
"benzene pkaBase should be null"
);
}