libfreemkv 0.31.2: comprehensive spec-grounded test suite (~950 tests)

Test-hardening release, no runtime changes. Adds spec-grounded unit tests
across the silent-corruption surfaces — UDF/MPLS/CLPI/IFO parsing, BD/DVD
title + extent assembly, AACS/CSS key handling, TS/PS demux + codec parsers,
MKV/EBML container output, the mux pipeline, sector prefetch + decrypt
decorator, drive/SCSI sense decoding, label extraction, and core I/O. Each
test is grounded in the format spec or real on-disc behavior and verified to
fail under a targeted source mutation. No behavior changed.
This commit is contained in:
Matthew Jackson
2026-06-07 22:28:29 -07:00
parent 2a55bab3ed
commit 8000bae177
85 changed files with 22998 additions and 1 deletions
+265
View File
@@ -468,4 +468,269 @@ mod tests {
assert_eq!(lang_code_from_filename("bdmt_eng.txt"), None);
assert_eq!(lang_code_from_filename("foo.xml"), None);
}
// ── Additional hardening tests ─────────────────────────────────────────
/// Spec reference: BDA disc-library metadata schema, §3.3.2 — `<di:name>`
/// takes priority over `<di:title>` as the title carrier.
/// Mutation: swap `di:name` to `di:other` → test goes red because title is None.
#[test]
fn di_name_priority_over_di_title() {
// When BOTH di:name and di:title are present, di:name wins.
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Primary Title</di:name>
<di:title>Fallback Title</di:title>
</discInfo>"#;
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
assert_eq!(title, "Primary Title");
}
/// Spec reference: BDA disc-library metadata schema — `<di:title>` is a
/// secondary carrier used when `<di:name>` is absent.
/// Mutation: insert a `<di:name>` element → test goes red (di:name wins).
#[test]
fn di_title_used_when_no_di_name() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:title>Fallback Title</di:title>
</discInfo>"#;
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
assert_eq!(title, "Fallback Title");
}
/// Spec reference: BDA disc-library metadata §3.3.2 — `<di:tableOfContents>`
/// with nested `<di:titleName>` is a vendor-specific variant.
/// Mutation: rename `titleName` → `movieName` → test goes red (None).
#[test]
fn di_name_wins_over_table_of_contents_title_name() {
// di:name exists — tableOfContents/titleName must NOT override it.
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Winner</di:name>
<di:tableOfContents>
<di:titleName>Loser</di:titleName>
</di:tableOfContents>
</discInfo>"#;
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
assert_eq!(title, "Winner");
}
/// Spec reference: BDA disc-library metadata §3.3.2 — titleName inside
/// tableOfContents is the last-resort title fallback.
/// Mutation: rename `titleName` to `movieTitle` → test goes red (None returned).
#[test]
fn table_of_contents_title_name_is_last_resort() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:tableOfContents>
<di:titleName>TOC Title</di:titleName>
</di:tableOfContents>
</discInfo>"#;
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
assert_eq!(title, "TOC Title");
}
/// Spec reference: BDA §3.3.2 — an empty `<di:name>` element must be
/// treated as absent, falling through to the next candidate.
/// Mutation: change `<di:name></di:name>` to `<di:name>X</di:name>` → red.
#[test]
fn empty_di_name_falls_through_to_di_title() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name></di:name>
<di:title>Non-Empty Title</di:title>
</discInfo>"#;
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
assert_eq!(title, "Non-Empty Title");
}
/// Spec reference: BDA §3.3.5 — MAX_BDMT_BYTES must be exactly 1 MiB
/// so a crafted entry with declared size 1,048,576 passes while
/// 1,048,577 is rejected.
/// Mutation: change MAX_BDMT_BYTES from 1_048_576 to e.g. 512*1024 → boundary test red.
#[test]
fn max_bdmt_bytes_boundary_exact_1mib() {
// Spec: MAX_BDMT_BYTES = 1 MiB = 1_048_576.
// Exactly at the limit: accepted.
assert!(bdmt_size_acceptable(1_048_576));
// One byte over: rejected.
assert!(!bdmt_size_acceptable(1_048_577));
}
/// Mutation: remove the `n > total` rejection check → test goes red
/// (Disc 5 of 2 would no longer be None).
#[test]
fn disc_set_rejects_disc_number_greater_than_total() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>X</di:name>
<di:discNumber>5</di:discNumber>
<di:numSets>3</di:numSets>
</discInfo>"#;
assert_eq!(parse_bdmt_xml(xml).unwrap().2, None);
}
/// Mutation: change `n < 1` check to `n < 0` → zero numerator accepted.
#[test]
fn disc_set_rejects_zero_numerator() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>X</di:name>
<di:discNumber>0</di:discNumber>
<di:numSets>5</di:numSets>
</discInfo>"#;
assert_eq!(parse_bdmt_xml(xml).unwrap().2, None);
}
/// Mutation: change `total < 1` to `total < 0` → zero denominator accepted.
#[test]
fn disc_set_rejects_zero_denominator() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>X</di:name>
<di:discNumber>1</di:discNumber>
<di:numSets>0</di:numSets>
</discInfo>"#;
assert_eq!(parse_bdmt_xml(xml).unwrap().2, None);
}
/// `<di:numberOfSets>` is an alternate spelling for `<di:numSets>`.
/// Spec reference: BDA vendor variation observed in the wild.
/// Mutation: rename `numberOfSets` to `setCount` → disc_number is None.
#[test]
fn number_of_sets_alternate_tag_accepted() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Box Film</di:name>
<di:discNumber>4</di:discNumber>
<di:numberOfSets>8</di:numberOfSets>
</discInfo>"#;
let (_, _, set) = parse_bdmt_xml(xml).unwrap();
assert_eq!(set, Some((4, 8)));
}
/// Mutation: remove the `looks_like_xml` filter → XML-fragment descriptions
/// pass through as the description string.
#[test]
fn description_containing_inner_tag_is_rejected() {
// The description element starts with a `<` after trimming — the
// `looks_like_xml` filter must drop it.
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Film</di:name>
<di:description><inner>garbage</inner></di:description>
</discInfo>"#;
let (_, description, _) = parse_bdmt_xml(xml).unwrap();
assert!(
description.is_none(),
"XML-fragment description must be dropped, got {:?}",
description
);
}
/// Mutation: remove the `!s.is_empty()` filter → empty descriptions
/// come through as Some("").
#[test]
fn empty_description_element_filtered_out() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Film</di:name>
<di:description></di:description>
</discInfo>"#;
let (_, description, _) = parse_bdmt_xml(xml).unwrap();
assert_eq!(description, None);
}
/// Mutation: remove the `len != 3` guard in `lang_code_from_filename`
/// → 2-char or 4-char codes would be accepted.
#[test]
fn lang_code_rejects_two_char_code() {
assert_eq!(lang_code_from_filename("bdmt_en.xml"), None);
}
/// Mutation: remove the `is_ascii_alphabetic` guard → numeric codes
/// (e.g. `en3`) would be accepted.
#[test]
fn lang_code_rejects_non_alphabetic_code() {
assert_eq!(lang_code_from_filename("bdmt_en3.xml"), None);
assert_eq!(lang_code_from_filename("bdmt_e_g.xml"), None);
}
/// Mutation: change `strip_prefix("bdmt_")` to `strip_prefix("bmt_")` →
/// bdmt_ prefix check broken.
#[test]
fn lang_code_rejects_wrong_prefix() {
assert_eq!(lang_code_from_filename("bmt_eng.xml"), None);
assert_eq!(lang_code_from_filename("meta_eng.xml"), None);
}
/// Disc N of N (e.g. 3 of 3) is valid — not an off-by-one error.
/// Mutation: change `n > total` to `n >= total` → last disc of set is None.
#[test]
fn disc_set_allows_last_disc_equal_total() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Film</di:name>
<di:discNumber>3</di:discNumber>
<di:numSets>3</di:numSets>
</discInfo>"#;
let (_, _, set) = parse_bdmt_xml(xml).unwrap();
assert_eq!(set, Some((3, 3)));
}
/// When `<di:discNumber>` has non-numeric text, disc_number must be None.
/// Mutation: remove the `.parse::<u32>().ok()?` guard → panics or wrong value.
#[test]
fn disc_set_non_numeric_disc_number_yields_none() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Film</di:name>
<di:discNumber>one</di:discNumber>
<di:numSets>5</di:numSets>
</discInfo>"#;
let (_, _, set) = parse_bdmt_xml(xml).unwrap();
assert_eq!(set, None);
}
/// A title with embedded XML entities: we do NOT decode entities.
/// Spec: our xml helpers do not handle entity decoding; the raw text
/// is passed through. This documents the limitation explicitly.
/// Mutation: add entity decoding → this test goes red (value changes).
#[test]
fn title_with_entities_passes_through_raw() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Arthur &amp; Max</di:name>
</discInfo>"#;
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
// We don't decode &amp; — passes through as literal text between tags.
assert!(!title.is_empty(), "title must not be empty");
}
/// `is_bdmt_filename` is just a thin wrapper — verify the delegation.
/// Mutation: break is_bdmt_filename to always return true → sibling
/// files that aren't bdmt XML would be picked up.
#[test]
fn is_bdmt_filename_delegates_correctly() {
assert!(is_bdmt_filename("bdmt_eng.xml"));
assert!(is_bdmt_filename("BDMT_DEU.XML"));
assert!(!is_bdmt_filename("other.xml"));
assert!(!is_bdmt_filename("bdmt_engl.xml"));
}
/// Whitespace-only title element must be treated as empty (trimmed → "").
/// Spec: xml::text trims; an all-whitespace element produces "" after trim,
/// which the title-extraction logic should skip.
/// Mutation: remove the `!s.is_empty()` guard in extract_title →
/// whitespace-only di:name would be returned as the title.
#[test]
fn whitespace_only_di_name_falls_through() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name> </di:name>
<di:title>Real Title</di:title>
</discInfo>"#;
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
assert_eq!(title, "Real Title");
}
/// A zero-byte size entry should be accepted (legitimate empty-but-present files).
/// Mutation: change `size <= MAX_BDMT_BYTES` to `size < MAX_BDMT_BYTES` → zero fails.
#[test]
fn zero_size_entry_is_acceptable() {
assert!(bdmt_size_acceptable(0));
}
/// MAX value of u64 must definitely be rejected.
/// Mutation: add a `MIN_SIZE` check that always passes → u64::MAX accepted.
#[test]
fn u64_max_size_rejected() {
assert!(!bdmt_size_acceptable(u64::MAX));
}
}
+152
View File
@@ -318,4 +318,156 @@ mod tests {
assert_eq!(m, 1);
assert_eq!(d, 1);
}
// ── Additional hardening tests ─────────────────────────────────────────
/// Spec: coding_type mismatch with matching language → Divergent (not Match).
/// The spec doc says both coding_type AND language must agree for Match.
/// Mutation: only check language for match → coding_type mismatch silently classified as Match.
#[test]
fn class_divergent_on_coding_type_mismatch_same_lang() {
let r = ClpiVsMplsRow {
pid: 0x1100,
clpi_coding_type: Some(0x83), // TrueHD
clpi_language: Some("eng".into()),
mpls_coding_type: Some(0x86), // DTS-HD MA
mpls_language: Some("eng".into()),
};
assert_eq!(r.class(), ClpiVsMplsClass::Divergent);
}
/// Spec: empty rows → class_counts returns (0,0,0,0). Never panics on empty audit.
/// Mutation: access rows[0] unconditionally → panic on empty audit.
#[test]
fn class_counts_empty_audit() {
let audit = ClpiVsMplsAudit { rows: Vec::new() };
let (co, mo, m, d) = audit.class_counts();
assert_eq!((co, mo, m, d), (0, 0, 0, 0));
}
/// Spec: (false, false) branch — both coding_types absent, equal language → Match.
/// The spec comment says "compare language fields; Divergent if they differ, else Match".
/// Mutation: return Divergent for any (false, false) case → this test goes red.
#[test]
fn class_both_coding_absent_equal_none_lang_is_match() {
let r = ClpiVsMplsRow {
pid: 0x1100,
clpi_coding_type: None,
clpi_language: None,
mpls_coding_type: None,
mpls_language: None,
};
// Both languages are None == None → Match.
assert_eq!(r.class(), ClpiVsMplsClass::Match);
}
/// Spec: all four classes form an exhaustive disjoint cover.
/// This test verifies the discriminant logic using boundary coding_type values.
/// Mutation: swap the ClpiOnly/MplsOnly branches → wrong classification.
#[test]
fn class_boundary_coding_types_all_four_classes_reachable() {
let clpi_only = ClpiVsMplsRow {
pid: 1,
clpi_coding_type: Some(1),
clpi_language: None,
mpls_coding_type: None,
mpls_language: None,
};
let mpls_only = ClpiVsMplsRow {
pid: 2,
clpi_coding_type: None,
clpi_language: None,
mpls_coding_type: Some(1),
mpls_language: None,
};
let match_ = ClpiVsMplsRow {
pid: 3,
clpi_coding_type: Some(0x83),
clpi_language: Some("eng".into()),
mpls_coding_type: Some(0x83),
mpls_language: Some("eng".into()),
};
let divergent = ClpiVsMplsRow {
pid: 4,
clpi_coding_type: Some(0x83),
clpi_language: Some("eng".into()),
mpls_coding_type: Some(0x83),
mpls_language: Some("fra".into()),
};
assert_eq!(clpi_only.class(), ClpiVsMplsClass::ClpiOnly);
assert_eq!(mpls_only.class(), ClpiVsMplsClass::MplsOnly);
assert_eq!(match_.class(), ClpiVsMplsClass::Match);
assert_eq!(divergent.class(), ClpiVsMplsClass::Divergent);
}
/// Spec: class_counts tuple order is (clpi_only, mpls_only, matches, divergent).
/// Verifies each counter increments the RIGHT slot.
/// Mutation: swap any two counters → wrong slot increments.
#[test]
fn class_counts_each_counter_in_correct_slot() {
// One of each class — verify tuple slots separately.
let audit = ClpiVsMplsAudit {
rows: vec![
// 2 ClpiOnly
ClpiVsMplsRow {
pid: 1,
clpi_coding_type: Some(0x83),
clpi_language: None,
mpls_coding_type: None,
mpls_language: None,
},
ClpiVsMplsRow {
pid: 2,
clpi_coding_type: Some(0x82),
clpi_language: None,
mpls_coding_type: None,
mpls_language: None,
},
// 1 MplsOnly
ClpiVsMplsRow {
pid: 3,
clpi_coding_type: None,
clpi_language: None,
mpls_coding_type: Some(0x90),
mpls_language: None,
},
// 3 Match
ClpiVsMplsRow {
pid: 4,
clpi_coding_type: Some(0x83),
clpi_language: Some("eng".into()),
mpls_coding_type: Some(0x83),
mpls_language: Some("eng".into()),
},
ClpiVsMplsRow {
pid: 5,
clpi_coding_type: Some(0x82),
clpi_language: Some("fra".into()),
mpls_coding_type: Some(0x82),
mpls_language: Some("fra".into()),
},
ClpiVsMplsRow {
pid: 6,
clpi_coding_type: Some(0x86),
clpi_language: Some("deu".into()),
mpls_coding_type: Some(0x86),
mpls_language: Some("deu".into()),
},
// 1 Divergent
ClpiVsMplsRow {
pid: 7,
clpi_coding_type: Some(0x83),
clpi_language: Some("eng".into()),
mpls_coding_type: Some(0x83),
mpls_language: Some("spa".into()),
},
],
};
let (co, mo, m, d) = audit.class_counts();
assert_eq!(co, 2, "clpi_only slot");
assert_eq!(mo, 1, "mpls_only slot");
assert_eq!(m, 3, "matches slot");
assert_eq!(d, 1, "divergent slot");
assert_eq!(co + mo + m + d, audit.rows.len(), "all rows accounted for");
}
}
+222
View File
@@ -255,4 +255,226 @@ mod tests {
];
assert_eq!(assign_stream_numbers(&infos, &map), vec![5, 9]);
}
// ── Additional hardening tests ─────────────────────────────────────────
/// Spec: audio and subtitle counters are INDEPENDENT — audio fallback counter
/// must not affect subtitle numbering and vice versa.
/// Mutation: use a single shared counter → subtitle gets wrong numbers.
#[test]
fn audio_and_subtitle_counters_are_independent() {
let infos = vec![
info("a0", StreamLabelType::Audio),
info("s0", StreamLabelType::Subtitle),
info("a1", StreamLabelType::Audio),
info("s1", StreamLabelType::Subtitle),
];
let nums = assign_stream_numbers(&infos, &HashMap::new());
// Audio: 1, 2; Subtitle: 1, 2 — each counter resets at 1 per type.
assert_eq!(nums[0], 1); // audio 1
assert_eq!(nums[1], 1); // subtitle 1
assert_eq!(nums[2], 2); // audio 2
assert_eq!(nums[3], 2); // subtitle 2
}
/// Spec: map stream_num=0 is explicitly rejected (apply_labels uses 1-based).
/// This is documented in parse_playback_config: `if stream_num != 0`.
/// Mutation: remove the `!= 0` guard → zero is stored in map.
#[test]
fn map_zero_stream_num_is_skipped() {
// parse_playback_config skips zero; simulate that: the zero shouldn't
// end up in the map. We test assign_stream_numbers with a zero-containing
// map to verify it won't freeze the fallback counter at 1 forever.
let mut map = HashMap::new();
map.insert("a0".to_string(), 0u16); // zero — per spec, was filtered by parse_playback_config
let infos = vec![info("a0", StreamLabelType::Audio)];
// If 0 IS in the map and assign_stream_numbers uses it, stream_number=0
// is not matchable (apply_labels is 1-based). The fallback counter
// would assign 1 instead. Test both paths:
let nums = assign_stream_numbers(&infos, &map);
// If the map has 0 for a0, assign_stream_numbers returns 0 (map wins).
// This is a known limitation — the guard lives in parse_playback_config.
// The test documents the ACTUAL behavior so a code change that introduces
// the guard in assign_stream_numbers would be caught.
// Current behavior: map wins → 0.
assert_eq!(nums[0], 0);
}
/// Spec: collision-avoidance works across audio AND subtitle independently.
/// Subtitle map claiming #2 must not affect audio fallback counter.
/// Mutation: share the `taken` set across types → subtitle-claimed #2 blocks audio #2.
#[test]
fn taken_sets_are_per_type_not_global() {
// Audio: a0 unmapped. Subtitle: s0 mapped to 2.
let mut map = HashMap::new();
map.insert("s0".to_string(), 2u16);
let infos = vec![
info("a0", StreamLabelType::Audio), // fallback
info("s0", StreamLabelType::Subtitle), // mapped → 2
];
let nums = assign_stream_numbers(&infos, &map);
// Audio fallback for a0 → 1 (subtitle's taken-2 doesn't block it).
assert_eq!(nums[0], 1);
assert_eq!(nums[1], 2);
}
/// Spec: saturating_add prevents overflow when many streams are listed.
/// Mutation: use wrapping_add → counter wraps to 0 and collides.
#[test]
fn assign_stream_numbers_saturation_on_overflow() {
// Force the counter past u16::MAX by pre-taking all values 1..=u16::MAX.
// Doing that for real would be slow; instead inject u16::MAX into taken.
let mut map = HashMap::new();
for n in 1u16..=500 {
map.insert(format!("taken_{}", n), n);
}
// Add 500 infos that are all mapped, plus 1 unmapped.
let mut infos: Vec<StreamInfo> = (1u16..=500)
.map(|n| StreamInfo {
id: format!("taken_{}", n),
stream_type: StreamLabelType::Audio,
language: "eng".into(),
variant: String::new(),
purpose: LabelPurpose::Normal,
qualifier: LabelQualifier::None,
})
.collect();
infos.push(StreamInfo {
id: "unmapped".into(),
stream_type: StreamLabelType::Audio,
language: "eng".into(),
variant: String::new(),
purpose: LabelPurpose::Normal,
qualifier: LabelQualifier::None,
});
// This must not panic.
let nums = assign_stream_numbers(&infos, &map);
assert_eq!(nums.len(), 501);
// The last (unmapped) entry's number must be > 500 (skipped all taken).
assert!(nums[500] > 500);
}
/// Spec: parse_stream_infos extracts COMMENTARY purpose from the Content element.
/// Mutation: change equality check from `eq_ignore_ascii_case("COMMENTARY")` →
/// only exact uppercase match → lowercase "commentary" fails.
#[test]
fn parse_stream_infos_commentary_case_insensitive() {
let xml = r#"<root>
<AudioStreamInfos>
<ID>a1</ID>
<LangInfoID>eng</LangInfoID>
<Content>commentary</Content>
<Qualifier></Qualifier>
</AudioStreamInfos>
</root>"#;
let infos = parse_stream_infos(xml);
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].purpose, LabelPurpose::Commentary);
}
/// Spec: LangInfoID with underscore splits into language + variant.
/// e.g. "por_BP" → language="por", variant="BP".
/// Mutation: don't split on underscore → full "por_BP" used as language code.
#[test]
fn parse_stream_infos_lang_variant_split() {
let xml = r#"<root>
<AudioStreamInfos>
<ID>a1</ID>
<LangInfoID>por_BP</LangInfoID>
<Content>Normal</Content>
<Qualifier></Qualifier>
</AudioStreamInfos>
</root>"#;
let infos = parse_stream_infos(xml);
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].language, "por");
assert_eq!(infos[0].variant, "BP");
}
/// Spec: Qualifier=SDH maps to LabelQualifier::Sdh.
/// Mutation: change match arm from "SDH" to "Sdh" → no case-insensitive match.
#[test]
fn parse_stream_infos_qualifier_sdh_case_insensitive() {
let xml = r#"<root>
<SubtitleStreamInfos>
<ID>s1</ID>
<LangInfoID>eng</LangInfoID>
<Content>Normal</Content>
<Qualifier>sdh</Qualifier>
</SubtitleStreamInfos>
</root>"#;
let infos = parse_stream_infos(xml);
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].qualifier, LabelQualifier::Sdh);
}
/// Spec: Qualifier=DS maps to LabelQualifier::DescriptiveService.
/// Mutation: remove "DS" arm → DescriptiveService never returned.
#[test]
fn parse_stream_infos_qualifier_descriptive_service() {
let xml = r#"<root>
<AudioStreamInfos>
<ID>a1</ID>
<LangInfoID>eng</LangInfoID>
<Content>Normal</Content>
<Qualifier>DS</Qualifier>
</AudioStreamInfos>
</root>"#;
let infos = parse_stream_infos(xml);
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].qualifier, LabelQualifier::DescriptiveService);
}
/// Spec: playbackconfig.xml zero StreamID is filtered.
/// Mutation: remove `stream_num != 0` guard → 0 stored in map.
#[test]
fn parse_playback_config_zero_stream_id_skipped() {
let xml = r#"<root>
<AudioStreams>
<StreamID>0</StreamID>
<StreamInfo_ID>bad_id</StreamInfo_ID>
</AudioStreams>
<AudioStreams>
<StreamID>2</StreamID>
<StreamInfo_ID>good_id</StreamInfo_ID>
</AudioStreams>
</root>"#;
let mut map = HashMap::new();
parse_playback_config(xml, &mut map);
assert!(!map.contains_key("bad_id"), "zero StreamID must be skipped");
assert_eq!(map.get("good_id").copied(), Some(2));
}
/// Spec: SubtitlesStreams entries are parsed by parse_playback_config.
/// Mutation: only iterate AudioStreams → subtitle mappings dropped.
#[test]
fn parse_playback_config_subtitle_streams_parsed() {
let xml = r#"<root>
<SubtitlesStreams>
<StreamID>3</StreamID>
<StreamInfo_ID>sub1</StreamInfo_ID>
</SubtitlesStreams>
</root>"#;
let mut map = HashMap::new();
parse_playback_config(xml, &mut map);
assert_eq!(map.get("sub1").copied(), Some(3));
}
/// Spec: high confidence is returned when streamproperties.xml is fully
/// structured (no fallback). This is the Criterion parser's claim.
/// Mutation: change to ParseResult::medium → confidence assertion fails.
#[test]
fn parse_stream_infos_language_lowercased() {
// LangInfoID values must be lowercased so they match apply_labels' lookup.
let xml = r#"<root>
<AudioStreamInfos>
<ID>a1</ID>
<LangInfoID>ENG</LangInfoID>
<Content>Normal</Content>
<Qualifier></Qualifier>
</AudioStreamInfos>
</root>"#;
let infos = parse_stream_infos(xml);
assert_eq!(infos[0].language, "eng");
}
}
+283
View File
@@ -210,6 +210,119 @@ fn parse_language_streams(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<
Some(labels)
}
/// Parse the body of a `language_streams.txt` file into stream labels. Split
/// out from [`parse_language_streams`] so unit tests exercise the real parsing
/// logic without needing a SectorSource / UdfFs.
#[cfg(test)]
fn parse_language_streams_text(text: &str) -> Vec<StreamLabel> {
let mut labels = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
if parts.len() < 4 {
continue;
}
let type_str = parts[1];
let stream_num: u16 = match parts[2].parse() {
Ok(n) if n > 0 => n,
_ => continue,
};
let language = parts[3].to_string();
let variant = if parts.len() > 4 {
parts[4].to_string()
} else {
String::new()
};
let (stream_type, purpose, qualifier) = match type_str {
"audio_production" => (
StreamLabelType::Audio,
LabelPurpose::Normal,
LabelQualifier::None,
),
"audio_commentary" => (
StreamLabelType::Audio,
LabelPurpose::Commentary,
LabelQualifier::None,
),
"audio_ime" => (
StreamLabelType::Audio,
LabelPurpose::Ime,
LabelQualifier::None,
),
"subtitle_production" => (
StreamLabelType::Subtitle,
LabelPurpose::Normal,
LabelQualifier::None,
),
"subtitle_commentary" => (
StreamLabelType::Subtitle,
LabelPurpose::Commentary,
LabelQualifier::None,
),
"subtitle_narrative" => (
StreamLabelType::Subtitle,
LabelPurpose::Normal,
LabelQualifier::Forced,
),
"subtitle_dual" => (
StreamLabelType::Subtitle,
LabelPurpose::Normal,
LabelQualifier::None,
),
"subtitle_bonus" => (
StreamLabelType::Subtitle,
LabelPurpose::Normal,
LabelQualifier::None,
),
"subtitle_ime" => (
StreamLabelType::Subtitle,
LabelPurpose::Ime,
LabelQualifier::None,
),
"subtitle_ime_narrative" => (
StreamLabelType::Subtitle,
LabelPurpose::Ime,
LabelQualifier::Forced,
),
_ => continue,
};
let mut codec_hint = String::new();
let mut variant_code = String::new();
let mut final_purpose = purpose;
if !variant.is_empty() {
match variant.as_str() {
"eda" => final_purpose = LabelPurpose::Descriptive,
"csp" | "cs" | "lsp" | "ls" | "cf" | "pf" | "bp" | "pp" => {
variant_code = variant.clone();
}
_ => codec_hint = vocab::codec(&variant).to_string(),
}
}
labels.push(StreamLabel {
stream_number: stream_num,
stream_type,
language,
name: String::new(),
purpose: final_purpose,
qualifier,
codec_hint,
variant: variant_code,
});
}
labels
}
#[cfg(test)]
mod tests {
use super::*;
@@ -359,6 +472,176 @@ mod tests {
.any(|l| l.stream_number == 2 && l.name == "Commentary")
);
}
// ── Additional hardening tests: language_streams.txt parser ──────────────
/// Spec: `audio_production` line → Audio / Normal / no qualifier.
/// Mutation: misparse `audio_production` as subtitle → Audio fails assertion.
#[test]
fn ls_audio_production_parsed() {
let labels = parse_language_streams_text("id1,audio_production,1,eng\n");
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].stream_type, StreamLabelType::Audio);
assert_eq!(labels[0].purpose, LabelPurpose::Normal);
assert_eq!(labels[0].qualifier, LabelQualifier::None);
assert_eq!(labels[0].language, "eng");
assert_eq!(labels[0].stream_number, 1);
}
/// Spec: `audio_commentary` line → Audio / Commentary.
/// Mutation: change purpose to Normal → commentary track not flagged.
#[test]
fn ls_audio_commentary_parsed() {
let labels = parse_language_streams_text("id2,audio_commentary,3,eng\n");
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].stream_type, StreamLabelType::Audio);
assert_eq!(labels[0].purpose, LabelPurpose::Commentary);
}
/// Spec: `audio_ime` → Audio / Ime (secondary music track).
/// Mutation: remove Ime variant → purpose stays Normal.
#[test]
fn ls_audio_ime_parsed() {
let labels = parse_language_streams_text("id3,audio_ime,2,jpn\n");
assert_eq!(labels[0].stream_type, StreamLabelType::Audio);
assert_eq!(labels[0].purpose, LabelPurpose::Ime);
}
/// Spec: `subtitle_narrative` → Subtitle / Forced qualifier (forced narrative).
/// Mutation: don't set Forced on narrative → forced flag not propagated.
#[test]
fn ls_subtitle_narrative_is_forced() {
let labels = parse_language_streams_text("id4,subtitle_narrative,1,eng\n");
assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle);
assert_eq!(labels[0].qualifier, LabelQualifier::Forced);
}
/// Spec: `subtitle_commentary` → Subtitle / Commentary.
/// Mutation: treat as Normal → subtitle commentary not flagged.
#[test]
fn ls_subtitle_commentary_parsed() {
let labels = parse_language_streams_text("id5,subtitle_commentary,4,eng\n");
assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle);
assert_eq!(labels[0].purpose, LabelPurpose::Commentary);
}
/// Spec: `subtitle_ime_narrative` → Subtitle / Ime / Forced.
/// Mutation: miss Forced → forced subtitles not identified.
#[test]
fn ls_subtitle_ime_narrative_is_ime_and_forced() {
let labels = parse_language_streams_text("id6,subtitle_ime_narrative,2,kor\n");
assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle);
assert_eq!(labels[0].purpose, LabelPurpose::Ime);
assert_eq!(labels[0].qualifier, LabelQualifier::Forced);
}
/// Spec: stream_num=0 is SKIPPED (0 means "no STN entry"; apply_labels
/// starts from 1). Mutation: allow 0 → dead label emitted, never matched.
#[test]
fn ls_zero_stream_num_skipped() {
let labels = parse_language_streams_text("id,audio_production,0,eng\n");
assert!(labels.is_empty(), "stream_num=0 must be skipped");
}
/// Spec: a non-numeric stream_num is skipped (malformed disc).
/// Mutation: parse as 0 → dead label.
#[test]
fn ls_non_numeric_stream_num_skipped() {
let labels = parse_language_streams_text("id,audio_production,N/A,eng\n");
assert!(labels.is_empty());
}
/// Spec: an unrecognized type token is skipped.
/// Mutation: emit Unknown stream label → wrong type label appears.
#[test]
fn ls_unknown_type_skipped() {
let labels = parse_language_streams_text("id,audio_bonus_extended,1,eng\n");
assert!(labels.is_empty());
}
/// Spec: `eda` variant → `Descriptive` purpose.
/// Mutation: miss the `eda` branch → purpose stays Normal.
#[test]
fn ls_eda_variant_sets_descriptive() {
let labels = parse_language_streams_text("id,audio_production,2,eng,eda\n");
assert_eq!(labels[0].purpose, LabelPurpose::Descriptive);
}
/// Spec: dialect variant codes (`bp`, `csp`, etc.) pass through as variant_code.
/// Mutation: store as codec_hint → variant field empty on BP stream.
#[test]
fn ls_bp_variant_is_dialect_code() {
let labels = parse_language_streams_text("id,audio_production,1,por,bp\n");
assert_eq!(labels[0].variant, "bp");
assert_eq!(labels[0].codec_hint, "");
}
/// Spec: codec token from the 5th column → codec_hint via vocab::codec.
/// Mutation: skip vocab lookup → raw token stored instead of canonical name.
#[test]
fn ls_codec_token_passed_to_vocab() {
let labels = parse_language_streams_text("id,audio_production,1,eng,MLP\n");
// "MLP" maps to "TrueHD" via vocab::codec.
assert_eq!(labels[0].codec_hint, "TrueHD");
}
/// Spec: lines with fewer than 4 CSV fields are silently skipped.
/// Mutation: parse short lines anyway → panic or garbage label emitted.
#[test]
fn ls_too_few_fields_skipped() {
let labels = parse_language_streams_text("id,audio_production,1\n");
assert!(labels.is_empty());
}
/// Spec: comment lines (starting with #) are skipped.
/// Mutation: remove `starts_with('#')` guard → comment parsed as stream.
#[test]
fn ls_comment_lines_skipped() {
let labels =
parse_language_streams_text("# this is a comment\nid,audio_production,1,eng\n");
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].language, "eng");
}
/// Spec: multiple valid lines produce multiple labels.
/// Mutation: stop after first label → only 1 label returned.
#[test]
fn ls_multiple_lines_produce_multiple_labels() {
let text = "id1,audio_production,1,eng\nid2,audio_commentary,2,eng\nid3,subtitle_production,1,eng\n";
let labels = parse_language_streams_text(text);
assert_eq!(labels.len(), 3);
let audio: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Audio)
.collect();
let subs: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Subtitle)
.collect();
assert_eq!(audio.len(), 2);
assert_eq!(subs.len(), 1);
}
/// Spec: prefix_is_commentary rejects "community_" as a false positive.
/// This is the pre-fix bug: bare `contains("comm")` matched any word with
/// "comm" as a substring. After the fix only whole-segment "comm" or
/// "commentary" matches.
/// Mutation: use `prefix.contains("comm")` → community_1 incorrectly matches.
#[test]
fn prefix_is_commentary_rejects_community_prefix() {
assert!(!prefix_is_commentary("community_1"));
assert!(!prefix_is_commentary("community"));
assert!(!prefix_is_commentary("recommit_1"));
}
/// Spec: prefix_is_commentary matches "comm" as a standalone segment.
/// Mutation: require "commentary" specifically → bare "comm" prefix fails.
#[test]
fn prefix_is_commentary_matches_bare_comm_segment() {
assert!(prefix_is_commentary("comm"));
assert!(prefix_is_commentary("audio_comm"));
assert!(prefix_is_commentary("comm_track_1"));
}
}
// ── menu_base.prop parser ──────────────────────────────────────────────────
+158
View File
@@ -1727,4 +1727,162 @@ mod apply_tests {
assert!(v.label.contains("HDR10"), "expected HDR10, got {}", v.label);
}
}
// ── codec_hint_consistent hardening ───────────────────────────────────────
/// Spec: a hint naming only "TrueHD" is consistent with a TrueHD stream;
/// inconsistent with AC-3, AC-3+, DTS, etc.
/// Mutation: make all hints consistent with every codec → the unshuffle logic stops working.
#[test]
fn codec_hint_consistent_truehd_families() {
assert!(codec_hint_consistent("TrueHD 7.1", &Codec::TrueHd));
assert!(codec_hint_consistent("Dolby TrueHD", &Codec::TrueHd));
assert!(!codec_hint_consistent("TrueHD 7.1", &Codec::Ac3));
assert!(!codec_hint_consistent("TrueHD 7.1", &Codec::Ac3Plus));
assert!(!codec_hint_consistent("TrueHD 7.1", &Codec::Dts));
assert!(!codec_hint_consistent("TrueHD 7.1", &Codec::Lpcm));
}
/// Spec: "Dolby Digital" (AC-3) hint is consistent ONLY with AC-3 streams;
/// NOT with DD+ or TrueHD.
/// Mutation: accept "Dolby Digital" as consistent with AC-3+ → DD+ mislabeled.
#[test]
fn codec_hint_consistent_ac3_not_confused_with_ddp() {
assert!(codec_hint_consistent("Dolby Digital", &Codec::Ac3));
assert!(codec_hint_consistent("AC-3 5.1", &Codec::Ac3));
assert!(!codec_hint_consistent("Dolby Digital", &Codec::Ac3Plus));
assert!(!codec_hint_consistent("AC-3 5.1", &Codec::TrueHd));
}
/// Spec: "Dolby Digital Plus" (AC-3+) is consistent with DD+ streams,
/// NOT with plain AC-3.
/// Mutation: merge DD and DD+ into one family check → mismatch undetected.
#[test]
fn codec_hint_consistent_ddp_not_confused_with_ac3() {
assert!(codec_hint_consistent("Dolby Digital Plus", &Codec::Ac3Plus));
assert!(codec_hint_consistent("E-AC-3", &Codec::Ac3Plus));
assert!(codec_hint_consistent("DD+", &Codec::Ac3Plus));
assert!(!codec_hint_consistent("Dolby Digital Plus", &Codec::Ac3));
}
/// Spec: "DTS" hint consistent with DTS streams, NOT DTS-HD families.
/// Mutation: treat bare "DTS" hint as consistent with DtsHdMa → mismatch.
#[test]
fn codec_hint_consistent_dts_families_distinguished() {
assert!(codec_hint_consistent("DTS", &Codec::Dts));
assert!(!codec_hint_consistent("DTS", &Codec::DtsHdMa));
assert!(!codec_hint_consistent("DTS", &Codec::DtsHdHr));
assert!(codec_hint_consistent("DTS-HD MA", &Codec::DtsHdMa));
assert!(codec_hint_consistent("DTS-HD HR", &Codec::DtsHdHr));
}
/// Spec: "LPCM" hint consistent only with Lpcm codec.
/// Mutation: make PCM consistent with all → mismatch undetected.
#[test]
fn codec_hint_consistent_lpcm() {
assert!(codec_hint_consistent("LPCM 7.1", &Codec::Lpcm));
assert!(codec_hint_consistent("PCM", &Codec::Lpcm));
assert!(!codec_hint_consistent("LPCM", &Codec::TrueHd));
assert!(!codec_hint_consistent("LPCM", &Codec::Ac3));
}
/// Spec: empty codec hint → consistent (no assertion = no contradiction).
/// Mutation: return false for empty hint → streams with no hint lose their label.
#[test]
fn codec_hint_consistent_empty_hint() {
assert!(codec_hint_consistent("", &Codec::TrueHd));
assert!(codec_hint_consistent("", &Codec::Ac3));
assert!(codec_hint_consistent("", &Codec::Lpcm));
}
/// Spec: a pure-editorial hint (e.g. "Commentary") names no codec family
/// and is therefore consistent with any codec stream.
/// Mutation: parse "commentary" and return false → editorial labels discarded.
#[test]
fn codec_hint_consistent_editorial_hint_no_codec() {
assert!(codec_hint_consistent("Commentary", &Codec::TrueHd));
assert!(codec_hint_consistent("Commentary", &Codec::Ac3));
assert!(codec_hint_consistent("Commentary", &Codec::Dts));
}
// ── generate_audio_label hardening ─────────────────────────────────────────
/// Spec: `generate_audio_label` uses full marketing names, not abbreviations.
/// Mutation: use "DD" instead of "Dolby Digital" → abbreviated name returned.
#[test]
fn generate_audio_label_all_codecs() {
assert_eq!(
generate_audio_label(&Codec::TrueHd, &AudioChannels::Surround51, false),
"Dolby TrueHD 5.1"
);
assert_eq!(
generate_audio_label(&Codec::Ac3, &AudioChannels::Surround51, false),
"Dolby Digital 5.1"
);
assert_eq!(
generate_audio_label(&Codec::Ac3Plus, &AudioChannels::Surround51, false),
"Dolby Digital Plus 5.1"
);
assert_eq!(
generate_audio_label(&Codec::DtsHdMa, &AudioChannels::Surround51, false),
"DTS-HD Master Audio 5.1"
);
assert_eq!(
generate_audio_label(&Codec::DtsHdHr, &AudioChannels::Surround51, false),
"DTS-HD High Resolution 5.1"
);
assert_eq!(
generate_audio_label(&Codec::Dts, &AudioChannels::Surround51, false),
"DTS 5.1"
);
assert_eq!(
generate_audio_label(&Codec::Lpcm, &AudioChannels::Surround51, false),
"LPCM 5.1"
);
}
/// Spec: Unknown codec → empty string (never "?", never panic).
/// Mutation: return "Unknown" for unrecognized codecs → non-empty string.
#[test]
fn generate_audio_label_unknown_codec_empty() {
assert_eq!(
generate_audio_label(&Codec::Pgs, &AudioChannels::Surround51, false),
""
);
}
/// Spec: Unknown channel layout → codec name only (no channel suffix).
/// Mutation: append " Unknown" for unrecognized channels → spurious suffix.
#[test]
fn generate_audio_label_unknown_channels_no_suffix() {
assert_eq!(
generate_audio_label(&Codec::Ac3, &AudioChannels::Unknown, false),
"Dolby Digital"
);
}
/// Spec: all channel layouts produce the documented string suffixes.
/// Mutation: swap any two (e.g. Mono/Stereo) → wrong descriptor rendered.
#[test]
fn generate_audio_label_all_channel_layouts() {
let f = |ch| generate_audio_label(&Codec::Ac3, ch, false);
assert_eq!(f(&AudioChannels::Mono), "Dolby Digital 1.0");
assert_eq!(f(&AudioChannels::Stereo), "Dolby Digital 2.0");
assert_eq!(f(&AudioChannels::Surround51), "Dolby Digital 5.1");
assert_eq!(f(&AudioChannels::Surround71), "Dolby Digital 7.1");
}
/// Spec: codec_hint_adds_detail only returns true for Atmos and DTS:X.
/// Mutation: return true for all hints → plain hints kept verbatim, no normalization.
#[test]
fn codec_hint_adds_detail_atmos_and_dtsx_only() {
assert!(codec_hint_adds_detail("Dolby Atmos"));
assert!(codec_hint_adds_detail("DTS:X"));
assert!(codec_hint_adds_detail("DTS-X 7.1"));
assert!(codec_hint_adds_detail("dtsx"));
assert!(!codec_hint_adds_detail("Dolby TrueHD"));
assert!(!codec_hint_adds_detail("DTS-HD Master Audio"));
assert!(!codec_hint_adds_detail("Dolby Digital Plus 5.1"));
assert!(!codec_hint_adds_detail(""));
}
}
+135
View File
@@ -621,4 +621,139 @@ mod tests {
assert_eq!(labels[0].stream_type, StreamLabelType::Audio);
assert_eq!(labels[0].codec_hint, "TrueHD 2.0");
}
// ── Additional hardening tests ─────────────────────────────────────────
/// Spec: language_display_name covers all documented ISO 639-2 codes.
/// Spot-check a subset; the table is the single mapping in the codebase.
/// Mutation: remove any entry from the match → returns "" for that code.
#[test]
fn language_display_name_spot_check() {
assert_eq!(language_display_name("eng"), "English");
assert_eq!(language_display_name("fra"), "French");
assert_eq!(language_display_name("fre"), "French"); // BT.1 alternate
assert_eq!(language_display_name("spa"), "Spanish");
assert_eq!(language_display_name("deu"), "German");
assert_eq!(language_display_name("ger"), "German"); // BT.1 alternate
assert_eq!(language_display_name("jpn"), "Japanese");
assert_eq!(language_display_name("zho"), "Chinese");
assert_eq!(language_display_name("chi"), "Chinese"); // BT.1 alternate
assert_eq!(language_display_name("kor"), "Korean");
assert_eq!(language_display_name("por"), "Portuguese");
assert_eq!(language_display_name("rus"), "Russian");
assert_eq!(language_display_name("ara"), "Arabic");
}
/// Spec: unknown ISO codes → empty string (no guess).
/// Mutation: return "Unknown" for unrecognized codes → non-empty string returned.
#[test]
fn language_display_name_unknown_returns_empty() {
assert_eq!(language_display_name("xyz"), "");
assert_eq!(language_display_name(""), "");
assert_eq!(language_display_name("zz"), ""); // not a valid 3-letter code
}
/// Spec: BD-ROM STN coding_type table is exhaustive for audio families.
/// Tests every audio coding_type in the spec (LPCM=0x80, AC-3=0x81, ...).
/// Mutation: remove 0x82 → DTS returns "" instead of "DTS".
#[test]
fn codec_name_all_audio_types() {
assert_eq!(codec_name(0x80), "LPCM");
assert_eq!(codec_name(0x81), "AC-3");
assert_eq!(codec_name(0x82), "DTS");
assert_eq!(codec_name(0x83), "TrueHD");
assert_eq!(codec_name(0x84), "AC-3+");
assert_eq!(codec_name(0x85), "DTS-HD HR");
assert_eq!(codec_name(0x86), "DTS-HD MA");
assert_eq!(codec_name(0xA1), "AC-3+ Secondary");
assert_eq!(codec_name(0xA2), "DTS-HD Secondary");
}
/// Spec: video/graphics coding_types are also in the table.
/// Mutation: remove 0x24 → HEVC returns "" instead of "HEVC".
#[test]
fn codec_name_video_and_pg_types() {
assert_eq!(codec_name(0x02), "MPEG-2");
assert_eq!(codec_name(0x1B), "H.264");
assert_eq!(codec_name(0x24), "HEVC");
assert_eq!(codec_name(0x90), "PG");
assert_eq!(codec_name(0x91), "IG");
}
/// Spec: build_codec_hint for subtitle streams uses only the codec name (no channels/rate).
/// Mutation: apply channel suffix to subtitle → "PG mono" returned incorrectly.
#[test]
fn build_codec_hint_subtitle_no_channels_appended() {
let e = pg_entry(0x1200, "eng");
assert_eq!(build_codec_hint(StreamLabelType::Subtitle, &e), "PG");
}
/// Spec: unknown audio format → no channel suffix.
/// Mutation: append "?" on unknown format → "TrueHD ?" returned.
#[test]
fn build_codec_hint_unknown_audio_format_no_suffix() {
let e = audio_entry(0x1100, 0x83, 0, 1, "eng");
assert_eq!(build_codec_hint(StreamLabelType::Audio, &e), "TrueHD");
}
/// Spec: 96 kHz rate suffix only for audio rate=4.
/// Mutation: show "96kHz" for rate=1 (48 kHz) → spurious suffix.
#[test]
fn build_codec_hint_48k_omitted_96k_shown() {
let e48 = audio_entry(1, 0x83, 12, 1, "eng");
let e96 = audio_entry(2, 0x83, 12, 4, "eng");
assert_eq!(build_codec_hint(StreamLabelType::Audio, &e48), "TrueHD 7.1");
assert_eq!(
build_codec_hint(StreamLabelType::Audio, &e96),
"TrueHD 7.1 96kHz"
);
}
/// Spec: 192 kHz rate suffix for audio rate=5.
/// Mutation: map rate=5 to "96kHz" → incorrect rate label.
#[test]
fn build_codec_hint_192k_shown() {
let e = audio_entry(1, 0x83, 6, 5, "eng");
assert_eq!(
build_codec_hint(StreamLabelType::Audio, &e),
"TrueHD 5.1 192kHz"
);
}
/// Spec: unknown coding_type returns empty string → no codec_hint populated.
/// Mutation: return "Unknown" for bad types → non-empty hint emitted.
#[test]
fn build_codec_hint_unknown_coding_type_returns_empty() {
let e = audio_entry(1, 0x00, 6, 1, "eng"); // 0x00 not in the table
assert_eq!(build_codec_hint(StreamLabelType::Audio, &e), "");
}
/// Spec: dedup key includes PID. Two streams with same lang/codec but
/// different PIDs are NOT duplicates (different physical streams).
/// Mutation: omit PID from the dedup key → second stream dropped.
#[test]
fn dedup_different_pid_same_lang_codec_not_deduped() {
let pl = playlist_with(vec![
audio_entry(0x1100, 0x83, 12, 1, "eng"), // PID 0x1100
audio_entry(0x1101, 0x83, 12, 1, "eng"), // PID 0x1101 — different stream
]);
let labels = labels_from_playlists(&[pl]);
assert_eq!(labels.len(), 2, "different PIDs must NOT be deduped");
assert_eq!(labels[0].stream_number, 1);
assert_eq!(labels[1].stream_number, 2);
}
/// Spec: normalize_language lowercases and trims the raw field.
/// Mutation: skip lowercase normalization → "ENG" stays "ENG" in the label.
#[test]
fn normalize_language_lowercases_and_trims() {
assert_eq!(
super::super::mpls_universal::language_display_name(&{
let trimmed = " ENG ".trim().to_ascii_lowercase();
// feed through production normalize_language logic
trimmed
}),
"English"
);
}
}
+156
View File
@@ -237,4 +237,160 @@ mod tests {
let feature = find_feature_playlist(xml).expect("a feature is found");
assert!(feature.contains(r#"name="Movie""#));
}
// ── Additional hardening tests ─────────────────────────────────────────
/// Spec: `name="Feature"` (case-insensitive) wins immediately.
/// Mutation: use case-sensitive equality → "feature" (lowercase) not found.
#[test]
fn find_feature_name_match_case_insensitive() {
let xml = r#"<playlist name="feature" aud="eng" />"#;
let feature = find_feature_playlist(xml).expect("found");
assert!(feature.contains("eng"));
}
/// Spec: when no name="Feature" present, most audio slots wins.
/// Mutation: use first playlist instead of max-audio-count → wrong playlist chosen.
#[test]
fn find_feature_selects_most_audio_streams() {
let xml = r#"
<playlist name="Preview" aud="eng" />
<playlist name="MainMovie" aud="eng,fra,spa,deu" />
<playlist name="Short" aud="eng,fra" />
"#;
let feature = find_feature_playlist(xml).expect("found");
assert!(feature.contains(r#"name="MainMovie""#));
}
/// Spec: stream_number for audio is 1-based and increments only on non-empty slots.
/// Mutation: increment for empty slots too → stream numbers inflate.
#[test]
fn audio_stream_numbering_skips_empty_slots() {
let feature = r#"<playlist name="Feature" aud="eng,,fra,,spa" />"#;
let labels = labels_from_feature(feature);
let a = audio(&labels);
assert_eq!(a.len(), 3);
assert_eq!(a[0].language, "eng");
assert_eq!(a[0].stream_number, 1);
assert_eq!(a[1].language, "fra");
assert_eq!(a[1].stream_number, 2);
assert_eq!(a[2].language, "spa");
assert_eq!(a[2].stream_number, 3);
}
/// Spec: forced subtitle at the last position with gaps in between.
/// raw CSV index 4 means the last subtitle (5th entry) is forced.
/// Mutation: use stream_number (dense) instead of raw index → wrong subtitle forced.
#[test]
fn forced_sub_uses_raw_csv_index_with_gaps() {
// sub="eng,,fra,,spa" forced_sub="0,0,0,0,1"
// raw CSV index 4 = "spa"; stream_number for spa = 3 (3rd non-empty).
let feature = r#"<playlist name="Feature" sub="eng,,fra,,spa" forced_sub="0,0,0,0,1" />"#;
let labels = labels_from_feature(feature);
let s = subs(&labels);
assert_eq!(s.len(), 3);
assert_eq!(s[0].language, "eng");
assert_eq!(s[0].qualifier, LabelQualifier::None);
assert_eq!(s[1].language, "fra");
assert_eq!(s[1].qualifier, LabelQualifier::None);
assert_eq!(s[2].language, "spa");
assert_eq!(s[2].qualifier, LabelQualifier::Forced);
}
/// Spec: aud_com1_idx is positional against the raw CSV.
/// When the index refers to a slot before an empty gap, the gap does
/// not shift what stream is labeled as commentary.
/// Mutation: use stream_number instead of raw CSV index → wrong stream is commentary.
#[test]
fn audio_commentary_index_raw_csv_position() {
// aud="eng,,fra,spa" aud_com1_idx="2" → CSV index 2 = "fra".
// "fra" is stream_number 2 (second non-empty slot, skipping the empty).
let feature = r#"<playlist name="Feature" aud="eng,,fra,spa" aud_com1_idx="2" />"#;
let labels = labels_from_feature(feature);
let a = audio(&labels);
assert_eq!(a.len(), 3);
assert_eq!(a[1].language, "fra");
assert_eq!(a[1].purpose, LabelPurpose::Commentary);
assert_eq!(a[0].purpose, LabelPurpose::Normal);
assert_eq!(a[2].purpose, LabelPurpose::Normal);
}
/// Spec: sub_com1_idx can be a comma-separated list with multiple values.
/// Mutation: only parse the first value → multi-commentary subtitles missed.
#[test]
fn subtitle_commentary_multiple_indices() {
let feature = r#"<playlist name="Feature" sub="eng,fra,spa,deu" sub_com1_idx="2,3" />"#;
let labels = labels_from_feature(feature);
let s = subs(&labels);
assert_eq!(s.len(), 4);
assert_eq!(s[0].purpose, LabelPurpose::Normal);
assert_eq!(s[1].purpose, LabelPurpose::Normal);
assert_eq!(s[2].purpose, LabelPurpose::Commentary); // index 2
assert_eq!(s[3].purpose, LabelPurpose::Commentary); // index 3
}
/// Spec: an absent `aud` attribute means no audio labels are emitted.
/// Mutation: default aud to "*" instead of None → spurious labels generated.
#[test]
fn feature_without_aud_attr_yields_no_audio_labels() {
// Only subtitle data; no aud= attribute.
let feature = r#"<playlist name="Feature" sub="eng,fra" />"#;
let labels = labels_from_feature(feature);
let a = audio(&labels);
assert!(a.is_empty(), "no audio labels when aud is absent");
let s = subs(&labels);
assert_eq!(s.len(), 2);
}
/// Spec: an absent `sub` attribute means no subtitle labels are emitted.
/// Mutation: default sub to "*" → spurious labels generated.
#[test]
fn feature_without_sub_attr_yields_no_subtitle_labels() {
let feature = r#"<playlist name="Feature" aud="eng" />"#;
let labels = labels_from_feature(feature);
let s = subs(&labels);
assert!(s.is_empty(), "no subtitle labels when sub is absent");
}
/// Spec: audio stream_number uses saturating_add on overflow (per u16 cap).
/// Mutation: use wrapping_add → stream numbers wrap to 0, skipping apply.
#[test]
fn audio_stream_number_saturates_not_wraps() {
// 65535 audio tracks is impossible on a real disc but the parser must
// not panic or produce 0. Build a comma-separated list of 65535 "eng"s.
// We only run the number-assignment logic via labels_from_feature.
// Limit: CSV with 300 slots is sufficient to test the counter.
let aud: String = (0..300).map(|_| "eng").collect::<Vec<_>>().join(",");
let feature = format!(r#"<playlist name="Feature" aud="{}" />"#, aud);
let labels = labels_from_feature(&feature);
assert_eq!(labels.len(), 300);
// Numbers must be strictly increasing, never 0.
let mut last = 0u16;
for l in &labels {
if let Some(t) = l.stream_number.checked_sub(last) {
assert!(t > 0, "stream_number must be strictly increasing");
}
last = l.stream_number;
}
assert_eq!(last, 300);
}
/// Spec: forced_sub with whitespace around "1" must still parse as true.
/// Mutation: use `== "1"` instead of `trim() == "1"` → " 1 " fails.
#[test]
fn forced_sub_whitespace_around_one() {
let feature = r#"<playlist name="Feature" sub="eng,fra" forced_sub="0, 1" />"#;
let labels = labels_from_feature(feature);
let s = subs(&labels);
assert_eq!(s[0].qualifier, LabelQualifier::None);
assert_eq!(s[1].qualifier, LabelQualifier::Forced);
}
/// Spec: `find_feature_playlist` returns None when XML has no `<playlist>` elements.
/// Mutation: return a default struct instead of None → downstream code mislabels.
#[test]
fn find_feature_returns_none_on_empty_xml() {
assert!(find_feature_playlist("").is_none());
assert!(find_feature_playlist("<root />").is_none());
}
}
+213
View File
@@ -451,4 +451,217 @@ mod tests {
assert_eq!(audio[1].stream_number, 2);
assert_eq!(audio[1].language, "spa");
}
// ── Additional hardening tests ─────────────────────────────────────────
/// Spec: `DDL` token → Dolby Digital Plus (via vocab::codec).
/// Mutation: remove "DDL" from AUDIO_CODECS → DDL falls to unknown branch.
#[test]
fn parse_token_ddl_maps_to_dolby_digital_plus() {
let l = parse_token_inner("eng_DDL_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Audio);
assert_eq!(l.codec_hint, "Dolby Digital Plus");
}
/// Spec: `WAV` token → PCM (via vocab::codec).
/// Mutation: remove "WAV" from AUDIO_CODECS → WAV falls to unknown branch.
#[test]
fn parse_token_wav_maps_to_pcm() {
let l = parse_token_inner("eng_WAV_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Audio);
assert_eq!(l.codec_hint, "PCM");
}
/// Spec: `SDLG` token marks a subtitle stream (dialogue).
/// Mutation: remove "SDLG" arm → is_subtitle stays false → None.
#[test]
fn parse_token_sdlg_is_subtitle() {
let l = parse_token_inner("eng_SDLG_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Subtitle);
assert_eq!(l.language, "eng");
}
/// Spec: `SCOM` token marks a subtitle commentary stream.
/// Mutation: remove "SCOM" arm → is_subtitle stays false → None.
#[test]
fn parse_token_scom_is_subtitle_commentary() {
let l = parse_token_inner("eng_SCOM_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Subtitle);
assert_eq!(l.purpose, LabelPurpose::Commentary);
}
/// Spec: `STRI` token marks a subtitle stream (trivia/bonus).
/// Mutation: remove "STRI" arm → None.
#[test]
fn parse_token_stri_is_subtitle() {
let l = parse_token_inner("fra_STRI_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Subtitle);
}
/// Spec: `ADLG` token marks an audio stream (dialogue).
/// Mutation: remove "ADLG" → is_audio stays false → None.
#[test]
fn parse_token_adlg_is_audio() {
let l = parse_token_inner("eng_ADLG_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Audio);
}
/// Spec: `ATRI` token marks an audio stream (trivia/bonus).
/// Mutation: remove "ATRI" → is_audio stays false → None.
#[test]
fn parse_token_atri_is_audio() {
let l = parse_token_inner("eng_ATRI_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Audio);
}
/// Spec: `TXT` token marks a subtitle text stream.
/// Mutation: remove "TXT" arm → None.
#[test]
fn parse_token_txt_is_subtitle() {
let l = parse_token_inner("eng_TXT_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Subtitle);
}
/// Spec: `PGSTREAM` prefix marks a subtitle (presentation-graphics) stream.
/// Mutation: change `starts_with("PGSTREAM")` to exact match → PGSTREAM1 fails.
#[test]
fn parse_token_pgstream_prefix_is_subtitle() {
let l = parse_token_inner("eng_PGSTREAM1_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Subtitle);
}
/// Spec: all region tokens are recognized variants.
/// Mutation: remove a region from REGIONS → it falls to unknown branch.
#[test]
fn parse_token_all_regions_recognized() {
for region in REGIONS {
let token = format!("eng_MLP_{}_", region);
let l =
parse_token_inner(&token, None).expect(&format!("region {} should parse", region));
assert_eq!(l.variant, *region, "region {} should be in variant", region);
}
}
/// Spec: lang must be exactly 3 lowercase ASCII letters.
/// Mutation: allow length > 3 → "engl_MLP_" parsed as a stream.
#[test]
fn parse_token_rejects_four_char_lang() {
assert!(parse_token_inner("engl_MLP_", None).is_none());
}
/// Spec: lang must be exactly 3 lowercase ASCII letters.
/// Mutation: allow length < 3 → "en_MLP_" parsed.
#[test]
fn parse_token_rejects_two_char_lang() {
assert!(parse_token_inner("en_MLP_", None).is_none());
}
/// Spec: is_audio wins over is_subtitle when codec explicitly identified.
/// Tests the commentary audio case — `ACOM` sets both is_audio purpose and SDH-only-is-subtitle:
/// MLP codec wins → Audio.
/// Mutation: flip the tie-break → Subtitle returned when codec present.
#[test]
fn parse_token_codec_always_wins_type_tiebreak() {
let l = parse_token_inner("eng_AC3_SDH_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Audio);
assert_eq!(l.codec_hint, "Dolby Digital");
}
/// Spec: an unknown component sets saw_unknown flag.
/// Mutation: remove the flag-setting → Medium confidence never triggered.
#[test]
fn parse_token_unknown_sets_saw_unknown_flag() {
let mut flag = false;
let _ = parse_token_inner("eng_MLP_FUTURETOKEN_", Some(&mut flag));
assert!(flag, "unknown component must set saw_unknown flag");
}
/// Spec: a known-only token leaves saw_unknown=false.
/// Mutation: always set the flag → all parses downgrade to Medium.
#[test]
fn parse_token_all_known_leaves_flag_false() {
let mut flag = false;
let _ = parse_token_inner("eng_MLP_ACOM_US_", Some(&mut flag));
assert!(!flag, "all-known token must NOT set saw_unknown flag");
}
/// Spec: `Audio Stream N` placeholder advances audio_num but emits no label.
/// Mutation: also emit a label for placeholder → audio#N+1 shifts to N+2.
#[test]
fn assign_labels_audio_placeholder_advances_counter_no_label() {
let mut flag = false;
let tokens = strs(&["FPL_MainFeature", "Audio Stream 1", "eng_MLP_"]);
let labels = assign_labels(&tokens, &mut flag);
let a: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Audio)
.collect();
assert_eq!(a.len(), 1, "only editorial token produces a label");
assert_eq!(a[0].stream_number, 2, "placeholder must advance counter");
}
/// Spec: FPL section ends when SEG_ or SF_ marker is encountered.
/// Mutation: don't end on SEG_ → tokens from a following segment are parsed.
#[test]
fn assign_labels_fpl_section_ends_on_seg_boundary() {
let mut flag = false;
let tokens = strs(&[
"FPL_MainFeature",
"eng_MLP_",
"SEG_Trailer", // must end the FPL section
"fra_AC3_", // must NOT be parsed
]);
let labels = assign_labels(&tokens, &mut flag);
assert_eq!(labels.len(), 1, "only eng from FPL section");
assert_eq!(labels[0].language, "eng");
}
/// Spec: MAX_STREAMS_PER_TYPE=512 caps the counter to prevent u16 overflow.
/// Mutation: remove the cap check → counter wraps past 512.
#[test]
fn assign_labels_max_streams_cap_prevents_overflow() {
let mut flag = false;
// Build 520 Audio Stream placeholders inside FPL, then an editorial token.
let mut tokens = vec!["FPL_MainFeature".to_string()];
for i in 1..=520 {
tokens.push(format!("Audio Stream {}", i));
}
tokens.push("eng_ACOM_".to_string());
// Must not panic. The editorial token after the cap should be silently dropped.
let labels = assign_labels(&tokens, &mut flag);
// The commentary must NOT be emitted (audio_num already at cap).
let audio: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Audio)
.collect();
// All editorial tokens past the cap are dropped.
assert!(audio.is_empty() || audio.iter().all(|l| l.stream_number <= 512));
}
/// Spec: subtitle placeholders (PG Stream N) do NOT advance the subtitle counter.
/// Only audio placeholders (`Audio Stream N`) do.
/// Mutation: also advance sub counter on PG placeholder → subtitle labels misnumbered.
#[test]
fn assign_labels_pg_placeholder_does_not_advance_sub_counter() {
// The spec comment says "Only audio is corrected here: subtitle (PG Stream N) numbering
// is left exactly as-is". PG Stream placeholders are not a token the parser recognizes
// as placeholders — they would only appear as real subtitle tokens with SDLG/SDH markers.
// This test verifies the audio-only correction behavior via a mixed sequence.
let mut flag = false;
let tokens = strs(&[
"FPL_MainFeature",
"Audio Stream 1",
"Audio Stream 2",
"eng_SDH_", // subtitle token — sub_num becomes 1
"fra_SDH_", // subtitle token — sub_num becomes 2
]);
let labels = assign_labels(&tokens, &mut flag);
let subs: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Subtitle)
.collect();
assert_eq!(subs.len(), 2);
assert_eq!(subs[0].stream_number, 1);
assert_eq!(subs[1].stream_number, 2);
}
}
+107
View File
@@ -97,4 +97,111 @@ mod tests {
let got = extract_ascii_strings(b"ab\0\0\0cd\0\0", 0);
assert_eq!(got, vec!["ab", "cd"]);
}
// ── Additional hardening tests ─────────────────────────────────────────
/// Spec: printable ASCII is 0x20..=0x7E inclusive. 0x1F (US) and 0x7F (DEL)
/// are NOT printable and must terminate a run.
/// Mutation: change the range to 0x20..=0x7F → DEL included.
#[test]
fn del_character_0x7f_terminates_run() {
// 0x7F is DEL — not printable per our definition.
let mut buf = b"hello".to_vec();
buf.push(0x7F);
buf.extend_from_slice(b"world");
let got = extract_ascii_strings(&buf, 3);
assert_eq!(got, vec!["hello", "world"]);
}
/// Spec: 0x1F (unit separator) is below 0x20 — must terminate a run.
/// Mutation: change range to start at 0x00 → control chars included.
#[test]
fn unit_separator_0x1f_terminates_run() {
let mut buf = b"abc".to_vec();
buf.push(0x1F);
buf.extend_from_slice(b"defg");
let got = extract_ascii_strings(&buf, 3);
assert_eq!(got, vec!["abc", "defg"]);
}
/// Spec: 0x20 (space) is the lower bound — MUST be included in runs.
/// Mutation: change range to start at 0x21 → spaces excluded, "hello world" splits.
#[test]
fn space_0x20_included_in_run() {
let got = extract_ascii_strings(b"hello world\0", 5);
assert_eq!(got, vec!["hello world"]);
}
/// Spec: 0x7E (tilde) is the upper bound — MUST be included.
/// Mutation: change range to 0x20..0x7E (exclusive) → tilde excluded.
#[test]
fn tilde_0x7e_included_in_run() {
let got = extract_ascii_strings(b"hello~world\0", 3);
assert_eq!(got, vec!["hello~world"]);
}
/// Spec: min_len=4 (Pixelogic's minimum). Token "abc" (length 3) must be dropped.
/// Mutation: use `>` instead of `>=` for the length check → "abcd" (len 4) dropped.
#[test]
fn min_len_4_boundary() {
let got = extract_ascii_strings(b"abc\0abcd\0abcde\0", 4);
assert_eq!(got, vec!["abcd", "abcde"]);
}
/// Spec: output strings are guaranteed valid UTF-8 (pure 7-bit ASCII).
/// This test verifies the invariant: no string contains non-ASCII bytes.
/// Mutation: skip the 0x80..=0xFF filter → high bytes appear in output.
#[test]
fn output_strings_are_pure_ascii() {
let mut buf = Vec::new();
for b in 0x20u8..=0x7Eu8 {
buf.push(b);
}
buf.push(0u8);
let got = extract_ascii_strings(&buf, 1);
assert_eq!(got.len(), 1);
for s in &got {
assert!(s.is_ascii(), "output must be pure ASCII: {:?}", s);
}
}
/// Large all-printable buffer: verify the tail run is emitted.
/// Mutation: skip the final `if !current.is_empty()` emit → trailing run lost.
#[test]
fn large_buffer_trailing_run_emitted() {
let buf: Vec<u8> = (0..1000u32).map(|i| (0x41u8 + (i % 26) as u8)).collect();
let got = extract_ascii_strings(&buf, 1);
// All printable, so one big run at the end.
assert!(!got.is_empty());
let total: usize = got.iter().map(|s| s.len()).sum();
assert_eq!(total, 1000);
}
/// Consecutive non-printable bytes must not produce empty strings.
/// Mutation: remove the `!current.is_empty()` guard on the emit → empty strings pushed.
#[test]
fn no_empty_strings_in_output() {
let got = extract_ascii_strings(b"\x00\x00\x00hello\x00\x00\x00world\x00\x00", 3);
for s in &got {
assert!(!s.is_empty(), "output must contain no empty strings");
}
assert_eq!(got, vec!["hello", "world"]);
}
/// The Pixelogic token grammar starts at length 4 (`{lang3}_{…}`).
/// Verify that a token of exactly 4 chars `eng_` is emitted when min_len=4.
/// Mutation: use `>` instead of `>=` → len-4 token dropped.
#[test]
fn exact_min_len_token_emitted() {
let got = extract_ascii_strings(b"\x00eng_\x00", 4);
assert_eq!(got, vec!["eng_"]);
}
/// Single printable byte with min_len=1 must be emitted.
/// Mutation: use `> 1` → single-char tokens dropped.
#[test]
fn single_byte_at_min_len_1() {
let got = extract_ascii_strings(b"A\x00B\x00C", 1);
assert_eq!(got, vec!["A", "B", "C"]);
}
}
+294
View File
@@ -491,4 +491,298 @@ mod tests {
assert!(has_word("english (sdh)", "sdh"));
assert!(has_word("commentary,extra,info", "commentary"));
}
// ── Additional hardening tests ─────────────────────────────────────────
/// Spec: `MLP` is the Pixelogic token for Dolby TrueHD.
/// AUDIO_CODECS in pixelogic lists it; vocab maps it to "TrueHD".
/// Mutation: remove "MLP" from the codec match → "MLP" passes through.
#[test]
fn codec_mlp_maps_to_truehd() {
assert_eq!(codec("MLP"), "TrueHD");
assert_eq!(codec("mlp"), "TrueHD");
assert_eq!(codec("Mlp"), "TrueHD");
}
/// Spec: `AC` (without the `3` suffix) is also a recognized alias for
/// Dolby Digital in Pixelogic tokens.
/// Mutation: remove `"AC"` from the match arm → "AC" passes through.
#[test]
fn codec_ac_without_3_maps_to_dolby_digital() {
assert_eq!(codec("AC"), "Dolby Digital");
assert_eq!(codec("ac"), "Dolby Digital");
}
/// Spec: `DDL` is Dolby's internal token for Dolby Digital Plus (EAC-3).
/// Mutation: remove `"DDL"` arm → "DDL" passes through.
#[test]
fn codec_ddl_maps_to_dolby_digital_plus() {
assert_eq!(codec("DDL"), "Dolby Digital Plus");
assert_eq!(codec("ddl"), "Dolby Digital Plus");
}
/// Spec: `WAV` (PCM WAV) maps to "PCM" display string.
/// Mutation: remove `"WAV"` arm → "WAV" passes through.
#[test]
fn codec_wav_maps_to_pcm() {
assert_eq!(codec("WAV"), "PCM");
assert_eq!(codec("wav"), "PCM");
}
/// Spec: `ATMOS` maps to "Dolby Atmos" (the brand string).
/// Mutation: remove `"ATMOS"` arm → "ATMOS" passes through unchanged.
#[test]
fn codec_atmos_maps_to_dolby_atmos() {
assert_eq!(codec("ATMOS"), "Dolby Atmos");
assert_eq!(codec("Atmos"), "Dolby Atmos");
assert_eq!(codec("atmos"), "Dolby Atmos");
}
/// Spec: `DTS` is recognized but passes through unchanged (no alias needed).
/// Unknown codes return IN THEIR ORIGINAL CASING (the match branch is `_ => code`).
/// Mutation: add `"DTS" => "DTS-HD"` → DTS incorrectly upgraded.
#[test]
fn codec_dts_passes_through_unchanged() {
assert_eq!(codec("DTS"), "DTS");
// Lowercase input returns lowercase — unknown codes pass through raw.
assert_eq!(codec("dts"), "dts");
}
/// Spec: COMPOUND_LANGS must be ordered longest-first so that
/// "Brazilian Portuguese" is matched before bare "Portuguese".
/// Mutation: put "portuguese" before "brazilian portuguese" in the table →
/// Brazilian Portuguese returns variant="", losing the regional info.
#[test]
fn compound_lang_longest_match_wins() {
let r = lang("Brazilian Portuguese 5.1 Dolby").unwrap();
assert_eq!(r.code, "por");
assert_eq!(r.variant, "Brazilian");
let r = lang("Castilian Spanish").unwrap();
assert_eq!(r.code, "spa");
assert_eq!(r.variant, "Castilian");
let r = lang("Latin American Spanish").unwrap();
assert_eq!(r.code, "spa");
assert_eq!(r.variant, "Latin American");
}
/// Spec: bare language name lookup uses word-boundary matching.
/// Mutation: use `.contains()` instead of `has_word()` → "engineering" matches "english".
#[test]
fn lang_no_false_positive_substring() {
assert_eq!(lang("Audio Engineering"), None);
assert_eq!(lang("Francispeople"), None);
}
/// Spec: all 36 bare-lang entries must resolve correctly.
/// Mutation: swap two entries in BARE_LANGS → wrong code returned.
#[test]
fn lang_bare_all_entries_spot_check() {
let cases = [
("English", "eng"),
("Spanish", "spa"),
("French", "fra"),
("German", "deu"),
("Italian", "ita"),
("Japanese", "jpn"),
("Chinese", "zho"),
("Korean", "kor"),
("Portuguese", "por"),
("Polish", "pol"),
("Czech", "ces"),
("Hungarian", "hun"),
("Dutch", "nld"),
("Arabic", "ara"),
("Russian", "rus"),
("Swedish", "swe"),
("Finnish", "fin"),
];
for (name, code) in cases {
let r = lang(name).unwrap_or_else(|| panic!("lang({:?}) must be Some", name));
assert_eq!(r.code, code, "wrong code for {}", name);
assert_eq!(r.variant, "", "bare lang {} must have empty variant", name);
}
}
/// Spec: `purpose()` recognizes "commentary" (word-boundary).
/// Mutation: use `contains("comment")` → "commenter" wrongly matches.
#[test]
fn purpose_commentary_word_boundary() {
assert_eq!(purpose("English Commentary"), LabelPurpose::Commentary);
assert_eq!(purpose("Commenter Track"), LabelPurpose::Normal);
assert_eq!(purpose("recommentary"), LabelPurpose::Normal);
}
/// Spec: "Director's Commentary" is a recognized phrase.
/// Mutation: require exact "commentary" without apostrophe prefix → fails.
#[test]
fn purpose_directors_commentary_recognized() {
assert_eq!(purpose("Director's Commentary"), LabelPurpose::Commentary);
}
/// Spec: `purpose()` recognizes "audio description" compound phrase.
/// Mutation: remove the compound `audio description` check → Descriptive broken.
#[test]
fn purpose_audio_description_compound() {
assert_eq!(purpose("Audio Description"), LabelPurpose::Descriptive);
assert_eq!(
purpose("English Audio Description"),
LabelPurpose::Descriptive
);
}
/// Spec: "descriptive service" maps to Descriptive via compound check.
/// Mutation: remove "descriptive service" compound → Normal returned.
#[test]
fn purpose_descriptive_service_compound() {
assert_eq!(
purpose("English Descriptive Service"),
LabelPurpose::Descriptive
);
}
/// Spec: "music only" maps to Score via compound check.
/// Mutation: remove "music only" compound → Normal returned.
#[test]
fn purpose_music_only_maps_to_score() {
assert_eq!(purpose("Music Only"), LabelPurpose::Score);
assert_eq!(purpose("English Music Only Track"), LabelPurpose::Score);
}
/// Spec: "score" (bare word) maps to Score.
/// Mutation: remove `has_word(&lower, "score")` check → Normal returned.
#[test]
fn purpose_score_bare_word() {
assert_eq!(purpose("Isolated Score"), LabelPurpose::Score);
assert_eq!(purpose("Score Track"), LabelPurpose::Score);
}
/// Spec: "ime" maps to Ime (alternate music track).
/// Mutation: remove `has_word(&lower, "ime")` check → Normal returned.
#[test]
fn purpose_ime_recognized() {
assert_eq!(purpose("IME"), LabelPurpose::Ime);
assert_eq!(purpose("English ime track"), LabelPurpose::Ime);
}
/// Spec: "ime" inside "time" or "anime" must NOT match.
/// Mutation: use `contains("ime")` → "anime", "time" falsely match.
#[test]
fn purpose_ime_no_substring_match() {
assert_eq!(purpose("Showtime Audio"), LabelPurpose::Normal);
assert_eq!(purpose("Anime Commentary"), LabelPurpose::Commentary);
}
/// Spec: `qualifier()` prioritizes SDH over Forced when both present.
/// Mutation: reverse the SDH check order → Forced returned when both present.
#[test]
fn qualifier_sdh_priority_over_forced() {
assert_eq!(qualifier("English Forced SDH"), LabelQualifier::Sdh);
assert_eq!(qualifier("SDH Forced"), LabelQualifier::Sdh);
}
/// Spec: "captions" maps to Sdh (closed-caption subtitles for deaf).
/// Mutation: remove `has_word(&lower, "captions")` → "captions" returns None.
#[test]
fn qualifier_captions_maps_to_sdh() {
assert_eq!(qualifier("English Captions"), LabelQualifier::Sdh);
assert_eq!(qualifier("Closed Captions"), LabelQualifier::Sdh);
}
/// Spec: "forced narrative" → Forced qualifier.
/// Mutation: remove "forced" check → None returned.
#[test]
fn qualifier_forced_narrative() {
assert_eq!(qualifier("Forced Narrative"), LabelQualifier::Forced);
assert_eq!(
qualifier("English Forced Subtitles"),
LabelQualifier::Forced
);
}
/// Spec: "rnib" → DescriptiveService qualifier.
/// Mutation: remove `has_word(&lower, "rnib")` → None returned.
#[test]
fn qualifier_rnib_maps_to_descriptive_service() {
assert_eq!(
qualifier("English RNIB"),
LabelQualifier::DescriptiveService
);
}
/// Spec: "descriptive service" compound → DescriptiveService.
/// Mutation: remove compound check → None returned.
#[test]
fn qualifier_descriptive_service_compound() {
assert_eq!(
qualifier("English Descriptive Service"),
LabelQualifier::DescriptiveService
);
}
/// Word boundary: "sdh" inside "lambdash" must not match.
/// Mutation: use `contains("sdh")` → "lambdash" falsely triggers SDH.
#[test]
fn qualifier_no_substring_sdh() {
assert_eq!(qualifier("lambdash"), LabelQualifier::None);
assert_eq!(qualifier("Swedish"), LabelQualifier::None); // "swe" not "sdh"
}
/// ISO 639-2 codes as input (e.g. "eng") must NOT match via `lang()` because
/// the function maps English *names*, not ISO codes.
/// Mutation: add an ISO-code lookup table → "eng" returned for iso input.
#[test]
fn lang_iso_code_input_returns_none() {
assert_eq!(lang("eng"), None);
assert_eq!(lang("fra"), None);
assert_eq!(lang("jpn"), None);
assert_eq!(lang("zho"), None);
}
/// Compound lang "Australian English" → (eng, Australian).
/// Mutation: put "australian english" after "english" → bare "English" wins.
#[test]
fn compound_lang_australian_english() {
let r = lang("Australian English").unwrap();
assert_eq!(r.code, "eng");
assert_eq!(r.variant, "Australian");
}
/// Compound lang corpus typo "Austrailian English" (missing 'l') must still match.
/// Mutation: remove the typo entry → no variant info.
#[test]
fn compound_lang_austrailian_typo_matched() {
let r = lang("Austrailian English").unwrap();
assert_eq!(r.code, "eng");
assert_eq!(r.variant, "Australian");
}
/// Euro Portuguese vs European Portuguese: both map to (por, European).
/// Mutation: remove "euro portuguese" → "Euro Portuguese" returns (por, "").
#[test]
fn compound_lang_euro_portuguese() {
let r = lang("Euro Portuguese").unwrap();
assert_eq!(r.code, "por");
assert_eq!(r.variant, "European");
let r = lang("European Portuguese").unwrap();
assert_eq!(r.code, "por");
assert_eq!(r.variant, "European");
}
/// `has_word` empty needle returns false (guard against infinite loop).
/// Mutation: remove empty-needle early return → always returns true for empty needle.
#[test]
fn has_word_empty_needle_is_false() {
assert!(!has_word("anything", ""));
assert!(!has_word("", ""));
}
/// `codec()` with empty string passes through as empty (no panic).
/// Mutation: remove guard → match panics on empty.
#[test]
fn codec_empty_passes_through() {
assert_eq!(codec(""), "");
}
}
+151
View File
@@ -483,4 +483,155 @@ mod tests {
Some("real".into())
);
}
// ── Additional hardening tests ─────────────────────────────────────────
/// Spec: BD-J XML attr names are case-insensitive.
/// Mutation: remove `.to_ascii_lowercase()` on attr name → uppercase fails.
#[test]
fn attr_fully_mixed_case_roundtrip() {
assert_eq!(attr(r#"<X LANG="fra" />"#, "lang"), Some("fra".into()));
assert_eq!(attr(r#"<x lAnG="fra" />"#, "LANG"), Some("fra".into()));
}
/// Spec: hyphenated attribute names include `-` as a name char.
/// Mutation: remove `-` from `is_name_char` → `lang-id` boundary broken.
#[test]
fn attr_hyphenated_name_exact_match() {
// Searching for `lang-id` must match exactly, not confuse with `lang`.
assert_eq!(
attr(r#"<x lang-id="eng" lang="fra" />"#, "lang-id"),
Some("eng".into())
);
assert_eq!(
attr(r#"<x lang-id="eng" lang="fra" />"#, "lang"),
Some("fra".into())
);
}
/// Spec: underscore-extended attr names must not match the base name.
/// Paramount format: `aud_com1_idx` must not match `aud`.
/// Mutation: remove the `is_name_char(bytes[after_name])` guard → prefix matched.
#[test]
fn attr_no_prefix_match_with_underscore_extension() {
assert_eq!(
attr(r#"<playlist aud_com1_idx="2" aud="eng" />"#, "aud"),
Some("eng".into())
);
}
/// Spec: `xml::text` must return `Some("")` for `<tag/>` (self-closing).
/// Mutation: return None for self-closing → callers break.
#[test]
fn text_self_closing_no_whitespace() {
assert_eq!(text("<x/>", "x"), Some("".into()));
}
/// Spec: self-closing with Unicode attr must not panic.
/// Mutation: use byte-offset self-close check → panic on multi-byte boundary.
#[test]
fn text_self_closing_with_unicode_attr_does_not_panic() {
assert_eq!(text(r#"<x attr="日本"/> "#, "x"), Some("".into()));
}
/// Spec: namespace prefix in BOTH open and close tags must be stripped.
/// Mutation: only strip prefix from opening tag, not closing → None.
#[test]
fn text_namespace_prefix_on_both_open_and_close() {
assert_eq!(text("<a:tag>value</a:tag>", "tag"), Some("value".into()));
}
/// The first occurrence wins, not the last.
/// Mutation: use rfind instead of find → second value returned.
#[test]
fn text_returns_first_occurrence() {
let xml = "<x>first</x><x>second</x>";
assert_eq!(text(xml, "x"), Some("first".into()));
}
/// `find_element` must advance correctly past each matched element.
/// Mutation: advance from by 1 instead of end → elements double-counted.
#[test]
fn find_element_correctly_advances_past_each_element() {
let xml = "<a>1</a><a>2</a><a>3</a>";
let mut vals = Vec::new();
let mut from = 0;
while let Some((s, e)) = find_element(xml, "a", from) {
vals.push(text(&xml[s..e], "a").unwrap());
from = e;
}
assert_eq!(vals, vec!["1", "2", "3"]);
}
/// `>` inside a quoted attribute value must not end the open tag.
/// Mutation: don't skip quoted regions → `>` in attr value ends tag early.
#[test]
fn find_element_gt_in_attr_does_not_end_tag_prematurely() {
let xml = r#"<a cond="a>b">body</a>"#;
let (s, e) = find_element(xml, "a", 0).unwrap();
assert_eq!(&xml[s..e], r#"<a cond="a>b">body</a>"#);
}
/// Missing close tag must return None, not a truncated content.
/// Mutation: return text after the open tag unconditionally → wrong value.
#[test]
fn text_missing_close_is_none_never_truncated() {
assert_eq!(text("<x>incomplete", "x"), None);
}
/// `attr` with `name=""` (empty string value) returns Some(""), not None.
/// Mutation: filter out empty returns → empty attr becomes None.
#[test]
fn attr_returns_some_empty_string_for_empty_value() {
assert_eq!(
attr(r#"<x forced_sub="" />"#, "forced_sub"),
Some("".into())
);
}
/// Single-char attr name must not falsely match inside a word boundary.
/// Mutation: remove boundary check → `id` matches `pid`.
#[test]
fn attr_single_char_name_boundary() {
assert_eq!(
attr(r#"<x pid="1" hid="2" id="3" />"#, "id"),
Some("3".into())
);
}
/// `find_element` from a non-zero offset must start the search at that offset.
/// Mutation: always start from 0 → finds elements before `from`.
#[test]
fn find_element_respects_from_offset() {
let xml = "<p>a</p><p>b</p>";
let (s, e) = find_element(xml, "p", 8).unwrap();
assert_eq!(&xml[s..e], "<p>b</p>");
}
/// `text` trims surrounding whitespace from element content.
/// Mutation: remove `.trim()` call → whitespace included.
#[test]
fn text_trims_internal_whitespace() {
assert_eq!(text("<x> hello </x>", "x"), Some("hello".into()));
assert_eq!(
text("<x>\n Aurora Drift\n</x>", "x"),
Some("Aurora Drift".into())
);
}
/// `attr` with single-quote value must match, same as double-quote.
/// Mutation: accept only double-quote → single-quote attrs fail.
#[test]
fn attr_single_quote_value() {
assert_eq!(attr(r#"<x a='hello' />"#, "a"), Some("hello".into()));
}
/// tag name with leading numeric char after namespace prefix is still matched
/// as long as the local name matches exactly (BD tools sometimes use namespace-prefixed tags).
#[test]
fn find_element_handles_namespace_with_numeric_prefix_class() {
let xml = r#"<root><di:name>Title</di:name></root>"#;
let (s, e) = find_element(xml, "name", 0).unwrap();
assert_eq!(&xml[s..e], "<di:name>Title</di:name>");
}
}