use pdbrust::{parse_pdb_reader, parse_pdb_string};
use proptest::prelude::*;
const ATOM: &str =
"ATOM 1 N MET A 1 27.340 24.430 2.614 1.00 9.67 N ";
#[test]
fn short_header_and_title_lines_do_not_abort_parsing() {
let structure = parse_pdb_string(&format!("HEADER\nTITLE\n{ATOM}\n")).unwrap();
assert_eq!(structure.atoms.len(), 1);
}
#[test]
fn title_continuation_lines_are_joined() {
let input = format!(
"TITLE CRYSTAL STRUCTURE OF HUMAN UBIQUITIN\n\
TITLE 2 AT 1.8 ANGSTROM RESOLUTION\n{ATOM}\n"
);
let structure = parse_pdb_string(&input).unwrap();
assert_eq!(
structure.title.as_deref(),
Some("CRYSTAL STRUCTURE OF HUMAN UBIQUITIN AT 1.8 ANGSTROM RESOLUTION")
);
}
#[test]
fn unnumbered_remarks_are_kept_as_free_text() {
let input = format!("REMARK GENERATED BY TRJCONV\nREMARK This is free text\n{ATOM}\n");
let structure = parse_pdb_string(&input).unwrap();
let remarks: Vec<(i32, &str)> = structure
.remarks
.iter()
.map(|r| (r.number, r.content.as_str()))
.collect();
assert_eq!(
remarks,
vec![(0, "GENERATED BY TRJCONV"), (0, "This is free text")]
);
assert_eq!(structure.atoms.len(), 1);
}
#[test]
fn numbered_remarks_keep_number_and_text() {
let structure = parse_pdb_string("REMARK 2 RESOLUTION. 2.00 ANGSTROMS.\n").unwrap();
assert_eq!(structure.remarks[0].number, 2);
assert_eq!(
structure.remarks[0].content,
"RESOLUTION. 2.00 ANGSTROMS."
);
}
#[test]
fn multibyte_characters_do_not_panic_or_shift_columns() {
let structure = parse_pdb_string("HEADER ÅBC\n").unwrap();
assert_eq!(structure.header.as_deref(), Some("BC"));
let atom_line =
"ATOM 1 Cα GLY A 1 27.340 24.430 2.614 1.00 9.67 C ";
let atom = &parse_pdb_string(atom_line).unwrap().atoms[0];
assert_eq!(
(
atom.name.as_str(),
atom.residue_name.as_str(),
atom.residue_seq
),
("Cα", "GLY", 1)
);
assert_eq!((atom.x, atom.y, atom.z), (27.340, 24.430, 2.614));
}
#[test]
fn invalid_utf8_bytes_do_not_fail_the_parse() {
let mut bytes = b"REMARK 3 B-FACTORS IN \xC5**2\n".to_vec();
bytes.extend_from_slice(ATOM.as_bytes());
let structure = parse_pdb_reader(&bytes[..]).unwrap();
assert_eq!(structure.remarks[0].number, 3);
assert_eq!(structure.atoms.len(), 1);
}
#[test]
fn blank_occupancy_and_b_factor_use_defaults() {
let line = "ATOM 1 N MET A 1 27.340 24.430 2.614 N ";
let atom = &parse_pdb_string(line).unwrap().atoms[0];
assert_eq!((atom.occupancy, atom.temp_factor), (1.0, 0.0));
assert_eq!(atom.element, "N");
}
#[test]
fn hybrid36_serial_and_residue_numbers_are_decoded() {
let line = "ATOM A0000 CA GLY AA000 1.000 2.000 3.000 1.00 0.00 C ";
let atom = &parse_pdb_string(line).unwrap().atoms[0];
assert_eq!((atom.serial, atom.residue_seq), (100_000, 10_000));
assert_eq!(atom.chain_id, "A");
}
#[test]
fn model_numbers_are_read_from_short_model_lines() {
let input = format!("MODEL 1\n{ATOM}\nENDMDL\nMODEL 2\n{ATOM}\nENDMDL\n");
let structure = parse_pdb_string(&input).unwrap();
let serials: Vec<i32> = structure.models.iter().map(|m| m.serial).collect();
assert_eq!(serials, vec![1, 2]);
}
#[test]
fn blank_model_numbers_are_numbered_sequentially() {
let input = format!("MODEL \n{ATOM}\nENDMDL\nMODEL \n{ATOM}\nENDMDL\n");
let structure = parse_pdb_string(&input).unwrap();
let serials: Vec<i32> = structure.models.iter().map(|m| m.serial).collect();
assert_eq!(serials, vec![1, 2]);
}
#[test]
fn seqres_serial_numbers_above_99_are_read() {
let structure = parse_pdb_string("SEQRES 100 A 1300 ALA GLY SER\n").unwrap();
let seqres = &structure.seqres[0];
assert_eq!((seqres.serial, seqres.chain_id.as_str()), (100, "A"));
assert_eq!(seqres.residues, vec!["ALA", "GLY", "SER"]);
}
#[test]
fn conect_serials_in_hybrid36_are_decoded() {
let structure = parse_pdb_string("CONECTA0000A0001\n").unwrap();
let conect = &structure.connects[0];
assert_eq!((conect.atom1, conect.atom2), (100_000, 100_001));
}
#[test]
fn conect_record_without_bonded_atoms_is_skipped() {
let structure = parse_pdb_string(&format!("{ATOM}\nCONECT 1\n")).unwrap();
assert!(structure.connects.is_empty());
assert_eq!(structure.atoms.len(), 1);
}
#[test]
fn ssbond_with_blank_symmetry_columns_uses_identity() {
let line = format!("SSBOND 1 CYS A 6 CYS A 11{}", " ".repeat(45));
let structure = parse_pdb_string(&line).unwrap();
let bond = &structure.ssbonds[0];
assert_eq!((bond.sym1, bond.sym2), (1555, 1555));
assert_eq!((bond.residue1_seq, bond.residue2_seq), (6, 11));
}
#[test]
fn short_ssbond_lines_keep_default_symmetry_and_length() {
let line = "SSBOND 1 CYS A 85 CYS A 101 2.03 ";
let structure = parse_pdb_string(line).unwrap();
let bond = &structure.ssbonds[0];
assert_eq!((bond.sym1, bond.sym2), (1555, 1555));
assert_eq!((bond.residue1_seq, bond.residue2_seq), (85, 101));
}
const RECORD_NAMES: &[&str] = &[
"HEADER", "TITLE ", "REMARK", "ATOM ", "HETATM", "MODEL ", "ENDMDL", "SEQRES", "CONECT",
"SSBOND", "TER ", "END ", "HEAD", "",
];
proptest! {
#![proptest_config(ProptestConfig::with_cases(2000))]
#[test]
fn parsing_pdb_like_text_never_panics(
lines in prop::collection::vec(
(prop::sample::select(RECORD_NAMES), "[ -~Åα\u{FFFD}]{0,90}"),
0..8,
)
) {
let text: String = lines
.iter()
.map(|(record, tail)| format!("{record}{tail}\n"))
.collect();
let _ = parse_pdb_string(&text);
}
#[test]
fn parsing_arbitrary_bytes_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..400)) {
let _ = parse_pdb_reader(&bytes[..]);
}
}