Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions datafusion/bio-function-vep/src/annotate_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11931,6 +11931,173 @@ mod tests {
assert!(matches!(escaped, std::borrow::Cow::Borrowed(_)));
}

// ── Port of ensembl-vep/t/OutputFactory_VCF.t (v2, 2026-05-28) ────────────
//
// Audit-trail tests for the `output_hash_to_vcf_info_chunk` family of
// subtests in `ensembl-vep/t/OutputFactory_VCF.t`. See detailed_plan
// `porting-tests/detailed_plans/OutputFactory_VCF.md` for the full
// per-subtest table.
//
// Subtests #15/#17/#18/#19 are GREEN via the existing `test_csq_escape_*`
// helpers above; the audit-trail tests below re-cite them by Perl-subtest
// number to satisfy sztywno-1:1 traceability.
//
// Subtests #14, #16, #20 are **firm blocked-future-work** (Phase D
// 2026-05-27 reclassification): vepyr's `csq_escape` is column-agnostic
// and per-character, diverging from Perl's column-aware `s/\s+/\_/g`
// run-collapse. See commented stubs in
// `tests/port_output_factory_vcf.rs` for the documented divergence.

/// Port of `t/OutputFactory_VCF.t` subtest #15 (Perl L214-218).
///
/// Perl: `output_hash_to_vcf_info_chunk({Consequence => '-'})`
/// → `'|' x 20` (the `-` sentinel is erased to empty inside the
/// per-field formatter, then pipe-joined).
///
/// Vepyr: `csq_escape("-")` returns `""`. The pipe-join shape is
/// covered by `golden_benchmark::tests::csq_pipe_count_matches_field_count`
/// (Axis B B1).
///
/// Detailed plan row #15.
#[test]
fn port_output_factory_vcf_subtest_15_dash_erased() {
assert_eq!(csq_escape("-"), "");
}

/// Port of `t/OutputFactory_VCF.t` subtest #17 (Perl L226-230).
///
/// Perl: `output_hash_to_vcf_info_chunk({Allele => ';'})`
/// → `'%3B' + ('|' x 20)`.
///
/// Vepyr: `csq_escape(";")` returns `"%3B"`.
///
/// Detailed plan row #17.
#[test]
fn port_output_factory_vcf_subtest_17_semicolon_percent_encoded() {
assert_eq!(csq_escape(";"), "%3B");
}

/// Port of `t/OutputFactory_VCF.t` subtest #18 (Perl L232-236).
///
/// Perl: `output_hash_to_vcf_info_chunk({Allele => '|'})`
/// → `'&' + ('|' x 20)`.
///
/// Vepyr: `csq_escape("|")` returns `"&"`.
///
/// Detailed plan row #18.
#[test]
fn port_output_factory_vcf_subtest_18_pipe_becomes_ampersand() {
assert_eq!(csq_escape("|"), "&");
}

/// Port of `t/OutputFactory_VCF.t` subtest #19 (Perl L238-242).
///
/// Perl: `output_hash_to_vcf_info_chunk({Allele => ','})`
/// → `'&' + ('|' x 20)`.
///
/// Vepyr: `csq_escape(",")` returns `"&"`.
///
/// Detailed plan row #19.
#[test]
fn port_output_factory_vcf_subtest_19_comma_becomes_ampersand() {
assert_eq!(csq_escape(","), "&");
}

/// Port of `t/OutputFactory_VCF.t` subtest #21 (Perl L254-258).
///
/// Perl: `output_hash_to_vcf_info_chunk({Allele => 'A', SIFT => '0'})`
/// → `'A|0'`.
///
/// The semantic under test is: the per-field formatter does NOT coerce
/// a string `"0"` to empty (Perl truthiness trap). Vepyr's `csq_escape`
/// is string-typed, so the contract here is: `csq_escape("0") == "0"`
/// (string preserved, not erased like `csq_escape("-") == ""`).
///
/// Detailed plan row #21.
#[test]
fn port_output_factory_vcf_subtest_21_string_zero_kept() {
let escaped = csq_escape("0");
assert_eq!(escaped, "0");
// Round-trip via Cow::Borrowed (no allocation needed for unchanged input).
assert!(matches!(escaped, std::borrow::Cow::Borrowed("0")));
}

