use serde::Deserialize;
pub use super::mutalyzer::{
AcceptedDivergence, Axis, Improvement, KnownBug, Policy, SpecCitation, SpecSection,
};
use super::schema::{validate_cluster_refs, Cluster};
use super::summary::{DispositionKind, MemberRow, SummaryModel};
pub const CORPUS_TITLE: &str = "hgvs-rs-projection";
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct Fixture {
pub description: String,
pub source: String,
pub source_commit: String,
pub license: String,
pub refreshed_at: String,
#[serde(default)]
pub clusters: Vec<Cluster>,
pub cases: Vec<Case>,
}
impl Fixture {
pub fn cluster_refs(&self) -> Vec<(&str, &str)> {
let mut refs = Vec::new();
for case in &self.cases {
let input = case.input.as_str();
for cluster in [
case.accepted_divergence
.as_ref()
.and_then(|d| d.cluster.as_deref()),
case.known_bug.as_ref().and_then(|d| d.cluster.as_deref()),
case.improvement.as_ref().and_then(|d| d.cluster.as_deref()),
case.spec_citation
.as_ref()
.and_then(|d| d.cluster.as_deref()),
]
.into_iter()
.flatten()
{
refs.push((input, cluster));
}
}
refs
}
pub fn validate_clusters(&self) -> Result<(), String> {
validate_cluster_refs(&self.clusters, self.cluster_refs())
}
pub fn to_summary(&self) -> SummaryModel {
let mut rows = Vec::new();
for case in &self.cases {
let input = case.input.as_str();
if let Some(d) = &case.accepted_divergence {
rows.push(MemberRow {
cluster: d.cluster.clone(),
input: input.to_string(),
axis: d.axis.as_str().to_string(),
kind: DispositionKind::AcceptedDivergence,
ferro_output: None,
tracking_issue: None,
});
}
if let Some(d) = &case.known_bug {
rows.push(MemberRow {
cluster: d.cluster.clone(),
input: input.to_string(),
axis: d.axis.as_str().to_string(),
kind: DispositionKind::KnownBug,
ferro_output: None,
tracking_issue: Some(d.tracking_issue),
});
}
if let Some(d) = &case.improvement {
rows.push(MemberRow {
cluster: d.cluster.clone(),
input: input.to_string(),
axis: d.axis.as_str().to_string(),
kind: DispositionKind::Improvement,
ferro_output: None,
tracking_issue: Some(d.tracking_issue),
});
}
if let Some(d) = &case.spec_citation {
rows.push(MemberRow {
cluster: d.cluster.clone(),
input: input.to_string(),
axis: d.axis.as_str().to_string(),
kind: DispositionKind::SpecCitation,
ferro_output: None,
tracking_issue: None,
});
}
}
SummaryModel {
title: CORPUS_TITLE.to_string(),
clusters: self.clusters.clone(),
rows,
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct Case {
#[serde(default)]
pub keywords: Vec<String>,
pub input: String,
#[serde(default)]
pub coding: Option<String>,
#[serde(default)]
pub protein_description: Option<String>,
#[serde(default)]
pub coding_protein_descriptions: Option<Vec<Vec<String>>>,
#[serde(default)]
pub noncoding: Option<Vec<String>>,
#[serde(default = "default_true")]
pub to_test: bool,
#[serde(default)]
pub accepted_divergence: Option<AcceptedDivergence>,
#[serde(default)]
pub known_bug: Option<KnownBug>,
#[serde(default)]
pub improvement: Option<Improvement>,
#[serde(default)]
pub spec_citation: Option<SpecCitation>,
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
const BASE: &str =
r#""description":"t","source":"t","source_commit":"t","license":"t","refreshed_at":"t""#;
fn parse(clusters: &str, cases: &str) -> Fixture {
let json = format!("{{{BASE},\"clusters\":[{clusters}],\"cases\":[{cases}]}}");
serde_json::from_str(&json).expect("fixture should deserialize")
}
#[test]
fn case_parses_each_projection_shape() {
let cases = r#"
{"input":"NC_000002.11:g.96780553C>T",
"coding_protein_descriptions":[["NM_000682.5:c.1345G>A","NP_000673.2:p.Ala449Thr"]]},
{"input":"NC_000002.11:g.96778665_96778666insA",
"coding":"NM_000682.5:c.*1879_*1880insT"},
{"input":"NM_000051.3:c.9170_9171delGA",
"protein_description":"NP_000042.3:p.Ter3057Pheext*4"},
{"input":"NC_000001.10:g.12776161G>A",
"noncoding":["NR_111984.1:n.44G>A"]},
{"input":"x","to_test":false}
"#;
let fixture = parse("", cases);
assert_eq!(fixture.cases.len(), 5);
assert!(fixture.cases[0].coding_protein_descriptions.is_some());
assert_eq!(
fixture.cases[1].coding.as_deref(),
Some("NM_000682.5:c.*1879_*1880insT")
);
assert_eq!(
fixture.cases[2].protein_description.as_deref(),
Some("NP_000042.3:p.Ter3057Pheext*4")
);
assert!(fixture.cases[3].noncoding.is_some());
assert!(fixture.cases[0].to_test);
assert!(!fixture.cases[4].to_test);
}
#[test]
fn coding_axis_disposition_roundtrips() {
let cases = r#"
{"input":"g","coding":"c",
"accepted_divergence":{"axis":"coding",
"policy":"ferro-policy-121-gene-symbol-selector","cluster":"sel"}}
"#;
let clusters = r#"{"id":"sel","title":"selector","spec_section":"refseq.md"}"#;
let fixture = parse(clusters, cases);
let ad = fixture.cases[0]
.accepted_divergence
.as_ref()
.expect("accepted_divergence present");
assert_eq!(ad.axis, Axis::Coding);
assert_eq!(ad.axis.as_str(), "coding");
assert!(fixture.validate_clusters().is_ok());
let summary = fixture.to_summary();
assert_eq!(summary.title, "hgvs-rs-projection");
assert_eq!(summary.rows.len(), 1);
assert_eq!(summary.rows[0].axis, "coding");
}
#[test]
fn dangling_disposition_cluster_ref_is_rejected() {
let cases = r#"
{"input":"g","coding":"c",
"spec_citation":{"axis":"coding",
"section":"HGVS protein reference (bare NP)","cluster":"missing"}}
"#;
let err = parse("", cases)
.validate_clusters()
.expect_err("a dangling disposition cluster ref must be rejected");
assert!(err.contains("missing"), "{err}");
}
#[test]
fn loads_cases_json() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/hgvs-rs-projection/cases.json"
);
let content = fs::read_to_string(path).expect("read cases.json");
let fixture: Fixture = serde_json::from_str(&content).expect("parse cases.json");
assert!(!fixture.cases.is_empty(), "cases.json should have cases");
assert!(
fixture.cases.iter().any(|c| c.to_test),
"cases.json should have at least one to_test case"
);
assert_eq!(
fixture.source_commit, "cf0e5a8d1b9a2ae3e883a58826f397aa6cb52e23",
"committed cases.json must be pinned to the vendored hgvs-rs SHA"
);
}
}