use serde::Deserialize;
use super::schema::{validate_cluster_refs, Cluster};
use super::summary::{DispositionKind, MemberRow, SummaryModel};
pub const CORPUS_TITLE: &str = "mutalyzer-normalize";
#[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>,
#[serde(default)]
pub comparator_provenance: Option<ComparatorProvenance>,
pub cases: Vec<Case>,
}
#[derive(Debug, Deserialize, Default)]
#[allow(dead_code)]
pub struct ComparatorProvenance {
pub note: String,
pub validated_at: String,
pub reference_identity: String,
pub mutalyzer: String,
pub mutalyzer_hgvs_parser: String,
#[serde(default)]
pub biocommons_hgvs: String,
#[serde(default)]
pub biocommons_seqrepo: String,
#[serde(default)]
pub hgvs_rs: String,
}
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.improvement.as_ref().and_then(|d| d.cluster.as_deref()),
case.reference_unavailable
.as_ref()
.and_then(|d| d.cluster.as_deref()),
]
.into_iter()
.flatten()
{
refs.push((input, cluster));
}
for cluster in case
.accepted_divergences
.iter()
.filter_map(|d| d.cluster.as_deref())
.chain(case.known_bugs.iter().filter_map(|d| d.cluster.as_deref()))
.chain(
case.accepted_rejections
.iter()
.filter_map(|d| d.cluster.as_deref()),
)
.chain(
case.spec_citations
.iter()
.filter_map(|d| d.cluster.as_deref()),
)
{
refs.push((input, cluster));
}
}
refs
}
pub fn validate_clusters(&self) -> Result<(), String> {
validate_cluster_refs(&self.clusters, self.cluster_refs())?;
self.validate_multi_axis_annotation_uniqueness()
}
fn validate_multi_axis_annotation_uniqueness(&self) -> Result<(), String> {
fn check_unique(
input: &str,
kind: &str,
axes: impl IntoIterator<Item = Axis>,
) -> Result<(), String> {
let mut seen: Vec<Axis> = Vec::new();
for axis in axes {
if seen.contains(&axis) {
return Err(format!(
"case {input:?} has more than one {kind} for axis {:?}; \
the matcher honors only one per axis",
axis.as_str(),
));
}
seen.push(axis);
}
Ok(())
}
for case in &self.cases {
check_unique(
&case.input,
"spec_citation",
case.spec_citations.iter().map(|c| c.axis),
)?;
check_unique(
&case.input,
"accepted_divergence",
case.accepted_divergences.iter().map(|d| d.axis),
)?;
check_unique(
&case.input,
"known_bug",
case.known_bugs.iter().map(|d| d.axis),
)?;
check_unique(
&case.input,
"accepted_rejection",
case.accepted_rejections.iter().map(|d| d.axis),
)?;
}
Ok(())
}
pub fn to_summary(&self) -> SummaryModel {
let mut rows = Vec::new();
for case in &self.cases {
let input = case.input.as_str();
for d in &case.accepted_divergences {
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,
});
}
for d in &case.known_bugs {
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.reference_unavailable {
rows.push(MemberRow {
cluster: d.cluster.clone(),
input: input.to_string(),
axis: d.axis.as_str().to_string(),
kind: DispositionKind::ReferenceUnavailable,
ferro_output: None,
tracking_issue: Some(d.tracking_issue),
});
}
for d in &case.spec_citations {
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,
});
}
for d in &case.accepted_rejections {
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,
});
}
}
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 normalized: Option<String>,
#[serde(default)]
pub genomic: Option<String>,
#[serde(default)]
pub coding_protein_descriptions: Option<Vec<Vec<String>>>,
#[serde(default)]
pub protein_description: Option<String>,
#[serde(default)]
pub rna_description: Option<String>,
#[serde(default)]
pub errors: Option<Vec<String>>,
#[serde(default)]
pub infos: Option<Vec<String>>,
#[serde(default)]
pub noncoding: Option<Vec<String>>,
#[serde(default = "default_true")]
pub to_test: bool,
#[serde(
default,
rename = "accepted_divergence",
deserialize_with = "de_one_or_many"
)]
pub accepted_divergences: Vec<AcceptedDivergence>,
#[serde(default, rename = "known_bug", deserialize_with = "de_one_or_many")]
pub known_bugs: Vec<KnownBug>,
#[serde(default)]
pub improvement: Option<Improvement>,
#[serde(default)]
pub reference_unavailable: Option<ReferenceUnavailable>,
#[serde(default, rename = "spec_citation", deserialize_with = "de_one_or_many")]
pub spec_citations: Vec<SpecCitation>,
#[serde(
default,
rename = "accepted_rejection",
deserialize_with = "de_one_or_many"
)]
pub accepted_rejections: Vec<AcceptedRejection>,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub enum Axis {
Normalized,
Genomic,
Coding,
ProteinDescription,
CodingProteinDescriptions,
RnaDescription,
Noncoding,
Errors,
Infos,
}
impl Axis {
pub fn as_str(self) -> &'static str {
match self {
Axis::Normalized => "normalized",
Axis::Genomic => "genomic",
Axis::Coding => "coding",
Axis::ProteinDescription => "protein_description",
Axis::CodingProteinDescriptions => "coding_protein_descriptions",
Axis::RnaDescription => "rna_description",
Axis::Noncoding => "noncoding",
Axis::Errors => "errors",
Axis::Infos => "infos",
}
}
}
impl std::fmt::Display for Axis {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum Policy {
#[serde(rename = "ferro-policy-121-gene-symbol-selector")]
GeneSymbolSelector121,
#[serde(rename = "ferro-policy-499-shuffle-applied-compound-allele")]
ShuffleAppliedCompoundAllele499,
#[serde(rename = "ferro-policy-whole-cds-del-met1")]
WholeCdsDeletionMet1,
#[serde(rename = "ferro-policy-745-homopolymer-repeat-contraction")]
HomopolymerRepeatContraction745,
#[serde(rename = "ferro-policy-486-ensembl-transcript-supported")]
EnsemblTranscriptSupported486,
#[serde(rename = "ferro-policy-486-parse-time-rejection-taxonomy")]
ParseTimeRejectionTaxonomy486,
#[serde(rename = "ferro-policy-486-selector-existence-not-validated")]
SelectorExistenceNotValidated486,
#[serde(rename = "ferro-policy-486-transcript-coordinate-lenient")]
TranscriptCoordinateLenient486,
#[serde(rename = "ferro-policy-654-positional-insert-literal-expansion")]
PositionalInsertLiteralExpansion654,
#[serde(rename = "ferro-policy-654-transcript-coordinate-preserved")]
TranscriptCoordinatePreserved654,
#[serde(rename = "ferro-policy-654-mutalyzer-no-normalized-form")]
MutalyzerNoNormalizedForm654,
#[serde(rename = "ferro-policy-654-explicit-identity-rendering")]
ExplicitIdentityRendering654,
#[serde(rename = "ferro-policy-763-transcript-set-enumeration")]
TranscriptSetEnumeration763,
#[serde(rename = "ferro-policy-499-shuffle-applied-single-variant")]
ShuffleAppliedSingleVariant499,
#[serde(rename = "ferro-policy-73-cross-ref-cds-resolved-no-enocds")]
CrossRefCdsResolvedNoEnocds,
#[serde(rename = "ferro-policy-911-stop-insertion-not-cterminal-delins")]
StopInsertionNotCterminalDelins911,
#[serde(rename = "ferro-policy-923-bare-np-no-parent-context")]
BareNpNoParentContext923,
#[serde(rename = "ferro-policy-938-ensembl-version-required")]
EnsemblVersionRequired938,
#[serde(rename = "ferro-policy-853-refseq-transcript-sequence-authoritative")]
RefseqTranscriptSequenceAuthoritative853,
#[serde(rename = "ferro-policy-1835-canonical-coalesced-default")]
CanonicalCoalescedDefault1835,
#[serde(rename = "ferro-policy-1616-separation-one-genomic-split")]
SeparationOneGenomicSplit1616,
#[serde(rename = "ferro-policy-1616-re-derived-partition-not-authored")]
ReDerivedPartitionNotAuthored1616,
}
impl Policy {
pub fn as_str(self) -> &'static str {
match self {
Policy::GeneSymbolSelector121 => "ferro-policy-121-gene-symbol-selector",
Policy::SeparationOneGenomicSplit1616 => {
"ferro-policy-1616-separation-one-genomic-split"
}
Policy::ReDerivedPartitionNotAuthored1616 => {
"ferro-policy-1616-re-derived-partition-not-authored"
}
Policy::ShuffleAppliedCompoundAllele499 => {
"ferro-policy-499-shuffle-applied-compound-allele"
}
Policy::TranscriptSetEnumeration763 => "ferro-policy-763-transcript-set-enumeration",
Policy::ShuffleAppliedSingleVariant499 => {
"ferro-policy-499-shuffle-applied-single-variant"
}
Policy::CrossRefCdsResolvedNoEnocds => {
"ferro-policy-73-cross-ref-cds-resolved-no-enocds"
}
Policy::BareNpNoParentContext923 => "ferro-policy-923-bare-np-no-parent-context",
Policy::EnsemblVersionRequired938 => "ferro-policy-938-ensembl-version-required",
Policy::RefseqTranscriptSequenceAuthoritative853 => {
"ferro-policy-853-refseq-transcript-sequence-authoritative"
}
Policy::CanonicalCoalescedDefault1835 => {
"ferro-policy-1835-canonical-coalesced-default"
}
Policy::WholeCdsDeletionMet1 => "ferro-policy-whole-cds-del-met1",
Policy::HomopolymerRepeatContraction745 => {
"ferro-policy-745-homopolymer-repeat-contraction"
}
Policy::EnsemblTranscriptSupported486 => {
"ferro-policy-486-ensembl-transcript-supported"
}
Policy::ParseTimeRejectionTaxonomy486 => {
"ferro-policy-486-parse-time-rejection-taxonomy"
}
Policy::SelectorExistenceNotValidated486 => {
"ferro-policy-486-selector-existence-not-validated"
}
Policy::TranscriptCoordinateLenient486 => {
"ferro-policy-486-transcript-coordinate-lenient"
}
Policy::PositionalInsertLiteralExpansion654 => {
"ferro-policy-654-positional-insert-literal-expansion"
}
Policy::TranscriptCoordinatePreserved654 => {
"ferro-policy-654-transcript-coordinate-preserved"
}
Policy::MutalyzerNoNormalizedForm654 => "ferro-policy-654-mutalyzer-no-normalized-form",
Policy::ExplicitIdentityRendering654 => "ferro-policy-654-explicit-identity-rendering",
Policy::StopInsertionNotCterminalDelins911 => {
"ferro-policy-911-stop-insertion-not-cterminal-delins"
}
}
}
}
impl std::fmt::Display for Policy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum ReferenceUnavailableReason {
#[serde(rename = "accession-version-absent")]
AccessionVersionAbsent,
#[serde(rename = "ensembl-absent-from-refseq-manifest")]
EnsemblAbsentFromRefseqManifest,
}
impl ReferenceUnavailableReason {
pub fn as_str(self) -> &'static str {
match self {
ReferenceUnavailableReason::AccessionVersionAbsent => "accession-version-absent",
ReferenceUnavailableReason::EnsemblAbsentFromRefseqManifest => {
"ensembl-absent-from-refseq-manifest"
}
}
}
}
impl std::fmt::Display for ReferenceUnavailableReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum RejectionReason {
#[serde(rename = "ferro-policy-654-malformed-input-rejected")]
MalformedInputRejected654,
#[serde(rename = "ferro-policy-758-transcript-flank-not-numberable-in-c")]
TranscriptFlankNotNumberableInC758,
#[serde(rename = "ferro-policy-785-version-substitution-refused")]
VersionSubstitutionRefused785,
#[serde(rename = "ferro-policy-858-nonstandard-or-absent-selector-declined")]
NonstandardOrAbsentSelectorDeclined858,
#[serde(rename = "ferro-policy-853-ng-partial-transcript-coverage-declined")]
NgPartialTranscriptCoverageDeclined853,
#[serde(rename = "ferro-policy-857-non-cds-no-projection")]
NonCdsNoProjection857,
}
impl RejectionReason {
pub fn disposition_empty_projection(self) -> bool {
matches!(
self,
RejectionReason::NonCdsNoProjection857
| RejectionReason::NonstandardOrAbsentSelectorDeclined858
)
}
pub fn as_str(self) -> &'static str {
match self {
RejectionReason::MalformedInputRejected654 => {
"ferro-policy-654-malformed-input-rejected"
}
RejectionReason::TranscriptFlankNotNumberableInC758 => {
"ferro-policy-758-transcript-flank-not-numberable-in-c"
}
RejectionReason::VersionSubstitutionRefused785 => {
"ferro-policy-785-version-substitution-refused"
}
RejectionReason::NonstandardOrAbsentSelectorDeclined858 => {
"ferro-policy-858-nonstandard-or-absent-selector-declined"
}
RejectionReason::NgPartialTranscriptCoverageDeclined853 => {
"ferro-policy-853-ng-partial-transcript-coverage-declined"
}
RejectionReason::NonCdsNoProjection857 => "ferro-policy-857-non-cds-no-projection",
}
}
}
impl std::fmt::Display for RejectionReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct AcceptedRejection {
pub axis: Axis,
pub reason: RejectionReason,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub cluster: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct AcceptedDivergence {
pub axis: Axis,
pub policy: Policy,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub cluster: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct KnownBug {
pub axis: Axis,
pub tracking_issue: u64,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub cluster: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct ReferenceUnavailable {
pub axis: Axis,
pub reason: ReferenceUnavailableReason,
pub tracking_issue: u64,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub cluster: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct Improvement {
pub axis: Axis,
pub tracking_issue: u64,
pub section: SpecSection,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub cluster: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum SpecSection {
#[serde(rename = "HGVS §Prioritization")]
Prioritization,
#[serde(rename = "HGVS protein reference (bare NP)")]
ProteinReference,
#[serde(rename = "HGVS §RefSeqGene transcript selection")]
RefSeqGeneSelector,
#[serde(rename = "HGVS §Substitution (no frameshift)")]
SubstitutionConsequence,
#[serde(rename = "HGVS protein initiation codon (Met1?)")]
ProteinInitiationCodon,
#[serde(rename = "HGVS §Transcript flanking (not c.-numberable)")]
TranscriptFlankNotNumberable,
#[serde(rename = "HGVS §Standards (three-letter ext Ter preferred over ext*)")]
ExtensionTerGlyph,
#[serde(rename = "HGVS §Repeated (coding codon exception)")]
RepeatCodingCodonException,
#[serde(rename = "HGVS §Repeated (DNA contraction)")]
RepeatDnaContraction,
#[serde(rename = "HGVS §RNA alleles (predicted bracket placement)")]
RnaAlleleBracketPlacement,
#[serde(rename = "HGVS §Inserted inversion (reverse complement)")]
InsertedInversion,
#[serde(rename = "HGVS §Synonymous (residue-level p.(Xaa=))")]
SynonymousResidueLevel,
#[serde(rename = "HGVS §3'-rule (exon/exon-junction exception)")]
ThreePrimeRuleExonJunction,
#[serde(rename = "HGVS §Copy-range delins (coding-frame reference)")]
CopyRangeCodingFrame,
#[serde(rename = "HGVS §delins (one-amino-acid exception)")]
DelinsOneAminoAcidException,
#[serde(rename = "HGVS §Separated variants described individually")]
SeparatedVariantsIndividually,
}
impl SpecSection {
pub fn as_str(self) -> &'static str {
match self {
SpecSection::Prioritization => "HGVS §Prioritization",
SpecSection::SeparatedVariantsIndividually => {
"HGVS §Separated variants described individually"
}
SpecSection::ProteinReference => "HGVS protein reference (bare NP)",
SpecSection::RefSeqGeneSelector => "HGVS §RefSeqGene transcript selection",
SpecSection::SubstitutionConsequence => "HGVS §Substitution (no frameshift)",
SpecSection::ProteinInitiationCodon => "HGVS protein initiation codon (Met1?)",
SpecSection::TranscriptFlankNotNumberable => {
"HGVS §Transcript flanking (not c.-numberable)"
}
SpecSection::ExtensionTerGlyph => {
"HGVS §Standards (three-letter ext Ter preferred over ext*)"
}
SpecSection::RepeatCodingCodonException => "HGVS §Repeated (coding codon exception)",
SpecSection::RepeatDnaContraction => "HGVS §Repeated (DNA contraction)",
SpecSection::RnaAlleleBracketPlacement => {
"HGVS §RNA alleles (predicted bracket placement)"
}
SpecSection::InsertedInversion => "HGVS §Inserted inversion (reverse complement)",
SpecSection::SynonymousResidueLevel => "HGVS §Synonymous (residue-level p.(Xaa=))",
SpecSection::ThreePrimeRuleExonJunction => {
"HGVS §3'-rule (exon/exon-junction exception)"
}
SpecSection::CopyRangeCodingFrame => "HGVS §Copy-range delins (coding-frame reference)",
SpecSection::DelinsOneAminoAcidException => "HGVS §delins (one-amino-acid exception)",
}
}
}
impl std::fmt::Display for SpecSection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct SpecCitation {
pub axis: Axis,
pub section: SpecSection,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub cluster: Option<String>,
}
fn default_true() -> bool {
true
}
fn de_one_or_many<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany<T> {
One(T),
Many(Vec<T>),
}
Ok(match Option::<OneOrMany<T>>::deserialize(deserializer)? {
None => Vec::new(),
Some(OneOrMany::One(item)) => vec![item],
Some(OneOrMany::Many(items)) => items,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_projection_opt_in_reasons_are_exactly_857_and_858() {
fn expected_opt_in(reason: RejectionReason) -> bool {
match reason {
RejectionReason::NonCdsNoProjection857
| RejectionReason::NonstandardOrAbsentSelectorDeclined858 => true,
RejectionReason::MalformedInputRejected654
| RejectionReason::TranscriptFlankNotNumberableInC758
| RejectionReason::VersionSubstitutionRefused785
| RejectionReason::NgPartialTranscriptCoverageDeclined853 => false,
}
}
for r in [
RejectionReason::MalformedInputRejected654,
RejectionReason::TranscriptFlankNotNumberableInC758,
RejectionReason::VersionSubstitutionRefused785,
RejectionReason::NonstandardOrAbsentSelectorDeclined858,
RejectionReason::NgPartialTranscriptCoverageDeclined853,
RejectionReason::NonCdsNoProjection857,
] {
assert_eq!(
r.disposition_empty_projection(),
expected_opt_in(r),
"{} empty-projection opt-in disagrees with the exhaustive expectation",
r.as_str()
);
}
}
#[test]
fn accepted_rejection_857_reason_round_trips() {
let ar: AcceptedRejection = serde_json::from_str(
r#"{"axis":"protein_description","reason":"ferro-policy-857-non-cds-no-projection"}"#,
)
.expect("accepted_rejection with the #903 reason should deserialize");
assert_eq!(ar.reason, RejectionReason::NonCdsNoProjection857);
assert_eq!(ar.reason.as_str(), "ferro-policy-857-non-cds-no-projection");
}
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 comparator_provenance_absent_still_parses() {
let f = parse("", "");
assert!(f.comparator_provenance.is_none());
}
#[test]
fn comparator_provenance_parses_and_exposes_versions() {
let json = format!(
"{{{BASE},{},\"clusters\":[],\"cases\":[]}}",
r#""comparator_provenance":{
"note":"n","validated_at":"2026-06-30",
"reference_identity":"629f0400076369c8",
"mutalyzer":"3.1.1","mutalyzer_hgvs_parser":"0.3.9",
"biocommons_hgvs":"1.5.6","biocommons_seqrepo":"0.6.11",
"hgvs_rs":"0.20.2-1-gb6513b3"
}"#
);
let f: Fixture = serde_json::from_str(&json).expect("provenance should deserialize");
let p = f
.comparator_provenance
.expect("comparator_provenance present");
assert_eq!(p.reference_identity, "629f0400076369c8");
assert_eq!(p.mutalyzer, "3.1.1");
assert_eq!(p.mutalyzer_hgvs_parser, "0.3.9");
assert_eq!(p.biocommons_hgvs, "1.5.6");
assert_eq!(p.biocommons_seqrepo, "0.6.11");
assert_eq!(p.hgvs_rs, "0.20.2-1-gb6513b3");
}
#[test]
fn cluster_refs_collects_every_disposition_kind() {
let clusters = r#"
{"id":"sel","title":"RefSeqGene selector","spec_section":"background/refseq.md"},
{"id":"np","title":"bare NP","spec_section":"protein"},
{"id":"rej","title":"malformed input rejected","spec_section":"DNA"}
"#;
let cases = r#"
{"input":"A","improvement":{"axis":"normalized","tracking_issue":500,
"section":"HGVS §RefSeqGene transcript selection","cluster":"sel"}},
{"input":"B","spec_citation":{"axis":"protein_description",
"section":"HGVS protein reference (bare NP)","cluster":"np"}},
{"input":"C","accepted_rejection":{"axis":"normalized",
"reason":"ferro-policy-654-malformed-input-rejected","cluster":"rej"}}
"#;
let fixture = parse(clusters, cases);
let mut refs = fixture.cluster_refs();
refs.sort();
assert_eq!(refs, vec![("A", "sel"), ("B", "np"), ("C", "rej")]);
assert!(fixture.validate_clusters().is_ok());
let summary = fixture.to_summary();
assert_eq!(summary.title, "mutalyzer-normalize");
assert_eq!(summary.rows.len(), 3);
let imp = summary
.rows
.iter()
.find(|r| r.kind == DispositionKind::Improvement)
.expect("improvement row");
assert_eq!(imp.tracking_issue, Some(500));
assert_eq!(imp.axis, "normalized");
assert_eq!(imp.cluster.as_deref(), Some("sel"));
assert!(summary
.rows
.iter()
.any(|r| r.kind == DispositionKind::SpecCitation));
let rej = summary
.rows
.iter()
.find(|r| r.kind == DispositionKind::AcceptedDivergence)
.expect("accepted_rejection row (mapped to AcceptedDivergence)");
assert_eq!(rej.input, "C");
assert_eq!(rej.axis, "normalized");
assert_eq!(rej.cluster.as_deref(), Some("rej"));
assert_eq!(rej.tracking_issue, None);
}
#[test]
fn dangling_disposition_cluster_ref_is_rejected() {
let clusters = r#"{"id":"np","title":"bare NP","spec_section":"protein"}"#;
let cases = r#"
{"input":"B","spec_citation":{"axis":"protein_description",
"section":"HGVS protein reference (bare NP)","cluster":"missing"}}
"#;
let err = parse(clusters, cases)
.validate_clusters()
.expect_err("a dangling disposition cluster ref must be rejected");
assert!(err.contains("missing"), "{err}");
}
#[test]
fn dispositions_without_clusters_are_ok() {
let cases = r#"{"input":"A","improvement":{"axis":"normalized",
"tracking_issue":500,"section":"HGVS §RefSeqGene transcript selection"}}"#;
let fixture = parse("", cases);
assert!(fixture.cluster_refs().is_empty());
assert!(fixture.validate_clusters().is_ok());
}
#[test]
fn spec_citation_single_object_and_array_both_parse() {
let one = r#"{"input":"S","spec_citation":{"axis":"normalized",
"section":"HGVS §Prioritization"}}"#;
let fixture = parse("", one);
assert_eq!(fixture.cases[0].spec_citations.len(), 1);
assert_eq!(fixture.cases[0].spec_citations[0].axis, Axis::Normalized);
let clusters = r#"
{"id":"cre","title":"coding repeat","spec_section":"DNA"},
{"id":"np","title":"bare NP","spec_section":"protein"}
"#;
let many = r#"{"input":"M","spec_citation":[
{"axis":"normalized","section":"HGVS §Repeated (coding codon exception)","cluster":"cre"},
{"axis":"protein_description","section":"HGVS protein reference (bare NP)","cluster":"np"}
]}"#;
let fixture = parse(clusters, many);
let case = &fixture.cases[0];
assert_eq!(case.spec_citations.len(), 2);
assert_eq!(case.spec_citations[0].axis, Axis::Normalized);
assert_eq!(case.spec_citations[1].axis, Axis::ProteinDescription);
let mut refs = fixture.cluster_refs();
refs.sort();
assert_eq!(refs, vec![("M", "cre"), ("M", "np")]);
assert!(fixture.validate_clusters().is_ok());
let spec_rows = fixture
.to_summary()
.rows
.into_iter()
.filter(|r| r.kind == DispositionKind::SpecCitation)
.count();
assert_eq!(spec_rows, 2);
}
#[test]
fn spec_citation_explicit_null_is_empty() {
let fixture = parse("", r#"{"input":"N","spec_citation":null}"#);
assert!(fixture.cases[0].spec_citations.is_empty());
}
#[test]
fn accepted_rejection_single_object_and_array_both_parse() {
let one = r#"{"input":"S","accepted_rejection":{"axis":"normalized",
"reason":"ferro-policy-654-malformed-input-rejected"}}"#;
let fixture = parse("", one);
assert_eq!(fixture.cases[0].accepted_rejections.len(), 1);
assert_eq!(
fixture.cases[0].accepted_rejections[0].axis,
Axis::Normalized
);
let many = r#"{"input":"M","accepted_rejection":[
{"axis":"normalized","reason":"ferro-policy-654-malformed-input-rejected"},
{"axis":"genomic","reason":"ferro-policy-654-malformed-input-rejected"}
]}"#;
let case = &parse("", many).cases[0];
assert_eq!(case.accepted_rejections.len(), 2);
assert_eq!(case.accepted_rejections[0].axis, Axis::Normalized);
assert_eq!(case.accepted_rejections[1].axis, Axis::Genomic);
assert!(parse("", r#"{"input":"N"}"#).cases[0]
.accepted_rejections
.is_empty());
}
#[test]
fn duplicate_accepted_rejection_axis_is_rejected() {
let cases = r#"{"input":"D","accepted_rejection":[
{"axis":"genomic","reason":"ferro-policy-654-malformed-input-rejected"},
{"axis":"genomic","reason":"ferro-policy-758-transcript-flank-not-numberable-in-c"}
]}"#;
let err = parse("", cases)
.validate_clusters()
.expect_err("two rejections on the same axis must be rejected");
assert!(err.contains("genomic"), "{err}");
assert!(err.contains("accepted_rejection"), "{err}");
}
#[test]
fn duplicate_spec_citation_axis_is_rejected() {
let cases = r#"{"input":"D","spec_citation":[
{"axis":"normalized","section":"HGVS §Prioritization"},
{"axis":"normalized","section":"HGVS §Repeated (coding codon exception)"}
]}"#;
let err = parse("", cases)
.validate_clusters()
.expect_err("two citations on the same axis must be rejected");
assert!(err.contains("normalized"), "{err}");
let ok = r#"{"input":"O","spec_citation":[
{"axis":"normalized","section":"HGVS §Prioritization"},
{"axis":"protein_description","section":"HGVS protein reference (bare NP)"}
]}"#;
assert!(parse("", ok).validate_clusters().is_ok());
}
#[test]
fn spec_citation_array_element_typo_rejected() {
let json = format!(
"{{{BASE},\"cases\":[{}]}}",
r#"{"input":"x","spec_citation":[
{"axis":"normalized","section":"HGVS §Prioritization"},
{"axis":"protein_description","section":"not a real section"}
]}"#
);
let result: Result<Fixture, _> = serde_json::from_str(&json);
assert!(
result.is_err(),
"expected a typo'd section in an array element to be rejected; got {:?}",
result.ok(),
);
}
}