/// Port of `t/OutputFactory_VCF.t` subtest #22 (Perl L260-264).
///
/// Perl: `output_hash_to_vcf_info_chunk({Allele => 'A', SIFT => 0})`
/// → `'A|0'`.
///
/// Vepyr's typed CSQ row builder formats `i64=0` / `f64=0.0` via
/// `format!("{}", v)` which emits `"0"` (NOT empty). The string-level
/// contract is captured by #21 above; this row tests the round-trip
/// from a numeric source via the standard `to_string()` path — vepyr's
/// type system makes the empty-vs-"0" distinction automatic, so the
/// assertion is on the post-stringification surface.
///
/// Detailed plan row #22.
#[test]
fn port_output_factory_vcf_subtest_22_numeric_zero_kept() {
// Numeric 0 stringifies to "0", then csq_escape passes it through
// unchanged (no special characters).
let stringified = 0_i64.to_string();
assert_eq!(stringified, "0");
assert_eq!(csq_escape(&stringified), "0");

let stringified_f = 0.0_f64.to_string();
assert_eq!(stringified_f, "0");
assert_eq!(csq_escape(&stringified_f), "0");
}

/// Port of `t/OutputFactory_VCF.t` subtest #23 (Perl L266-270).
///
/// Perl: `output_hash_to_vcf_info_chunk({Allele => '', SIFT => 0})`
/// → `'|0'`.
///
/// The semantic distinction: empty-string remains empty; numeric 0
/// becomes `"0"`. Vepyr's `csq_escape` preserves both:
/// `csq_escape("") == ""` and `csq_escape("0") == "0"`. They are
/// independent in the per-field formatter — empty-vs-falsy is a typed
/// distinction at the source-data level (None vs Some(0)).
///
/// Detailed plan row #23.
#[test]
fn port_output_factory_vcf_subtest_23_empty_string_vs_numeric_zero() {
assert_eq!(csq_escape(""), "");
assert_eq!(csq_escape("0"), "0");
// The two values produce DIFFERENT escaped outputs, confirming the
// empty-vs-falsy distinction.
assert_ne!(csq_escape(""), csq_escape("0"));
}

/// Port of `t/OutputFactory_VCF.t` subtest #28 (Perl L329-336).
///
/// Perl: with transcript stable_id mutated to `'ENST00,00 03|07;301'`,
/// the CSQ Feature column emits the escaped form `ENST00&00_03&07%3B301`
/// (Perl's `s/\s+/\_/g` collapses the two-space run to a single `_`).
///
/// Vepyr divergence: `csq_escape` is per-character (line 2135:
/// `ch if ch.is_whitespace()` matches each whitespace ONE AT A TIME),
/// so the two spaces become two underscores. The vepyr-emitted value
/// is therefore `ENST00&00__03&07%3B301` (TWO underscores), not Perl's
/// `ENST00&00_03&07%3B301`. This divergence is firm blocked-future-work
/// (detailed plan row #20; see `csq_escape` whitespace run-collapse
/// entry in `future-work-vepyr.md`). The escape-of-other-chars
/// (`,` → `&`, `|` → `&`, `;` → `%3B`) is the same in both.
///
/// Detailed plan row #28 (with #20 divergence note).
#[test]
fn port_output_factory_vcf_subtest_28_escape_invalid_chars_with_whitespace_divergence() {
// verified via reading `csq_escape` source at annotate_provider.rs:2118-2148:
// per-character, no whitespace run-collapse.
let input = "ENST00,00 03|07;301";
let escaped = csq_escape(input);
// Vepyr-semantic: two underscores (per-char). Perl-semantic would
// be one underscore (s/\s+/\_/g). This test locks in vepyr's
// current behavior; the Perl-parity fix is tracked as
// `csq_escape` whitespace run-collapse future-work.
assert_eq!(escaped, "ENST00&00__03&07%3B301");
}

// ── Port of ensembl-vep/t/AnnotationSource.t (Tier 2 port, 2026-05-28) ─────
//
// Maps Perl subtests in `AnnotationSource.t` to Rust unit-/integration-port
Expand Down
182 changes: 182 additions & 0 deletions datafusion/bio-function-vep/src/golden_benchmark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,188 @@ chr22\t100\t.\tA\tG\t.\t.\tCSQ=G|missense_variant|MODERATE
assert_eq!(everything_fields[22], "VARIANT_CLASS");
}

