Files
libfreemkv/src/labels/pixelogic.rs
T
Matthew Jackson 4959b48386 Bind borrowed stream labels by the stream they name, not by a slot
A vendor label's `stream_number` is a slot in the one stream table its
config blob describes. The labels merged in from the playlists — to
cover streams the vendor named nothing for — carried a different number
entirely: a dense counter over every distinct stream found while
scanning the whole disc in directory order, related to no playlist's
slot numbering at all. Two coordinate systems, one field name. The
merge matched them by equality and the binder then counted streams
against the result.

Measured over the 44-image corpus: 22 discs merge such labels; of the
566 places one lands on a stream, 443 (78%) are a stream it does not
describe — the label states the PID it read itself from and it is a
different one. 142 of those are stopped by the language check 94377c7
added; 301 are applied. Those labels carry no editorial payload, so the
direct damage is confined to codec text — but the polluted list is also
what the anchor gate reads, and on 11 disc/stream-type pairs it is what
decides the anchor, which is how it reaches the vendor's forced and SDH
flags. 53 anchor facts are harvested off a merged slot. The clip-info
orphans had the same shape, numbered from `max + 1` of a list they share
no coordinate system with.

A label now either NAMES its stream — `StreamId { clip, pid }`, read out
of the very table `disc::bluray` builds the stream from — or it does
not, and only the ones that do not are ever reached by counting:

  * `label_at` returns vendor labels only, so the two numberings can no
    longer be confused by construction.
  * Playlist and clip-info labels bind by id. Exact, no anchor, no
    sequence, no ordinal.
  * A named stream outranks a guessed one, so an editorial flag reaches
    a stream only where the disc's own numbering puts it there.
  * The presence of an id is the provenance the anchor gate was missing.
    It reads the vendor's slots alone now, so a slot the vendor never
    named no longer breaks the sequence — under-yield is the normal
    shape of these blobs — and a title with fewer streams than the list
    has slots is no longer eligible to hold it. Ranking prefers the
    title that positively confirms most of the list.
  * An orphan is in no playlist, hence in no title, so it binds to
    nothing rather than to whatever counted its way.

The MPLS floor is one label per physical stream keyed by `(clip, PID)`,
not by `(type, language, codec, pid)`: the same PID in two clips is two
streams, and the old key collapsed them. Its `stream_number` is now the
entry's real slot in its own playlist's table.

41 of 44 images are byte-identical; all three that move lose a label
they should not have had, and no feature title changes on any image. A
dozen featurette playlists stop reporting a feature subtitle's SDH
marking on their own unrelated subtitle; eleven menu and bonus titles
stop advertising the feature's object-audio format on plain stereo; and
on a disc whose every title carries a single audio stream — too short a
table to anchor anything — a regional-variant tag asserted on all
seventeen titles is now asserted on none, in exchange for every title
stating the codec it actually carries, which none of them did.

Six tests had been asserting the invented numbering, including one
pinning the disc-global counter as a deliberate property.

Also fixes a defect in the same family that the corpus work surfaced:
`pgs_forced_probe::apply_verdicts` set `forced` but left `qualifier`,
so a demoted track shipped with a metadata sidecar calling it forced
next to a Matroska header saying it is not. Only a forced claim is
cleared; an SDH marking is not the probe's to touch.
2026-08-02 18:52:40 -07:00

1389 lines
59 KiB
Rust

