1#![forbid(unsafe_code)]
9
10pub mod coords;
11pub mod dg;
12pub mod minimize;
13pub mod pdb;
14pub mod xyz;
15
16pub use coords::{Coords3D, Point3};
17pub use dg::generate_coords;
18pub use minimize::{MinimizeConfig, minimize, minimize_with_config};
19pub use pdb::{PdbAtom, parse_pdb_atoms, pdb_to_molecule, write_pdb};
20pub use xyz::{XyzError, parse_xyz, write_xyz};
21
22#[cfg(test)]
27mod tests {
28 use chematic_core::AtomIdx;
29 use chematic_smiles::parse;
30
31 use crate::{
32 coords::Point3,
33 dg::generate_coords,
34 pdb::{parse_pdb_atoms, pdb_to_molecule, write_pdb},
35 xyz::{XyzError, parse_xyz, write_xyz},
36 };
37
38 #[test]
44 fn test_point3_distance() {
45 let a = Point3::new(3.0, 4.0, 0.0);
46 let b = Point3::zero();
47 let d = a.distance(&b);
48 assert!((d - 5.0).abs() < 1e-10, "expected 5.0, got {d}");
49 }
50
51 #[test]
53 fn test_point3_cross_product() {
54 let x = Point3::new(1.0, 0.0, 0.0);
55 let y = Point3::new(0.0, 1.0, 0.0);
56 let z = x.cross(&y);
57 assert!((z.x - 0.0).abs() < 1e-10);
58 assert!((z.y - 0.0).abs() < 1e-10);
59 assert!((z.z - 1.0).abs() < 1e-10);
60 }
61
62 #[test]
68 fn test_single_atom_at_origin() {
69 let mol = parse("O").expect("oxygen SMILES");
70 let coords = generate_coords(&mol);
71 assert_eq!(coords.atom_count(), 1);
72 let p = coords.get(AtomIdx(0));
73 assert!((p.x).abs() < 1e-10 && (p.y).abs() < 1e-10 && (p.z).abs() < 1e-10);
74 }
75
76 #[test]
78 fn test_ethane_bond_length() {
79 let mol = parse("CC").expect("ethane SMILES");
80 let coords = generate_coords(&mol);
81 assert_eq!(coords.atom_count(), 2);
82 let p0 = coords.get(AtomIdx(0));
83 let p1 = coords.get(AtomIdx(1));
84 let d = p0.distance(&p1);
85 assert!(
86 (d - 1.54).abs() < 0.1,
87 "ethane C-C distance expected ~1.54, got {d}"
88 );
89 }
90
91 #[test]
93 fn test_propane_distinct_atoms() {
94 let mol = parse("CCC").expect("propane SMILES");
95 let coords = generate_coords(&mol);
96 assert_eq!(coords.atom_count(), 3);
97 let positions: Vec<_> = (0..3).map(|i| coords.get(AtomIdx(i))).collect();
98 for i in 0..3 {
99 for j in (i + 1)..3 {
100 let d = positions[i].distance(&positions[j]);
101 assert!(d > 0.1, "atoms {i} and {j} are too close (d={d:.4})");
102 }
103 }
104 }
105
106 #[test]
108 fn test_benzene_ring() {
109 let mol = parse("c1ccccc1").expect("benzene SMILES");
110 let coords = generate_coords(&mol);
111 assert_eq!(coords.atom_count(), 6);
112
113 let cx = (0..6).map(|i| coords.get(AtomIdx(i)).x).sum::<f64>() / 6.0;
115 let cy = (0..6).map(|i| coords.get(AtomIdx(i)).y).sum::<f64>() / 6.0;
116 let cz = (0..6).map(|i| coords.get(AtomIdx(i)).z).sum::<f64>() / 6.0;
117 let centroid = Point3::new(cx, cy, cz);
118
119 for i in 0..6 {
120 let p = coords.get(AtomIdx(i));
121 let d = p.distance(¢roid);
122 assert!(
123 d < 2.0,
124 "benzene atom {i} is {d:.3} Å from centroid, expected < 2.0"
125 );
126 }
127 }
128
129 #[test]
131 fn test_water_single_atom() {
132 let mol = parse("O").expect("water SMILES");
133 assert_eq!(mol.atom_count(), 1, "water has 1 heavy atom");
134 let coords = generate_coords(&mol);
135 assert_eq!(coords.atom_count(), 1);
136 let p = coords.get(AtomIdx(0));
137 assert!((p.x).abs() < 1e-10 && (p.y).abs() < 1e-10 && (p.z).abs() < 1e-10);
138 }
139
140 #[test]
142 fn test_disconnected_four_atoms() {
143 let mol = parse("CC.CC").expect("disconnected ethane SMILES");
144 assert_eq!(mol.atom_count(), 4);
145 let coords = generate_coords(&mol);
146 assert_eq!(coords.atom_count(), 4);
147
148 let positions: Vec<_> = (0..4).map(|i| coords.get(AtomIdx(i))).collect();
150 for i in 0..4 {
151 for j in (i + 1)..4 {
152 let d = positions[i].distance(&positions[j]);
153 assert!(d > 0.1, "atoms {i} and {j} overlap (d={d:.4})");
154 }
155 }
156 }
157
158 #[test]
164 fn test_xyz_roundtrip_methane() {
165 let mol = parse("C").expect("methane SMILES");
166 let coords = generate_coords(&mol);
167 let xyz_str = write_xyz(&mol, &coords, "methane");
168
169 let (mol2, coords2) = parse_xyz(&xyz_str).expect("roundtrip parse");
170 assert_eq!(mol2.atom_count(), 1);
171 let p = coords2.get(AtomIdx(0));
172 assert!((p.x).abs() < 1e-6 && (p.y).abs() < 1e-6 && (p.z).abs() < 1e-6);
173 }
174
175 #[test]
177 fn test_xyz_ethane_roundtrip_distance() {
178 let mol = parse("CC").expect("ethane SMILES");
179 let coords = generate_coords(&mol);
180 let orig_dist = coords.get(AtomIdx(0)).distance(&coords.get(AtomIdx(1)));
181
182 let xyz_str = write_xyz(&mol, &coords, "ethane");
183 let (mol2, coords2) = parse_xyz(&xyz_str).expect("roundtrip parse");
184 assert_eq!(mol2.atom_count(), 2);
185
186 let parsed_dist = coords2.get(AtomIdx(0)).distance(&coords2.get(AtomIdx(1)));
187 assert!(
188 (parsed_dist - orig_dist).abs() < 0.01,
189 "distance changed: orig={orig_dist:.6}, parsed={parsed_dist:.6}"
190 );
191 }
192
193 #[test]
195 fn test_xyz_invalid_atom_count() {
196 let bad = "not_a_number\ncomment\n";
197 let result = parse_xyz(bad);
198 assert!(
199 matches!(result, Err(XyzError::InvalidAtomCount)),
200 "expected InvalidAtomCount error, got {:?}",
201 result.err()
202 );
203 }
204
205 #[test]
207 fn test_xyz_first_line_is_count() {
208 let mol = parse("CCC").expect("propane SMILES");
209 let coords = generate_coords(&mol);
210 let xyz_str = write_xyz(&mol, &coords, "propane");
211 let first_line = xyz_str.lines().next().unwrap();
212 assert_eq!(first_line.trim(), "3");
213 }
214
215 #[test]
221 fn test_pdb_parse_minimal_hetatm() {
222 let pdb_line = "HETATM 1 C LIG A 1 1.000 2.000 3.000 1.00 0.00 C\n";
224 let atoms = parse_pdb_atoms(pdb_line);
225 assert_eq!(atoms.len(), 1);
226 let a = &atoms[0];
227 assert_eq!(a.serial, 1);
228 assert!((a.x - 1.0).abs() < 1e-3, "x={}", a.x);
229 assert!((a.y - 2.0).abs() < 1e-3, "y={}", a.y);
230 assert!((a.z - 3.0).abs() < 1e-3, "z={}", a.z);
231 assert_eq!(a.element.trim(), "C");
232 }
233
234 #[test]
236 fn test_pdb_write_parse_roundtrip() {
237 let mol = parse("CCO").expect("ethanol SMILES");
238 let coords = generate_coords(&mol);
239
240 let pdb_str = write_pdb(&mol, &coords);
241 let parsed = parse_pdb_atoms(&pdb_str);
242
243 assert_eq!(parsed.len(), mol.atom_count());
244
245 for i in 0..mol.atom_count() {
247 let orig = coords.get(AtomIdx(i as u32));
248 let p = &parsed[i];
249 assert!(
250 (p.x - orig.x).abs() < 0.001,
251 "atom {i} x mismatch: orig={:.3} parsed={:.3}",
252 orig.x,
253 p.x
254 );
255 assert!(
256 (p.y - orig.y).abs() < 0.001,
257 "atom {i} y mismatch: orig={:.3} parsed={:.3}",
258 orig.y,
259 p.y
260 );
261 assert!(
262 (p.z - orig.z).abs() < 0.001,
263 "atom {i} z mismatch: orig={:.3} parsed={:.3}",
264 orig.z,
265 p.z
266 );
267 }
268 }
269
270 #[test]
272 fn test_pdb_to_molecule_bonding() {
273 let pdb = "HETATM 1 C LIG A 1 0.000 0.000 0.000 1.00 0.00 C\n\
274 HETATM 2 C LIG A 1 1.540 0.000 0.000 1.00 0.00 C\n\
275 END\n";
276 let atoms = parse_pdb_atoms(pdb);
277 let (mol, _coords) = pdb_to_molecule(&atoms);
278 assert_eq!(mol.atom_count(), 2);
279 assert_eq!(mol.bond_count(), 1);
280 }
281}