// ── Port of ensembl-vep/t/OutputFactory_VCF.t (v2, 2026-05-28) ────────────
//
// Subtest #7 (refseq mode adds BAM_EDIT) is already covered by
// `csq_field_names_for_refseq_and_merged_modes_insert_expected_fields`
// above; the dedicated test below re-cites it by Perl-subtest number to
// satisfy sztywno-1:1 traceability.
//
// See `porting-tests/detailed_plans/OutputFactory_VCF.md` for the full
// per-subtest table.

/// Port of `t/OutputFactory_VCF.t` subtest #7 (Perl L75-80).
///
/// Perl: `OutputFactory::VCF` with `refseq=1` emits the CSQ Format
/// string ending in `...SYMBOL_SOURCE|HGNC_ID|REFSEQ_MATCH|REFSEQ_OFFSET|GIVEN_REF|USED_REF|BAM_EDIT`.
///
/// Vepyr: `csq_field_names_for_mode_with_pick(false, true, false, false)`
/// inserts `REFSEQ_MATCH, REFSEQ_OFFSET, GIVEN_REF, USED_REF, BAM_EDIT`
/// at positions 28-32, ending the refseq-specific block at index 32 and
/// resuming with `VARIANT_CLASS` at 33.
///
/// Detailed plan row #7.
#[test]
fn port_output_factory_vcf_subtest_7_refseq_bam_edit_block() {
let refseq = csq_field_names_for_mode_with_pick(false, true, false, false);
// Block end at BAM_EDIT.
assert_eq!(refseq[32], "BAM_EDIT");
// Block start at REFSEQ_MATCH (immediately after SYMBOL_SOURCE/HGNC_ID).
assert_eq!(refseq[28], "REFSEQ_MATCH");
// Per Perl test header line: `SYMBOL_SOURCE|HGNC_ID|REFSEQ_MATCH|...|BAM_EDIT`.
// Vepyr's default (non-everything) ordering has SYMBOL_SOURCE at 21 and HGNC_ID at 22.
assert_eq!(refseq[21], "SYMBOL_SOURCE");
assert_eq!(refseq[22], "HGNC_ID");
// No SOURCE in pure refseq mode (vepyr replaces SOURCE with REFSEQ_*).
assert!(refseq.iter().all(|f| *f != "SOURCE"));
}

/// Port of `t/OutputFactory_VCF.t` subtest #8 (Perl L82-109).
///
/// Perl: default `fields()` returns a 22-tuple from `Allele` through
/// `SYMBOL_SOURCE` (then a `custom_test` for `--custom`, omitted in
/// vepyr — no `--custom` analogue).
///
/// Vepyr: `csq_field_names_for_mode(false, false, false)[..22]` matches
/// the same 22-tuple in the same order. Vepyr's list continues beyond
/// 22 (74 total non-everything fields) because more annotations have
/// been added since the Perl test was written; the first 22 are stable.
///
/// Detailed plan row #8.
#[test]
fn port_output_factory_vcf_subtest_8_default_fields_22_tuple() {
let default = csq_field_names_for_mode(false, false, false);
let expected_first_22 = [
"Allele",
"Consequence",
"IMPACT",
"SYMBOL",
"Gene",
"Feature_type",
"Feature",
"BIOTYPE",
"EXON",
"INTRON",
"HGVSc",
"HGVSp",
"cDNA_position",
"CDS_position",
"Protein_position",
"Amino_acids",
"Codons",
"Existing_variation",
"DISTANCE",
"STRAND",
"FLAGS",
"SYMBOL_SOURCE",
];
assert_eq!(&default[..22], &expected_first_22[..]);
// Vepyr extends beyond the Perl-era 22-tuple to 74 total.
assert_eq!(default.len(), 74);
}