//! Pixelogic — `bluray_project.bin`
//!
//! Binary file with embedded UTF-8 token strings in STN order per
//! playlist section. A common Pixelogic layout.
//!
//! Token format: `{lang}_{codec?}_{purpose?}_{region?}_`
use super::{
Confidence, LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, text,
vocab,
};
use crate::sector::SectorSource;
use crate::udf::UdfFs;
/// Known audio codec tokens
const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV", "AC"];
/// Sane upper bound on streams of one type within a single feature
/// section. The BD STN table caps audio at 32; this generous ceiling
/// stops a crafted blob with tens of thousands of stream tokens from
/// overflowing the u16 STN counters (panic in debug, wrap-to-0 in
/// release, which would misnumber subsequent labels).
const MAX_STREAMS_PER_TYPE: u16 = 512;
/// Sane upper bound on the number of DISTINCT video-slot entries one section
/// may list before the walk gives up on it. A section's stream list opens with
/// its video slots, and [`assign_labels`] remembers them to recognise where the
/// NEXT section starts (see the loop body). The BD STN table admits one primary
/// video plus at most 32 secondary ones, so a section claiming more than that is
/// not a stream list — and the memo must not grow without bound on disc bytes.
const MAX_VIDEO_SLOTS: usize = 33;
/// Known region tokens
const REGIONS: &[&str] = &[
"US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE",
];
/// How many DISTINCT uncatalogued token components one parse will retain for
/// the end-of-parse report. Disc bytes are untrusted, so the set that backs
/// the report is capped: a crafted blob carrying thousands of distinct
/// components must not grow it without bound. Past the cap the components are
/// still counted (and still logged individually at debug), just not retained
/// by name — the report says so.
const MAX_REPORTED_UNKNOWN: usize = 16;
/// Longest retained form of a single uncatalogued component. Components come
/// from disc bytes and can be arbitrarily long; truncation is by CHARS, not
/// bytes, so a multi-byte sequence can never be split (which would panic).
const MAX_UNKNOWN_LEN: usize = 32;
/// Collects the uncatalogued token components one parse ran into, so the run
/// can report them ONCE at the end instead of either staying silent or
/// emitting a line per occurrence.
///
/// Why aggregate: an unmapped vendor component is how a forced/SDH/commentary
/// qualifier goes missing, and a per-occurrence `debug!` is invisible in
/// practice — the gap only surfaces when a user complains about a mislabelled
/// track. But a per-occurrence `warn!` is unusable in the other direction: a
/// disc can carry dozens of per-language segment names that merely COLLIDE
/// with the `{lang3}_{component}` token shape (localized notice/disclaimer
/// clip names, for instance), and warning on each would bury real signal under
/// routine noise. One bounded, deduplicated line per parse is loud enough to
/// notice and quiet enough to live with.
#[derive(Debug, Default)]
struct UnknownParts {
/// Distinct components, deduplicated and ordered for a stable log line.
/// Bounded by [`MAX_REPORTED_UNKNOWN`].
seen: std::collections::BTreeSet<String>,
/// Total occurrences, including ones past the retention cap.
total: usize,
}
impl UnknownParts {
fn record(&mut self, part: &str) {
self.total = self.total.saturating_add(1);
if self.seen.len() >= MAX_REPORTED_UNKNOWN {
return;
}
// Char-wise truncation: `part` is uppercased disc text, not
// guaranteed ASCII, and slicing by byte offset could split a
// multi-byte char and panic.
self.seen
.insert(part.chars().take(MAX_UNKNOWN_LEN).collect());
}
fn is_empty(&self) -> bool {
self.total == 0
}
/// Emit the single end-of-parse report, if this parse hit anything.
fn report(&self) {
if self.is_empty() {
return;
}
let names: Vec<&str> = self.seen.iter().map(String::as_str).collect();
tracing::warn!(
components = ?names,
distinct = self.seen.len(),
occurrences = self.total,
truncated = self.seen.len() >= MAX_REPORTED_UNKNOWN,
"pixelogic: uncatalogued token components in this disc's label blob; \
any editorial meaning they carry (forced / SDH / commentary / dub) \
was NOT applied to the affected streams"
);
}
}
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "bluray_project.bin")
}
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
let data = super::read_jar_file(reader, udf, "bluray_project.bin")?;
// min_len=4 matches the prior local extract_strings impl. The token
// grammar is `{lang3}_{codec?}_{purpose?}_{region?}_` so the
// shortest meaningful run is 4 chars (lang + underscore).
let strings = text::extract_ascii_strings(&data, 4);
// Tracked across all parse_token calls in this run: which uncatalogued
// token components did the blob contain? If any, we downgrade confidence
// to Medium — the labels are still valid but the disc surfaced something
// we don't catalogue — and report them once (see `UnknownParts`). Parsing
// is single-threaded and sequential, so a plain owned collector suffices.
let mut unknown = UnknownParts::default();
let labels = assign_labels(&strings, &mut unknown);
// Reported even when the parse yields nothing: "we recognized the format,
// couldn't classify its components, and produced no labels" is precisely
// the case worth surfacing.
unknown.report();
if labels.is_empty() {
return None;
}
let confidence = if unknown.is_empty() {
Confidence::High
} else {
Confidence::Medium
};
Some(ParseResult { labels, confidence })
}
/// Walk the extracted token strings of the feature section and emit a
/// `StreamLabel` per editorial token, numbered in STN order. Split out
/// from `parse` so the section/numbering logic is unit-testable without
/// a `SectorSource`/`UdfFs`.
fn assign_labels(strings: &[String], unknown: &mut UnknownParts) -> Vec<StreamLabel> {
// The authoritative per-feature stream list lives in the `FPL_`
// (FeaturePLaylist) section, in STN order. `SEG_*` entries are menu
// segments (intros, logos, disclaimers, previews) that can also carry
// stray stream tokens — e.g. a `SEG_MainFeature` preview segment that
// lists only a commentary track. Anchoring on such a segment grabs the
// wrong streams and misnumbers them. So when the project ships any
// `FPL_` playlist, anchor exclusively on it; only fall back to
// `SEG_MainFeature` on discs that have no `FPL_` section at all.
let has_fpl = strings.iter().any(|s| s.starts_with("FPL_"));
let mut labels = Vec::new();
let mut in_feature = false;
let mut audio_num: u16 = 0;
let mut sub_num: u16 = 0;
// Which stream list the section is currently enumerating. Sections run
// video → audio → PG, so audio is the correct start, and it only matters
// for slots whose own type is unknowable (see the loop body).
let mut domain = StreamLabelType::Audio;
// The video slots this section has listed so far, in order. Used only to
// recognise where the section ENDS — see the `Video Stream` arm below.
let mut video_slots: Vec<&str> = Vec::new();
for s in strings {
// Detect feature section start
let is_start = if has_fpl {
s.starts_with("FPL_")
} else {
s.starts_with("SEG_MainFeature")
};
if is_start {
if in_feature {
break;
}
in_feature = true;
audio_num = 0;
sub_num = 0;
domain = StreamLabelType::Audio;
video_slots.clear();
continue;
}
// Detect section end
if in_feature && (s.starts_with("SEG_") || s.starts_with("SF_") || s.starts_with("FPL_")) {
break;
}
if !in_feature {
continue;
}
// Second way a section ends, and the only one some discs give us.
//
// The `SEG_`/`SF_`/`FPL_` markers above are section NAMES, and not every
// section is named: a project's trailing sections (the per-language
// notice, disclaimer and dub-credit cards) are emitted with a plain
// clip name, or none at all. When the feature playlist is the last
// NAMED section in the blob — which it is on most of the corpus — the
// name-only rule never fires again and the walk swallows the whole
// tail of the file as if it were more of the feature's stream list.
// Those trailing clip names are per-language (`{lang3}_{card}`), so
// they pass `is_stream_token` and each one silently advances an STN
// counter; the ones whose card name collides with a catalogued
// component (an `AC` card reads as the AC-3 codec) even emit a label,
// for an STN slot the feature playlist does not have.
//
// What every section does have, named or not, is a stream list that
// OPENS with its video slots. So a `Video Stream N` entry that repeats
// one this section already listed cannot be another slot in this
// section — it is the first entry of the next one, and this section is
// over. Distinct video entries are kept (a section may legitimately
// list a secondary video stream alongside the primary).
if s.starts_with("Video Stream") {
if video_slots.contains(&s.as_str()) || video_slots.len() >= MAX_VIDEO_SLOTS {
break;
}
video_slots.push(s);
continue;
}
// Stop accumulating once both counters reach the sane cap — a
// crafted blob can't drive them to u16 overflow.
if audio_num >= MAX_STREAMS_PER_TYPE && sub_num >= MAX_STREAMS_PER_TYPE {
break;
}
// Every entry in the section's stream list occupies one STN slot,
// whether or not it carries anything worth labelling. Pixelogic lists
// a slot as an editorial `{lang}_{codec|purpose|region}_…` token, or as
// a bare `Audio Stream N` / `PG Stream N` placeholder when no editorial
// label was authored. Both must advance the per-type counter, and so
// must an editorial token the grammar cannot classify — a
// region-only token (`fra_CF_`, `spa_LS_`: REGIONS sets `variant` but
// neither `is_audio` nor `is_subtitle`, so `parse_token_inner` returns
// `None`) or one whose only distinguishing component is uncatalogued
// (`jpn_DUB_`). Numbering only the slots that PARSE renumbers the rest
// 1..N and lands every surviving label on the wrong stream: on
// one observed UHD feature the seven `*_TXT_FOR_` forced tokens at PG
// STN 11-16/18 collapsed onto STN 2-8, flagging that disc's FULL
// subtitle tracks `forced` and leaving the real forced-narrative
// tracks unflagged.
//
// A slot the grammar cannot classify carries no stream type either, so
// it advances the list currently being enumerated: pixelogic sections
// run video → audio → PG, and `domain` follows the last slot whose type
// WAS known (a typed token or a typed placeholder), starting at Audio.
if let Some(kind) = placeholder_kind(s) {
domain = kind;
match kind {
StreamLabelType::Audio => {
if audio_num < MAX_STREAMS_PER_TYPE {
audio_num += 1;
}
}
StreamLabelType::Subtitle => {
if sub_num < MAX_STREAMS_PER_TYPE {
sub_num += 1;
}
}
}
continue;
}
if let Some(label) = parse_token_inner(s, Some(&mut *unknown)) {
domain = label.stream_type;
match label.stream_type {
StreamLabelType::Audio => {
if audio_num >= MAX_STREAMS_PER_TYPE {
continue;
}
audio_num += 1;
labels.push(StreamLabel {
stream_id: None,
stream_number: audio_num,
..label
});
}
StreamLabelType::Subtitle => {
if sub_num >= MAX_STREAMS_PER_TYPE {
continue;
}
sub_num += 1;
labels.push(StreamLabel {
stream_id: None,
stream_number: sub_num,
..label
});
}
}
} else if is_stream_token(s) {
// Token-shaped but unclassifiable: no label, but the slot is real.
match domain {
StreamLabelType::Audio => {
if audio_num < MAX_STREAMS_PER_TYPE {
audio_num += 1;
}
}
StreamLabelType::Subtitle => {
if sub_num < MAX_STREAMS_PER_TYPE {
sub_num += 1;
}
}
}
}
}
labels
}
/// The bare `Audio Stream N` / `PG Stream N` slot placeholders pixelogic emits
/// for a stream with no editorial label, and which list they belong to. `None`
/// for anything else (including the section's `AR_…` aspect-ratio entry, which
/// is not part of either numbered list; the `Video Stream N` entries are
/// consumed by the section-boundary rule in [`assign_labels`] before they get
/// here, and belong to neither list either).
fn placeholder_kind(s: &str) -> Option<StreamLabelType> {
if s.starts_with("Audio Stream") {
Some(StreamLabelType::Audio)
} else if s.starts_with("PG Stream") {
Some(StreamLabelType::Subtitle)
} else {
None
}
}
/// Whether a string has the shape of a pixelogic stream token —
/// `{lang3}_{component}…` — regardless of whether its components are
/// catalogued. The gate is exactly the one [`parse_token_inner`] applies
/// before it starts classifying, so every token that parser could ever accept
/// is recognised here as occupying a stream slot, and nothing else is.
fn is_stream_token(s: &str) -> bool {
let clean = s.trim().trim_start_matches('\t').trim_end_matches('_');
let mut parts = clean.split('_');
let Some(lang) = parts.next() else {
return false;
};
if parts.next().is_none() {
return false;
}
lang.len() == 3 && lang.chars().all(|c| c.is_ascii_lowercase())
}
fn parse_token_inner(s: &str, mut unknown: Option<&mut UnknownParts>) -> Option<StreamLabel> {
let clean = s.trim().trim_start_matches('\t').trim_end_matches('_');
let parts: Vec<&str> = clean.split('_').collect();
if parts.len() < 2 {
return None;
}
let lang = parts[0];
if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_lowercase()) {
return None;
}
let mut codec = String::new();
let mut purpose = LabelPurpose::Normal;
let mut qualifier = LabelQualifier::None;
let mut variant = String::new();
let mut is_subtitle = false;
let mut is_audio = false;
for &raw_part in &parts[1..] {
if raw_part.is_empty() {
continue;
}
// Token components are spec-uppercase (codec IDs, ADES/ACOM/SDH,
// region codes). vocab elsewhere is deliberately case-insensitive,
// so normalize each component to uppercase before the gate to
// avoid silently dropping a lowercase-authored token (which would
// fall through to the unknown branch and, with no is_audio/
// is_subtitle set, get the whole stream discarded below).
let part_up = raw_part.to_ascii_uppercase();
let part = part_up.as_str();
if AUDIO_CODECS.contains(&part) {
codec = vocab::codec(part).to_string();
is_audio = true;
} else if part == "ADES" {
purpose = LabelPurpose::Descriptive;
is_audio = true;
} else if part == "ACOM" {
purpose = LabelPurpose::Commentary;
is_audio = true;
} else if part == "ADLG" || part == "ATRI" {
is_audio = true;
} else if part == "SDH" {
qualifier = LabelQualifier::Sdh;
is_subtitle = true;
} else if part == "SDLG" {
is_subtitle = true;
} else if part == "SCOM" {
purpose = LabelPurpose::Commentary;
is_subtitle = true;
} else if part == "STRI" || part == "TXT" {
is_subtitle = true;
} else if part == "FOR" {
// `FOR` (forced) is a subtitle-domain qualifier. A token whose
// only non-language component is FOR (e.g. `eng_FOR_`) would
// otherwise classify as neither audio nor subtitle and be
// dropped at the `!is_audio && !is_subtitle` guard below. Treat
// a forced marker as a subtitle signal so the stream survives.
qualifier = LabelQualifier::Forced;
is_subtitle = true;
} else if part == "DUB" {
// `DUB` names the forced-narrative subtitle track authored to
// accompany that language's DUBBED audio presentation — the
// signs/on-screen-text pass a viewer needs when the dialogue is
// already dubbed. Same editorial class as `*_TXT_FOR_`, spelled
// differently by some authoring runs.
//
// Evidence (two independent discs in the corpus): the token
// appears only inside the PG list, embedded in an otherwise
// contiguous run of `{lang}[_{region}]_TXT_FOR_` siblings — one
// forced-narrative slot per localized language — and takes the
// slot where that language's forced entry belongs. The stream it
// lands on is a sparse PG track (sub-megabyte, a few dozen
// display sets), the signature of a forced-narrative pass rather
// than a full subtitle track.
//
// Deliberately NOT added to `vocab::qualifier`: that maps
// free-form ENGLISH label text, where a bare word "dub" means
// dubbed AUDIO. The forced-subtitle reading is specific to this
// vendor's token grammar, so it stays token-local here.
//
// Like `FOR`, this is a subtitle-domain signal: a token whose
// only non-language component is `DUB` must still classify as a
// subtitle or the `!is_audio && !is_subtitle` guard drops it.
qualifier = LabelQualifier::Forced;
is_subtitle = true;
} else if REGIONS.contains(&part) {
variant = part.to_string();
} else if part.starts_with("PGSTREAM") {
is_subtitle = true;
} else {
// Unknown token component — skip this single part rather
// than discarding the entire stream record. Pre-refactor
// behavior was `return None` here, which silently dropped
// any stream containing a single uncatalogued token (e.g.
// a new codec ID or framework variant). Better to surface
// what we know than discard a whole stream over one part,
// but flag the parse as Medium-confidence so callers know
// some data was elided.
tracing::debug!(part = %part, "pixelogic: unrecognized token component, skipping");
if let Some(acc) = unknown.as_deref_mut() {
acc.record(part);
}
}
}
if !is_audio && !is_subtitle {
return None;
}
// Tie-break for tokens that signal both domains (e.g. `eng_MLP_SDH_`
// sets is_audio via the codec and is_subtitle via SDH). An audio
// codec hint is the stronger, audio-domain signal, so prefer Audio
// when one is present (keeps the parsed codec_hint instead of
// discarding it); otherwise file as Subtitle. Pure-subtitle and
// pure-audio tokens are unaffected.
let has_audio_codec = is_audio && !codec.is_empty();
let stream_type = if is_subtitle && !has_audio_codec {
StreamLabelType::Subtitle
} else {
StreamLabelType::Audio
};
Some(StreamLabel {
stream_id: None,
stream_number: 0,
stream_type,
language: lang.to_string(),
name: String::new(),
purpose,
qualifier,
codec_hint: codec,
variant,
})
}
// extract_strings removed — replaced by super::text::extract_ascii_strings(data, 4).
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_token_basic_audio() {
let l = parse_token_inner("eng_MLP_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Audio);
assert_eq!(l.language, "eng");
assert_eq!(l.codec_hint, "TrueHD");
assert_eq!(l.purpose, LabelPurpose::Normal);
}
#[test]
fn parse_token_basic_subtitle_sdh() {
let l = parse_token_inner("eng_SDH_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Subtitle);
assert_eq!(l.language, "eng");
assert_eq!(l.qualifier, LabelQualifier::Sdh);
}
#[test]
fn parse_token_commentary() {
let l = parse_token_inner("eng_MLP_ACOM_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Audio);
assert_eq!(l.purpose, LabelPurpose::Commentary);
}
#[test]
fn parse_token_descriptive() {
let l = parse_token_inner("eng_AC3_ADES_", None).unwrap();
assert_eq!(l.purpose, LabelPurpose::Descriptive);
}
#[test]
fn parse_token_with_region() {
let l = parse_token_inner("eng_MLP_US_", None).unwrap();
assert_eq!(l.language, "eng");
assert_eq!(l.variant, "US");
}
#[test]
fn parse_token_unknown_component_does_not_kill_stream() {
// Regression: pre-refactor, an unrecognized token part returned
// None for the whole stream, silently dropping it. New
// behavior: skip the unknown part, surface what we know.
let l = parse_token_inner("eng_MLP_FUTUREFLAG_FOR_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Audio);
assert_eq!(l.language, "eng");
assert_eq!(l.codec_hint, "TrueHD");
assert_eq!(l.qualifier, LabelQualifier::Forced);
}
#[test]
fn parse_token_no_audio_or_subtitle_signal_returns_none() {
// A token that has only a language and an unknown part with
// no audio/subtitle classifier should still return None —
// there's no way to file it as a stream.
assert!(parse_token_inner("eng_UNKNOWN_", None).is_none());
}
#[test]
fn parse_token_rejects_non_lang_prefix() {
assert!(parse_token_inner("XX_MLP_", None).is_none());
assert!(parse_token_inner("ENG_MLP_", None).is_none()); // uppercase not accepted as ISO 639-2
}
#[test]
fn parse_token_dual_type_with_codec_prefers_audio() {
// `eng_MLP_SDH_` sets the audio codec (MLP) and the subtitle SDH
// qualifier. Policy: a codec hint wins -> Audio, and codec_hint is
// preserved rather than discarded.
let l = parse_token_inner("eng_MLP_SDH_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Audio);
assert_eq!(l.codec_hint, "TrueHD");
assert_eq!(l.qualifier, LabelQualifier::Sdh);
}
#[test]
fn parse_token_solo_forced_is_subtitle() {
// A token whose only non-language component is FOR must survive as
// a forced subtitle rather than being dropped.
let l = parse_token_inner("eng_FOR_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Subtitle);
assert_eq!(l.language, "eng");
assert_eq!(l.qualifier, LabelQualifier::Forced);
}
/// Immunity pin against the defect measured in the `paramount` parser: a
/// vendor "forced" marker that sits on a FULL dialogue track's own slot to
/// mean "this track also contains forced signs", read as "this track is
/// forced" and so flagging full dialogue tracks forced.
///
/// This grammar cannot express that. The forced marker is a component of a
/// slot's OWN token, so a forced-narrative pass occupies a slot of its own
/// (`{lang}_TXT_FOR_`, `{lang}_DUB_`) alongside the language's separate
/// full-dialogue slot — it is never a parallel array indexed against the
/// full tracks' slots, which is the shape that let one vendor's marker land
/// on a dialogue track.
///
/// Mutation: give any full-dialogue component (`SDLG`, `TXT`, `SDH`,
/// `STRI`, `SCOM`) a forced qualifier of its own.
#[test]
fn a_full_subtitle_token_is_never_forced_without_its_own_forced_component() {
for token in [
"eng_SDLG_",
"eng_TXT_",
"eng_SDH_",
"eng_STRI_",
"eng_SCOM_",
] {
let l = parse_token_inner(token, None)
.unwrap_or_else(|| panic!("{token} must classify as a subtitle"));
assert_eq!(l.stream_type, StreamLabelType::Subtitle);
assert_ne!(
l.qualifier,
LabelQualifier::Forced,
"{token} carries no forced component and must not be forced"
);
}
// And a language's forced pass is a SEPARATE slot from its full track,
// never a marker applied to the full track's slot.
let mut flag = UnknownParts::default();
let tokens = strs(&[
"FPL_MainFeature",
"PG Stream 1",
"eng_SDLG_", // PG slot 2 — the full dialogue track
"eng_TXT_FOR_", // PG slot 3 — its forced-narrative companion
]);
let labels = assign_labels(&tokens, &mut flag);
let subs: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Subtitle)
.map(|l| (l.stream_number, l.qualifier))
.collect();
assert_eq!(
subs,
vec![(2, LabelQualifier::None), (3, LabelQualifier::Forced),],
"the forced marker belongs to its own slot, not to the full track's"
);
}
#[test]
fn parse_token_components_are_case_insensitive() {
// Regression for the case-sensitive gate: a lowercase codec/
// qualifier component must classify identically to uppercase
// rather than falling through to the unknown branch and getting
// the whole stream dropped. The ISO 639-2 lang prefix is still
// required lowercase.
let l = parse_token_inner("eng_mlp_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Audio);
assert_eq!(l.codec_hint, "TrueHD");
let l = parse_token_inner("eng_ac3_acom_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Audio);
assert_eq!(l.purpose, LabelPurpose::Commentary);
assert_eq!(l.codec_hint, "Dolby Digital");
let l = parse_token_inner("eng_sdh_", None).unwrap();
assert_eq!(l.stream_type, StreamLabelType::Subtitle);
assert_eq!(l.qualifier, LabelQualifier::Sdh);
// Mixed-case region token still recognized as a variant.
let l = parse_token_inner("eng_MLP_us_", None).unwrap();
assert_eq!(l.variant, "US");
}
fn strs(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn assign_labels_numbers_commentary_behind_placeholders() {
// Observed case: the FPL_MainFeature playlist lists three unlabelled main
// audio tracks as `Audio Stream N` placeholders, then a lone
// `eng_ACOM_` commentary at STN slot 4. The commentary must land on
// audio #4, not collapse onto #1 (which would tag the main feature
// track as commentary).
let mut flag = UnknownParts::default();
let tokens = strs(&[
"FPL_MainFeature",
"Audio Stream 1",
"Audio Stream 2",
"Audio Stream 3",
"eng_ACOM_",
]);
let labels = assign_labels(&tokens, &mut flag);
let audio: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Audio)
.collect();
assert_eq!(audio.len(), 1, "only the commentary carries a label");
assert_eq!(audio[0].stream_number, 4, "commentary is STN slot 4");
assert_eq!(audio[0].purpose, LabelPurpose::Commentary);
assert_eq!(audio[0].language, "eng");
}
/// Taken from a real UHD feature's `SEG_MainFeature`: the PG list has
/// 18 slots, five of them bare `PG Stream N` placeholders and four more
/// carrying a token whose only non-language component is a REGION
/// (`fra_CF_`, `spa_LS_`, …) — which `parse_token_inner` rejects because
/// it signals neither audio nor subtitle. Every one of those still OCCUPIES
/// an STN slot, so the run of forced-narrative tokens sits at STN 11-18.
/// Numbering only the tokens that parse collapsed them onto STN 2-8 — the
/// disc's FULL subtitle tracks — so the player offered "English (forced)"
/// that renders the whole English dialogue.
///
/// The run is also where the vocabulary half of the same bug shows: the
/// slot at STN 17 spells its forced-narrative marker `DUB` rather than
/// `TXT_FOR`, and until `DUB` was catalogued that one track alone stayed
/// unflagged even once the numbering was right.
#[test]
fn assign_labels_numbers_subtitles_by_stn_slot_not_by_parsed_token() {
let mut flag = UnknownParts::default();
let tokens = strs(&[
"SEG_MainFeature",
"Video Stream 1",
"AR_169",
// Audio list: 9 STN slots.
"Audio Stream 1",
"eng_ADES_",
"fra_CF_",
"fra_PF_",
"Audio Stream 5",
"Audio Stream 6",
"spa_CS_",
"Audio Stream 8",
"spa_LS_",
// PG list: 18 STN slots, in order.
"PG Stream 1",
"eng_SDH_",
"fra_CF_",
"fra_PF_",
"PG Stream 5",
"PG Stream 6",
"spa_CS_",
"PG Stream 8",
"PG Stream 9",
"spa_LS_",
"eng_TXT_FOR_",
"fra_CF_TXT_FOR_",
"fra_PF_TXT_FOR_",
"deu_TXT_FOR_",
"ita_TXT_FOR_",
"spa_CS_TXT_FOR_",
"jpn_DUB_",
"spa_LS_TXT_FOR_",
]);
let labels = assign_labels(&tokens, &mut flag);
let sdh: Vec<_> = labels
.iter()
.filter(|l| l.qualifier == LabelQualifier::Sdh)
.map(|l| l.stream_number)
.collect();
assert_eq!(sdh, vec![2], "eng_SDH_ is PG STN slot 2");
let forced: Vec<_> = labels
.iter()
.filter(|l| l.qualifier == LabelQualifier::Forced)
.map(|l| l.stream_number)
.collect();
assert_eq!(
forced,
vec![11, 12, 13, 14, 15, 16, 17, 18],
"every forced-narrative token in the run sits at PG STN slots 11-18"
);
// The lone audio label is unaffected: `eng_ADES_` is audio STN slot 2.
let audio: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Audio)
.map(|l| l.stream_number)
.collect();
assert_eq!(audio, vec![2], "eng_ADES_ is audio STN slot 2");
}
#[test]
fn assign_labels_prefers_fpl_over_seg_mainfeature() {
// A `SEG_MainFeature` menu/preview segment carries a stray commentary
// token, but the real playlist is `FPL_MainFeature`. When an FPL_
// section exists, the SEG_ one must be ignored as an anchor — so we
// number from the FPL playlist, putting the commentary at slot 2.
let mut flag = UnknownParts::default();
let tokens = strs(&[
"SEG_MainFeature",
"eng_ACOM_", // stray token in the menu segment — must be ignored
"FPL_MainFeature",
"Audio Stream 1",
"eng_ACOM_",
]);
let labels = assign_labels(&tokens, &mut flag);
let audio: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Audio)
.collect();
assert_eq!(audio.len(), 1);
assert_eq!(audio[0].stream_number, 2, "numbered from the FPL playlist");
assert_eq!(audio[0].purpose, LabelPurpose::Commentary);
}
#[test]
fn assign_labels_falls_back_to_seg_without_fpl() {
// Discs with no FPL_ playlist still anchor on SEG_MainFeature.
let mut flag = UnknownParts::default();
let tokens = strs(&["SEG_MainFeature", "eng_MLP_", "spa_AC3_"]);
let labels = assign_labels(&tokens, &mut flag);
let audio: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Audio)
.collect();
assert_eq!(audio.len(), 2);
assert_eq!(audio[0].stream_number, 1);
assert_eq!(audio[0].language, "eng");
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)
.unwrap_or_else(|| panic!("region {region} should parse"));
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 is recorded by name, not merely flagged.
/// Mutation: drop the `record` call → Medium confidence never triggered
/// and the end-of-parse report never names the gap.
#[test]
fn parse_token_unknown_is_recorded_by_name() {
let mut acc = UnknownParts::default();
let _ = parse_token_inner("eng_MLP_FUTURETOKEN_", Some(&mut acc));
assert!(!acc.is_empty(), "unknown component must be recorded");
assert!(
acc.seen.contains("FUTURETOKEN"),
"the report must name the component, got {:?}",
acc.seen
);
}
/// Spec: a known-only token records nothing.
/// Mutation: always record → all parses downgrade to Medium and every
/// normal disc emits the warn.
#[test]
fn parse_token_all_known_records_nothing() {
let mut acc = UnknownParts::default();
let _ = parse_token_inner("eng_MLP_ACOM_US_", Some(&mut acc));
assert!(acc.is_empty(), "all-known token must record nothing");
}
/// 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 = UnknownParts::default();
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 = UnknownParts::default();
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 = UnknownParts::default();
// 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: the FPL section also ends on an `SF_` marker (not just
/// `SEG_`/`FPL_`). Only `assign_labels_fpl_section_ends_on_seg_boundary`
/// existed before, which cannot distinguish a mutated `||` chain from
/// the correct one (any single true operand already ends the section).
/// This test isolates the `SF_` alternative specifically.
/// Mutation: `||` -> `&&` in the end-of-section check would require
/// ALL THREE prefixes to match simultaneously (impossible for a real
/// single token), so the section would never end on `SF_` alone.
#[test]
fn assign_labels_fpl_section_ends_on_sf_boundary() {
let mut flag = UnknownParts::default();
let tokens = strs(&[
"FPL_MainFeature",
"eng_MLP_",
"SF_Something", // 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: the feature section also ends where the NEXT section's stream
/// list starts, which is the only boundary available when the feature
/// playlist is the last NAMED (`SEG_`/`SF_`/`FPL_`) section in the blob.
///
/// Shape taken from a corpus disc whose feature playlist is the last named
/// section: the trailing per-language notice/disclaimer cards are emitted
/// as unnamed sections, each opening with its own `Video Stream 1` /
/// `AR_…` pair and titled with a plain clip name. Those clip names are
/// `{lang3}_{card}`, so they pass the stream-token gate and each one
/// advances an STN counter; a card whose name collides with a catalogued
/// component (`AC` reads as the AC-3 codec) even emits a label, for an STN
/// slot the feature playlist does not have. On that disc the walk ran 95
/// entries past the end of the feature's own list, fabricated five audio
/// labels at STN 10-14 (the playlist has 9 audio slots), and reported 94
/// uncatalogued components — which also downgraded the whole parse from
/// High to Medium confidence.
///
/// Mutation: drop the repeated-video-slot boundary → `deu_Warning` and
/// `fra_ND` advance the subtitle counter and `eng_AC` emits a phantom
/// Dolby Digital label on an audio slot that does not exist.
#[test]
fn assign_labels_section_ends_at_the_next_sections_video_slot() {
let mut flag = UnknownParts::default();
let tokens = strs(&[
"FPL_MainFeature",
"Video Stream 1",
"AR_169",
// Audio list: 2 STN slots.
"Audio Stream 1",
"eng_ADES_",
// PG list: 2 STN slots.
"PG Stream 1",
"eng_SDH_",
// End of the feature's list. No named section follows — the next
// section is a notice card, announced only by its own video slot.
"deu_Warning",
"Video Stream 1",
"AR_169",
"fra_ND",
"Video Stream 1",
"AR_169",
"eng_AC",
]);
let labels = assign_labels(&tokens, &mut flag);
let got: Vec<(StreamLabelType, u16, &str)> = labels
.iter()
.map(|l| (l.stream_type, l.stream_number, l.language.as_str()))
.collect();
assert_eq!(
got,
vec![
(StreamLabelType::Audio, 2, "eng"),
(StreamLabelType::Subtitle, 2, "eng"),
],
"only the feature playlist's own slots are labelled"
);
// A card's name is emitted BEFORE its own section's video slot, so a
// forward-only walk meets the first one while still nominally inside
// the feature section and counts it. That residue is one entry, at the
// tail of a list nothing follows in — it cannot renumber any label,
// only cost the parse its High confidence. Every card behind it is
// past the boundary and never seen. Pinned rather than papered over.
assert_eq!(
flag.seen.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["WARNING"],
"only the card sitting between the last slot and the boundary leaks"
);
}
/// Companion to the above: the boundary is a video slot the section has
/// ALREADY listed, not any video slot. A section may legitimately list a
/// secondary video stream alongside the primary, and that must not cut its
/// audio and PG lists short.
/// Mutation: break on the first `Video Stream` entry seen after the
/// section start → the commentary at audio STN 2 disappears.
#[test]
fn assign_labels_keeps_a_sections_distinct_video_slots() {
let mut flag = UnknownParts::default();
let tokens = strs(&[
"FPL_MainFeature",
"Video Stream 1",
"Video Stream 2",
"AR_169",
"Audio Stream 1",
"eng_ACOM_",
]);
let labels = assign_labels(&tokens, &mut flag);
let audio: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Audio)
.collect();
assert_eq!(audio.len(), 1);
assert_eq!(audio[0].stream_number, 2, "audio list is not cut short");
assert_eq!(audio[0].purpose, LabelPurpose::Commentary);
}
/// The memo of a section's video slots is built from disc bytes, so it is
/// bounded: past [`MAX_VIDEO_SLOTS`] distinct entries the section is not a
/// stream list and the walk stops instead of retaining them all.
/// Mutation: drop the length guard → the memo grows with the blob.
#[test]
fn assign_labels_video_slot_memo_is_bounded() {
let mut flag = UnknownParts::default();
let mut tokens = vec!["FPL_MainFeature".to_string()];
for i in 1..=(MAX_VIDEO_SLOTS + 50) {
tokens.push(format!("Video Stream {i}"));
}
tokens.push("eng_ACOM_".to_string());
let labels = assign_labels(&tokens, &mut flag);
assert!(
labels.is_empty(),
"the walk stops once the video-slot memo is full"
);
}
/// Spec: the two per-type caps are independent — the loop only stops
/// early once BOTH audio and subtitle counters have reached
/// `MAX_STREAMS_PER_TYPE`. Reaching the audio cap alone must not cut
/// off subtitle processing.
/// Mutation: `&&` -> `||` in the outer stop-condition would break the
/// loop as soon as EITHER counter reaches the cap, silently dropping
/// a legitimate subtitle stream that comes after audio saturates.
#[test]
fn assign_labels_audio_cap_alone_does_not_stop_subtitle_processing() {
let mut flag = UnknownParts::default();
let mut tokens = vec!["FPL_MainFeature".to_string()];
for i in 1..=(MAX_STREAMS_PER_TYPE as usize) {
tokens.push(format!("Audio Stream {}", i));
}
// Subtitle counter is still 0 here — well under the cap.
tokens.push("eng_SDH_".to_string());
let labels = assign_labels(&tokens, &mut flag);
let subs: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Subtitle)
.collect();
assert_eq!(
subs.len(),
1,
"a subtitle stream after the audio cap (but under the subtitle \
cap) must still be labeled"
);
}
/// Companion to the above: with the subtitle counter saturated but
/// audio still under its cap, a subsequent audio token must still be
/// processed. Isolates the first `>=` operand (`audio_num >=
/// MAX_STREAMS_PER_TYPE`) from the second.
/// Mutation: `audio_num >= MAX_STREAMS_PER_TYPE` -> `audio_num <
/// MAX_STREAMS_PER_TYPE` would flip the stop-condition to trigger
/// whenever audio is UNDER cap and subtitle is AT/over cap — exactly
/// this scenario — dropping the trailing audio token.
#[test]
fn assign_labels_subtitle_cap_alone_does_not_stop_audio_processing() {
let mut flag = UnknownParts::default();
let mut tokens = vec!["FPL_MainFeature".to_string()];
for _ in 1..=(MAX_STREAMS_PER_TYPE as usize) {
tokens.push("eng_SDH_".to_string());
}
// Audio counter is still 0 here — well under the cap.
tokens.push("fra_MLP_".to_string());
let labels = assign_labels(&tokens, &mut flag);
let audio: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Audio)
.collect();
assert_eq!(
audio.len(),
1,
"an audio stream after the subtitle cap (but under the audio \
cap) must still be labeled"
);
}
/// Spec: a `PG Stream N` placeholder occupies a PG STN slot, so it advances
/// the subtitle counter exactly as `Audio Stream N` advances the audio one,
/// and the two counters stay independent.
/// Mutation: skip PG placeholders → every later subtitle label shifts down
/// by the number of unlabelled PG slots ahead of it.
#[test]
fn assign_labels_pg_placeholder_advances_sub_counter_only() {
let mut flag = UnknownParts::default();
let tokens = strs(&[
"FPL_MainFeature",
"Audio Stream 1",
"Audio Stream 2",
"eng_MLP_", // audio token — audio_num becomes 3
"PG Stream 1", // unlabelled PG slot 1
"PG Stream 2", // unlabelled PG slot 2
"eng_SDH_", // subtitle token — PG STN slot 3
"fra_SDH_", // subtitle token — PG STN slot 4
]);
let labels = assign_labels(&tokens, &mut flag);
let subs: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Subtitle)
.map(|l| l.stream_number)
.collect();
assert_eq!(subs, vec![3, 4], "PG placeholders occupy PG STN slots");
let audio: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Audio)
.map(|l| l.stream_number)
.collect();
assert_eq!(audio, vec![3], "PG placeholders must not touch audio");
}
/// Spec: a token-shaped entry the grammar cannot classify (`fra_CF_` —
/// REGION only, so it signals neither audio nor subtitle; `jpn_ZZQ_` —
/// an uncatalogued component) still occupies an STN slot in the list
/// currently being enumerated.
/// Mutation: skip unclassifiable tokens → later labels shift down.
#[test]
fn assign_labels_unclassifiable_token_still_occupies_a_slot() {
let mut flag = UnknownParts::default();
let tokens = strs(&[
"FPL_MainFeature",
"PG Stream 1", // switches the domain to PG, slot 1
"fra_CF_", // region-only: no label, but PG slot 2
"jpn_ZZQ_", // uncatalogued: no label, but PG slot 3
"eng_SDH_", // PG slot 4
]);
let labels = assign_labels(&tokens, &mut flag);
let subs: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Subtitle)
.map(|l| l.stream_number)
.collect();
assert_eq!(subs, vec![4]);
}
/// Spec: `DUB` is a subtitle-domain forced-narrative marker — the same
/// editorial class as `TXT_FOR`, spelled differently. A token whose ONLY
/// non-language component is `DUB` must survive the
/// `!is_audio && !is_subtitle` guard and come back flagged Forced.
/// Mutation: drop the `DUB` arm → the token falls through to the unknown
/// branch, classifies as neither domain, and the whole stream is dropped
/// (no label at all, so no forced flag on a genuine forced track).
#[test]
fn parse_token_dub_is_a_forced_narrative_subtitle() {
let l = parse_token_inner("jpn_DUB_", None).expect("DUB must classify as a subtitle");
assert_eq!(l.stream_type, StreamLabelType::Subtitle);
assert_eq!(l.language, "jpn");
assert_eq!(l.qualifier, LabelQualifier::Forced);
assert_eq!(l.purpose, LabelPurpose::Normal);
// Case-insensitive like every other component.
let l = parse_token_inner("jpn_dub_", None).expect("lowercase DUB must classify too");
assert_eq!(l.qualifier, LabelQualifier::Forced);
}
/// Spec: now that `DUB` is catalogued it must NOT be reported as an
/// uncatalogued component, so a disc that uses it keeps High confidence
/// and emits no warn.
/// Mutation: leave `DUB` in the unknown branch → the disc is downgraded to
/// Medium and warns on every parse.
#[test]
fn parse_token_dub_is_not_reported_as_uncatalogued() {
let mut acc = UnknownParts::default();
let _ = parse_token_inner("jpn_DUB_", Some(&mut acc));
assert!(acc.is_empty(), "DUB is catalogued, got {:?}", acc.seen);
}
/// Spec: a `DUB` slot sitting inside a run of `*_TXT_FOR_` siblings — the
/// shape both corpus discs show — yields a forced label on ITS OWN slot,
/// contiguous with its neighbours.
/// Mutation: classify DUB as audio → the PG run gains a hole at that slot
/// and the audio list gains a spurious entry.
#[test]
fn assign_labels_dub_slot_is_forced_in_a_forced_run() {
let mut flag = UnknownParts::default();
let tokens = strs(&[
"FPL_MainFeature",
"PG Stream 1",
"eng_TXT_FOR_", // PG slot 2
"fra_CF_TXT_FOR_",
"jpn_DUB_", // PG slot 4 — same class, different spelling
"spa_TXT_FOR_",
]);
let labels = assign_labels(&tokens, &mut flag);
let forced: Vec<_> = labels
.iter()
.filter(|l| l.qualifier == LabelQualifier::Forced)
.map(|l| (l.stream_type, l.stream_number, l.language.as_str()))
.collect();
assert_eq!(
forced,
vec![
(StreamLabelType::Subtitle, 2, "eng"),
(StreamLabelType::Subtitle, 3, "fra"),
(StreamLabelType::Subtitle, 4, "jpn"),
(StreamLabelType::Subtitle, 5, "spa"),
]
);
assert!(flag.is_empty(), "no uncatalogued components in this run");
}
/// Spec: `UnknownParts` deduplicates, so a disc carrying the SAME
/// uncatalogued component on dozens of entries reports it once. This is
/// what keeps the warn usable on discs whose per-language segment names
/// collide with the token shape.
/// Mutation: use a Vec instead of a set → `distinct` grows with occurrences.
#[test]
fn unknown_parts_dedups_but_counts_every_occurrence() {
let mut acc = UnknownParts::default();
for _ in 0..50 {
acc.record("ZZQ");
}
acc.record("QQZ");
assert_eq!(acc.seen.len(), 2, "two distinct components");
assert_eq!(acc.total, 51, "every occurrence counted");
}
/// Spec: the retained set is bounded — a crafted blob with thousands of
/// distinct components must not grow it without bound, and must not panic.
/// Mutation: drop the cap check → unbounded memory from disc bytes.
#[test]
fn unknown_parts_retention_is_bounded() {
let mut acc = UnknownParts::default();
for i in 0..10_000 {
acc.record(&format!("PART{}", i));
}
assert_eq!(acc.seen.len(), MAX_REPORTED_UNKNOWN);
assert_eq!(acc.total, 10_000, "occurrences still counted past the cap");
}
/// Spec: an over-long component is truncated by CHARS, so a multi-byte
/// sequence is never split. Byte-offset truncation would panic here.
/// Mutation: `part[..MAX_UNKNOWN_LEN].to_string()` → panics mid-char.
#[test]
fn unknown_parts_truncates_on_char_boundaries() {
let mut acc = UnknownParts::default();
let long: String = "é".repeat(500); // 2 bytes per char
acc.record(&long);
let stored = acc.seen.iter().next().expect("recorded");
assert_eq!(stored.chars().count(), MAX_UNKNOWN_LEN);
// Round-trips as valid UTF-8 — no split code point.
assert!(stored.chars().all(|c| c == 'é'));
}
/// Spec: the per-language notice/disclaimer clip names some discs carry
/// (`{lang}_ND`, `{lang}_Warning`, …) merely COLLIDE with the token shape.
/// They are not stream tokens and carry no editorial meaning, so they must
/// stay uncatalogued — mapping them would attach a qualifier to a stream
/// on the strength of a filename. What they must do is collapse into ONE
/// report rather than one line each.
/// Mutation: warn per occurrence → dozens of lines on an ordinary disc.
#[test]
fn unknown_parts_collapses_a_wall_of_segment_name_collisions() {
let mut acc = UnknownParts::default();
for lang in ["ara", "bul", "ces", "dan", "deu", "ell"] {
let _ = lang;
acc.record("ND");
acc.record("WARNING");
}
assert_eq!(acc.seen.len(), 2, "one entry per distinct component");
assert_eq!(acc.total, 12);
}
/// Spec: `is_stream_token` accepts exactly what `parse_token_inner`'s own
/// entry gate accepts — `{lang3}_{component}…` — so the section's
/// `Video Stream 1` / `AR_169` entries never consume a stream slot.
#[test]
fn is_stream_token_matches_the_parser_entry_gate() {
assert!(is_stream_token("eng_SDH_"));
assert!(is_stream_token("fra_CF_"));
assert!(is_stream_token("jpn_DUB_"));
assert!(!is_stream_token("eng"), "a bare language is not a token");
assert!(!is_stream_token("AR_169"), "AR is not a 3-letter language");
assert!(!is_stream_token("Video Stream 1"));
assert!(!is_stream_token("entry-markEa9"));
}
}