use std::process::Command;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_renkin")
}
fn run(args: &[&str]) -> serde_json::Value {
let out = Command::new(bin())
.args(args)
.output()
.expect("failed to spawn renkin");
assert!(
out.status.success(),
"renkin exited non-zero: {}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON")
}
const ASPIRIN: &str = "CC(=O)Oc1ccccc1C(=O)O";
const BUILDING_BLOCK: &str = "CC(=O)O";
#[test]
fn default_output_omits_search_diagnostics_when_route_found() {
let v = run(&[
"--target",
BUILDING_BLOCK,
"--depth",
"2",
"--max-routes",
"1",
]);
assert!(
v.get("search_diagnostics").is_none(),
"search_diagnostics must be absent by default: {v}"
);
}
#[test]
fn default_output_omits_search_diagnostics_when_no_route_found() {
let v = run(&[
"--target",
ASPIRIN,
"--depth",
"1",
"--max-routes",
"1",
"--building-blocks",
"/dev/null",
]);
assert_eq!(v["routes_found"], 0);
assert!(
v.get("search_diagnostics").is_none(),
"search_diagnostics must be absent by default even on the diagnostics branch: {v}"
);
assert!(v.get("diagnostics").is_some());
}
#[test]
fn search_diagnostics_flag_adds_block_when_route_found() {
let v = run(&[
"--target",
BUILDING_BLOCK,
"--depth",
"2",
"--max-routes",
"1",
"--search-diagnostics",
]);
let sd = v
.get("search_diagnostics")
.expect("search_diagnostics must be present with the flag");
for key in [
"beam_prune_invocations",
"candidates_evicted_total",
"rules_attempted_total",
"cross_template_duplicate_precursor_signatures",
"stock_terminal_candidates",
"non_stock_candidates",
"branching_by_depth",
] {
assert!(sd.get(key).is_some(), "missing field {key} in {sd}");
}
}
#[test]
fn search_diagnostics_flag_adds_block_when_no_route_found() {
let v = run(&[
"--target",
ASPIRIN,
"--depth",
"1",
"--max-routes",
"1",
"--building-blocks",
"/dev/null",
"--search-diagnostics",
]);
assert_eq!(v["routes_found"], 0);
assert!(v.get("search_diagnostics").is_some());
assert!(v["diagnostics"].get("nodes_expanded").is_some());
}
#[test]
fn candidate_trace_absent_without_the_flag() {
let v = run(&[
"--target",
BUILDING_BLOCK,
"--depth",
"2",
"--max-routes",
"1",
"--search-diagnostics",
]);
let sd = v.get("search_diagnostics").unwrap();
let trace = sd
.get("candidate_trace")
.expect("candidate_trace key must still be present (empty array)");
assert_eq!(
trace.as_array().unwrap().len(),
0,
"no records collected without --candidate-trace-limit"
);
for key in [
"candidates_generated_before_dedup",
"candidates_after_same_template_dedup",
"candidates_after_cross_template_dedup",
] {
assert!(sd.get(key).is_some(), "missing aggregate field {key}");
}
}
#[test]
fn candidate_trace_limit_implies_search_diagnostics_and_bounds_record_count() {
let v = run(&[
"--target",
ASPIRIN,
"--depth",
"2",
"--max-routes",
"1",
"--candidate-trace-limit",
"2",
]);
let sd = v
.get("search_diagnostics")
.expect("--candidate-trace-limit must imply --search-diagnostics");
let trace = sd["candidate_trace"].as_array().unwrap();
assert!(
trace.len() <= 2,
"cap must never be exceeded, got {}",
trace.len()
);
assert!(
!trace.is_empty(),
"aspirin's search must generate candidates"
);
for record in trace {
for key in [
"depth",
"parent_smiles",
"template_id",
"rule_name",
"provenance",
"precursor_signature",
"f_score",
"survived_beam",
"later_reached_stock",
] {
assert!(record.get(key).is_some(), "missing field {key} in {record}");
}
}
}
#[test]
fn candidate_trace_limit_missing_value_is_hard_error() {
let out = std::process::Command::new(bin())
.args(["--target", ASPIRIN, "--candidate-trace-limit"])
.output()
.expect("failed to spawn renkin");
assert!(!out.status.success());
assert!(
String::from_utf8_lossy(&out.stderr).contains("--candidate-trace-limit"),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn write_extracted_824_templates_file() -> std::path::PathBuf {
let test_name = std::thread::current()
.name()
.unwrap_or("unknown")
.replace("::", "_");
let path = std::env::temp_dir().join(format!(
"renkin_spectator_bond_cli_test_{}_{test_name}.smi",
std::process::id()
));
std::fs::write(
&path,
"[C:5]-[O:6]-[C:3](=[O:4])-[NH:2]-[C:1]>>[C:1]-[N:2]=[C:3]=[O:4].[C:5]-[OH:6]\t824\n",
)
.unwrap();
path
}
const OXAZOLIDINONE_TARGET: &str = "O=C2NCC(O2)Cc1ccccc1";
#[test]
fn spectator_bond_policy_defaults_to_off() {
let templates = write_extracted_824_templates_file();
let v = run(&[
"--target",
OXAZOLIDINONE_TARGET,
"--depth",
"1",
"--templates",
templates.to_str().unwrap(),
"--search-diagnostics",
]);
std::fs::remove_file(&templates).ok();
let sd = v.get("search_diagnostics").expect("flag was passed");
assert_eq!(
sd["spectator_bond_loss_findings"].as_array().unwrap().len(),
0,
"policy Off must never run the detectors, even against a rule/target pair that would \
flag if enabled: {sd}"
);
assert_eq!(sd["spectator_bond_gated_out"].as_array().unwrap().len(), 0);
}
#[test]
fn spectator_bond_policy_diagnostics_only_finds_but_never_excludes() {
let templates = write_extracted_824_templates_file();
let v = run(&[
"--target",
OXAZOLIDINONE_TARGET,
"--depth",
"1",
"--templates",
templates.to_str().unwrap(),
"--spectator-bond-policy",
"diagnostics-only",
"--search-diagnostics",
]);
std::fs::remove_file(&templates).ok();
let sd = v.get("search_diagnostics").expect("flag was passed");
let findings = sd["spectator_bond_loss_findings"].as_array().unwrap();
assert_eq!(
findings.len(),
1,
"the real extracted_824 defect must be detected through the CLI's own wiring, not just \
library internals: {sd}"
);
assert_eq!(findings[0]["case"], "matched_pair_undeclared");
assert_eq!(
sd["spectator_bond_gated_out"].as_array().unwrap().len(),
0,
"diagnostics-only must never exclude a candidate"
);
}
#[test]
fn spectator_bond_policy_gated_excludes_the_known_defect() {
let templates = write_extracted_824_templates_file();
let v = run(&[
"--target",
OXAZOLIDINONE_TARGET,
"--depth",
"1",
"--templates",
templates.to_str().unwrap(),
"--spectator-bond-policy",
"gated",
"--search-diagnostics",
]);
std::fs::remove_file(&templates).ok();
let sd = v.get("search_diagnostics").expect("flag was passed");
assert_eq!(
sd["spectator_bond_loss_findings"].as_array().unwrap().len(),
1
);
let gated_out = sd["spectator_bond_gated_out"].as_array().unwrap();
assert_eq!(
gated_out.len(),
1,
"the known-defective candidate must be excluded under Gated: {sd}"
);
assert!(!gated_out[0]["findings"].as_array().unwrap().is_empty());
assert!(
!gated_out[0]["precursor_smiles"]
.as_array()
.unwrap()
.is_empty()
);
}
#[test]
fn spectator_bond_policy_invalid_value_is_hard_error() {
let out = std::process::Command::new(bin())
.args(["--target", "CCO", "--spectator-bond-policy", "bogus"])
.output()
.expect("failed to spawn renkin");
assert!(!out.status.success());
assert!(
String::from_utf8_lossy(&out.stderr).contains("--spectator-bond-policy"),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn spectator_bond_policy_missing_value_is_hard_error() {
let out = std::process::Command::new(bin())
.args(["--target", "CCO", "--spectator-bond-policy"])
.output()
.expect("failed to spawn renkin");
assert!(!out.status.success());
assert!(
String::from_utf8_lossy(&out.stderr).contains("--spectator-bond-policy"),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}