/// Port of `t/OutputFactory_VCF.t` subtest #10 (Perl L143-175).
///
/// Perl: with `merged=1 + custom=1`, `fields()` contains `SOURCE`
/// EXACTLY ONCE (dedup verified) and REFSEQ_MATCH + REFSEQ_OFFSET are
/// present.
///
/// Vepyr: `csq_field_names_for_mode(false, false, true)` (merged mode)
/// emits REFSEQ_MATCH at 28 and SOURCE at 29 (interleaved, single
/// SOURCE), continuing through BAM_EDIT at 33. The `--custom` part of
/// Perl's dedup is moot — vepyr has no `--custom` analogue.
///
/// Detailed plan row #10.
#[test]
fn port_output_factory_vcf_subtest_10_merged_dedup_source() {
let merged = csq_field_names_for_mode(false, false, true);
// SOURCE appears exactly once (dedup).
let source_count = merged.iter().filter(|f| **f == "SOURCE").count();
assert_eq!(source_count, 1, "SOURCE must appear exactly once in merged mode");
// REFSEQ_MATCH and REFSEQ_OFFSET both present.
assert!(merged.iter().any(|f| *f == "REFSEQ_MATCH"));
assert!(merged.iter().any(|f| *f == "REFSEQ_OFFSET"));
// Verify the interleaving: REFSEQ_MATCH (28) → SOURCE (29) → REFSEQ_OFFSET (30).
let m_idx = merged.iter().position(|f| *f == "REFSEQ_MATCH").unwrap();
let s_idx = merged.iter().position(|f| *f == "SOURCE").unwrap();
let o_idx = merged.iter().position(|f| *f == "REFSEQ_OFFSET").unwrap();
assert_eq!(s_idx, m_idx + 1);
assert_eq!(o_idx, m_idx + 2);
}

// ── Axis B (vepyr-side accretive invariants) ───────────────────────────
//
// These pin vepyr-side properties that the Perl test does not exercise
// directly. See detailed_plan §Axis-B for the rationale.

/// Axis B B1 — pipe-count invariant.
///
/// Perl row #12 (`output_hash_to_vcf_info_chunk({})` → `'|' x 20`)
/// implicitly tests that the pipe-joined CSQ for an empty per-row
/// hash has `field_count - 1` pipes. Vepyr-side, the field count is
/// derived from `csq_field_names_for_mode_with_pick(...)` so this
/// invariant deserves a direct assertion. Regressions on field-list
/// changes surface immediately here.
///
/// Detailed plan Axis B B1.
#[test]
fn csq_pipe_count_matches_field_count_invariant() {
// Default mode: 74 fields → 73 pipes joining 74 empty groups.
let default = csq_field_names_for_mode(false, false, false);
let n = default.len();
let empty_joined: String = std::iter::repeat("").take(n).collect::<Vec<_>>().join("|");
let pipe_count = empty_joined.matches('|').count();
assert_eq!(
pipe_count,
n - 1,
"pipe-joined empty CSQ should have field_count - 1 pipes; got {} pipes for {} fields",
pipe_count,
n
);
assert_eq!(n, 74, "default field count is 74");

// --everything mode: 80 fields → 79 pipes.
let everything = csq_field_names_for_mode(true, false, false);
let m = everything.len();
assert_eq!(m, 80, "--everything field count is 80");

// Refseq mode: 78 fields.
let refseq = csq_field_names_for_mode(false, true, false);
assert_eq!(refseq.len(), 78, "refseq field count is 78");

// Merged mode: 79 fields.
let merged = csq_field_names_for_mode(false, false, true);
assert_eq!(merged.len(), 79, "merged field count is 79");
}

/// Axis B B2 — Ensembl-mode field list does NOT include REFSEQ_MATCH.
///
/// Perl row #10 covers `merged dedup SOURCE`; the negative assertion
/// for ensembl-mode is separate. Catches accidental field-list bleed
/// across modes (e.g., a regression where REFSEQ_MATCH leaks into
/// Ensembl-only output).
///
/// Detailed plan Axis B B2.
#[test]
fn csq_field_names_ensembl_mode_excludes_refseq_match() {
let ensembl = csq_field_names_for_mode_with_pick(false, false, false, false);
assert!(
ensembl.iter().all(|f| *f != "REFSEQ_MATCH"),
"REFSEQ_MATCH must not appear in Ensembl-only mode; got fields: {:?}",
ensembl
);
// Same for REFSEQ_OFFSET, GIVEN_REF, USED_REF, BAM_EDIT.
for refseq_field in ["REFSEQ_OFFSET", "GIVEN_REF", "USED_REF", "BAM_EDIT"] {
assert!(
ensembl.iter().all(|f| *f != refseq_field),
"{} must not appear in Ensembl-only mode",
refseq_field
);
}
// SOURCE should be present in Ensembl mode.
assert!(ensembl.iter().any(|f| *f == "SOURCE"));
}

#[test]
fn parse_csq_entries_splits_correctly() {
let csq = "G|missense_variant|MODERATE|TP53|ENSG00000141510|Transcript|ENST00000269305|protein_coding|rs123|1|HGNC|11998|Ensembl";
Expand Down
Loading
Loading