1#![forbid(unsafe_code)]
9
10pub mod conformer;
11pub mod coords;
12pub mod dg;
13pub mod minimize;
14pub mod pdb;
15pub mod shape_descriptors;
16pub mod stereo3d;
17pub mod xyz;
18
19pub use conformer::{ConformerEnsemble, ConformerError};
20pub use coords::{Coords3D, Point3};
21pub use dg::generate_coords;
22pub use minimize::{MinimizeConfig, minimize, minimize_with_config};
23pub use pdb::{PdbAtom, parse_pdb_atoms, pdb_to_molecule, write_pdb};
24pub use shape_descriptors::{
25 asphericity, eccentricity, npr1, npr2, plane_of_best_fit,
26 pmi, pmi1, pmi2, pmi3, radius_of_gyration,
27};
28pub use stereo3d::{StereoAssignment3D, assign_stereo_from_3d};
29pub use xyz::{XyzError, parse_xyz, write_xyz};
30
31#[cfg(test)]
36mod tests {
37 use chematic_core::AtomIdx;
38 use chematic_smiles::parse;
39
40 use crate::{
41 coords::Point3,
42 dg::generate_coords,
43 pdb::{parse_pdb_atoms, pdb_to_molecule, write_pdb},
44 xyz::{XyzError, parse_xyz, write_xyz},
45 };
46
47 #[test]
53 fn test_point3_distance() {
54 let a = Point3::new(3.0, 4.0, 0.0);
55 let b = Point3::zero();
56 let d = a.distance(&b);
57 assert!((d - 5.0).abs() < 1e-10, "expected 5.0, got {d}");
58 }
59
60 #[test]
62 fn test_point3_cross_product() {
63 let x = Point3::new(1.0, 0.0, 0.0);
64 let y = Point3::new(0.0, 1.0, 0.0);
65 let z = x.cross(&y);
66 assert!((z.x - 0.0).abs() < 1e-10);
67 assert!((z.y - 0.0).abs() < 1e-10);
68 assert!((z.z - 1.0).abs() < 1e-10);
69 }
70
71 #[test]
77 fn test_single_atom_at_origin() {
78 let mol = parse("O").expect("oxygen SMILES");
79 let coords = generate_coords(&mol);
80 assert_eq!(coords.atom_count(), 1);
81 let p = coords.get(AtomIdx(0));
82 assert!((p.x).abs() < 1e-10 && (p.y).abs() < 1e-10 && (p.z).abs() < 1e-10);
83 }
84
85 #[test]
87 fn test_ethane_bond_length() {
88 let mol = parse("CC").expect("ethane SMILES");
89 let coords = generate_coords(&mol);
90 assert_eq!(coords.atom_count(), 2);
91 let p0 = coords.get(AtomIdx(0));
92 let p1 = coords.get(AtomIdx(1));
93 let d = p0.distance(&p1);
94 assert!(
95 (d - 1.54).abs() < 0.1,
96 "ethane C-C distance expected ~1.54, got {d}"
97 );
98 }
99
100 #[test]
102 fn test_propane_distinct_atoms() {
103 let mol = parse("CCC").expect("propane SMILES");
104 let coords = generate_coords(&mol);
105 assert_eq!(coords.atom_count(), 3);
106 let positions: Vec<_> = (0..3).map(|i| coords.get(AtomIdx(i))).collect();
107 for i in 0..3 {
108 for j in (i + 1)..3 {
109 let d = positions[i].distance(&positions[j]);
110 assert!(d > 0.1, "atoms {i} and {j} are too close (d={d:.4})");
111 }
112 }
113 }
114
115 #[test]
117 fn test_benzene_ring() {
118 let mol = parse("c1ccccc1").expect("benzene SMILES");
119 let coords = generate_coords(&mol);
120 assert_eq!(coords.atom_count(), 6);
121
122 let cx = (0..6).map(|i| coords.get(AtomIdx(i)).x).sum::<f64>() / 6.0;
124 let cy = (0..6).map(|i| coords.get(AtomIdx(i)).y).sum::<f64>() / 6.0;
125 let cz = (0..6).map(|i| coords.get(AtomIdx(i)).z).sum::<f64>() / 6.0;
126 let centroid = Point3::new(cx, cy, cz);
127
128 for i in 0..6 {
129 let p = coords.get(AtomIdx(i));
130 let d = p.distance(¢roid);
131 assert!(
132 d < 2.0,
133 "benzene atom {i} is {d:.3} Å from centroid, expected < 2.0"
134 );
135 }
136 }
137
138 #[test]
140 fn test_water_single_atom() {
141 let mol = parse("O").expect("water SMILES");
142 assert_eq!(mol.atom_count(), 1, "water has 1 heavy atom");
143 let coords = generate_coords(&mol);
144 assert_eq!(coords.atom_count(), 1);
145 let p = coords.get(AtomIdx(0));
146 assert!((p.x).abs() < 1e-10 && (p.y).abs() < 1e-10 && (p.z).abs() < 1e-10);
147 }
148
149 #[test]
151 fn test_disconnected_four_atoms() {
152 let mol = parse("CC.CC").expect("disconnected ethane SMILES");
153 assert_eq!(mol.atom_count(), 4);
154 let coords = generate_coords(&mol);
155 assert_eq!(coords.atom_count(), 4);
156
157 let positions: Vec<_> = (0..4).map(|i| coords.get(AtomIdx(i))).collect();
159 for i in 0..4 {
160 for j in (i + 1)..4 {
161 let d = positions[i].distance(&positions[j]);
162 assert!(d > 0.1, "atoms {i} and {j} overlap (d={d:.4})");
163 }
164 }
165 }
166
167 #[test]
173 fn test_xyz_roundtrip_methane() {
174 let mol = parse("C").expect("methane SMILES");
175 let coords = generate_coords(&mol);
176 let xyz_str = write_xyz(&mol, &coords, "methane");
177
178 let (mol2, coords2) = parse_xyz(&xyz_str).expect("roundtrip parse");
179 assert_eq!(mol2.atom_count(), 1);
180 let p = coords2.get(AtomIdx(0));
181 assert!((p.x).abs() < 1e-6 && (p.y).abs() < 1e-6 && (p.z).abs() < 1e-6);
182 }
183
184 #[test]
186 fn test_xyz_ethane_roundtrip_distance() {
187 let mol = parse("CC").expect("ethane SMILES");
188 let coords = generate_coords(&mol);
189 let orig_dist = coords.get(AtomIdx(0)).distance(&coords.get(AtomIdx(1)));
190
191 let xyz_str = write_xyz(&mol, &coords, "ethane");
192 let (mol2, coords2) = parse_xyz(&xyz_str).expect("roundtrip parse");
193 assert_eq!(mol2.atom_count(), 2);
194
195 let parsed_dist = coords2.get(AtomIdx(0)).distance(&coords2.get(AtomIdx(1)));
196 assert!(
197 (parsed_dist - orig_dist).abs() < 0.01,
198 "distance changed: orig={orig_dist:.6}, parsed={parsed_dist:.6}"
199 );
200 }
201
202 #[test]
204 fn test_xyz_invalid_atom_count() {
205 let bad = "not_a_number\ncomment\n";
206 let result = parse_xyz(bad);
207 assert!(
208 matches!(result, Err(XyzError::InvalidAtomCount)),
209 "expected InvalidAtomCount error, got {:?}",
210 result.err()
211 );
212 }
213
214 #[test]
216 fn test_xyz_first_line_is_count() {
217 let mol = parse("CCC").expect("propane SMILES");
218 let coords = generate_coords(&mol);
219 let xyz_str = write_xyz(&mol, &coords, "propane");
220 let first_line = xyz_str.lines().next().unwrap();
221 assert_eq!(first_line.trim(), "3");
222 }
223
224 #[test]
230 fn test_pdb_parse_minimal_hetatm() {
231 let pdb_line = "HETATM 1 C LIG A 1 1.000 2.000 3.000 1.00 0.00 C\n";
233 let atoms = parse_pdb_atoms(pdb_line);
234 assert_eq!(atoms.len(), 1);
235 let a = &atoms[0];
236 assert_eq!(a.serial, 1);
237 assert!((a.x - 1.0).abs() < 1e-3, "x={}", a.x);
238 assert!((a.y - 2.0).abs() < 1e-3, "y={}", a.y);
239 assert!((a.z - 3.0).abs() < 1e-3, "z={}", a.z);
240 assert_eq!(a.element.trim(), "C");
241 }
242
243 #[test]
245 fn test_pdb_write_parse_roundtrip() {
246 let mol = parse("CCO").expect("ethanol SMILES");
247 let coords = generate_coords(&mol);
248
249 let pdb_str = write_pdb(&mol, &coords);
250 let parsed = parse_pdb_atoms(&pdb_str);
251
252 assert_eq!(parsed.len(), mol.atom_count());
253
254 for i in 0..mol.atom_count() {
256 let orig = coords.get(AtomIdx(i as u32));
257 let p = &parsed[i];
258 assert!(
259 (p.x - orig.x).abs() < 0.001,
260 "atom {i} x mismatch: orig={:.3} parsed={:.3}",
261 orig.x,
262 p.x
263 );
264 assert!(
265 (p.y - orig.y).abs() < 0.001,
266 "atom {i} y mismatch: orig={:.3} parsed={:.3}",
267 orig.y,
268 p.y
269 );
270 assert!(
271 (p.z - orig.z).abs() < 0.001,
272 "atom {i} z mismatch: orig={:.3} parsed={:.3}",
273 orig.z,
274 p.z
275 );
276 }
277 }
278
279 #[test]
281 fn test_pdb_to_molecule_bonding() {
282 let pdb = "HETATM 1 C LIG A 1 0.000 0.000 0.000 1.00 0.00 C\n\
283 HETATM 2 C LIG A 1 1.540 0.000 0.000 1.00 0.00 C\n\
284 END\n";
285 let atoms = parse_pdb_atoms(pdb);
286 let (mol, _coords) = pdb_to_molecule(&atoms);
287 assert_eq!(mol.atom_count(), 2);
288 assert_eq!(mol.bond_count(), 1);
289 }
290}