Skip to main content

imferno_core/validation/
mod.rs

1//! SMPTE ST 2067 Constraints Validation Framework
2//!
3//! Implements the factory + trait pattern for normative IMF constraints validation:
4//! one validator struct per SMPTE spec edition, dispatched by namespace URI.
5//!
6//! Each SMPTE spec version gets its own validator struct. The factory dispatches
7//! by namespace URI. Multiple validators run per CPL — typically one for core
8//! constraints and one (or more) for application profiles.
9//!
10//! ## Validation Architecture
11//!
12//! ```text
13//! CPL namespaces ──→ Factory ──→ [CoreConstraints2020, App2E2021, ...]
14//!                                  │                    │
15//!                             validate_cpl()       validate_cpl()
16//!                                  │                    │
17//!                          Vec<ValidationIssue>  Vec<ValidationIssue>
18//!                                  └────── merge ───────┘
19//! ```
20//!
21//! ## Implemented Specs
22//!
23//! - **ST 2067-2:2020** Core Constraints (`CoreConstraints2020`)
24//! - **ST 2067-2:2016** Core Constraints (`CoreConstraints2016`)
25//! - **ST 2067-2:2013** Core Constraints (`CoreConstraints2013`)
26//! - **ST 2067-21:2020/2023/2025** Application Profile #2E (`App2E2021`)
27//! - **ST 2067-201:2019/2021** IAB Level 0 Plug-in (`AppIabPlugin2019`, `AppIabPlugin2021`)
28//! - **ST 2067-202:2022** ISXD Plug-in (`AppIsxdPlugin2022`)
29
30pub mod codes;
31pub mod iab;
32pub mod iab_codes;
33pub mod isxd;
34pub mod isxd_codes;
35
36pub use iab::{AppIabPlugin2019, AppIabPlugin2021, AppIabPlugin2026, URI_2019, URI_2019_SCHEMAS};
37pub use isxd::{AppIsxdPlugin2022, URI_2022};
38
39use std::collections::{HashMap, HashSet};
40
41use self::codes::{St2067_21_2020, St2067_21_2023, St2067_21_2025};
42use crate::assetmap::codes::CoreConstraintsCode;
43use crate::cpl::codes::{St2067_3Code, St2067_3_2013, St2067_3_2016};
44use crate::cpl::CompositionPlaylist;
45use crate::cpl::{CodingEquations, ColorPrimaries, CplNamespace, EditRate, TransferCharacteristic};
46use crate::diagnostics::codes::ValidationCode;
47use crate::diagnostics::{Category, Location, Severity, ValidationIssue};
48
49// ═════════════════════════════════════════════════════════════════════════════
50// Trait
51// ═════════════════════════════════════════════════════════════════════════════
52
53/// A normative constraints validator, dispatched by namespace URI.
54///
55/// Each SMPTE spec version implements this trait. Multiple validators
56/// run per CPL — one for core constraints, one for CPL schema, and one
57/// (or more) for application profiles.
58pub trait ConstraintsValidator {
59    /// Human-readable specification identifier, e.g. "ST 2067-2:2020 Core Constraints".
60    fn spec_id(&self) -> &str;
61
62    /// Validate a CPL against this constraint set.
63    /// Returns a list of validation issues (may be empty for compliant content).
64    fn validate_cpl(&self, cpl: &CompositionPlaylist) -> Vec<ValidationIssue>;
65}
66
67// ═════════════════════════════════════════════════════════════════════════════
68// Factory
69// ═════════════════════════════════════════════════════════════════════════════
70
71/// Registry abstraction for resolving validators by namespace/profile URIs.
72pub trait ValidatorRegistry {
73    /// Resolve a single validator by namespace URI.
74    fn resolve_namespace(&self, namespace_uri: &str) -> Option<Box<dyn ConstraintsValidator>>;
75
76    /// Resolve all applicable validators for a CPL.
77    fn resolve_for_cpl(&self, cpl: &CompositionPlaylist) -> Vec<Box<dyn ConstraintsValidator>> {
78        let mut validators: Vec<Box<dyn ConstraintsValidator>> = Vec::new();
79
80        let core_ns = match &cpl.namespace {
81            CplNamespace::Smpte2067_3_2013 => Some("http://www.smpte-ra.org/schemas/2067-2/2013"),
82            CplNamespace::Smpte2067_3_2016 => Some("http://www.smpte-ra.org/schemas/2067-2/2016"),
83            _ => None,
84        };
85        if let Some(ns) = core_ns {
86            if let Some(v) = self.resolve_namespace(ns) {
87                validators.push(v);
88            }
89        }
90
91        if let Some(ref ext) = cpl.extension_properties {
92            if let Some(ref app_id) = ext.application_identification {
93                for uri in app_id.split_whitespace() {
94                    if let Some(v) = self.resolve_namespace(uri) {
95                        validators.push(v);
96                    }
97                }
98            }
99        }
100
101        validators
102    }
103}
104
105/// Built-in namespace/profile resolver for supported SMPTE validators.
106pub struct BuiltinValidatorRegistry;
107
108impl ValidatorRegistry for BuiltinValidatorRegistry {
109    fn resolve_namespace(&self, namespace_uri: &str) -> Option<Box<dyn ConstraintsValidator>> {
110        match namespace_uri {
111            "http://www.smpte-ra.org/schemas/2067-2/2013" => Some(Box::new(CoreConstraints2013)),
112            "http://www.smpte-ra.org/schemas/2067-2/2016" => Some(Box::new(CoreConstraints2016)),
113            "http://www.smpte-ra.org/ns/2067-2/2020" => Some(Box::new(CoreConstraints2020)),
114            "http://www.smpte-ra.org/ns/2067-21/2020" => Some(Box::new(App2E2020)),
115            "http://www.smpte-ra.org/schemas/2067-21/2014"
116            | "http://www.smpte-ra.org/schemas/2067-21/2015"
117            | "http://www.smpte-ra.org/schemas/2067-21/2016"
118            | "http://www.smpte-ra.org/ns/2067-21/2021"
119            | "http://www.smpte-ra.org/ns/2067-21/2023" => Some(Box::new(App2E2021)),
120            _ => None,
121        }
122    }
123}
124
125/// Optional validator selection overrides for registry-driven dispatch.
126///
127/// This allows callers (e.g. CLI) to pin the validator namespace(s) used during
128/// dispatch instead of relying solely on CPL-declared namespaces/app IDs.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum CoreSpecTarget {
131    St2067_2_2013,
132    St2067_2_2016,
133    St2067_2_2020,
134}
135
136impl CoreSpecTarget {
137    pub fn namespace_uri(&self) -> &'static str {
138        match self {
139            Self::St2067_2_2013 => "http://www.smpte-ra.org/schemas/2067-2/2013",
140            Self::St2067_2_2016 => "http://www.smpte-ra.org/schemas/2067-2/2016",
141            Self::St2067_2_2020 => "http://www.smpte-ra.org/ns/2067-2/2020",
142        }
143    }
144}
145
146impl std::str::FromStr for CoreSpecTarget {
147    type Err = String;
148
149    fn from_str(s: &str) -> Result<Self, Self::Err> {
150        match s {
151            "v2013" => Ok(Self::St2067_2_2013),
152            "v2016" => Ok(Self::St2067_2_2016),
153            "v2020" => Ok(Self::St2067_2_2020),
154            other => Err(format!(
155                "Unsupported coreSpec '{}'. Use auto|v2013|v2016|v2020",
156                other
157            )),
158        }
159    }
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum AppSpecTarget {
164    St2067_21_2020,
165    St2067_21_2021,
166    St2067_21_2023,
167}
168
169impl AppSpecTarget {
170    pub fn application_identification_uri(&self) -> &'static str {
171        match self {
172            Self::St2067_21_2020 => "http://www.smpte-ra.org/ns/2067-21/2020",
173            Self::St2067_21_2021 => "http://www.smpte-ra.org/ns/2067-21/2021",
174            Self::St2067_21_2023 => "http://www.smpte-ra.org/ns/2067-21/2023",
175        }
176    }
177}
178
179impl std::str::FromStr for AppSpecTarget {
180    type Err = String;
181
182    fn from_str(s: &str) -> Result<Self, Self::Err> {
183        match s {
184            "v2020" => Ok(Self::St2067_21_2020),
185            "v2021" => Ok(Self::St2067_21_2021),
186            "v2023" => Ok(Self::St2067_21_2023),
187            other => Err(format!(
188                "Unsupported app2eSpec '{}'. Use auto|none|v2020|v2021|v2023",
189                other
190            )),
191        }
192    }
193}
194
195/// Parse a core spec string, handling `"auto"` as `None` (auto-detect).
196pub fn parse_core_spec_target(s: &str) -> Result<Option<CoreSpecTarget>, String> {
197    match s {
198        "auto" => Ok(None),
199        other => Ok(Some(other.parse()?)),
200    }
201}
202
203/// Parse an app2e spec string, handling `"auto"` as `None` (auto-detect)
204/// and `"none"` as an empty list (skip app validation).
205pub fn parse_app_spec_targets(s: &str) -> Result<Option<Vec<AppSpecTarget>>, String> {
206    match s {
207        "auto" => Ok(None),
208        "none" => Ok(Some(vec![])),
209        other => Ok(Some(vec![other.parse()?])),
210    }
211}
212
213#[derive(Debug, Clone, Default)]
214pub struct ValidatorSelection {
215    /// Preferred typed core constraints selection.
216    pub core_spec: Option<CoreSpecTarget>,
217
218    /// Preferred typed app profile selection.
219    ///
220    /// When set, these app specs are used instead of CPL `ApplicationIdentification`.
221    pub app_specs: Option<Vec<AppSpecTarget>>,
222
223    /// Override for the core constraints namespace URI.
224    ///
225    /// Example: `http://www.smpte-ra.org/schemas/2067-2/2016`
226    ///
227    /// Prefer `core_spec` for strongly typed selection.
228    pub core_namespace_uri: Option<String>,
229
230    /// Override for ApplicationIdentification namespace URIs.
231    ///
232    /// When set, these URIs are used instead of CPL `ApplicationIdentification`.
233    /// Prefer `app_specs` for strongly typed selection.
234    pub application_identification_uris: Option<Vec<String>>,
235}
236
237/// Registry that applies caller-provided selection overrides, then resolves
238/// resulting URIs via built-in validator mappings.
239pub struct ConfigurableValidatorRegistry {
240    selection: ValidatorSelection,
241}
242
243impl ConfigurableValidatorRegistry {
244    pub fn new(selection: ValidatorSelection) -> Self {
245        Self { selection }
246    }
247}
248
249impl ValidatorRegistry for ConfigurableValidatorRegistry {
250    fn resolve_namespace(&self, namespace_uri: &str) -> Option<Box<dyn ConstraintsValidator>> {
251        BuiltinValidatorRegistry.resolve_namespace(namespace_uri)
252    }
253
254    fn resolve_for_cpl(&self, cpl: &CompositionPlaylist) -> Vec<Box<dyn ConstraintsValidator>> {
255        let mut validators: Vec<Box<dyn ConstraintsValidator>> = Vec::new();
256
257        let core_ns = if let Some(core_spec) = self.selection.core_spec {
258            Some(core_spec.namespace_uri())
259        } else if let Some(uri) = self.selection.core_namespace_uri.as_deref() {
260            Some(uri)
261        } else {
262            match &cpl.namespace {
263                CplNamespace::Smpte2067_3_2013 => {
264                    Some("http://www.smpte-ra.org/schemas/2067-2/2013")
265                }
266                CplNamespace::Smpte2067_3_2016 => {
267                    Some("http://www.smpte-ra.org/schemas/2067-2/2016")
268                }
269                _ => None,
270            }
271        };
272
273        if let Some(ns) = core_ns {
274            if let Some(v) = self.resolve_namespace(ns) {
275                validators.push(v);
276            }
277        }
278
279        if let Some(ref app_specs) = self.selection.app_specs {
280            for spec in app_specs {
281                if let Some(v) = self.resolve_namespace(spec.application_identification_uri()) {
282                    validators.push(v);
283                }
284            }
285        } else if let Some(ref app_uris) = self.selection.application_identification_uris {
286            for uri in app_uris {
287                if let Some(v) = self.resolve_namespace(uri) {
288                    validators.push(v);
289                }
290            }
291        } else if let Some(ref ext) = cpl.extension_properties {
292            if let Some(ref app_id) = ext.application_identification {
293                for uri in app_id.split_whitespace() {
294                    if let Some(v) = self.resolve_namespace(uri) {
295                        validators.push(v);
296                    }
297                }
298            }
299        }
300
301        validators
302    }
303}
304
305/// Look up a single validator by namespace URI.
306///
307/// Returns `None` for unrecognized namespaces.
308pub fn get_validator(namespace_uri: &str) -> Option<Box<dyn ConstraintsValidator>> {
309    BuiltinValidatorRegistry.resolve_namespace(namespace_uri)
310}
311
312/// Collect all applicable validators for a CPL.
313///
314/// Collects namespace URIs from:
315/// 1. CPL root namespace → maps to core constraints version
316/// 2. ApplicationIdentification → maps to application profile
317pub fn get_validators_for_cpl(cpl: &CompositionPlaylist) -> Vec<Box<dyn ConstraintsValidator>> {
318    BuiltinValidatorRegistry.resolve_for_cpl(cpl)
319}
320
321/// Run all applicable validators on a CPL and collect issues.
322///
323/// This is the main entry point for validation.
324pub fn validate_cpl(cpl: &CompositionPlaylist) -> Vec<ValidationIssue> {
325    validate_cpl_with_builtin_registry(cpl)
326}
327
328/// Run all applicable built-in validators on a CPL and collect issues.
329pub fn validate_cpl_with_builtin_registry(cpl: &CompositionPlaylist) -> Vec<ValidationIssue> {
330    let validators = get_validators_for_cpl(cpl);
331    let mut all_issues = Vec::new();
332    for v in &validators {
333        all_issues.extend(v.validate_cpl(cpl));
334    }
335    all_issues
336}
337
338/// Run all applicable validators from a provided registry on a CPL.
339///
340/// This is designed to plug directly into the package validator's
341/// `validate_package_structure_with_cpl_validator` seam.
342pub fn validate_cpl_with_registry(
343    cpl: &CompositionPlaylist,
344    registry: &dyn ValidatorRegistry,
345) -> Vec<ValidationIssue> {
346    let validators = registry.resolve_for_cpl(cpl);
347    let mut all_issues = Vec::new();
348    for v in &validators {
349        all_issues.extend(v.validate_cpl(cpl));
350    }
351    all_issues
352}
353
354// ═════════════════════════════════════════════════════════════════════════════
355// Colorimetry — ST 2067-21:2023 Table 2 / Table 3
356// ═════════════════════════════════════════════════════════════════════════════
357
358/// Named colorimetry systems per ST 2067-21:2023 Table 3.
359///
360/// Each system is a normative combination of ColorPrimaries, TransferCharacteristic,
361/// and CodingEquations. The validator identifies a color system from the descriptor's
362/// UL values, then checks it against the allowed set for the image characteristic row.
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
364pub enum ColorSystem {
365    /// COLOR.1: BT.601-625 primaries, BT.709 TC, BT.601 CE (SD PAL)
366    Color1,
367    /// COLOR.2: BT.601-525 primaries, BT.709 TC, BT.601 CE (SD NTSC)
368    Color2,
369    /// COLOR.3: BT.709 primaries, BT.709 TC, BT.709 CE (HD SDR)
370    Color3,
371    /// COLOR.4: BT.709 primaries, xvYCC 709 TC, BT.709 CE (extended gamut)
372    Color4,
373    /// COLOR.5: BT.2020 primaries, BT.2020 TC, BT.2020 NCL CE (UHD SDR)
374    Color5,
375    /// COLOR.6: P3 D65 primaries, PQ TC, None/RGB (HDR RGB)
376    Color6,
377    /// COLOR.7: BT.2020 primaries, PQ TC, BT.2020 NCL CE (HDR10)
378    Color7,
379    /// COLOR.8: BT.2020 primaries, HLG TC, BT.2020 NCL CE (HLG)
380    Color8,
381}
382
383impl ColorSystem {
384    /// Resolve a colorimetry system from its component ULs.
385    ///
386    /// Returns `None` if the combination does not match any defined Color System.
387    /// For COLOR.6 (RGB), `coding_eq` should be `None`.
388    pub fn from_components(
389        primaries: &ColorPrimaries,
390        transfer: &TransferCharacteristic,
391        coding_eq: Option<&CodingEquations>,
392    ) -> Option<Self> {
393        // Match CDCI (Y'C'BC'R) descriptors — CodingEquations is present.
394        // Match RGBA (R'G'B') descriptors — CodingEquations is None.
395        // For RGB content, the color system is determined by ColorPrimaries + TransferCharacteristic only.
396        match (primaries, transfer, coding_eq) {
397            // CDCI matches (explicit CodingEquations)
398            (
399                ColorPrimaries::Bt601_625,
400                TransferCharacteristic::Bt709,
401                Some(CodingEquations::Bt601),
402            ) => Some(Self::Color1),
403            (
404                ColorPrimaries::Bt601_525,
405                TransferCharacteristic::Bt709,
406                Some(CodingEquations::Bt601),
407            ) => Some(Self::Color2),
408            (
409                ColorPrimaries::Bt709,
410                TransferCharacteristic::Bt709,
411                Some(CodingEquations::Bt709),
412            ) => Some(Self::Color3),
413            (
414                ColorPrimaries::Bt709,
415                TransferCharacteristic::XvYcc709,
416                Some(CodingEquations::Bt709),
417            ) => Some(Self::Color4),
418            (
419                ColorPrimaries::Bt2020,
420                TransferCharacteristic::Bt2020,
421                Some(CodingEquations::Bt2020Ncl),
422            ) => Some(Self::Color5),
423            (
424                ColorPrimaries::Bt2020,
425                TransferCharacteristic::PqSt2084,
426                Some(CodingEquations::Bt2020Ncl),
427            ) => Some(Self::Color7),
428            (
429                ColorPrimaries::Bt2020,
430                TransferCharacteristic::Hlg,
431                Some(CodingEquations::Bt2020Ncl),
432            ) => Some(Self::Color8),
433            // RGB matches (CodingEquations not applicable for R'G'B' content)
434            (ColorPrimaries::Bt601_625, TransferCharacteristic::Bt709, None) => Some(Self::Color1),
435            (ColorPrimaries::Bt601_525, TransferCharacteristic::Bt709, None) => Some(Self::Color2),
436            (ColorPrimaries::Bt709, TransferCharacteristic::Bt709, None) => Some(Self::Color3),
437            (ColorPrimaries::Bt709, TransferCharacteristic::XvYcc709, None) => Some(Self::Color4),
438            (ColorPrimaries::Bt2020, TransferCharacteristic::Bt2020, None) => Some(Self::Color5),
439            (ColorPrimaries::P3D65, TransferCharacteristic::PqSt2084, None) => Some(Self::Color6),
440            (ColorPrimaries::Bt2020, TransferCharacteristic::PqSt2084, None) => Some(Self::Color7),
441            (ColorPrimaries::Bt2020, TransferCharacteristic::Hlg, None) => Some(Self::Color8),
442            _ => None,
443        }
444    }
445
446    /// Returns `true` for HDR color systems (PQ or HLG based).
447    pub fn is_hdr(&self) -> bool {
448        matches!(self, Self::Color6 | Self::Color7 | Self::Color8)
449    }
450
451    /// Returns `true` for PQ-based systems that require MaxCLL/MaxFALL.
452    /// Per ST 2067-21:2023 section 8.3.3.
453    pub fn requires_hdr_metadata(&self) -> bool {
454        matches!(self, Self::Color6 | Self::Color7)
455    }
456}
457
458impl std::fmt::Display for ColorSystem {
459    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
460        match self {
461            Self::Color1 => write!(f, "COLOR.1 (BT.601-625 / BT.709 / BT.601)"),
462            Self::Color2 => write!(f, "COLOR.2 (BT.601-525 / BT.709 / BT.601)"),
463            Self::Color3 => write!(f, "COLOR.3 (BT.709 / BT.709 / BT.709)"),
464            Self::Color4 => write!(f, "COLOR.4 (BT.709 / xvYCC 709 / BT.709)"),
465            Self::Color5 => write!(f, "COLOR.5 (BT.2020 / BT.2020 / BT.2020 NCL)"),
466            Self::Color6 => write!(f, "COLOR.6 (P3 D65 / PQ / RGB)"),
467            Self::Color7 => write!(f, "COLOR.7 (BT.2020 / PQ / BT.2020 NCL)"),
468            Self::Color8 => write!(f, "COLOR.8 (BT.2020 / HLG / BT.2020 NCL)"),
469        }
470    }
471}
472
473// ═════════════════════════════════════════════════════════════════════════════
474// Core Constraints — ST 2067-2
475//
476// Shared validation logic used by all core constraints versions.
477// ST 2067-2 core constraints — shared abstract base for 2013/2016/2020 editions.
478// ═════════════════════════════════════════════════════════════════════════════
479
480// xs:dateTime, TimecodeType pattern, and TotalRunningTime regex helpers were
481// gutted along with their XSD-overlap emission sites — see the runtime-XSD
482// architecture spike (uppsala-based) for the replacement path.
483
484/// Validates xs:anyURI: must not contain ASCII whitespace.
485///
486/// xs:anyURI allows empty strings per XSD spec, but IMF requires non-empty Agency values
487/// (checked separately). This validates the character-level constraint.
488fn is_valid_any_uri(s: &str) -> bool {
489    !s.chars().any(|c| c.is_ascii_whitespace())
490}
491
492// `validate_resource_list_non_empty` was gutted — XSD-overlap check
493// (CoreConstraintsCode::ResourceListEmpty mirrors xs:sequence + minOccurs=1
494// on the Resource element). Replacement comes via runtime-XSD validation.
495
496/// Shared core structure checks applied by all spec versions.
497fn validate_core_structure(
498    cpl: &CompositionPlaylist,
499    code: fn(CoreConstraintsCode) -> &'static str,
500    issues: &mut Vec<ValidationIssue>,
501) {
502    // Runtime XSD pre-pass: run the schema-level validator before the
503    // semantic checks so structural diagnostics fire first (later
504    // checks may have been induced by them). No-op only when
505    // source_xml is None (e.g., the CPL was constructed manually in
506    // tests rather than parsed from XML).
507    issues.extend(crate::xsd::validate_parsed_cpl(cpl));
508
509    let loc = Location::new().with_cpl(cpl.id);
510
511    // ───────────────────────────────────────────────────────────────────────────
512    // XSD-overlap structural checks (ContentTitle, TotalRunningTime, SegmentList,
513    // Segment-has-sequences, EditRate, IssueDate, IssueDate format,
514    // CompositionTimecode completeness + TimecodeRate>0 + TimecodeStartAddress
515    // format) were gutted. They mirrored constraints already expressed in the
516    // SMPTE XSDs and will be re-emitted by the runtime-XSD validation path
517    // (see spike branch) via a translator that maps schema diagnostics back
518    // into the CoreConstraintsCode catalogue.
519    // ───────────────────────────────────────────────────────────────────────────
520
521    // Cross-field semantic check: TimecodeRate must equal rounded CPL EditRate.
522    // KEPT — XSD cannot express this; it's prose-only (cross-field invariant).
523    if let (Some(ref tc), Some(ref er)) = (&cpl.composition_timecode, &cpl.edit_rate) {
524        if let Some(tc_rate) = tc.timecode_rate {
525            // EditRate as integer fps (for non-drop-frame comparison)
526            let edit_fps = if er.denominator > 0 {
527                (er.numerator as f64 / er.denominator as f64).round() as u32
528            } else {
529                0
530            };
531            if tc_rate != edit_fps {
532                issues.push(
533                    ValidationIssue::new(
534                        Severity::Warning,
535                        Category::Timing,
536                        code(CoreConstraintsCode::CompositionTimecodeRateMismatch),
537                        format!(
538                            "CompositionTimecode.TimecodeRate {} does not match CPL EditRate {}/{}  (≈{} fps)",
539                            tc_rate, er.numerator, er.denominator, edit_fps,
540                        ),
541                    )
542                    .with_location(loc.clone()),
543                );
544            }
545        }
546    }
547
548    // LocaleListNonEmpty + ResourceListEmpty were gutted — both are XSD-overlap
549    // (xs:sequence + minOccurs=1 on Locale / Resource elements respectively).
550
551    // ST 2067-3 constraints delegated to st2067-3 crate (§5.5.1.2, §6.4.2, §6.11, §6.12, §7.3, §7.4)
552    issues.extend(crate::cpl::validate_cpl_constraints(cpl));
553
554    // UUID uniqueness
555    validate_uuid_uniqueness(cpl, code, issues);
556
557    // Resource constraints (duration, timing)
558    validate_resource_constraints(cpl, code, issues);
559
560    // Virtual track continuity across segments
561    validate_virtual_track_continuity(cpl, code, issues);
562
563    // Virtual track edit rate consistency
564    validate_virtual_track_edit_rates(cpl, code, issues);
565
566    // Audio MCA label validation
567    validate_audio_mca_labels(cpl, code, issues);
568
569    // Timed text descriptor constraints (ST 2067-2 §10)
570    validate_timed_text_extended(cpl, code, issues);
571
572    // Segment track duration consistency
573    validate_segment_track_durations(cpl, issues);
574
575    // Digital Signatures (ST 2067-2 §8)
576    validate_digital_signature_notice(cpl, code, issues);
577
578    // §6.4.2: Every EssenceDescriptor must be referenced by at least one Resource.
579    // Applies to all spec versions (no-op when EDL is absent).
580    validate_dangling_essence_descriptors(cpl, code, issues);
581}
582
583// ─────────────────────────────────────────────────────────────────────────────
584// UUID uniqueness validation
585// ─────────────────────────────────────────────────────────────────────────────
586
587/// All Segment Ids, EssenceDescriptor Ids, and Resource Ids shall be unique within a CPL.
588///
589/// ST 2067-2 §6.1: "Each Id element shall be unique within the scope of the Composition."
590fn validate_uuid_uniqueness(
591    cpl: &CompositionPlaylist,
592    code: fn(CoreConstraintsCode) -> &'static str,
593    issues: &mut Vec<ValidationIssue>,
594) {
595    let cpl_loc = Location::new().with_cpl(cpl.id);
596
597    // Segment ID uniqueness
598    let mut segment_ids = HashSet::new();
599    for (i, segment) in cpl.segment_list.segments.iter().enumerate() {
600        let id_str = segment.id.to_string();
601        if !segment_ids.insert(id_str.clone()) {
602            issues.push(
603                ValidationIssue::new(
604                    Severity::Error,
605                    Category::Structure,
606                    code(CoreConstraintsCode::UniqueSegmentId),
607                    format!("Duplicate Segment Id '{}' at index {}", id_str, i),
608                )
609                .with_location(cpl_loc.clone().with_segment(i)),
610            );
611        }
612    }
613
614    // EssenceDescriptor ID uniqueness
615    // (empty EDL is reported by st2067-3::validate_cpl_constraints as 6.4.2/EssenceDescriptorListEmpty)
616    if let Some(ref edl) = cpl.essence_descriptor_list {
617        let mut ed_ids = HashSet::new();
618        for ed in &edl.essence_descriptors {
619            let id_str = ed.id.to_string();
620            if !ed_ids.insert(id_str.clone()) {
621                issues.push(
622                    ValidationIssue::new(
623                        Severity::Error,
624                        Category::Structure,
625                        code(CoreConstraintsCode::UniqueEssenceDescriptorId),
626                        format!("Duplicate EssenceDescriptor Id '{}'", id_str),
627                    )
628                    .with_location(cpl_loc.clone()),
629                );
630            }
631        }
632    }
633
634    // Resource ID uniqueness (across all segments and sequences)
635    let mut resource_ids = HashSet::new();
636    for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
637        let mut check_resources = |resources: &[crate::cpl::Resource], track_type: &str| {
638            for resource in resources {
639                let id_str = resource.id.to_string();
640                if !resource_ids.insert(id_str.clone()) {
641                    issues.push(
642                        ValidationIssue::new(
643                            Severity::Error,
644                            Category::Structure,
645                            code(CoreConstraintsCode::UniqueResourceId),
646                            format!(
647                                "Duplicate Resource Id '{}' in {} segment {}",
648                                id_str,
649                                track_type,
650                                seg_idx + 1,
651                            ),
652                        )
653                        .with_location(cpl_loc.clone().with_segment(seg_idx)),
654                    );
655                }
656            }
657        };
658
659        let sl = &segment.sequence_list;
660        for seq in &sl.main_image_sequences {
661            check_resources(&seq.resource_list.resources, "MainImageSequence");
662        }
663        for seq in &sl.main_audio_sequences {
664            check_resources(&seq.resource_list.resources, "MainAudioSequence");
665        }
666        for seq in &sl.subtitles_sequences {
667            check_resources(&seq.resource_list.resources, "SubtitlesSequence");
668        }
669        for seq in &sl.hearing_impaired_captions_sequences {
670            check_resources(
671                &seq.resource_list.resources,
672                "HearingImpairedCaptionsSequence",
673            );
674        }
675        for seq in &sl.forced_narrative_sequences {
676            check_resources(&seq.resource_list.resources, "ForcedNarrativeSequence");
677        }
678        for seq in &sl.iab_sequences {
679            check_resources(&seq.resource_list.resources, "IABSequence");
680        }
681        for seq in &sl.marker_sequences {
682            check_resources(&seq.resource_list.resources, "MarkerSequence");
683        }
684    }
685}
686
687// ─────────────────────────────────────────────────────────────────────────────
688// Resource constraints validation
689// ─────────────────────────────────────────────────────────────────────────────
690
691/// Validate individual resource constraints.
692///
693/// ST 2067-2 §6.10:
694/// - IntrinsicDuration shall be greater than 0.
695/// - If EntryPoint is present, EntryPoint + SourceDuration <= IntrinsicDuration.
696/// - If RepeatCount is present, it shall be greater than 0.
697/// - Non-marker resources shall have TrackFileId and SourceEncoding.
698fn validate_resource_constraints(
699    cpl: &CompositionPlaylist,
700    code: fn(CoreConstraintsCode) -> &'static str,
701    issues: &mut Vec<ValidationIssue>,
702) {
703    use crate::cpl::SequenceAccess;
704
705    for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
706        // Helper: validate resources within a sequence
707        let validate_resources =
708            |seq: &dyn SequenceAccess,
709             track_type: &str,
710             is_marker: bool,
711             issues: &mut Vec<ValidationIssue>| {
712                for (res_idx, resource) in seq.resource_list().resources.iter().enumerate() {
713                    let res_loc = Location::new()
714                        .with_cpl(cpl.id)
715                        .with_segment(seg_idx)
716                        .with_resource(res_idx);
717
718                    // IntrinsicDuration > 0
719                    if resource.intrinsic_duration == 0 {
720                        issues.push(
721                            ValidationIssue::new(
722                                Severity::Error,
723                                Category::Timing,
724                                code(CoreConstraintsCode::IntrinsicDuration),
725                                format!(
726                                    "{} resource {} IntrinsicDuration shall be greater than 0",
727                                    track_type, resource.id,
728                                ),
729                            )
730                            .with_location(res_loc.clone()),
731                        );
732                    }
733
734                    // B: EntryPoint < IntrinsicDuration (ST 2067-2 §6.10)
735                    let entry_point = resource.entry_point.unwrap_or(0);
736                    if resource.entry_point.is_some()
737                        && resource.intrinsic_duration > 0
738                        && entry_point >= resource.intrinsic_duration
739                    {
740                        issues.push(
741                            ValidationIssue::new(
742                                Severity::Error,
743                                Category::Timing,
744                                code(CoreConstraintsCode::EntryPoint),
745                                format!(
746                                    "{} resource {}: EntryPoint ({}) shall be less than \
747                                 IntrinsicDuration ({})",
748                                    track_type,
749                                    resource.id,
750                                    entry_point,
751                                    resource.intrinsic_duration,
752                                ),
753                            )
754                            .with_location(res_loc.clone()),
755                        );
756                    }
757
758                    // A: SourceDuration > 0 (ST 2067-2 §6.10)
759                    // EntryPoint + SourceDuration <= IntrinsicDuration
760                    if let Some(source_duration) = resource.source_duration {
761                        if source_duration == 0 {
762                            issues.push(
763                                ValidationIssue::new(
764                                    Severity::Error,
765                                    Category::Timing,
766                                    code(CoreConstraintsCode::SourceDuration),
767                                    format!(
768                                        "{} resource {}: SourceDuration shall be greater than 0",
769                                        track_type, resource.id,
770                                    ),
771                                )
772                                .with_location(res_loc.clone()),
773                            );
774                        }
775                        if entry_point + source_duration > resource.intrinsic_duration {
776                            issues.push(
777                                ValidationIssue::new(
778                                    Severity::Error,
779                                    Category::Timing,
780                                    code(CoreConstraintsCode::ResourceDuration),
781                                    format!(
782                                    "{} resource {}: EntryPoint ({}) + SourceDuration ({}) = {} \
783                                     exceeds IntrinsicDuration ({})",
784                                    track_type, resource.id,
785                                    entry_point, source_duration,
786                                    entry_point + source_duration,
787                                    resource.intrinsic_duration,
788                                ),
789                                )
790                                .with_location(res_loc.clone()),
791                            );
792                        }
793                    }
794
795                    // RepeatCount > 0 (if present)
796                    if let Some(repeat_count) = resource.repeat_count {
797                        if repeat_count == 0 {
798                            issues.push(
799                                ValidationIssue::new(
800                                    Severity::Error,
801                                    Category::Timing,
802                                    code(CoreConstraintsCode::RepeatCount),
803                                    format!(
804                                        "{} resource {} RepeatCount shall be greater than 0",
805                                        track_type, resource.id,
806                                    ),
807                                )
808                                .with_location(res_loc.clone()),
809                            );
810                        }
811                    }
812
813                    // Non-marker resources shall have TrackFileId
814                    if !is_marker && resource.track_file_id.is_none() {
815                        issues.push(
816                            ValidationIssue::new(
817                                Severity::Error,
818                                Category::Reference,
819                                code(CoreConstraintsCode::TrackFileId),
820                                format!(
821                                    "{} resource {} is missing TrackFileId",
822                                    track_type, resource.id,
823                                ),
824                            )
825                            .with_location(res_loc.clone()),
826                        );
827                    }
828                }
829            };
830
831        let sl = &segment.sequence_list;
832        for seq in &sl.main_image_sequences {
833            validate_resources(seq, "MainImageSequence", false, issues);
834        }
835        for seq in &sl.main_audio_sequences {
836            validate_resources(seq, "MainAudioSequence", false, issues);
837        }
838        for seq in &sl.subtitles_sequences {
839            validate_resources(seq, "SubtitlesSequence", false, issues);
840        }
841        for seq in &sl.hearing_impaired_captions_sequences {
842            validate_resources(seq, "HearingImpairedCaptionsSequence", false, issues);
843        }
844        for seq in &sl.forced_narrative_sequences {
845            validate_resources(seq, "ForcedNarrativeSequence", false, issues);
846        }
847        for seq in &sl.iab_sequences {
848            validate_resources(seq, "IABSequence", false, issues);
849        }
850        for seq in &sl.marker_sequences {
851            validate_resources(seq, "MarkerSequence", true, issues);
852        }
853    }
854}
855
856// ─────────────────────────────────────────────────────────────────────────────
857// Virtual track continuity
858// ─────────────────────────────────────────────────────────────────────────────
859
860/// Helper: collect all (TrackId, track_type) pairs from a segment's sequences.
861fn collect_track_ids(segment: &crate::cpl::Segment) -> HashMap<String, &'static str> {
862    use crate::cpl::SequenceAccess;
863
864    let mut track_ids = HashMap::new();
865    let sl = &segment.sequence_list;
866
867    for seq in &sl.main_image_sequences {
868        track_ids.insert(seq.track_id().to_string(), "MainImageSequence");
869    }
870    for seq in &sl.main_audio_sequences {
871        track_ids.insert(seq.track_id().to_string(), "MainAudioSequence");
872    }
873    for seq in &sl.subtitles_sequences {
874        track_ids.insert(seq.track_id().to_string(), "SubtitlesSequence");
875    }
876    for seq in &sl.hearing_impaired_captions_sequences {
877        track_ids.insert(
878            seq.track_id().to_string(),
879            "HearingImpairedCaptionsSequence",
880        );
881    }
882    for seq in &sl.forced_narrative_sequences {
883        track_ids.insert(seq.track_id().to_string(), "ForcedNarrativeSequence");
884    }
885    for seq in &sl.iab_sequences {
886        track_ids.insert(seq.track_id().to_string(), "IABSequence");
887    }
888    // Marker sequences participate in virtual tracks but are optional per segment
889
890    track_ids
891}
892
893/// Virtual track continuity: essence-bearing tracks that appear in one segment
894/// must appear in all segments.
895///
896/// ST 2067-2 §6.9: "A Virtual Track shall be present in every Segment of the
897/// Composition Playlist."
898fn validate_virtual_track_continuity(
899    cpl: &CompositionPlaylist,
900    code: fn(CoreConstraintsCode) -> &'static str,
901    issues: &mut Vec<ValidationIssue>,
902) {
903    let segments = &cpl.segment_list.segments;
904    if segments.len() < 2 {
905        return; // Nothing to check with 0 or 1 segments
906    }
907
908    // Collect the union of all essence-bearing TrackIds across all segments
909    let mut all_track_ids: HashMap<String, &'static str> = HashMap::new();
910    let mut per_segment_tracks: Vec<HashSet<String>> = Vec::new();
911
912    for segment in segments {
913        let tracks = collect_track_ids(segment);
914        let track_set: HashSet<String> = tracks.keys().cloned().collect();
915        for (id, tt) in &tracks {
916            all_track_ids.entry(id.clone()).or_insert(tt);
917        }
918        per_segment_tracks.push(track_set);
919    }
920
921    // Check that every track ID appears in every segment
922    for (track_id, track_type) in &all_track_ids {
923        for (seg_idx, seg_tracks) in per_segment_tracks.iter().enumerate() {
924            if !seg_tracks.contains(track_id) {
925                issues.push(
926                    ValidationIssue::new(
927                        Severity::Error,
928                        Category::Structure,
929                        code(CoreConstraintsCode::VirtualTrackContinuity),
930                        format!(
931                            "{} virtual track '{}' is missing from segment {} \
932                             but is present in other segments; \
933                             a virtual track shall be present in every segment",
934                            track_type,
935                            track_id,
936                            seg_idx + 1,
937                        ),
938                    )
939                    .with_location(Location::new().with_cpl(cpl.id).with_segment(seg_idx)),
940                );
941            }
942        }
943    }
944}
945
946// ─────────────────────────────────────────────────────────────────────────────
947// Virtual track edit rate consistency
948// ─────────────────────────────────────────────────────────────────────────────
949
950/// All resources in a virtual track shall have the same edit rate.
951///
952/// ST 2067-2 §6.9.3: "The value of the EditRate element within each Resource
953/// of a Virtual Track shall be identical."
954fn validate_virtual_track_edit_rates(
955    cpl: &CompositionPlaylist,
956    code: fn(CoreConstraintsCode) -> &'static str,
957    issues: &mut Vec<ValidationIssue>,
958) {
959    use crate::cpl::SequenceAccess;
960
961    // Map: TrackId → first-seen resolved EditRate.
962    // None on a resource means "inherit the CPL's edit rate" per ST 2067-3 §6.9.3,
963    // so we resolve it to the CPL value before recording or comparing.
964    let mut track_edit_rates: HashMap<String, EditRate> = HashMap::new();
965
966    for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
967        let check_sequence =
968            |seq: &dyn SequenceAccess,
969             track_type: &str,
970             issues: &mut Vec<ValidationIssue>,
971             track_edit_rates: &mut HashMap<String, EditRate>| {
972                let track_id = seq.track_id().to_string();
973                for resource in &seq.resource_list().resources {
974                    // Resolve: absent EditRate inherits the CPL's edit rate.
975                    // If neither resource nor CPL has an edit rate, skip this resource.
976                    let resolved_er = match resource.edit_rate.or(cpl.edit_rate) {
977                        Some(er) => er,
978                        None => continue,
979                    };
980                    match track_edit_rates.get(&track_id) {
981                        None => {
982                            // First resource for this track — record its resolved edit rate
983                            track_edit_rates.insert(track_id.clone(), resolved_er);
984                        }
985                        Some(&first_er) => {
986                            // Subsequent resource — compare resolved values
987                            if resolved_er != first_er {
988                                issues.push(
989                                ValidationIssue::new(
990                                    Severity::Error,
991                                    Category::Timing,
992                                    code(CoreConstraintsCode::VirtualTrackEditRate),
993                                    format!(
994                                        "{} virtual track '{}': resource {} has EditRate {}/{} \
995                                         but earlier resources have {}/{}; \
996                                         all resources in a virtual track shall have the same edit rate",
997                                        track_type, track_id, resource.id,
998                                        resolved_er.numerator, resolved_er.denominator,
999                                        first_er.numerator, first_er.denominator,
1000                                    ),
1001                                )
1002                                .with_location(
1003                                    Location::new()
1004                                        .with_cpl(cpl.id)
1005                                        .with_segment(seg_idx),
1006                                ),
1007                            );
1008                            }
1009                        }
1010                    }
1011                }
1012            };
1013
1014        let sl = &segment.sequence_list;
1015        for seq in &sl.main_image_sequences {
1016            check_sequence(seq, "MainImageSequence", issues, &mut track_edit_rates);
1017        }
1018        for seq in &sl.main_audio_sequences {
1019            check_sequence(seq, "MainAudioSequence", issues, &mut track_edit_rates);
1020        }
1021        for seq in &sl.subtitles_sequences {
1022            check_sequence(seq, "SubtitlesSequence", issues, &mut track_edit_rates);
1023        }
1024        for seq in &sl.hearing_impaired_captions_sequences {
1025            check_sequence(
1026                seq,
1027                "HearingImpairedCaptionsSequence",
1028                issues,
1029                &mut track_edit_rates,
1030            );
1031        }
1032        for seq in &sl.forced_narrative_sequences {
1033            check_sequence(
1034                seq,
1035                "ForcedNarrativeSequence",
1036                issues,
1037                &mut track_edit_rates,
1038            );
1039        }
1040        for seq in &sl.iab_sequences {
1041            check_sequence(seq, "IABSequence", issues, &mut track_edit_rates);
1042        }
1043    }
1044}
1045
1046// ─────────────────────────────────────────────────────────────────────────────
1047// Timed text extended validation (ST 2067-2 §10)
1048// ─────────────────────────────────────────────────────────────────────────────
1049
1050/// Extended validation for DCTimedTextDescriptor beyond NamespaceURI.
1051///
1052/// ST 2067-2 §10: SampleRate should be present and consistent with the CPL EditRate.
1053/// Language tags in RFC5646LanguageTagList should be well-formed.
1054fn validate_timed_text_extended(
1055    cpl: &CompositionPlaylist,
1056    code: fn(CoreConstraintsCode) -> &'static str,
1057    issues: &mut Vec<ValidationIssue>,
1058) {
1059    let edl = match &cpl.essence_descriptor_list {
1060        Some(edl) => edl,
1061        None => return,
1062    };
1063
1064    for ed in &edl.essence_descriptors {
1065        let tt = match &ed.dc_timed_text_descriptor {
1066            Some(tt) => tt,
1067            None => continue,
1068        };
1069
1070        let ed_loc = Location::new()
1071            .with_cpl(cpl.id)
1072            .with_path(format!("EssenceDescriptor/{}", ed.id));
1073
1074        // SampleRate should be present for timed text
1075        if tt.sample_rate.is_none() {
1076            issues.push(
1077                ValidationIssue::new(
1078                    Severity::Warning,
1079                    Category::Subtitle,
1080                    code(CoreConstraintsCode::TimedTextSampleRate),
1081                    format!(
1082                        "DCTimedTextDescriptor {}: SampleRate is missing; \
1083                         should be present for frame-accurate subtitle timing",
1084                        ed.id,
1085                    ),
1086                )
1087                .with_location(ed_loc.clone()),
1088            );
1089        }
1090
1091        // Validate language tags look well-formed (basic structural check)
1092        for tag in &tt.rfc5646_language_tag_list {
1093            let s = tag.as_str();
1094            if s.is_empty() {
1095                issues.push(
1096                    ValidationIssue::new(
1097                        Severity::Warning,
1098                        Category::Subtitle,
1099                        code(CoreConstraintsCode::TimedTextEmptyLanguageTag),
1100                        format!(
1101                            "DCTimedTextDescriptor {}: empty language tag in RFC5646LanguageTagList",
1102                            ed.id,
1103                        ),
1104                    )
1105                    .with_location(ed_loc.clone()),
1106                );
1107            } else if !s.chars().next().unwrap_or(' ').is_ascii_alphabetic() {
1108                issues.push(
1109                    ValidationIssue::new(
1110                        Severity::Warning,
1111                        Category::Subtitle,
1112                        code(CoreConstraintsCode::TimedTextMalformedLanguageTag),
1113                        format!(
1114                            "DCTimedTextDescriptor {}: language tag '{}' does not start with an ASCII letter (RFC 5646 primary subtag)",
1115                            ed.id, s,
1116                        ),
1117                    )
1118                    .with_location(ed_loc.clone()),
1119                );
1120            }
1121        }
1122    }
1123}
1124
1125// ─────────────────────────────────────────────────────────────────────────────
1126// Audio MCA label validation
1127// ─────────────────────────────────────────────────────────────────────────────
1128
1129/// Expected channel count for known soundfield groups.
1130fn expected_channel_count(tag: &crate::cpl::McaTagSymbol) -> Option<u32> {
1131    use crate::cpl::McaTagSymbol;
1132    match tag {
1133        McaTagSymbol::SgMono => Some(1),
1134        McaTagSymbol::SgSt => Some(2),
1135        McaTagSymbol::Sg51 => Some(6),
1136        McaTagSymbol::Sg71 | McaTagSymbol::Sg71Ds => Some(8),
1137        _ => None, // IAB, Other, channel labels — no fixed count
1138    }
1139}
1140
1141/// Validate audio MCA (Multi-Channel Audio) labels in essence descriptors.
1142///
1143/// Checks per ST 377-4 / ST 2067-2:
1144/// - WAVEPCMDescriptor with ChannelCount > 0 should have MCA sub-descriptors
1145/// - SoundfieldGroupLabelSubDescriptor should have MCATagSymbol
1146/// - Soundfield channel count should be consistent with WAVEPCMDescriptor.ChannelCount
1147/// - Audio sample rate should be present
1148fn validate_audio_mca_labels(
1149    cpl: &CompositionPlaylist,
1150    code: fn(CoreConstraintsCode) -> &'static str,
1151    issues: &mut Vec<ValidationIssue>,
1152) {
1153    let edl = match &cpl.essence_descriptor_list {
1154        Some(edl) => edl,
1155        None => return,
1156    };
1157
1158    for ed in &edl.essence_descriptors {
1159        let Some(ref wave) = ed.wave_pcm_descriptor else {
1160            continue;
1161        };
1162
1163        let ed_loc = Location::new()
1164            .with_cpl(cpl.id)
1165            .with_path(format!("EssenceDescriptor/{}", ed.id));
1166
1167        // Audio sample rate should be present
1168        if wave.audio_sample_rate.is_none() && wave.sample_rate.is_none() {
1169            issues.push(
1170                ValidationIssue::new(
1171                    Severity::Warning,
1172                    Category::Audio,
1173                    code(CoreConstraintsCode::AudioSampleRate),
1174                    format!(
1175                        "WAVEPCMDescriptor {} has no AudioSampleRate or SampleRate",
1176                        ed.id,
1177                    ),
1178                )
1179                .with_location(ed_loc.clone()),
1180            );
1181        }
1182
1183        // ChannelCount should be present and > 0
1184        let channel_count = match wave.channel_count {
1185            Some(0) => {
1186                issues.push(
1187                    ValidationIssue::new(
1188                        Severity::Error,
1189                        Category::Audio,
1190                        code(CoreConstraintsCode::ChannelCount),
1191                        format!("WAVEPCMDescriptor {} has ChannelCount of 0", ed.id,),
1192                    )
1193                    .with_location(ed_loc.clone()),
1194                );
1195                continue;
1196            }
1197            Some(n) => n,
1198            None => {
1199                issues.push(
1200                    ValidationIssue::new(
1201                        Severity::Warning,
1202                        Category::Audio,
1203                        code(CoreConstraintsCode::ChannelCount),
1204                        format!("WAVEPCMDescriptor {} has no ChannelCount", ed.id,),
1205                    )
1206                    .with_location(ed_loc.clone()),
1207                );
1208                continue;
1209            }
1210        };
1211
1212        // Check for MCA sub-descriptors
1213        let sub = match &wave.sub_descriptors {
1214            Some(sub) => sub,
1215            None => {
1216                issues.push(
1217                    ValidationIssue::new(
1218                        Severity::Warning,
1219                        Category::Audio,
1220                        code(CoreConstraintsCode::MCASubDescriptors),
1221                        format!(
1222                            "WAVEPCMDescriptor {} ({} channels) has no SubDescriptors; \
1223                             MCA labels are recommended for audio channel identification",
1224                            ed.id, channel_count,
1225                        ),
1226                    )
1227                    .with_location(ed_loc.clone()),
1228                );
1229                continue;
1230            }
1231        };
1232
1233        let sf = match &sub.soundfield_group_label_sub_descriptor {
1234            Some(sf) => sf,
1235            None => {
1236                issues.push(
1237                    ValidationIssue::new(
1238                        Severity::Warning,
1239                        Category::Audio,
1240                        code(CoreConstraintsCode::SoundfieldGroup),
1241                        format!(
1242                            "WAVEPCMDescriptor {} ({} channels) has SubDescriptors \
1243                             but no SoundfieldGroupLabelSubDescriptor",
1244                            ed.id, channel_count,
1245                        ),
1246                    )
1247                    .with_location(ed_loc.clone()),
1248                );
1249                continue;
1250            }
1251        };
1252
1253        // MCATagSymbol should be present
1254        let tag = match &sf.mca_tag_symbol {
1255            Some(tag) => tag,
1256            None => {
1257                issues.push(
1258                    ValidationIssue::new(
1259                        Severity::Warning,
1260                        Category::Audio,
1261                        code(CoreConstraintsCode::MCATagSymbol),
1262                        format!(
1263                            "SoundfieldGroupLabelSubDescriptor for {} is missing MCATagSymbol",
1264                            ed.id,
1265                        ),
1266                    )
1267                    .with_location(ed_loc.clone()),
1268                );
1269                continue;
1270            }
1271        };
1272
1273        // Channel count consistency with soundfield group
1274        if let Some(expected) = expected_channel_count(tag) {
1275            if channel_count != expected {
1276                issues.push(
1277                    ValidationIssue::new(
1278                        Severity::Error,
1279                        Category::Audio,
1280                        code(CoreConstraintsCode::SoundfieldChannelCount),
1281                        format!(
1282                            "WAVEPCMDescriptor {} has ChannelCount {} but MCATagSymbol '{}' \
1283                             expects {} channels",
1284                            ed.id, channel_count, tag, expected,
1285                        ),
1286                    )
1287                    .with_location(ed_loc),
1288                );
1289            }
1290        }
1291    }
1292}
1293
1294/// Validate timeline duration consistency within each segment.
1295///
1296/// Per ST 2067-2, within a single segment all virtual tracks (essence sequences)
1297/// must span the same total duration. The effective duration of a resource is:
1298///   effective = (SourceDuration or (IntrinsicDuration - EntryPoint)) × RepeatCount
1299fn validate_segment_track_durations(cpl: &CompositionPlaylist, issues: &mut Vec<ValidationIssue>) {
1300    use crate::cpl::SequenceAccess;
1301
1302    /// Compute the total effective duration of a sequence in real seconds (f64).
1303    ///
1304    /// Each resource's frame count is divided by its edit rate (falling back to
1305    /// the CPL's edit rate when absent). This normalises across track types that
1306    /// use different edit rates (e.g. video at 24/1 and audio at 48000/1).
1307    /// Returns None if no edit rate is available to normalise with.
1308    fn sequence_duration_seconds(
1309        seq: &dyn SequenceAccess,
1310        cpl_edit_rate: Option<&EditRate>,
1311    ) -> Option<f64> {
1312        let total: f64 = seq
1313            .resource_list()
1314            .resources
1315            .iter()
1316            .map(|r| {
1317                let er = r.edit_rate.as_ref().or(cpl_edit_rate)?;
1318                let numer = er.numerator as f64;
1319                let denom = er.denominator as f64;
1320                if numer == 0.0 || denom == 0.0 {
1321                    return Some(0.0);
1322                }
1323                let fps = numer / denom;
1324                let entry = r.entry_point.unwrap_or(0) as f64;
1325                let dur = r
1326                    .source_duration
1327                    .map(|d| d as f64)
1328                    .unwrap_or_else(|| (r.intrinsic_duration as f64) - entry);
1329                let repeat = r.repeat_count.unwrap_or(1).max(1) as f64;
1330                Some((dur / fps) * repeat)
1331            })
1332            .try_fold(0.0_f64, |acc, v| v.map(|x| acc + x))?;
1333        Some(total)
1334    }
1335
1336    // Two durations are considered equal if they differ by less than 1 ms.
1337    // This tolerates minor rounding in edit-rate arithmetic while still catching
1338    // genuine mismatches (which are always off by at least one full frame).
1339    const TOLERANCE_SECONDS: f64 = 0.001;
1340
1341    let seg_dur_code: &'static str = match &cpl.namespace {
1342        CplNamespace::Dci429_7 | CplNamespace::Smpte2067_3_2013 => {
1343            St2067_3_2013::for_code(St2067_3Code::SegmentDuration)
1344        }
1345        CplNamespace::Smpte2067_3_2016 | CplNamespace::Unknown(_) => {
1346            St2067_3_2016::for_code(St2067_3Code::SegmentDuration)
1347        }
1348    };
1349
1350    for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
1351        let sl = &segment.sequence_list;
1352        let seg_loc = Location::new().with_cpl(cpl.id).with_segment(seg_idx);
1353
1354        let er = cpl.edit_rate.as_ref();
1355
1356        // Collect (track_type, track_id, duration_seconds) for all non-marker sequences.
1357        // Skip sequences where no edit rate is available to normalise with.
1358        let mut track_durations: Vec<(&str, String, f64)> = Vec::new();
1359
1360        let push =
1361            |v: &mut Vec<(&str, String, f64)>, label: &'static str, seq: &dyn SequenceAccess| {
1362                if let Some(secs) = sequence_duration_seconds(seq, er) {
1363                    v.push((label, seq.track_id().to_string(), secs));
1364                }
1365            };
1366
1367        for seq in &sl.main_image_sequences {
1368            push(&mut track_durations, "MainImage", seq);
1369        }
1370        for seq in &sl.main_audio_sequences {
1371            push(&mut track_durations, "MainAudio", seq);
1372        }
1373        for seq in &sl.subtitles_sequences {
1374            push(&mut track_durations, "Subtitles", seq);
1375        }
1376        for seq in &sl.hearing_impaired_captions_sequences {
1377            push(&mut track_durations, "HICaptions", seq);
1378        }
1379        for seq in &sl.forced_narrative_sequences {
1380            push(&mut track_durations, "ForcedNarrative", seq);
1381        }
1382        for seq in &sl.iab_sequences {
1383            push(&mut track_durations, "IAB", seq);
1384        }
1385        for seq in &sl.isxd_sequences {
1386            push(&mut track_durations, "ISXD", seq);
1387        }
1388
1389        if track_durations.len() < 2 {
1390            continue; // Nothing to compare
1391        }
1392
1393        // All tracks in a segment should have the same real-time duration
1394        let first_secs = track_durations[0].2;
1395        for (track_type, track_id, duration_secs) in &track_durations[1..] {
1396            if (duration_secs - first_secs).abs() > TOLERANCE_SECONDS {
1397                issues.push(
1398                    ValidationIssue::new(
1399                        Severity::Error,
1400                        Category::Timing,
1401                        seg_dur_code,
1402                        format!(
1403                            "Segment {} {} track {}: duration {:.3}s differs from {} track {}: duration {:.3}s — \
1404                             all virtual tracks in a segment shall have equal duration",
1405                            seg_idx + 1,
1406                            track_type,
1407                            &track_id[..8.min(track_id.len())],
1408                            duration_secs,
1409                            track_durations[0].0,
1410                            &track_durations[0].1[..8.min(track_durations[0].1.len())],
1411                            first_secs,
1412                        ),
1413                    )
1414                    .with_location(seg_loc.clone()),
1415                );
1416            }
1417        }
1418    }
1419}
1420
1421/// ST 2067-2 §8: Digital signature notice.
1422///
1423/// Digital signatures (XML-DSIG Signer/Signature elements) are not currently
1424/// parsed or validated. This outputs an info-level notice for CPLs using spec
1425/// versions that support digital signatures.
1426fn validate_digital_signature_notice(
1427    cpl: &CompositionPlaylist,
1428    code: fn(CoreConstraintsCode) -> &'static str,
1429    issues: &mut Vec<ValidationIssue>,
1430) {
1431    // Digital signatures are supported from ST 2067-2:2016 onwards.
1432    // Since we don't parse Signer/Signature XML elements, we cannot validate them.
1433    // Output an info notice for awareness.
1434    let supports_signatures = matches!(cpl.namespace, CplNamespace::Smpte2067_3_2016);
1435    if supports_signatures {
1436        issues.push(
1437            ValidationIssue::new(
1438                Severity::Info,
1439                Category::Security,
1440                code(CoreConstraintsCode::DigitalSignature),
1441                "Digital signature validation (ST 2067-2 §8) is not currently performed; \
1442                 Signer/Signature XML elements are not parsed",
1443            )
1444            .with_location(Location::new().with_cpl(cpl.id)),
1445        );
1446    }
1447}
1448
1449/// ST 2067-2 §6.4.2: Every EssenceDescriptor in the EssenceDescriptorList shall be
1450/// referenced by at least one Resource's SourceEncoding element.
1451///
1452/// An ED that exists in the EDL but is not referenced by any Resource is a
1453/// "dangling" descriptor — it occupies space and implies metadata but has no
1454/// corresponding essence in the composition. This is rejected by ST 2067-2.
1455fn validate_dangling_essence_descriptors(
1456    cpl: &CompositionPlaylist,
1457    code: fn(CoreConstraintsCode) -> &'static str,
1458    issues: &mut Vec<ValidationIssue>,
1459) {
1460    let edl = match &cpl.essence_descriptor_list {
1461        Some(edl) => edl,
1462        None => return,
1463    };
1464
1465    // Collect the set of all SourceEncoding UUIDs referenced by any Resource.
1466    let mut referenced: HashSet<String> = HashSet::new();
1467
1468    for segment in &cpl.segment_list.segments {
1469        let sl = &segment.sequence_list;
1470        for seq in &sl.main_image_sequences {
1471            for r in &seq.resource_list.resources {
1472                if let Some(ref se) = r.source_encoding {
1473                    referenced.insert(se.to_string());
1474                }
1475            }
1476        }
1477        for seq in &sl.main_audio_sequences {
1478            for r in &seq.resource_list.resources {
1479                if let Some(ref se) = r.source_encoding {
1480                    referenced.insert(se.to_string());
1481                }
1482            }
1483        }
1484        for seq in &sl.subtitles_sequences {
1485            for r in &seq.resource_list.resources {
1486                if let Some(ref se) = r.source_encoding {
1487                    referenced.insert(se.to_string());
1488                }
1489            }
1490        }
1491        for seq in &sl.iab_sequences {
1492            for r in &seq.resource_list.resources {
1493                if let Some(ref se) = r.source_encoding {
1494                    referenced.insert(se.to_string());
1495                }
1496            }
1497        }
1498        for seq in &sl.hearing_impaired_captions_sequences {
1499            for r in &seq.resource_list.resources {
1500                if let Some(ref se) = r.source_encoding {
1501                    referenced.insert(se.to_string());
1502                }
1503            }
1504        }
1505        for seq in &sl.forced_narrative_sequences {
1506            for r in &seq.resource_list.resources {
1507                if let Some(ref se) = r.source_encoding {
1508                    referenced.insert(se.to_string());
1509                }
1510            }
1511        }
1512        for seq in &sl.isxd_sequences {
1513            for r in &seq.resource_list.resources {
1514                if let Some(ref se) = r.source_encoding {
1515                    referenced.insert(se.to_string());
1516                }
1517            }
1518        }
1519    }
1520
1521    // Any ED whose ID is not in the referenced set is dangling.
1522    for ed in &edl.essence_descriptors {
1523        let id = ed.id.to_string();
1524        if !referenced.contains(&id) {
1525            issues.push(
1526                ValidationIssue::new(
1527                    Severity::Error,
1528                    Category::Reference,
1529                    code(CoreConstraintsCode::DanglingEssenceDescriptor),
1530                    format!(
1531                        "EssenceDescriptor {} is present in EssenceDescriptorList but not \
1532                         referenced by any Resource's SourceEncoding (ST 2067-2 §6.4.2)",
1533                        id
1534                    ),
1535                )
1536                .with_location(Location::new().with_cpl(cpl.id)),
1537            );
1538        }
1539    }
1540}
1541
1542// ─────────────────────────────────────────────────────────────────────────────
1543// ST 2067-2:2020 Core Constraints
1544// ─────────────────────────────────────────────────────────────────────────────
1545
1546/// ST 2067-2:2020 Core Constraints validator.
1547///
1548/// Strictest version: EssenceDescriptorList required, SourceEncoding references
1549/// must resolve, ApplicationIdentification required for application profiles.
1550pub struct CoreConstraints2020;
1551
1552impl ConstraintsValidator for CoreConstraints2020 {
1553    fn spec_id(&self) -> &str {
1554        "ST 2067-2:2020"
1555    }
1556
1557    fn validate_cpl(&self, cpl: &CompositionPlaylist) -> Vec<ValidationIssue> {
1558        let mut issues = Vec::new();
1559        let loc = Location::new().with_cpl(cpl.id);
1560
1561        validate_core_structure(
1562            cpl,
1563            crate::assetmap::codes::St2067_2_2020_Core::for_code,
1564            &mut issues,
1565        );
1566
1567        // 2020-specific: EssenceDescriptorList is required
1568        if cpl.essence_descriptor_list.is_none() {
1569            issues.push(
1570                ValidationIssue::new(
1571                    Severity::Error,
1572                    Category::Structure,
1573                    crate::assetmap::codes::St2067_2_2020_Core::for_code(
1574                        CoreConstraintsCode::EssenceDescriptorList,
1575                    ),
1576                    "EssenceDescriptorList is required per ST 2067-2:2020",
1577                )
1578                .with_location(loc),
1579            );
1580        }
1581
1582        // SourceEncoding references are validated by st2067-3::validate_cpl_constraints
1583        // (called from validate_core_structure) for all sequence types.
1584
1585        issues
1586    }
1587}
1588
1589// ─────────────────────────────────────────────────────────────────────────────
1590// ST 2067-2:2016 Core Constraints
1591// ─────────────────────────────────────────────────────────────────────────────
1592
1593/// ST 2067-2:2016 Core Constraints validator.
1594///
1595/// EssenceDescriptorList is required.
1596pub struct CoreConstraints2016;
1597
1598impl ConstraintsValidator for CoreConstraints2016 {
1599    fn spec_id(&self) -> &str {
1600        "ST 2067-2:2016"
1601    }
1602
1603    fn validate_cpl(&self, cpl: &CompositionPlaylist) -> Vec<ValidationIssue> {
1604        let mut issues = Vec::new();
1605
1606        validate_core_structure(
1607            cpl,
1608            crate::assetmap::codes::St2067_2_2016_Core::for_code,
1609            &mut issues,
1610        );
1611
1612        // 2016: EssenceDescriptorList is required (same as 2020)
1613        if cpl.essence_descriptor_list.is_none() {
1614            issues.push(
1615                ValidationIssue::new(
1616                    Severity::Error,
1617                    Category::Structure,
1618                    crate::assetmap::codes::St2067_2_2016_Core::for_code(
1619                        CoreConstraintsCode::EssenceDescriptorList,
1620                    ),
1621                    "EssenceDescriptorList is required per ST 2067-2:2016",
1622                )
1623                .with_location(Location::new().with_cpl(cpl.id)),
1624            );
1625        }
1626
1627        // SourceEncoding references validated by st2067-3::validate_cpl_constraints.
1628
1629        issues
1630    }
1631}
1632
1633// ─────────────────────────────────────────────────────────────────────────────
1634// ST 2067-2:2013 Core Constraints
1635// ─────────────────────────────────────────────────────────────────────────────
1636
1637/// ST 2067-2:2013 Core Constraints validator.
1638///
1639/// Most permissive version: EssenceDescriptorList is optional,
1640/// ApplicationIdentification does not exist.
1641pub struct CoreConstraints2013;
1642
1643impl ConstraintsValidator for CoreConstraints2013 {
1644    fn spec_id(&self) -> &str {
1645        "ST 2067-2:2013"
1646    }
1647
1648    fn validate_cpl(&self, cpl: &CompositionPlaylist) -> Vec<ValidationIssue> {
1649        let mut issues = Vec::new();
1650
1651        validate_core_structure(
1652            cpl,
1653            crate::assetmap::codes::St2067_2_2013_Core::for_code,
1654            &mut issues,
1655        );
1656
1657        // 2013: EssenceDescriptorList is OPTIONAL — no check
1658        // 2013: ApplicationIdentification does not exist — no check
1659        // SourceEncoding references validated by st2067-3::validate_cpl_constraints.
1660
1661        issues
1662    }
1663}
1664
1665// ═════════════════════════════════════════════════════════════════════════════
1666// Quantization — ST 2067-21:2023 Table 5 / Table 11 / Table 13
1667// ═════════════════════════════════════════════════════════════════════════════
1668
1669/// Quantization system per ST 2067-21:2023 Table 5.
1670#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1671pub enum QuantizationSystem {
1672    /// QE.1: Narrow range (BT.709/BT.601 quantization)
1673    /// R'G'B': D = INT((219 * X + 16) * 2^(n-8))
1674    Qe1,
1675    /// QE.2: Full range
1676    /// R'G'B': D = INT(X * (2^n - 1))
1677    Qe2,
1678}
1679
1680/// Table 11: Component Max Ref and Component Min Ref values (RGBA).
1681///
1682/// Returns `(min_ref, max_ref)` for the given quantization system and bit depth.
1683pub fn component_ref_values(qe: QuantizationSystem, bit_depth: u32) -> Option<(u32, u32)> {
1684    match (qe, bit_depth) {
1685        // QE.1: narrow range
1686        (QuantizationSystem::Qe1, 8) => Some((16, 235)),
1687        (QuantizationSystem::Qe1, 10) => Some((64, 940)),
1688        (QuantizationSystem::Qe1, 12) => Some((256, 3760)),
1689        (QuantizationSystem::Qe1, 16) => Some((4096, 60160)),
1690        // QE.2: full range
1691        (QuantizationSystem::Qe2, 8) => Some((0, 255)),
1692        (QuantizationSystem::Qe2, 10) => Some((0, 1023)),
1693        (QuantizationSystem::Qe2, 12) => Some((0, 4095)),
1694        (QuantizationSystem::Qe2, 16) => Some((0, 65535)),
1695        _ => None,
1696    }
1697}
1698
1699/// Table 13: Black Ref Level, White Ref Level, and Color Range (CDCI).
1700///
1701/// Returns `(black_ref, white_ref, color_range)` for the given color system and bit depth.
1702pub fn cdci_ref_values(color_sys: &ColorSystem, bit_depth: u32) -> Option<(u32, u32, u32)> {
1703    match (color_sys, bit_depth) {
1704        // COLOR.1, COLOR.2, COLOR.3
1705        (ColorSystem::Color1 | ColorSystem::Color2 | ColorSystem::Color3, 8) => {
1706            Some((16, 235, 225))
1707        }
1708        (ColorSystem::Color1 | ColorSystem::Color2 | ColorSystem::Color3, 10) => {
1709            Some((64, 940, 897))
1710        }
1711        (ColorSystem::Color1 | ColorSystem::Color2 | ColorSystem::Color3, 12) => {
1712            Some((256, 3760, 3585))
1713        }
1714        (ColorSystem::Color1 | ColorSystem::Color2 | ColorSystem::Color3, 16) => {
1715            Some((4096, 60160, 57345))
1716        }
1717        // COLOR.4
1718        (ColorSystem::Color4, 8) => Some((16, 235, 254)),
1719        (ColorSystem::Color4, 10) => Some((64, 940, 1013)),
1720        // COLOR.5, COLOR.7, COLOR.8
1721        (ColorSystem::Color5 | ColorSystem::Color7 | ColorSystem::Color8, 10) => {
1722            Some((64, 940, 897))
1723        }
1724        (ColorSystem::Color5 | ColorSystem::Color7 | ColorSystem::Color8, 12) => {
1725            Some((256, 3760, 3585))
1726        }
1727        (ColorSystem::Color5 | ColorSystem::Color7 | ColorSystem::Color8, 16) => {
1728            Some((4096, 60160, 57345))
1729        }
1730        _ => None,
1731    }
1732}
1733
1734// ═════════════════════════════════════════════════════════════════════════════
1735// Application Profile #2E — ST 2067-21
1736//
1737// Validates image essence against Table 2 (allowed parameters) and
1738// Table 3 (named colorimetry systems).
1739//
1740// ST 2067-21 Application #2 / #2E — shared abstract base across editions.
1741// ═════════════════════════════════════════════════════════════════════════════
1742
1743/// The exact ApplicationIdentification URI per ST 2067-21:2023 Table 15.
1744const APP2E_APPLICATION_IDENTIFICATION: &str = "http://www.smpte-ra.org/ns/2067-21/2021";
1745
1746/// ST 2067-21:2023 Application Profile #2E Validator.
1747///
1748/// Validates:
1749/// - Table 8: Generic Picture Essence Descriptor constraints (FrameLayout, etc.)
1750///
1751/// Validate a J2K PictureEssenceCoding UL against the App2E profile constraints.
1752///
1753/// Rules (§6.2.5):
1754/// - `Jpeg2000Ht`: allowed only when `allow_ht = true` (App2E from 2021 onward).
1755/// - `Jpeg2000Imf4k`: stored width must be 2049–4096, stored height ≤ 3112.
1756/// - `Jpeg2000Imf2k`: stored width must be 1–2048, stored height ≤ 1556.
1757/// - `Jpeg2000Broadcast`: stored width must be 1–3840, stored height ≤ 2160.
1758/// - `Jpeg2000` (generic node): no resolution bounds enforced.
1759/// - All other J2K variants (should not occur after CODEC_TABLE is correct): error.
1760// `clippy::collapsible_match` would suggest moving each `if !cond` into a guard, but
1761// the wildcard arm has different semantics (specific to non-J2K codecs), so collapsing
1762// would route the "passes-validation" case through it incorrectly.
1763#[allow(clippy::collapsible_match)]
1764fn validate_j2k_profile(
1765    codec: &crate::cpl::VideoCodec,
1766    stored_width: u32,
1767    stored_height: u32,
1768    allow_ht: bool,
1769    loc: &Location,
1770    issues: &mut Vec<ValidationIssue>,
1771) {
1772    use crate::cpl::VideoCodec;
1773    match codec {
1774        VideoCodec::Jpeg2000Ht => {
1775            if !allow_ht {
1776                // App2E 2020 does not include HT-J2K. Added in 2021.
1777                issues.push(
1778                    ValidationIssue::new(
1779                        Severity::Error,
1780                        Category::Encoding,
1781                        St2067_21_2023::J2KHtNotAllowed.code(),
1782                        "JPEG 2000 HT (ISO 15444-15) is not permitted by App2E 2020. \
1783                         HT-J2K was introduced in ST 2067-21:2021.",
1784                    )
1785                    .with_location(loc.clone()),
1786                );
1787            }
1788            // When allowed, no resolution bounds check for HT — HT
1789            // conformance lives in the codestream parameters, not the
1790            // resolution envelope.
1791        }
1792        VideoCodec::Jpeg2000Imf4k => {
1793            // 4K IMF profile: width in (2048, 4096], height in (0, 3112]
1794            if !(stored_width > 2048
1795                && stored_width <= 4096
1796                && stored_height > 0
1797                && stored_height <= 3112)
1798            {
1799                issues.push(
1800                    ValidationIssue::new(
1801                        Severity::Error,
1802                        Category::Encoding,
1803                        St2067_21_2023::J2K4KResolution.code(),
1804                        format!(
1805                            "JPEG 2000 IMF 4K Profile does not support image resolution \
1806                             ({}/{}); width must be 2049–4096, height 1–3112",
1807                            stored_width, stored_height
1808                        ),
1809                    )
1810                    .with_location(loc.clone()),
1811                );
1812            }
1813        }
1814        VideoCodec::Jpeg2000Imf2k => {
1815            // 2K IMF profile: width in (0, 2048], height in (0, 1556]
1816            if !(stored_width > 0
1817                && stored_width <= 2048
1818                && stored_height > 0
1819                && stored_height <= 1556)
1820            {
1821                issues.push(
1822                    ValidationIssue::new(
1823                        Severity::Error,
1824                        Category::Encoding,
1825                        St2067_21_2023::J2K2KResolution.code(),
1826                        format!(
1827                            "JPEG 2000 IMF 2K Profile does not support image resolution \
1828                             ({}/{}); width must be 1–2048, height 1–1556",
1829                            stored_width, stored_height
1830                        ),
1831                    )
1832                    .with_location(loc.clone()),
1833                );
1834            }
1835        }
1836        VideoCodec::Jpeg2000Broadcast => {
1837            // Broadcast Contribution profile: width in (0, 3840], height in (0, 2160]
1838            if !(stored_width > 0
1839                && stored_width <= 3840
1840                && stored_height > 0
1841                && stored_height <= 2160)
1842            {
1843                issues.push(
1844                    ValidationIssue::new(
1845                        Severity::Error,
1846                        Category::Encoding,
1847                        St2067_21_2023::J2KBcpResolution.code(),
1848                        format!(
1849                            "JPEG 2000 Broadcast Contribution Profile does not support image \
1850                             resolution ({}/{}); width must be 1–3840, height 1–2160",
1851                            stored_width, stored_height
1852                        ),
1853                    )
1854                    .with_location(loc.clone()),
1855                );
1856            }
1857        }
1858        VideoCodec::Jpeg2000 => {
1859            // Generic J2K node UL (03010100) — accepted without resolution bounds check.
1860            // In well-formed App2E content this should always be a specific sub-profile,
1861            // but generic ULs appear in some older packages and are not rejected by the
1862            // profile check (they fall outside the profile/resolution table).
1863        }
1864        _ => {
1865            // Any non-J2K codec: already caught by is_jpeg2000_family() check upstream.
1866        }
1867    }
1868}
1869
1870/// - Table 10: RGBA Descriptor constraints (ComponentMaxRef/MinRef, ScanningDirection)
1871/// - Table 11: Component Max/Min Ref values by quantization system and bit depth
1872/// - Table 12: CDCI Descriptor constraints (subsampling, siting, ref levels)
1873/// - Table 13: Black/White Ref Level and Color Range by colorimetry and bit depth
1874/// - Table 3: Colorimetry system cross-validation (COLOR.1 through COLOR.8)
1875/// - §6.2.5: JPEG 2000 codec requirement + J2K profile/resolution validation
1876/// - §7.1 / Table 15: ApplicationIdentification exact URI
1877/// - §7.2: Homogeneous image essence within a composition
1878/// - §7.5: MaxCLL/MaxFALL presence for PQ-based HDR (COLOR.6/COLOR.7)
1879pub struct App2E2021;
1880
1881impl App2E2021 {
1882    /// Validate all image essence descriptors in the CPL.
1883    /// `allow_ht`: when false, HT-J2K is rejected (App2E 2020 mode).
1884    fn validate_image_descriptors(
1885        &self,
1886        cpl: &CompositionPlaylist,
1887        allow_ht: bool,
1888        issues: &mut Vec<ValidationIssue>,
1889    ) {
1890        let edl = match &cpl.essence_descriptor_list {
1891            Some(edl) => edl,
1892            None => return, // Core constraints will catch missing EDL
1893        };
1894
1895        for ed in &edl.essence_descriptors {
1896            if let Some(ref rgba) = ed.rgba_descriptor {
1897                self.validate_rgba_descriptor(&ed.id.to_string(), rgba, cpl, allow_ht, issues);
1898            }
1899            if let Some(ref cdci) = ed.cdci_descriptor {
1900                self.validate_cdci_descriptor(&ed.id.to_string(), cdci, cpl, allow_ht, issues);
1901            }
1902        }
1903    }
1904
1905    /// Validate an RGBA (RGB) picture descriptor.
1906    ///
1907    /// Checks Table 8 (Generic Picture Descriptor), Table 10 (RGBA Descriptor),
1908    /// Table 11 (Component Max/Min Ref), and Table 3 (colorimetry systems).
1909    fn validate_rgba_descriptor(
1910        &self,
1911        ed_id: &str,
1912        rgba: &crate::cpl::RGBADescriptor,
1913        cpl: &CompositionPlaylist,
1914        allow_ht: bool,
1915        issues: &mut Vec<ValidationIssue>,
1916    ) {
1917        let loc = Location::new()
1918            .with_cpl(cpl.id)
1919            .with_path(format!("EssenceDescriptor[{}]/RGBADescriptor", ed_id));
1920
1921        // ── Table 8: Generic Picture Essence Descriptor ──────────────────────
1922
1923        // §6.2.5: PictureCompression shall be JPEG 2000 (ISO 15444-1 or 15444-15)
1924        if let Some(ref codec) = rgba.picture_compression {
1925            if !codec.is_jpeg2000_family() {
1926                issues.push(
1927                    ValidationIssue::new(
1928                        Severity::Error,
1929                        Category::Encoding,
1930                        St2067_21_2023::J2KRequired.code().to_string(),
1931                        format!(
1932                            "PictureCompression shall be JPEG 2000 for App2E, found: {}",
1933                            codec
1934                        ),
1935                    )
1936                    .with_location(loc.clone()),
1937                );
1938            } else {
1939                // §6.2.5: J2K profile must be a recognized App2E profile with valid resolution.
1940                let w = rgba.stored_width.unwrap_or(0);
1941                let h = rgba.stored_height.unwrap_or(0);
1942                validate_j2k_profile(codec, w, h, allow_ht, &loc, issues);
1943            }
1944        }
1945
1946        // Table 8: FrameLayout — §6.2.1.3
1947        if let Some(ref fl) = rgba.frame_layout {
1948            if fl != "FullFrame" && fl != "SeparateFields" {
1949                issues.push(
1950                    ValidationIssue::new(
1951                        Severity::Error,
1952                        Category::Video,
1953                        St2067_21_2023::FrameLayout.code().to_string(),
1954                        format!(
1955                            "FrameLayout shall be FullFrame (00h) or SeparateFields (01h), found: {}",
1956                            fl
1957                        ),
1958                    )
1959                    .with_location(loc.clone()),
1960                );
1961            }
1962        }
1963
1964        // Table 8: StoredF2Offset — shall not be present
1965        if rgba.stored_f2_offset.is_some() {
1966            issues.push(
1967                ValidationIssue::new(
1968                    Severity::Error,
1969                    Category::Video,
1970                    St2067_21_2023::StoredF2Offset.code().to_string(),
1971                    "StoredF2Offset shall not be present (Table 8)",
1972                )
1973                .with_location(loc.clone()),
1974            );
1975        }
1976
1977        // Table 8: SampledWidth — shall not be present or shall be equal to StoredWidth
1978        if let Some(sw) = rgba.sampled_width {
1979            if let Some(stored_w) = rgba.stored_width {
1980                if sw != stored_w {
1981                    issues.push(
1982                        ValidationIssue::new(
1983                            Severity::Error,
1984                            Category::Video,
1985                            St2067_21_2023::SampledWidth.code().to_string(),
1986                            format!(
1987                                "SampledWidth ({}) shall not be present or shall be equal to StoredWidth ({})",
1988                                sw, stored_w
1989                            ),
1990                        )
1991                        .with_location(loc.clone()),
1992                    );
1993                }
1994            }
1995        }
1996
1997        // Table 8: SampledHeight — shall not be present or shall be equal to StoredHeight
1998        if let Some(sh) = rgba.sampled_height {
1999            if let Some(stored_h) = rgba.stored_height {
2000                if sh != stored_h {
2001                    issues.push(
2002                        ValidationIssue::new(
2003                            Severity::Error,
2004                            Category::Video,
2005                            St2067_21_2023::SampledHeight.code().to_string(),
2006                            format!(
2007                                "SampledHeight ({}) shall not be present or shall be equal to StoredHeight ({})",
2008                                sh, stored_h
2009                            ),
2010                        )
2011                        .with_location(loc.clone()),
2012                    );
2013                }
2014            }
2015        }
2016
2017        // Table 8: SampledXOffset — shall not be present or shall be 0
2018        if let Some(sxo) = rgba.sampled_x_offset {
2019            if sxo != 0 {
2020                issues.push(
2021                    ValidationIssue::new(
2022                        Severity::Error,
2023                        Category::Video,
2024                        St2067_21_2023::SampledXOffset.code().to_string(),
2025                        format!(
2026                            "SampledXOffset shall not be present or shall be 0, found: {}",
2027                            sxo
2028                        ),
2029                    )
2030                    .with_location(loc.clone()),
2031                );
2032            }
2033        }
2034
2035        // Table 8: SampledYOffset — shall not be present or shall be 0
2036        if let Some(syo) = rgba.sampled_y_offset {
2037            if syo != 0 {
2038                issues.push(
2039                    ValidationIssue::new(
2040                        Severity::Error,
2041                        Category::Video,
2042                        St2067_21_2023::SampledYOffset.code().to_string(),
2043                        format!(
2044                            "SampledYOffset shall not be present or shall be 0, found: {}",
2045                            syo
2046                        ),
2047                    )
2048                    .with_location(loc.clone()),
2049                );
2050            }
2051        }
2052
2053        // Table 8: AlphaTransparency — shall not be present
2054        if rgba.alpha_transparency.is_some() {
2055            issues.push(
2056                ValidationIssue::new(
2057                    Severity::Error,
2058                    Category::Video,
2059                    St2067_21_2023::AlphaTransparency.code().to_string(),
2060                    "AlphaTransparency shall not be present (Table 8)",
2061                )
2062                .with_location(loc.clone()),
2063            );
2064        }
2065
2066        // Table 8: ImageAlignmentOffset — shall not be present
2067        if rgba.image_alignment_offset.is_some() {
2068            issues.push(
2069                ValidationIssue::new(
2070                    Severity::Error,
2071                    Category::Video,
2072                    St2067_21_2023::ImageAlignmentOffset.code().to_string(),
2073                    "ImageAlignmentOffset shall not be present (Table 8)",
2074                )
2075                .with_location(loc.clone()),
2076            );
2077        }
2078
2079        // Table 8: ImageStartOffset — shall not be present
2080        if rgba.image_start_offset.is_some() {
2081            issues.push(
2082                ValidationIssue::new(
2083                    Severity::Error,
2084                    Category::Video,
2085                    St2067_21_2023::ImageStartOffset.code().to_string(),
2086                    "ImageStartOffset shall not be present (Table 8)",
2087                )
2088                .with_location(loc.clone()),
2089            );
2090        }
2091
2092        // Table 8: ImageEndOffset — shall not be present
2093        if rgba.image_end_offset.is_some() {
2094            issues.push(
2095                ValidationIssue::new(
2096                    Severity::Error,
2097                    Category::Video,
2098                    St2067_21_2023::ImageEndOffset.code().to_string(),
2099                    "ImageEndOffset shall not be present (Table 8)",
2100                )
2101                .with_location(loc.clone()),
2102            );
2103        }
2104
2105        // Table 8: FieldDominance — conditional on FrameLayout
2106        if let Some(ref fl) = rgba.frame_layout {
2107            if fl == "FullFrame" && rgba.field_dominance.is_some() {
2108                issues.push(
2109                    ValidationIssue::new(
2110                        Severity::Error,
2111                        Category::Video,
2112                        St2067_21_2023::FieldDominance.code().to_string(),
2113                        "FieldDominance shall not be present for progressive (FullFrame) content",
2114                    )
2115                    .with_location(loc.clone()),
2116                );
2117            }
2118            if fl == "SeparateFields" && rgba.field_dominance.is_none() {
2119                issues.push(
2120                    ValidationIssue::new(
2121                        Severity::Error,
2122                        Category::Video,
2123                        St2067_21_2023::FieldDominance.code().to_string(),
2124                        "FieldDominance shall be present for interlaced (SeparateFields) content",
2125                    )
2126                    .with_location(loc.clone()),
2127                );
2128            }
2129        }
2130
2131        // Table 9: StoredWidth/StoredHeight vs FrameLayout
2132        // For interlaced (SeparateFields), StoredHeight = ImageFrameHeight / 2
2133        // For progressive (FullFrame), StoredHeight = ImageFrameHeight
2134        // Note: We validate that StoredWidth/StoredHeight are present since they define
2135        // the image dimensions required by Section 6.2.1.1
2136
2137        // §6.2.4: ColorPrimaries shall be present and recognized
2138        match &rgba.color_primaries {
2139            Some(cp) if matches!(cp, ColorPrimaries::Unknown(_)) => {
2140                issues.push(
2141                    ValidationIssue::new(
2142                        Severity::Error,
2143                        Category::Video,
2144                        St2067_21_2023::ColorPrimariesUnknown.code().to_string(),
2145                        format!("Unrecognized ColorPrimaries UL: {}", cp),
2146                    )
2147                    .with_location(loc.clone()),
2148                );
2149            }
2150            None => {
2151                issues.push(
2152                    ValidationIssue::new(
2153                        Severity::Error,
2154                        Category::Video,
2155                        St2067_21_2023::ColorPrimariesMissing.code().to_string(),
2156                        "ColorPrimaries shall be present (Table 8)",
2157                    )
2158                    .with_location(loc.clone()),
2159                );
2160            }
2161            _ => {}
2162        }
2163
2164        // §6.2.2: TransferCharacteristic shall be present and recognized
2165        match &rgba.transfer_characteristic {
2166            Some(tc) if matches!(tc, TransferCharacteristic::Unknown(_)) => {
2167                issues.push(
2168                    ValidationIssue::new(
2169                        Severity::Error,
2170                        Category::Video,
2171                        St2067_21_2023::TransferCharacteristicUnknown
2172                            .code()
2173                            .to_string(),
2174                        format!("Unrecognized TransferCharacteristic UL: {}", tc),
2175                    )
2176                    .with_location(loc.clone()),
2177                );
2178            }
2179            None => {
2180                issues.push(
2181                    ValidationIssue::new(
2182                        Severity::Error,
2183                        Category::Video,
2184                        St2067_21_2023::TransferCharacteristicMissing
2185                            .code()
2186                            .to_string(),
2187                        "TransferCharacteristic shall be present (Table 8)",
2188                    )
2189                    .with_location(loc.clone()),
2190                );
2191            }
2192            _ => {}
2193        }
2194
2195        // ── Table 10: RGBA Descriptor ────────────────────────────────────────
2196
2197        // Table 10: ComponentMaxRef shall be present
2198        if rgba.component_max_ref.is_none() {
2199            issues.push(
2200                ValidationIssue::new(
2201                    Severity::Error,
2202                    Category::Video,
2203                    St2067_21_2023::ComponentMaxRef.code().to_string(),
2204                    "ComponentMaxRef shall be present (Table 10)",
2205                )
2206                .with_location(loc.clone()),
2207            );
2208        }
2209
2210        // Table 10: ComponentMinRef shall be present
2211        if rgba.component_min_ref.is_none() {
2212            issues.push(
2213                ValidationIssue::new(
2214                    Severity::Error,
2215                    Category::Video,
2216                    St2067_21_2023::ComponentMinRef.code().to_string(),
2217                    "ComponentMinRef shall be present (Table 10)",
2218                )
2219                .with_location(loc.clone()),
2220            );
2221        }
2222
2223        // Table 10: ScanningDirection shall be present and equal to 00h
2224        match &rgba.scanning_direction {
2225            Some(sd) if sd != "ScanningDirection_LeftToRightTopToBottom" => {
2226                issues.push(
2227                    ValidationIssue::new(
2228                        Severity::Error,
2229                        Category::Video,
2230                        St2067_21_2023::ScanningDirection.code().to_string(),
2231                        format!(
2232                            "ScanningDirection shall be 00h (LeftToRightTopToBottom), found: {}",
2233                            sd
2234                        ),
2235                    )
2236                    .with_location(loc.clone()),
2237                );
2238            }
2239            None => {
2240                issues.push(
2241                    ValidationIssue::new(
2242                        Severity::Error,
2243                        Category::Video,
2244                        St2067_21_2023::ScanningDirection.code().to_string(),
2245                        "ScanningDirection shall be present (Table 10)",
2246                    )
2247                    .with_location(loc.clone()),
2248                );
2249            }
2250            _ => {} // Valid: present and equals 00h
2251        }
2252
2253        // Table 10: AlphaMaxRef — shall not be present
2254        if rgba.alpha_max_ref.is_some() {
2255            issues.push(
2256                ValidationIssue::new(
2257                    Severity::Error,
2258                    Category::Video,
2259                    St2067_21_2023::AlphaMaxRef.code().to_string(),
2260                    "AlphaMaxRef shall not be present (Table 10)",
2261                )
2262                .with_location(loc.clone()),
2263            );
2264        }
2265
2266        // Table 10: AlphaMinRef — shall not be present
2267        if rgba.alpha_min_ref.is_some() {
2268            issues.push(
2269                ValidationIssue::new(
2270                    Severity::Error,
2271                    Category::Video,
2272                    St2067_21_2023::AlphaMinRef.code().to_string(),
2273                    "AlphaMinRef shall not be present (Table 10)",
2274                )
2275                .with_location(loc.clone()),
2276            );
2277        }
2278
2279        // Table 10: Palette — shall not be present
2280        if rgba.palette.is_some() {
2281            issues.push(
2282                ValidationIssue::new(
2283                    Severity::Error,
2284                    Category::Video,
2285                    St2067_21_2023::Palette.code().to_string(),
2286                    "Palette shall not be present (Table 10)",
2287                )
2288                .with_location(loc.clone()),
2289            );
2290        }
2291
2292        // Table 10: PaletteLayout — shall not be present
2293        if rgba.palette_layout.is_some() {
2294            issues.push(
2295                ValidationIssue::new(
2296                    Severity::Error,
2297                    Category::Video,
2298                    St2067_21_2023::PaletteLayout.code().to_string(),
2299                    "PaletteLayout shall not be present (Table 10)",
2300                )
2301                .with_location(loc.clone()),
2302            );
2303        }
2304
2305        // Table 11: Validate ComponentMaxRef/MinRef values against quantization system.
2306        // Determine QE from the component ref values themselves:
2307        // QE.2 has MinRef=0 at all bit depths; QE.1 has MinRef>0.
2308        if let (Some(min_ref), Some(max_ref)) = (rgba.component_min_ref, rgba.component_max_ref) {
2309            let qe = if min_ref == 0 {
2310                QuantizationSystem::Qe2
2311            } else {
2312                QuantizationSystem::Qe1
2313            };
2314            // Determine bit depth from max_ref
2315            let bit_depth = match max_ref {
2316                235 | 255 => Some(8u32),
2317                940 | 1023 => Some(10),
2318                3760 | 4095 => Some(12),
2319                60160 | 65535 => Some(16),
2320                _ => None,
2321            };
2322            if let Some(bd) = bit_depth {
2323                if let Some((expected_min, expected_max)) = component_ref_values(qe, bd) {
2324                    if min_ref != expected_min || max_ref != expected_max {
2325                        issues.push(
2326                            ValidationIssue::new(
2327                                Severity::Error,
2328                                Category::Video,
2329                                St2067_21_2023::ComponentRefValues.code().to_string(),
2330                                format!(
2331                                    "ComponentMinRef={}, ComponentMaxRef={} do not match \
2332                                     {:?} at {} bits (expected min={}, max={})",
2333                                    min_ref, max_ref, qe, bd, expected_min, expected_max
2334                                ),
2335                            )
2336                            .with_location(loc.clone()),
2337                        );
2338                    }
2339                }
2340            }
2341        }
2342
2343        // ── Table 3: Colorimetry system ──────────────────────────────────────
2344
2345        if let (Some(cp), Some(tc)) = (&rgba.color_primaries, &rgba.transfer_characteristic) {
2346            let color_sys = ColorSystem::from_components(cp, tc, None);
2347            if color_sys.is_none()
2348                && !matches!(cp, ColorPrimaries::Unknown(_))
2349                && !matches!(tc, TransferCharacteristic::Unknown(_))
2350            {
2351                issues.push(
2352                    ValidationIssue::new(
2353                        Severity::Error,
2354                        Category::Video,
2355                        St2067_21_2023::ColorSystem.code().to_string(),
2356                        format!(
2357                            "ColorPrimaries={} + TransferCharacteristic={} does not form a \
2358                             recognized Color System for RGB",
2359                            cp, tc
2360                        ),
2361                    )
2362                    .with_location(loc.clone()),
2363                );
2364            }
2365        }
2366
2367        // §7.5: MaxCLL/MaxFALL for PQ-based systems (optional per spec)
2368        self.check_hdr_metadata(rgba.transfer_characteristic.as_ref(), cpl, &loc, issues);
2369
2370        // ── Table 14: JPEG 2000 Picture Sub Descriptor ────────────────────────
2371        self.validate_j2k_sub_descriptor(
2372            rgba.sub_descriptors.as_ref(),
2373            rgba.picture_compression.as_ref(),
2374            &loc,
2375            issues,
2376        );
2377    }
2378
2379    /// Validate a CDCI (Y'C'BC'R) picture descriptor.
2380    ///
2381    /// Checks Table 8 (Generic Picture Descriptor), Table 12 (CDCI Descriptor),
2382    /// Table 13 (Black/White Ref Level), and Table 3 (colorimetry systems).
2383    fn validate_cdci_descriptor(
2384        &self,
2385        ed_id: &str,
2386        cdci: &crate::cpl::CDCIDescriptor,
2387        cpl: &CompositionPlaylist,
2388        allow_ht: bool,
2389        issues: &mut Vec<ValidationIssue>,
2390    ) {
2391        let loc = Location::new()
2392            .with_cpl(cpl.id)
2393            .with_path(format!("EssenceDescriptor[{}]/CDCIDescriptor", ed_id));
2394
2395        // ── Table 8: Generic Picture Essence Descriptor ──────────────────────
2396
2397        // §6.2.5: PictureCompression shall be JPEG 2000
2398        if let Some(ref codec) = cdci.picture_compression {
2399            if !codec.is_jpeg2000_family() {
2400                issues.push(
2401                    ValidationIssue::new(
2402                        Severity::Error,
2403                        Category::Encoding,
2404                        St2067_21_2023::J2KRequired.code().to_string(),
2405                        format!(
2406                            "PictureCompression shall be JPEG 2000 for App2E, found: {}",
2407                            codec
2408                        ),
2409                    )
2410                    .with_location(loc.clone()),
2411                );
2412            } else {
2413                // §6.2.5: J2K profile must be a recognized App2E profile with valid resolution.
2414                let w = cdci.stored_width.unwrap_or(0);
2415                let h = cdci.stored_height.unwrap_or(0);
2416                validate_j2k_profile(codec, w, h, allow_ht, &loc, issues);
2417            }
2418        }
2419
2420        // Table 8: FrameLayout — §6.2.1.3
2421        if let Some(ref fl) = cdci.frame_layout {
2422            if fl != "FullFrame" && fl != "SeparateFields" {
2423                issues.push(
2424                    ValidationIssue::new(
2425                        Severity::Error,
2426                        Category::Video,
2427                        St2067_21_2023::FrameLayout.code().to_string(),
2428                        format!(
2429                            "FrameLayout shall be FullFrame (00h) or SeparateFields (01h), found: {}",
2430                            fl
2431                        ),
2432                    )
2433                    .with_location(loc.clone()),
2434                );
2435            }
2436        }
2437
2438        // Table 8: StoredF2Offset — shall not be present
2439        if cdci.stored_f2_offset.is_some() {
2440            issues.push(
2441                ValidationIssue::new(
2442                    Severity::Error,
2443                    Category::Video,
2444                    St2067_21_2023::StoredF2Offset.code().to_string(),
2445                    "StoredF2Offset shall not be present (Table 8)",
2446                )
2447                .with_location(loc.clone()),
2448            );
2449        }
2450
2451        // Table 8: SampledWidth — shall not be present or shall be equal to StoredWidth
2452        if let Some(sw) = cdci.sampled_width {
2453            if let Some(stored_w) = cdci.stored_width {
2454                if sw != stored_w {
2455                    issues.push(
2456                        ValidationIssue::new(
2457                            Severity::Error,
2458                            Category::Video,
2459                            St2067_21_2023::SampledWidth.code().to_string(),
2460                            format!(
2461                                "SampledWidth ({}) shall not be present or shall be equal to StoredWidth ({})",
2462                                sw, stored_w
2463                            ),
2464                        )
2465                        .with_location(loc.clone()),
2466                    );
2467                }
2468            }
2469        }
2470
2471        // Table 8: SampledHeight — shall not be present or shall be equal to StoredHeight
2472        if let Some(sh) = cdci.sampled_height {
2473            if let Some(stored_h) = cdci.stored_height {
2474                if sh != stored_h {
2475                    issues.push(
2476                        ValidationIssue::new(
2477                            Severity::Error,
2478                            Category::Video,
2479                            St2067_21_2023::SampledHeight.code().to_string(),
2480                            format!(
2481                                "SampledHeight ({}) shall not be present or shall be equal to StoredHeight ({})",
2482                                sh, stored_h
2483                            ),
2484                        )
2485                        .with_location(loc.clone()),
2486                    );
2487                }
2488            }
2489        }
2490
2491        // Table 8: SampledXOffset — shall not be present or shall be 0
2492        if let Some(sxo) = cdci.sampled_x_offset {
2493            if sxo != 0 {
2494                issues.push(
2495                    ValidationIssue::new(
2496                        Severity::Error,
2497                        Category::Video,
2498                        St2067_21_2023::SampledXOffset.code().to_string(),
2499                        format!(
2500                            "SampledXOffset shall not be present or shall be 0, found: {}",
2501                            sxo
2502                        ),
2503                    )
2504                    .with_location(loc.clone()),
2505                );
2506            }
2507        }
2508
2509        // Table 8: SampledYOffset — shall not be present or shall be 0
2510        if let Some(syo) = cdci.sampled_y_offset {
2511            if syo != 0 {
2512                issues.push(
2513                    ValidationIssue::new(
2514                        Severity::Error,
2515                        Category::Video,
2516                        St2067_21_2023::SampledYOffset.code().to_string(),
2517                        format!(
2518                            "SampledYOffset shall not be present or shall be 0, found: {}",
2519                            syo
2520                        ),
2521                    )
2522                    .with_location(loc.clone()),
2523                );
2524            }
2525        }
2526
2527        // Table 8: AlphaTransparency — shall not be present
2528        if cdci.alpha_transparency.is_some() {
2529            issues.push(
2530                ValidationIssue::new(
2531                    Severity::Error,
2532                    Category::Video,
2533                    St2067_21_2023::AlphaTransparency.code().to_string(),
2534                    "AlphaTransparency shall not be present (Table 8)",
2535                )
2536                .with_location(loc.clone()),
2537            );
2538        }
2539
2540        // Table 8: ImageAlignmentOffset — shall not be present
2541        if cdci.image_alignment_offset.is_some() {
2542            issues.push(
2543                ValidationIssue::new(
2544                    Severity::Error,
2545                    Category::Video,
2546                    St2067_21_2023::ImageAlignmentOffset.code().to_string(),
2547                    "ImageAlignmentOffset shall not be present (Table 8)",
2548                )
2549                .with_location(loc.clone()),
2550            );
2551        }
2552
2553        // Table 8: ImageStartOffset — shall not be present
2554        if cdci.image_start_offset.is_some() {
2555            issues.push(
2556                ValidationIssue::new(
2557                    Severity::Error,
2558                    Category::Video,
2559                    St2067_21_2023::ImageStartOffset.code().to_string(),
2560                    "ImageStartOffset shall not be present (Table 8)",
2561                )
2562                .with_location(loc.clone()),
2563            );
2564        }
2565
2566        // Table 8: ImageEndOffset — shall not be present
2567        if cdci.image_end_offset.is_some() {
2568            issues.push(
2569                ValidationIssue::new(
2570                    Severity::Error,
2571                    Category::Video,
2572                    St2067_21_2023::ImageEndOffset.code().to_string(),
2573                    "ImageEndOffset shall not be present (Table 8)",
2574                )
2575                .with_location(loc.clone()),
2576            );
2577        }
2578
2579        // Table 8: FieldDominance — conditional on FrameLayout
2580        if let Some(ref fl) = cdci.frame_layout {
2581            if fl == "FullFrame" && cdci.field_dominance.is_some() {
2582                issues.push(
2583                    ValidationIssue::new(
2584                        Severity::Error,
2585                        Category::Video,
2586                        St2067_21_2023::FieldDominance.code().to_string(),
2587                        "FieldDominance shall not be present for progressive (FullFrame) content",
2588                    )
2589                    .with_location(loc.clone()),
2590                );
2591            }
2592            if fl == "SeparateFields" && cdci.field_dominance.is_none() {
2593                issues.push(
2594                    ValidationIssue::new(
2595                        Severity::Error,
2596                        Category::Video,
2597                        St2067_21_2023::FieldDominance.code().to_string(),
2598                        "FieldDominance shall be present for interlaced (SeparateFields) content",
2599                    )
2600                    .with_location(loc.clone()),
2601                );
2602            }
2603        }
2604
2605        // §6.2.4: ColorPrimaries shall be present and recognized
2606        match &cdci.color_primaries {
2607            Some(cp) if matches!(cp, ColorPrimaries::Unknown(_)) => {
2608                issues.push(
2609                    ValidationIssue::new(
2610                        Severity::Error,
2611                        Category::Video,
2612                        St2067_21_2023::ColorPrimariesUnknown.code().to_string(),
2613                        format!("Unrecognized ColorPrimaries UL: {}", cp),
2614                    )
2615                    .with_location(loc.clone()),
2616                );
2617            }
2618            None => {
2619                issues.push(
2620                    ValidationIssue::new(
2621                        Severity::Error,
2622                        Category::Video,
2623                        St2067_21_2023::ColorPrimariesMissing.code().to_string(),
2624                        "ColorPrimaries shall be present (Table 8)",
2625                    )
2626                    .with_location(loc.clone()),
2627                );
2628            }
2629            _ => {}
2630        }
2631
2632        // §6.2.2: TransferCharacteristic shall be present and recognized
2633        match &cdci.transfer_characteristic {
2634            Some(tc) if matches!(tc, TransferCharacteristic::Unknown(_)) => {
2635                issues.push(
2636                    ValidationIssue::new(
2637                        Severity::Error,
2638                        Category::Video,
2639                        St2067_21_2023::TransferCharacteristicUnknown
2640                            .code()
2641                            .to_string(),
2642                        format!("Unrecognized TransferCharacteristic UL: {}", tc),
2643                    )
2644                    .with_location(loc.clone()),
2645                );
2646            }
2647            None => {
2648                issues.push(
2649                    ValidationIssue::new(
2650                        Severity::Error,
2651                        Category::Video,
2652                        St2067_21_2023::TransferCharacteristicMissing
2653                            .code()
2654                            .to_string(),
2655                        "TransferCharacteristic shall be present (Table 8)",
2656                    )
2657                    .with_location(loc.clone()),
2658                );
2659            }
2660            _ => {}
2661        }
2662
2663        // §6.2.3: CodingEquations shall be present for Y'C'BC'R
2664        match &cdci.coding_equations {
2665            Some(ce) if matches!(ce, CodingEquations::Unknown(_)) => {
2666                issues.push(
2667                    ValidationIssue::new(
2668                        Severity::Error,
2669                        Category::Video,
2670                        St2067_21_2023::CodingEquationsUnknown.code().to_string(),
2671                        format!("Unrecognized CodingEquations UL: {}", ce),
2672                    )
2673                    .with_location(loc.clone()),
2674                );
2675            }
2676            None => {
2677                issues.push(
2678                    ValidationIssue::new(
2679                        Severity::Error,
2680                        Category::Video,
2681                        St2067_21_2023::CodingEquationsMissing.code().to_string(),
2682                        "CodingEquations shall be present for Y'C'BC'R (Table 8)",
2683                    )
2684                    .with_location(loc.clone()),
2685                );
2686            }
2687            _ => {}
2688        }
2689
2690        // ── Table 12: CDCI Descriptor ────────────────────────────────────────
2691
2692        // Table 12: ComponentDepth shall be present and equal to pixel bit depth (8/10/12/16)
2693        match cdci.component_depth {
2694            Some(depth) if !matches!(depth, 8 | 10 | 12 | 16) => {
2695                issues.push(
2696                    ValidationIssue::new(
2697                        Severity::Error,
2698                        Category::Video,
2699                        St2067_21_2023::ComponentDepth.code().to_string(),
2700                        format!(
2701                            "ComponentDepth {} is not allowed; shall be 8, 10, 12, or 16",
2702                            depth
2703                        ),
2704                    )
2705                    .with_location(loc.clone()),
2706                );
2707            }
2708            None => {
2709                issues.push(
2710                    ValidationIssue::new(
2711                        Severity::Error,
2712                        Category::Video,
2713                        St2067_21_2023::ComponentDepth.code().to_string(),
2714                        "ComponentDepth shall be present (Table 12)",
2715                    )
2716                    .with_location(loc.clone()),
2717                );
2718            }
2719            _ => {}
2720        }
2721
2722        // Table 12: HorizontalSubsampling — §6.4.2
2723        // 1 = 4:4:4, 2 = 4:2:2
2724        match cdci.horizontal_subsampling {
2725            Some(hs) if hs != 1 && hs != 2 => {
2726                issues.push(
2727                    ValidationIssue::new(
2728                        Severity::Error,
2729                        Category::Video,
2730                        St2067_21_2023::HorizontalSubsampling.code().to_string(),
2731                        format!(
2732                            "HorizontalSubsampling shall be 1 (4:4:4) or 2 (4:2:2), found: {}",
2733                            hs
2734                        ),
2735                    )
2736                    .with_location(loc.clone()),
2737                );
2738            }
2739            None => {
2740                issues.push(
2741                    ValidationIssue::new(
2742                        Severity::Error,
2743                        Category::Video,
2744                        St2067_21_2023::HorizontalSubsampling.code().to_string(),
2745                        "HorizontalSubsampling shall be present and equal to 1 (4:4:4) or 2 (4:2:2)",
2746                    )
2747                    .with_location(loc.clone()),
2748                );
2749            }
2750            _ => {}
2751        }
2752
2753        // Table 12: VerticalSubsampling shall be 1
2754        match cdci.vertical_subsampling {
2755            Some(vs) if vs != 1 => {
2756                issues.push(
2757                    ValidationIssue::new(
2758                        Severity::Error,
2759                        Category::Video,
2760                        St2067_21_2023::VerticalSubsampling.code().to_string(),
2761                        format!("VerticalSubsampling shall be 1, found: {}", vs),
2762                    )
2763                    .with_location(loc.clone()),
2764                );
2765            }
2766            None => {
2767                issues.push(
2768                    ValidationIssue::new(
2769                        Severity::Error,
2770                        Category::Video,
2771                        St2067_21_2023::VerticalSubsampling.code().to_string(),
2772                        "VerticalSubsampling shall be present and equal to 1",
2773                    )
2774                    .with_location(loc.clone()),
2775                );
2776            }
2777            _ => {}
2778        }
2779
2780        // Table 12: ColorSiting shall be present and 0
2781        match cdci.color_siting {
2782            Some(cs) if cs != 0 => {
2783                issues.push(
2784                    ValidationIssue::new(
2785                        Severity::Error,
2786                        Category::Video,
2787                        St2067_21_2023::ColorSiting.code().to_string(),
2788                        format!("ColorSiting shall be 0, found: {}", cs),
2789                    )
2790                    .with_location(loc.clone()),
2791                );
2792            }
2793            None => {
2794                issues.push(
2795                    ValidationIssue::new(
2796                        Severity::Error,
2797                        Category::Video,
2798                        St2067_21_2023::ColorSiting.code().to_string(),
2799                        "ColorSiting shall be present and equal to 0",
2800                    )
2801                    .with_location(loc.clone()),
2802                );
2803            }
2804            _ => {}
2805        }
2806
2807        // Table 12: ReversedByteOrder — shall not be present
2808        if cdci.reversed_byte_order.is_some() {
2809            issues.push(
2810                ValidationIssue::new(
2811                    Severity::Error,
2812                    Category::Video,
2813                    St2067_21_2023::ReversedByteOrder.code().to_string(),
2814                    "ReversedByteOrder shall not be present (Table 12)",
2815                )
2816                .with_location(loc.clone()),
2817            );
2818        }
2819
2820        // Table 12: PaddingBits — shall not be present
2821        if cdci.padding_bits.is_some() {
2822            issues.push(
2823                ValidationIssue::new(
2824                    Severity::Error,
2825                    Category::Video,
2826                    St2067_21_2023::PaddingBits.code().to_string(),
2827                    "PaddingBits shall not be present (Table 12)",
2828                )
2829                .with_location(loc.clone()),
2830            );
2831        }
2832
2833        // Table 12: AlphaSampleDepth — shall not be present
2834        if cdci.alpha_sample_depth.is_some() {
2835            issues.push(
2836                ValidationIssue::new(
2837                    Severity::Error,
2838                    Category::Video,
2839                    St2067_21_2023::AlphaSampleDepth.code().to_string(),
2840                    "AlphaSampleDepth shall not be present (Table 12)",
2841                )
2842                .with_location(loc.clone()),
2843            );
2844        }
2845
2846        // ── Table 13: Black/White Ref Level and Color Range ──────────────────
2847
2848        // Determine the color system to look up correct reference values
2849        if let (Some(cp), Some(tc), Some(ce)) = (
2850            &cdci.color_primaries,
2851            &cdci.transfer_characteristic,
2852            &cdci.coding_equations,
2853        ) {
2854            // Table 3: Validate colorimetry system triplet
2855            let color_sys = ColorSystem::from_components(cp, tc, Some(ce));
2856            if let Some(ref cs) = color_sys {
2857                // Table 13: Validate Black/White Ref Level and Color Range
2858                if let Some(depth) = cdci.component_depth {
2859                    if let Some((exp_black, exp_white, exp_range)) = cdci_ref_values(cs, depth) {
2860                        if let Some(black) = cdci.black_ref_level {
2861                            if black != exp_black {
2862                                issues.push(
2863                                    ValidationIssue::new(
2864                                        Severity::Error,
2865                                        Category::Video,
2866                                        St2067_21_2023::BlackRefLevel.code().to_string(),
2867                                        format!(
2868                                            "BlackRefLevel={} for {} at {}-bit; expected {}",
2869                                            black, cs, depth, exp_black
2870                                        ),
2871                                    )
2872                                    .with_location(loc.clone()),
2873                                );
2874                            }
2875                        }
2876                        if let Some(white) = cdci.white_ref_level {
2877                            if white != exp_white {
2878                                issues.push(
2879                                    ValidationIssue::new(
2880                                        Severity::Error,
2881                                        Category::Video,
2882                                        St2067_21_2023::WhiteRefLevel.code().to_string(),
2883                                        format!(
2884                                            "WhiteRefLevel={} for {} at {}-bit; expected {}",
2885                                            white, cs, depth, exp_white
2886                                        ),
2887                                    )
2888                                    .with_location(loc.clone()),
2889                                );
2890                            }
2891                        }
2892                        if let Some(range) = cdci.color_range {
2893                            if range != exp_range {
2894                                issues.push(
2895                                    ValidationIssue::new(
2896                                        Severity::Error,
2897                                        Category::Video,
2898                                        St2067_21_2023::ColorRange.code().to_string(),
2899                                        format!(
2900                                            "ColorRange={} for {} at {}-bit; expected {}",
2901                                            range, cs, depth, exp_range
2902                                        ),
2903                                    )
2904                                    .with_location(loc.clone()),
2905                                );
2906                            }
2907                        }
2908                    }
2909                }
2910            } else if !matches!(cp, ColorPrimaries::Unknown(_))
2911                && !matches!(tc, TransferCharacteristic::Unknown(_))
2912                && !matches!(ce, CodingEquations::Unknown(_))
2913            {
2914                issues.push(
2915                    ValidationIssue::new(
2916                        Severity::Error,
2917                        Category::Video,
2918                        St2067_21_2023::ColorSystem.code().to_string(),
2919                        format!(
2920                            "ColorPrimaries={} + TransferCharacteristic={} + CodingEquations={} \
2921                             does not form a recognized Color System",
2922                            cp, tc, ce
2923                        ),
2924                    )
2925                    .with_location(loc.clone()),
2926                );
2927            }
2928        }
2929
2930        // §7.5: MaxCLL/MaxFALL for PQ-based systems (optional per spec)
2931        self.check_hdr_metadata(cdci.transfer_characteristic.as_ref(), cpl, &loc, issues);
2932
2933        // ── Table 14: JPEG 2000 Picture Sub Descriptor ────────────────────────
2934        self.validate_j2k_sub_descriptor(
2935            cdci.sub_descriptors.as_ref(),
2936            cdci.picture_compression.as_ref(),
2937            &loc,
2938            issues,
2939        );
2940    }
2941
2942    /// Section 7.5: MaxCLL and MaxFALL for PQ (ST 2084) content.
2943    ///
2944    /// Per ST 2067-21:2023 §7.5, MaxCLL and MaxFALL have 0..1 cardinality for
2945    /// COLOR.6/COLOR.7 (PQ-based systems). They are OPTIONAL, not required.
2946    /// We emit an informational note when they are absent.
2947    fn check_hdr_metadata(
2948        &self,
2949        tc: Option<&TransferCharacteristic>,
2950        cpl: &CompositionPlaylist,
2951        loc: &Location,
2952        issues: &mut Vec<ValidationIssue>,
2953    ) {
2954        if let Some(TransferCharacteristic::PqSt2084) = tc {
2955            let has_hdr_metadata = cpl
2956                .extension_properties
2957                .as_ref()
2958                .map(|ext| ext.max_cll.is_some() && ext.max_fall.is_some())
2959                .unwrap_or(false);
2960            if !has_hdr_metadata {
2961                issues.push(
2962                    ValidationIssue::new(
2963                        Severity::Info,
2964                        Category::Video,
2965                        St2067_21_2023::MaxCLLMaxFALL.code().to_string(),
2966                        "MaxCLL and MaxFALL are not present for PQ (ST 2084) content; \
2967                         per §7.5 they are optional (0..1 cardinality)",
2968                    )
2969                    .with_location(loc.clone())
2970                    .with_suggestion("Consider adding MaxCLL and MaxFALL to ExtensionProperties"),
2971                );
2972            }
2973        }
2974    }
2975
2976    /// Table 14: JPEG 2000 Picture Sub Descriptor validation.
2977    ///
2978    /// Shared between RGBA and CDCI descriptor validation.
2979    fn validate_j2k_sub_descriptor(
2980        &self,
2981        sub_descriptors: Option<&crate::cpl::VideoSubDescriptors>,
2982        picture_compression: Option<&crate::cpl::VideoCodec>,
2983        loc: &Location,
2984        issues: &mut Vec<ValidationIssue>,
2985    ) {
2986        // Only validate J2K sub-descriptor if codec is JPEG 2000
2987        let is_j2k = picture_compression
2988            .map(|pc| pc.is_jpeg2000_family())
2989            .unwrap_or(false);
2990        if !is_j2k {
2991            return;
2992        }
2993
2994        let j2k_sub = sub_descriptors.and_then(|sd| sd.jpeg2000_sub_descriptor.as_ref());
2995
2996        let j2k_sub = match j2k_sub {
2997            Some(sub) => sub,
2998            None => {
2999                // Table 14 says it shall be present, but it might not be serialized in CPL
3000                // (it's an MXF concept). Emit warning to make this normative gap visible.
3001                issues.push(
3002                    ValidationIssue::new(
3003                        Severity::Warning,
3004                        Category::Encoding,
3005                        St2067_21_2023::Jpeg2000SubDescriptor.code().to_string(),
3006                        "JPEG2000SubDescriptor is missing; Table 14 requires this descriptor for JPEG 2000 picture essence",
3007                    )
3008                    .with_location(loc.clone())
3009                    .with_suggestion("Include JPEG2000SubDescriptor metadata in CPL/mapping when available"),
3010                );
3011                return;
3012            }
3013        };
3014
3015        // Table 14: CodingStyleDefault (Coding Style) — shall be present
3016        if j2k_sub.coding_style_default.is_none() {
3017            issues.push(
3018                ValidationIssue::new(
3019                    Severity::Error,
3020                    Category::Encoding,
3021                    St2067_21_2023::CodingStyle.code().to_string(),
3022                    "CodingStyleDefault (Coding Style) shall be present (Table 14)",
3023                )
3024                .with_location(loc.clone()),
3025            );
3026        }
3027
3028        // Table 14: J2CLayout — shall be present (§6.5.2)
3029        if j2k_sub.j2c_layout.is_none() {
3030            issues.push(
3031                ValidationIssue::new(
3032                    Severity::Error,
3033                    Category::Encoding,
3034                    St2067_21_2023::J2CLayout.code().to_string(),
3035                    "J2CLayout shall be present (Table 14, §6.5.2)",
3036                )
3037                .with_location(loc.clone()),
3038            );
3039        }
3040
3041        // Table 14: J2KExtendedCapabilities — shall be present if ISO/IEC 15444-15 coding
3042        // Rsiz bit 14 (0x4000 = 16384) indicates Part 15 (HTJ2K) profile
3043        if let Some(rsiz) = j2k_sub.rsiz {
3044            if rsiz & 0x4000 != 0 && j2k_sub.j2k_extended_capabilities.is_none() {
3045                issues.push(
3046                    ValidationIssue::new(
3047                        Severity::Error,
3048                        Category::Encoding,
3049                        St2067_21_2023::J2KExtendedCapabilities.code().to_string(),
3050                        "J2KExtendedCapabilities shall be present when ISO/IEC 15444-15 coding is used (Table 14)",
3051                    )
3052                    .with_location(loc.clone()),
3053                );
3054            }
3055        }
3056    }
3057
3058    /// Section 7.2: All image descriptors in a composition must use the same color system.
3059    fn validate_homogeneous_image_essence(
3060        &self,
3061        cpl: &CompositionPlaylist,
3062        issues: &mut Vec<ValidationIssue>,
3063    ) {
3064        let edl = match &cpl.essence_descriptor_list {
3065            Some(edl) => edl,
3066            None => return,
3067        };
3068
3069        let mut color_systems: Vec<ColorSystem> = Vec::new();
3070
3071        for ed in &edl.essence_descriptors {
3072            if let Some(ref cdci) = ed.cdci_descriptor {
3073                if let (Some(cp), Some(tc), Some(ce)) = (
3074                    &cdci.color_primaries,
3075                    &cdci.transfer_characteristic,
3076                    &cdci.coding_equations,
3077                ) {
3078                    if let Some(cs) = ColorSystem::from_components(cp, tc, Some(ce)) {
3079                        color_systems.push(cs);
3080                    }
3081                }
3082            }
3083            if let Some(ref rgba) = ed.rgba_descriptor {
3084                if let (Some(cp), Some(tc)) = (&rgba.color_primaries, &rgba.transfer_characteristic)
3085                {
3086                    if let Some(cs) = ColorSystem::from_components(cp, tc, None) {
3087                        color_systems.push(cs);
3088                    }
3089                }
3090            }
3091        }
3092
3093        if !color_systems.is_empty() {
3094            let first = &color_systems[0];
3095            if !color_systems.iter().all(|cs| cs == first) {
3096                let unique: HashSet<_> = color_systems.iter().collect();
3097                let mut systems: Vec<_> = unique.iter().map(|cs| cs.to_string()).collect();
3098                systems.sort();
3099                issues.push(
3100                    ValidationIssue::new(
3101                        Severity::Error,
3102                        Category::Video,
3103                        St2067_21_2023::HomogeneousImageEssence.code(),
3104                        format!(
3105                            "Heterogeneous image essence: found {} different color systems ({}); \
3106                             all image essence in a composition shall use the same color system",
3107                            unique.len(),
3108                            systems.join(", ")
3109                        ),
3110                    )
3111                    .with_location(Location::new().with_cpl(cpl.id)),
3112                );
3113            }
3114        }
3115    }
3116
3117    /// §7.4 Segment Duration: If the average number of audio samples per Composition
3118    /// Edit Unit is not an integer, the duration of each Segment shall be an integer
3119    /// multiple of 5/Composition Edit Rate (i.e., segment duration in edit units must
3120    /// be divisible by 5).
3121    fn validate_segment_duration(
3122        &self,
3123        cpl: &CompositionPlaylist,
3124        issues: &mut Vec<ValidationIssue>,
3125    ) {
3126        // Need Composition Edit Rate to compute samples-per-edit-unit.
3127        let edit_rate = match &cpl.edit_rate {
3128            Some(er) if er.numerator > 0 && er.denominator > 0 => er,
3129            _ => return,
3130        };
3131
3132        // Collect audio sample rates from WAVEPCMDescriptors.
3133        let edl = match &cpl.essence_descriptor_list {
3134            Some(edl) => edl,
3135            None => return,
3136        };
3137
3138        let mut non_integer_audio = false;
3139        for ed in &edl.essence_descriptors {
3140            if let Some(ref wave) = ed.wave_pcm_descriptor {
3141                if let Some(ref asr) = wave.audio_sample_rate {
3142                    // samples_per_edit_unit = (asr_num / asr_den) / (er_num / er_den)
3143                    //                       = (asr_num * er_den) / (asr_den * er_num)
3144                    // Non-integer if (asr_num * er_den) % (asr_den * er_num) != 0
3145                    let numerator = asr.numerator as u64 * edit_rate.denominator as u64;
3146                    let denominator = asr.denominator as u64 * edit_rate.numerator as u64;
3147                    if denominator > 0 && !numerator.is_multiple_of(denominator) {
3148                        non_integer_audio = true;
3149                        break;
3150                    }
3151                }
3152            }
3153        }
3154
3155        if !non_integer_audio {
3156            return;
3157        }
3158
3159        // Constraint applies: each segment's duration (in edit units) must be divisible by 5.
3160        // Segment duration = sum of effective durations of resources in the main image track.
3161        for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
3162            let mut segment_duration: u64 = 0;
3163            for seq in &segment.sequence_list.main_image_sequences {
3164                for resource in &seq.resource_list.resources {
3165                    let effective = resource
3166                        .source_duration
3167                        .unwrap_or(resource.intrinsic_duration);
3168                    segment_duration += effective;
3169                }
3170            }
3171
3172            if segment_duration > 0 && !segment_duration.is_multiple_of(5) {
3173                issues.push(
3174                    ValidationIssue::new(
3175                        Severity::Error,
3176                        Category::Timing,
3177                        St2067_21_2023::SegmentDurationMultiple.code().to_string(),
3178                        format!(
3179                            "Segment {} duration ({} edit units) is not a multiple of 5; \
3180                             §7.4 requires segment duration to be an integer multiple of \
3181                             5/Composition Edit Rate when audio samples per edit unit is non-integer",
3182                            seg_idx + 1,
3183                            segment_duration,
3184                        ),
3185                    )
3186                    .with_location(
3187                        Location::new()
3188                            .with_cpl(cpl.id)
3189                            .with_segment(seg_idx),
3190                    ),
3191                );
3192            }
3193        }
3194    }
3195
3196    /// Common App2E validation rules, parameterised by `allow_ht`.
3197    ///
3198    /// Called by both `App2E2021::validate_cpl` (allow_ht = true) and
3199    /// `App2E2020::validate_cpl` (allow_ht = false). This avoids duplicating
3200    /// the complete rule set for the 2020 vs 2021 HT-J2K difference.
3201    pub fn validate_all(
3202        &self,
3203        cpl: &CompositionPlaylist,
3204        allow_ht: bool,
3205        issues: &mut Vec<ValidationIssue>,
3206    ) {
3207        // Validate image descriptors (Tables 8/10/11/12/13)
3208        self.validate_image_descriptors(cpl, allow_ht, issues);
3209        // §8.2: Homogeneous image essence
3210        self.validate_homogeneous_image_essence(cpl, issues);
3211        // §7.4: Segment duration alignment for non-integer audio sample counts
3212        self.validate_segment_duration(cpl, issues);
3213        // §6.5: Audio quantization bits
3214        self.validate_audio_quantization(cpl, issues);
3215        // Tables 4-6: Image resolution
3216        self.validate_image_resolution(cpl, issues);
3217        // Tables 4-6: Image frame rate
3218        self.validate_image_frame_rate(cpl, issues);
3219        // §6.5: Audio sample rate (48000 Hz)
3220        self.validate_audio_sample_rate(cpl, issues);
3221        // §6.2: Essence descriptor completeness
3222        self.validate_descriptor_completeness(cpl, issues);
3223        // §6.2.1: FrameLayout shall be FullFrame (progressive)
3224        self.validate_frame_layout_progressive(cpl, issues);
3225        // §7.3: Audio channel layout homogeneity within a composition
3226        self.validate_audio_channel_homogeneity(cpl, issues);
3227        // §5.6: HearingImpaired/ForcedNarrative captions shall use timed text
3228        self.validate_caption_track_constraints(cpl, issues);
3229        // §5.1.3: ContentMaturityRating shall use recognized agency/rating pairs
3230        self.validate_content_maturity_rating(cpl, issues);
3231        // §5.3: LocaleList language/region validation
3232        self.validate_locale_list(cpl, issues);
3233    }
3234}
3235
3236impl ConstraintsValidator for App2E2021 {
3237    fn spec_id(&self) -> &str {
3238        "ST 2067-21:2023 (App2E)"
3239    }
3240
3241    fn validate_cpl(&self, cpl: &CompositionPlaylist) -> Vec<ValidationIssue> {
3242        let mut issues = Vec::new();
3243
3244        // §7.1 / Table 15: ApplicationIdentification shall be the exact URI
3245        if let Some(ref ext) = cpl.extension_properties {
3246            if let Some(ref app_id) = ext.application_identification {
3247                if app_id != APP2E_APPLICATION_IDENTIFICATION {
3248                    issues.push(
3249                        ValidationIssue::new(
3250                            Severity::Warning,
3251                            Category::Metadata,
3252                            St2067_21_2023::AppIdMismatch.code().to_string(),
3253                            format!(
3254                                "ApplicationIdentification '{}' does not match Table 15 value '{}'",
3255                                app_id, APP2E_APPLICATION_IDENTIFICATION
3256                            ),
3257                        )
3258                        .with_location(Location::new().with_cpl(cpl.id)),
3259                    );
3260                }
3261            }
3262        }
3263
3264        self.validate_all(
3265            cpl,
3266            true, /* HT-J2K permitted in App2E 2021 */
3267            &mut issues,
3268        );
3269
3270        issues
3271    }
3272}
3273
3274// ═════════════════════════════════════════════════════════════════════════════
3275// App2E 2020 Validator
3276// ═════════════════════════════════════════════════════════════════════════════
3277
3278/// The exact ApplicationIdentification URI per ST 2067-21:2020 Table 15.
3279const APP2E_2020_APPLICATION_IDENTIFICATION: &str = "http://www.smpte-ra.org/ns/2067-21/2020";
3280
3281/// ST 2067-21:2020 Application Profile #2E Validator.
3282///
3283/// Identical to App2E 2021 except HT-J2K (ISO 15444-15) is **not** permitted —
3284/// HT-J2K support was only added in the 2021 edition of ST 2067-21.
3285pub struct App2E2020;
3286
3287impl ConstraintsValidator for App2E2020 {
3288    fn spec_id(&self) -> &str {
3289        "ST 2067-21:2020 (App2E)"
3290    }
3291
3292    fn validate_cpl(&self, cpl: &CompositionPlaylist) -> Vec<ValidationIssue> {
3293        let mut issues = Vec::new();
3294
3295        // §7.1 / Table 15: ApplicationIdentification shall be the exact URI
3296        if let Some(ref ext) = cpl.extension_properties {
3297            if let Some(ref app_id) = ext.application_identification {
3298                if app_id != APP2E_2020_APPLICATION_IDENTIFICATION {
3299                    issues.push(
3300                        ValidationIssue::new(
3301                            Severity::Warning,
3302                            Category::Metadata,
3303                            St2067_21_2020::AppIdMismatch.code().to_string(),
3304                            format!(
3305                                "ApplicationIdentification '{}' does not match Table 15 value '{}'",
3306                                app_id, APP2E_2020_APPLICATION_IDENTIFICATION
3307                            ),
3308                        )
3309                        .with_location(Location::new().with_cpl(cpl.id)),
3310                    );
3311                }
3312            }
3313        }
3314
3315        App2E2021.validate_all(
3316            cpl,
3317            false, /* HT-J2K not permitted in App2E 2020 */
3318            &mut issues,
3319        );
3320
3321        issues
3322    }
3323}
3324
3325impl App2E2021 {
3326    /// ST 2067-21 §6.5: QuantizationBits shall be 16 or 24.
3327    fn validate_audio_quantization(
3328        &self,
3329        cpl: &CompositionPlaylist,
3330        issues: &mut Vec<ValidationIssue>,
3331    ) {
3332        let edl = match &cpl.essence_descriptor_list {
3333            Some(edl) => edl,
3334            None => return,
3335        };
3336
3337        for ed in &edl.essence_descriptors {
3338            if let Some(ref wave) = ed.wave_pcm_descriptor {
3339                if let Some(qb) = wave.quantization_bits {
3340                    if qb != 16 && qb != 24 {
3341                        issues.push(
3342                            ValidationIssue::new(
3343                                Severity::Error,
3344                                Category::Audio,
3345                                St2067_21_2023::QuantizationBits.code().to_string(),
3346                                format!(
3347                                    "WAVEPCMDescriptor {} has QuantizationBits {} \
3348                                     but ST 2067-21 §6.5 requires 16 or 24",
3349                                    ed.id, qb,
3350                                ),
3351                            )
3352                            .with_location(
3353                                Location::new()
3354                                    .with_cpl(cpl.id)
3355                                    .with_path(format!("EssenceDescriptor/{}", ed.id)),
3356                            ),
3357                        );
3358                    }
3359                }
3360            }
3361        }
3362    }
3363}
3364
3365// ── ST 2067-21 Tables 4-6: Image system constraints ─────────────────────────
3366
3367/// Allowed image system tiers per ST 2067-21 Tables 4-6.
3368///
3369/// Each tier defines valid (StoredWidth, StoredHeight) pairs and the set of
3370/// allowed frame rates (as EditRate numerator/denominator pairs).
3371struct ImageSystemTier {
3372    name: &'static str,
3373    dimensions: &'static [(u32, u32)],
3374}
3375
3376/// ST 2067-21 Tables 4-6: Allowed frame rates for all image system tiers.
3377///
3378/// These are common across 2K, 4K, and 8K tiers.
3379const ALLOWED_FRAME_RATES: &[(u32, u32)] = &[
3380    (24, 1),       // 24 fps
3381    (24000, 1001), // 23.976 fps
3382    (25, 1),       // 25 fps
3383    (30, 1),       // 30 fps
3384    (30000, 1001), // 29.97 fps
3385    (48, 1),       // 48 fps (HFR)
3386    (48000, 1001), // 47.952 fps (HFR)
3387    (50, 1),       // 50 fps (HFR)
3388    (60, 1),       // 60 fps (HFR)
3389    (60000, 1001), // 59.94 fps (HFR)
3390];
3391
3392/// All defined image system tiers.
3393const IMAGE_SYSTEM_TIERS: &[ImageSystemTier] = &[
3394    ImageSystemTier {
3395        name: "2K (Table 4)",
3396        dimensions: &[(1920, 1080), (2048, 1080)],
3397    },
3398    ImageSystemTier {
3399        name: "4K (Table 5)",
3400        dimensions: &[(3840, 2160), (4096, 2160)],
3401    },
3402    ImageSystemTier {
3403        name: "8K (Table 6)",
3404        dimensions: &[(7680, 4320)],
3405    },
3406];
3407
3408impl App2E2021 {
3409    /// ST 2067-21 §5.2: Validate image resolution (StoredWidth × StoredHeight).
3410    ///
3411    /// The stored dimensions must match one of the allowed image system tiers.
3412    fn validate_image_resolution(
3413        &self,
3414        cpl: &CompositionPlaylist,
3415        issues: &mut Vec<ValidationIssue>,
3416    ) {
3417        let edl = match &cpl.essence_descriptor_list {
3418            Some(edl) => edl,
3419            None => return,
3420        };
3421
3422        let all_allowed: Vec<(u32, u32)> = IMAGE_SYSTEM_TIERS
3423            .iter()
3424            .flat_map(|t| t.dimensions.iter().copied())
3425            .collect();
3426
3427        for ed in &edl.essence_descriptors {
3428            let (width, height, desc_type) = if let Some(ref rgba) = ed.rgba_descriptor {
3429                (rgba.stored_width, rgba.stored_height, "RGBA")
3430            } else if let Some(ref cdci) = ed.cdci_descriptor {
3431                (cdci.stored_width, cdci.stored_height, "CDCI")
3432            } else {
3433                continue;
3434            };
3435
3436            let (w, h) = match (width, height) {
3437                (Some(w), Some(h)) => (w, h),
3438                _ => continue, // Completeness validation will catch missing fields
3439            };
3440
3441            if !all_allowed.contains(&(w, h)) {
3442                let tier_names: Vec<&str> = IMAGE_SYSTEM_TIERS.iter().map(|t| t.name).collect();
3443                issues.push(
3444                    ValidationIssue::new(
3445                        Severity::Error,
3446                        Category::Video,
3447                        St2067_21_2023::Resolution.code().to_string(),
3448                        format!(
3449                            "{} descriptor {}: StoredWidth×StoredHeight {}×{} is not an allowed App2E image system dimension (allowed tiers: {})",
3450                            desc_type, ed.id, w, h, tier_names.join(", ")
3451                        ),
3452                    )
3453                    .with_location(
3454                        Location::new()
3455                            .with_cpl(cpl.id)
3456                            .with_path(format!("EssenceDescriptor/{}", ed.id)),
3457                    ),
3458                );
3459            }
3460        }
3461    }
3462
3463    /// ST 2067-21 §5.2: Validate image frame rate (SampleRate on descriptor).
3464    ///
3465    /// The descriptor's SampleRate must be one of the allowed edit rates.
3466    fn validate_image_frame_rate(
3467        &self,
3468        cpl: &CompositionPlaylist,
3469        issues: &mut Vec<ValidationIssue>,
3470    ) {
3471        let edl = match &cpl.essence_descriptor_list {
3472            Some(edl) => edl,
3473            None => return,
3474        };
3475
3476        for ed in &edl.essence_descriptors {
3477            let (sample_rate, desc_type) = if let Some(ref rgba) = ed.rgba_descriptor {
3478                (rgba.sample_rate.as_ref(), "RGBA")
3479            } else if let Some(ref cdci) = ed.cdci_descriptor {
3480                (cdci.sample_rate.as_ref(), "CDCI")
3481            } else {
3482                continue;
3483            };
3484
3485            let rate = match sample_rate {
3486                Some(r) => r,
3487                None => continue, // Completeness validation will catch missing SampleRate
3488            };
3489
3490            let is_allowed = ALLOWED_FRAME_RATES
3491                .iter()
3492                .any(|&(n, d)| rate.numerator == n && rate.denominator == d);
3493
3494            if !is_allowed {
3495                let allowed_str: Vec<String> = ALLOWED_FRAME_RATES
3496                    .iter()
3497                    .map(|&(n, d)| {
3498                        let fps = n as f64 / d as f64;
3499                        format!("{:.3}", fps)
3500                    })
3501                    .collect();
3502                issues.push(
3503                    ValidationIssue::new(
3504                        Severity::Error,
3505                        Category::Video,
3506                        St2067_21_2023::FrameRate.code().to_string(),
3507                        format!(
3508                            "{} descriptor {}: SampleRate {}/{} ({:.3} fps) is not an allowed App2E frame rate (allowed: {} fps)",
3509                            desc_type, ed.id, rate.numerator, rate.denominator,
3510                            rate.as_f64(),
3511                            allowed_str.join(", ")
3512                        ),
3513                    )
3514                    .with_location(
3515                        Location::new()
3516                            .with_cpl(cpl.id)
3517                            .with_path(format!("EssenceDescriptor/{}", ed.id)),
3518                    ),
3519                );
3520            }
3521        }
3522    }
3523
3524    /// ST 2067-21 §6.5: AudioSampleRate shall be 48000 Hz.
3525    fn validate_audio_sample_rate(
3526        &self,
3527        cpl: &CompositionPlaylist,
3528        issues: &mut Vec<ValidationIssue>,
3529    ) {
3530        let edl = match &cpl.essence_descriptor_list {
3531            Some(edl) => edl,
3532            None => return,
3533        };
3534
3535        for ed in &edl.essence_descriptors {
3536            let wave = match &ed.wave_pcm_descriptor {
3537                Some(w) => w,
3538                None => continue,
3539            };
3540
3541            if let Some(ref rate) = wave.audio_sample_rate {
3542                // 48000 Hz = 48000/1
3543                if rate.numerator != 48000 || rate.denominator != 1 {
3544                    issues.push(
3545                        ValidationIssue::new(
3546                            Severity::Error,
3547                            Category::Audio,
3548                            St2067_21_2023::AudioSampleRate.code().to_string(),
3549                            format!(
3550                                "WAVEPCMDescriptor {}: AudioSampleRate {}/{} ({} Hz) is not 48000 Hz",
3551                                ed.id, rate.numerator, rate.denominator,
3552                                rate.numerator / rate.denominator.max(1)
3553                            ),
3554                        )
3555                        .with_location(
3556                            Location::new()
3557                                .with_cpl(cpl.id)
3558                                .with_path(format!("EssenceDescriptor/{}", ed.id)),
3559                        ),
3560                    );
3561                }
3562            }
3563        }
3564    }
3565
3566    /// ST 2067-21 §6.2: Validate that required fields are present on essence descriptors.
3567    ///
3568    /// Image descriptors (RGBA/CDCI) must have: StoredWidth, StoredHeight, SampleRate,
3569    /// FrameLayout, ColorPrimaries, TransferCharacteristic, PictureCompression.
3570    /// Audio descriptors (WAVE PCM) must have: ChannelCount, QuantizationBits.
3571    fn validate_descriptor_completeness(
3572        &self,
3573        cpl: &CompositionPlaylist,
3574        issues: &mut Vec<ValidationIssue>,
3575    ) {
3576        let edl = match &cpl.essence_descriptor_list {
3577            Some(edl) => edl,
3578            None => return,
3579        };
3580
3581        for ed in &edl.essence_descriptors {
3582            let ed_loc = Location::new()
3583                .with_cpl(cpl.id)
3584                .with_path(format!("EssenceDescriptor/{}", ed.id));
3585
3586            // Image descriptor required fields
3587            if let Some(ref rgba) = ed.rgba_descriptor {
3588                let mut push = |code: &'static str, field: &'static str| {
3589                    issues.push(
3590                        ValidationIssue::new(
3591                            Severity::Error,
3592                            Category::Encoding,
3593                            code.to_string(),
3594                            format!(
3595                                "RGBADescriptor {}: required field {} is missing",
3596                                ed.id, field
3597                            ),
3598                        )
3599                        .with_location(ed_loc.clone()),
3600                    );
3601                };
3602                if rgba.stored_width.is_none() {
3603                    push(St2067_21_2023::RequiredStoredWidth.code(), "StoredWidth");
3604                }
3605                if rgba.stored_height.is_none() {
3606                    push(St2067_21_2023::RequiredStoredHeight.code(), "StoredHeight");
3607                }
3608                if rgba.sample_rate.is_none() {
3609                    push(St2067_21_2023::RequiredSampleRate.code(), "SampleRate");
3610                }
3611                if rgba.frame_layout.is_none() {
3612                    push(St2067_21_2023::RequiredFrameLayout.code(), "FrameLayout");
3613                }
3614                if rgba.color_primaries.is_none() {
3615                    push(
3616                        St2067_21_2023::RequiredColorPrimaries.code(),
3617                        "ColorPrimaries",
3618                    );
3619                }
3620                if rgba.transfer_characteristic.is_none() {
3621                    push(
3622                        St2067_21_2023::RequiredTransferCharacteristic.code(),
3623                        "TransferCharacteristic",
3624                    );
3625                }
3626                if rgba.picture_compression.is_none() {
3627                    push(
3628                        St2067_21_2023::RequiredPictureCompression.code(),
3629                        "PictureCompression",
3630                    );
3631                }
3632            }
3633
3634            if let Some(ref cdci) = ed.cdci_descriptor {
3635                let mut push = |code: &'static str, field: &'static str| {
3636                    issues.push(
3637                        ValidationIssue::new(
3638                            Severity::Error,
3639                            Category::Encoding,
3640                            code.to_string(),
3641                            format!(
3642                                "CDCIDescriptor {}: required field {} is missing",
3643                                ed.id, field
3644                            ),
3645                        )
3646                        .with_location(ed_loc.clone()),
3647                    );
3648                };
3649                if cdci.stored_width.is_none() {
3650                    push(St2067_21_2023::RequiredStoredWidth.code(), "StoredWidth");
3651                }
3652                if cdci.stored_height.is_none() {
3653                    push(St2067_21_2023::RequiredStoredHeight.code(), "StoredHeight");
3654                }
3655                if cdci.sample_rate.is_none() {
3656                    push(St2067_21_2023::RequiredSampleRate.code(), "SampleRate");
3657                }
3658                if cdci.frame_layout.is_none() {
3659                    push(St2067_21_2023::RequiredFrameLayout.code(), "FrameLayout");
3660                }
3661                if cdci.color_primaries.is_none() {
3662                    push(
3663                        St2067_21_2023::RequiredColorPrimaries.code(),
3664                        "ColorPrimaries",
3665                    );
3666                }
3667                if cdci.transfer_characteristic.is_none() {
3668                    push(
3669                        St2067_21_2023::RequiredTransferCharacteristic.code(),
3670                        "TransferCharacteristic",
3671                    );
3672                }
3673                if cdci.picture_compression.is_none() {
3674                    push(
3675                        St2067_21_2023::RequiredPictureCompression.code(),
3676                        "PictureCompression",
3677                    );
3678                }
3679                if cdci.component_depth.is_none() {
3680                    push(
3681                        St2067_21_2023::RequiredComponentDepth.code(),
3682                        "ComponentDepth",
3683                    );
3684                }
3685            }
3686
3687            if let Some(ref wave) = ed.wave_pcm_descriptor {
3688                let mut push = |code: &'static str, field: &'static str| {
3689                    issues.push(
3690                        ValidationIssue::new(
3691                            Severity::Error,
3692                            Category::Audio,
3693                            code.to_string(),
3694                            format!(
3695                                "WAVEPCMDescriptor {}: required field {} is missing",
3696                                ed.id, field
3697                            ),
3698                        )
3699                        .with_location(ed_loc.clone()),
3700                    );
3701                };
3702                if wave.channel_count.is_none() {
3703                    push(St2067_21_2023::RequiredChannelCount.code(), "ChannelCount");
3704                }
3705                if wave.quantization_bits.is_none() {
3706                    push(
3707                        St2067_21_2023::RequiredQuantizationBits.code(),
3708                        "QuantizationBits",
3709                    );
3710                }
3711            }
3712        }
3713    }
3714
3715    /// ST 2067-21 §6.2.1: FrameLayout shall be FullFrame (progressive) for App2E.
3716    ///
3717    /// All image systems defined in Tables 4-6 (2K, 4K, 8K) are progressive-scan only.
3718    /// SeparateFields (interlaced) is not permitted for App2E content.
3719    fn validate_frame_layout_progressive(
3720        &self,
3721        cpl: &CompositionPlaylist,
3722        issues: &mut Vec<ValidationIssue>,
3723    ) {
3724        let edl = match &cpl.essence_descriptor_list {
3725            Some(edl) => edl,
3726            None => return,
3727        };
3728
3729        for ed in &edl.essence_descriptors {
3730            let (frame_layout, desc_type) = if let Some(ref rgba) = ed.rgba_descriptor {
3731                (rgba.frame_layout.as_deref(), "RGBA")
3732            } else if let Some(ref cdci) = ed.cdci_descriptor {
3733                (cdci.frame_layout.as_deref(), "CDCI")
3734            } else {
3735                continue;
3736            };
3737
3738            if let Some(fl) = frame_layout {
3739                if fl != "FullFrame" {
3740                    issues.push(
3741                        ValidationIssue::new(
3742                            Severity::Error,
3743                            Category::Video,
3744                            St2067_21_2023::FrameLayoutInterlaced.code().to_string(),
3745                            format!(
3746                                "{} descriptor {}: FrameLayout '{}' is not permitted for App2E; \
3747                                 all image systems (Tables 4-6) shall be FullFrame (progressive)",
3748                                desc_type, ed.id, fl,
3749                            ),
3750                        )
3751                        .with_location(
3752                            Location::new()
3753                                .with_cpl(cpl.id)
3754                                .with_path(format!("EssenceDescriptor/{}", ed.id)),
3755                        )
3756                        .with_suggestion("Set FrameLayout to FullFrame (00h)"),
3757                    );
3758                }
3759            }
3760        }
3761    }
3762
3763    /// Intentionally empty — per-virtual-track descriptor homogeneity is
3764    /// enforced by CoreConstraints `6.9/Homogeneity` SourceEncoding checks.
3765    #[allow(clippy::ptr_arg)]
3766    fn validate_audio_channel_homogeneity(
3767        &self,
3768        _cpl: &CompositionPlaylist,
3769        _issues: &mut Vec<ValidationIssue>,
3770    ) {
3771    }
3772
3773    /// ST 2067-21 §5.6: HearingImpairedCaptions and ForcedNarrative sequences
3774    /// shall reference timed text essence descriptors.
3775    fn validate_caption_track_constraints(
3776        &self,
3777        cpl: &CompositionPlaylist,
3778        issues: &mut Vec<ValidationIssue>,
3779    ) {
3780        // Build map from EssenceDescriptor ID → has DCTimedTextDescriptor
3781        let ed_map: HashMap<String, bool> = cpl
3782            .essence_descriptor_list
3783            .as_ref()
3784            .map(|edl| {
3785                edl.essence_descriptors
3786                    .iter()
3787                    .map(|ed| (ed.id.to_string(), ed.dc_timed_text_descriptor.is_some()))
3788                    .collect()
3789            })
3790            .unwrap_or_default();
3791
3792        // Check HIC sequences
3793        for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
3794            for seq in &segment.sequence_list.hearing_impaired_captions_sequences {
3795                for (res_idx, resource) in seq.resource_list.resources.iter().enumerate() {
3796                    if let Some(ref se) = resource.source_encoding {
3797                        let se_str = se.to_string();
3798                        if let Some(&has_ttml) = ed_map.get(&se_str) {
3799                            if !has_ttml {
3800                                issues.push(
3801                                    ValidationIssue::new(
3802                                        Severity::Error,
3803                                        Category::Subtitle,
3804                                        St2067_21_2025::HICTimedText.code().to_string(),
3805                                        format!(
3806                                            "HearingImpairedCaptions resource {} references descriptor '{}' \
3807                                             which is not a DCTimedTextDescriptor",
3808                                            res_idx, se_str,
3809                                        ),
3810                                    )
3811                                    .with_location(
3812                                        Location::new()
3813                                            .with_cpl(cpl.id)
3814                                            .with_segment(seg_idx)
3815                                            .with_resource(res_idx),
3816                                    ),
3817                                );
3818                            }
3819                        }
3820                    }
3821                }
3822            }
3823
3824            // Check ForcedNarrative sequences
3825            for seq in &segment.sequence_list.forced_narrative_sequences {
3826                for (res_idx, resource) in seq.resource_list.resources.iter().enumerate() {
3827                    if let Some(ref se) = resource.source_encoding {
3828                        let se_str = se.to_string();
3829                        if let Some(&has_ttml) = ed_map.get(&se_str) {
3830                            if !has_ttml {
3831                                issues.push(
3832                                    ValidationIssue::new(
3833                                        Severity::Error,
3834                                        Category::Subtitle,
3835                                        St2067_21_2025::FNTimedText.code().to_string(),
3836                                        format!(
3837                                            "ForcedNarrative resource {} references descriptor '{}' \
3838                                             which is not a DCTimedTextDescriptor",
3839                                            res_idx, se_str,
3840                                        ),
3841                                    )
3842                                    .with_location(
3843                                        Location::new()
3844                                            .with_cpl(cpl.id)
3845                                            .with_segment(seg_idx)
3846                                            .with_resource(res_idx),
3847                                    ),
3848                                );
3849                            }
3850                        }
3851                    }
3852                }
3853            }
3854        }
3855    }
3856
3857    /// ST 2067-21 §5.1.3: ContentMaturityRating — if present, agency must be non-empty.
3858    /// Also validates ApplicationIdentification is present (required for App2E).
3859    fn validate_content_maturity_rating(
3860        &self,
3861        cpl: &CompositionPlaylist,
3862        issues: &mut Vec<ValidationIssue>,
3863    ) {
3864        // App2E requires ApplicationIdentification when ExtensionProperties is present.
3865        // If ExtensionProperties is absent, the check is deferred (CPL may lack it
3866        // because it's being tested for other properties only).
3867        if let Some(ref ext) = cpl.extension_properties {
3868            if ext.application_identification.is_none() {
3869                issues.push(
3870                    ValidationIssue::new(
3871                        Severity::Error,
3872                        Category::Metadata,
3873                        St2067_21_2023::ApplicationIdentification.code(),
3874                        "ApplicationIdentification is required for App2E compositions",
3875                    )
3876                    .with_location(Location::new().with_cpl(cpl.id)),
3877                );
3878            }
3879        }
3880
3881        // Validate ContentMaturityRating entries in each Locale
3882        if let Some(ref locale_list) = cpl.locale_list {
3883            for (locale_idx, locale) in locale_list.locales.iter().enumerate() {
3884                if let Some(ref cmr_list) = locale.content_maturity_rating_list {
3885                    for (rating_idx, rating) in cmr_list.ratings.iter().enumerate() {
3886                        // Agency is required and must be non-empty
3887                        if rating.agency.trim().is_empty() {
3888                            issues.push(
3889                                ValidationIssue::new(
3890                                    Severity::Error,
3891                                    Category::Metadata,
3892                                    St2067_21_2023::ContentMaturityRatingAgency.code(),
3893                                    format!(
3894                                        "ContentMaturityRating[{}] in Locale[{}] has empty Agency",
3895                                        rating_idx, locale_idx
3896                                    ),
3897                                )
3898                                .with_location(Location::new().with_cpl(cpl.id)),
3899                            );
3900                        } else if !is_valid_any_uri(&rating.agency) {
3901                            // XSD: Agency is xs:anyURI — no ASCII whitespace allowed
3902                            issues.push(
3903                                ValidationIssue::new(
3904                                    Severity::Error,
3905                                    Category::Metadata,
3906                                    St2067_21_2023::ContentMaturityRatingAgencyUri.code(),
3907                                    format!(
3908                                        "ContentMaturityRating[{}] in Locale[{}] Agency '{}' \
3909                                         is not a valid xs:anyURI (contains whitespace)",
3910                                        rating_idx, locale_idx, rating.agency,
3911                                    ),
3912                                )
3913                                .with_location(Location::new().with_cpl(cpl.id)),
3914                            );
3915                        }
3916                    }
3917                }
3918            }
3919        }
3920    }
3921
3922    /// ST 2067-21 §5.3: Validate LocaleList language tags and region codes.
3923    ///
3924    /// Language tags should be well-formed RFC 5646 (start with alpha).
3925    /// Region codes should be well-formed ISO 3166-1 alpha-2 (2 uppercase letters).
3926    fn validate_locale_list(&self, cpl: &CompositionPlaylist, issues: &mut Vec<ValidationIssue>) {
3927        let locale_list = match &cpl.locale_list {
3928            Some(ll) => ll,
3929            None => return,
3930        };
3931
3932        let cpl_loc = Location::new().with_cpl(cpl.id);
3933
3934        for (i, locale) in locale_list.locales.iter().enumerate() {
3935            // Validate language tags
3936            if let Some(ref ll) = locale.language_list {
3937                for tag in &ll.languages {
3938                    let s = tag.as_str();
3939                    if s.is_empty() {
3940                        issues.push(
3941                            ValidationIssue::new(
3942                                Severity::Warning,
3943                                Category::Metadata,
3944                                St2067_21_2023::EmptyLanguageTag.code().to_string(),
3945                                format!("Locale[{}]: empty language tag in LanguageList", i),
3946                            )
3947                            .with_location(cpl_loc.clone()),
3948                        );
3949                    } else if !s.chars().next().unwrap_or(' ').is_ascii_alphabetic() {
3950                        issues.push(
3951                            ValidationIssue::new(
3952                                Severity::Warning,
3953                                Category::Metadata,
3954                                St2067_21_2023::MalformedLanguageTag.code().to_string(),
3955                                format!(
3956                                    "Locale[{}]: language tag '{}' does not start with an ASCII letter (RFC 5646)",
3957                                    i, s,
3958                                ),
3959                            )
3960                            .with_location(cpl_loc.clone()),
3961                        );
3962                    }
3963                }
3964            }
3965
3966            // Validate region codes: BCP 47 §2.2.4 — 2-letter ISO 3166-1 or 3-digit UN M.49
3967            if let Some(ref rl) = locale.region_list {
3968                for region in &rl.regions {
3969                    let is_alpha2 =
3970                        region.len() == 2 && region.chars().all(|c| c.is_ascii_uppercase());
3971                    let is_un_m49 = region.len() == 3 && region.chars().all(|c| c.is_ascii_digit());
3972                    if !is_alpha2 && !is_un_m49 {
3973                        issues.push(
3974                            ValidationIssue::new(
3975                                Severity::Warning,
3976                                Category::Metadata,
3977                                St2067_21_2023::RegionCode.code().to_string(),
3978                                format!(
3979                                    "Locale[{}]: region '{}' is not a valid BCP 47 region subtag (expected 2-letter ISO 3166-1 or 3-digit UN M.49)",
3980                                    i, region,
3981                                ),
3982                            )
3983                            .with_location(cpl_loc.clone()),
3984                        );
3985                    }
3986                }
3987            }
3988        }
3989    }
3990}
3991
3992// ═════════════════════════════════════════════════════════════════════════════
3993// Tests
3994// ═════════════════════════════════════════════════════════════════════════════
3995
3996// ── FromStr / parse helper tests ─────────────────────────────────────────────
3997
3998#[cfg(test)]
3999mod spec_target_tests {
4000    use super::*;
4001
4002    #[test]
4003    fn core_spec_target_from_str_valid() {
4004        assert_eq!(
4005            "v2013".parse::<CoreSpecTarget>().unwrap(),
4006            CoreSpecTarget::St2067_2_2013
4007        );
4008        assert_eq!(
4009            "v2016".parse::<CoreSpecTarget>().unwrap(),
4010            CoreSpecTarget::St2067_2_2016
4011        );
4012        assert_eq!(
4013            "v2020".parse::<CoreSpecTarget>().unwrap(),
4014            CoreSpecTarget::St2067_2_2020
4015        );
4016    }
4017
4018    #[test]
4019    fn core_spec_target_from_str_invalid() {
4020        assert!("v2099".parse::<CoreSpecTarget>().is_err());
4021        assert!("auto".parse::<CoreSpecTarget>().is_err());
4022        assert!("".parse::<CoreSpecTarget>().is_err());
4023    }
4024
4025    #[test]
4026    fn app_spec_target_from_str_valid() {
4027        assert_eq!(
4028            "v2020".parse::<AppSpecTarget>().unwrap(),
4029            AppSpecTarget::St2067_21_2020
4030        );
4031        assert_eq!(
4032            "v2021".parse::<AppSpecTarget>().unwrap(),
4033            AppSpecTarget::St2067_21_2021
4034        );
4035        assert_eq!(
4036            "v2023".parse::<AppSpecTarget>().unwrap(),
4037            AppSpecTarget::St2067_21_2023
4038        );
4039    }
4040
4041    #[test]
4042    fn app_spec_target_from_str_invalid() {
4043        assert!("none".parse::<AppSpecTarget>().is_err());
4044        assert!("garbage".parse::<AppSpecTarget>().is_err());
4045    }
4046
4047    #[test]
4048    fn parse_core_spec_target_auto() {
4049        assert_eq!(parse_core_spec_target("auto").unwrap(), None);
4050    }
4051
4052    #[test]
4053    fn parse_core_spec_target_specific() {
4054        assert_eq!(
4055            parse_core_spec_target("v2020").unwrap(),
4056            Some(CoreSpecTarget::St2067_2_2020),
4057        );
4058    }
4059
4060    #[test]
4061    fn parse_app_spec_targets_auto() {
4062        assert_eq!(parse_app_spec_targets("auto").unwrap(), None);
4063    }
4064
4065    #[test]
4066    fn parse_app_spec_targets_none() {
4067        assert_eq!(parse_app_spec_targets("none").unwrap(), Some(vec![]));
4068    }
4069
4070    #[test]
4071    fn parse_app_spec_targets_specific() {
4072        assert_eq!(
4073            parse_app_spec_targets("v2023").unwrap(),
4074            Some(vec![AppSpecTarget::St2067_21_2023]),
4075        );
4076    }
4077
4078    #[test]
4079    fn parse_app_spec_targets_invalid() {
4080        assert!(parse_app_spec_targets("garbage").is_err());
4081    }
4082}
4083
4084#[cfg(test)]
4085mod tests {
4086    use super::*;
4087    use crate::assetmap::ImfUuid;
4088    use crate::cpl::{
4089        CDCIDescriptor, CompositionTimecode, ContentKindElement, ContentMaturityRating,
4090        ContentMaturityRatingList, ContentVersion, ContentVersionList, DCTimedTextDescriptor,
4091        EssenceDescriptor, EssenceDescriptorList, ExtensionProperties, ForcedNarrativeSequence,
4092        HearingImpairedCaptionsSequence, LanguageList, LanguageString, Locale, LocaleList,
4093        MainAudioSequence, MainImageSequence, MarkerInfo, MarkerLabelElement, MarkerSequence,
4094        RGBADescriptor, RegionList, Resource, ResourceList, Segment, SegmentList, SequenceList,
4095        SubtitlesSequence, WAVEPCMDescriptor,
4096    };
4097    use crate::cpl::{ContentKind, EditRate, LanguageTag, MarkerLabel, VideoCodec};
4098    use crate::diagnostics::codes::ValidationCode;
4099    use crate::validation::codes::St2067_21_2023;
4100
4101    // ── Helpers ──────────────────────────────────────────────────────────────
4102
4103    fn uuid(n: u8) -> ImfUuid {
4104        ImfUuid::parse(&format!("00000000-0000-0000-0000-{:012}", n)).unwrap()
4105    }
4106
4107    fn empty_sequence_list() -> SequenceList {
4108        SequenceList {
4109            marker_sequences: vec![],
4110            main_image_sequences: vec![],
4111            main_audio_sequences: vec![],
4112            subtitles_sequences: vec![],
4113            hearing_impaired_captions_sequences: vec![],
4114            forced_narrative_sequences: vec![],
4115            iab_sequences: vec![],
4116            isxd_sequences: vec![],
4117        }
4118    }
4119
4120    fn make_resource(source_encoding: Option<ImfUuid>) -> Resource {
4121        Resource {
4122            id: uuid(99),
4123            annotation: None,
4124            edit_rate: None,
4125            intrinsic_duration: 100,
4126            entry_point: None,
4127            source_duration: None,
4128            source_encoding,
4129            track_file_id: Some(uuid(50)),
4130            repeat_count: None,
4131            key_id: None,
4132            hash: None,
4133            markers: vec![],
4134        }
4135    }
4136
4137    fn minimal_cpl() -> CompositionPlaylist {
4138        CompositionPlaylist {
4139            namespace: CplNamespace::Smpte2067_3_2016,
4140            id: uuid(1),
4141            annotation: None,
4142            issue_date: "2024-01-01T00:00:00Z".to_string(),
4143            issuer: None,
4144            creator: None,
4145            content_originator: None,
4146            content_title: LanguageString {
4147                text: "Test".to_string(),
4148                language: Some(LanguageTag::new("en")),
4149            },
4150            content_kind: ContentKindElement::from(ContentKind::Feature),
4151            content_version_list: None,
4152            locale_list: None,
4153            essence_descriptor_list: None,
4154            edit_rate: None,
4155            total_running_time: None,
4156            extension_properties: None,
4157            composition_timecode: None,
4158            segment_list: SegmentList {
4159                segments: vec![Segment {
4160                    id: uuid(2),
4161                    sequence_list: empty_sequence_list(),
4162                }],
4163            },
4164            has_signer: false,
4165            has_signature: false,
4166            source_xml: None,
4167        }
4168    }
4169
4170    fn cpl_with_cdci_descriptor(
4171        primaries: ColorPrimaries,
4172        transfer: TransferCharacteristic,
4173        coding_eq: CodingEquations,
4174        depth: u32,
4175    ) -> CompositionPlaylist {
4176        let ed_id = uuid(10);
4177        let mut cpl = minimal_cpl();
4178        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
4179            essence_descriptors: vec![EssenceDescriptor {
4180                id: ed_id,
4181                rgba_descriptor: None,
4182                cdci_descriptor: Some(CDCIDescriptor {
4183                    instance_id: None,
4184                    stored_width: Some(1920),
4185                    stored_height: Some(1080),
4186                    display_width: Some(1920),
4187                    display_height: Some(1080),
4188                    sample_rate: Some(EditRate::new(24, 1)),
4189                    image_aspect_ratio: None,
4190                    color_primaries: Some(primaries),
4191                    transfer_characteristic: Some(transfer),
4192                    coding_equations: Some(coding_eq),
4193                    picture_compression: Some(VideoCodec::Jpeg2000),
4194                    component_depth: Some(depth),
4195                    frame_layout: Some("FullFrame".to_string()),
4196                    display_f2_offset: None,
4197                    horizontal_subsampling: Some(2),
4198                    vertical_subsampling: Some(1),
4199                    color_siting: Some(0),
4200                    black_ref_level: Some(64),
4201                    white_ref_level: Some(940),
4202                    color_range: Some(897),
4203                    stored_f2_offset: None,
4204                    sampled_width: None,
4205                    sampled_height: None,
4206                    sampled_x_offset: None,
4207                    sampled_y_offset: None,
4208                    alpha_transparency: None,
4209                    image_alignment_offset: None,
4210                    image_start_offset: None,
4211                    image_end_offset: None,
4212                    field_dominance: None,
4213                    reversed_byte_order: None,
4214                    padding_bits: None,
4215                    alpha_sample_depth: None,
4216                    linked_track_id: None,
4217                    active_width: None,
4218                    active_height: None,
4219                    sub_descriptors: None,
4220                }),
4221                wave_pcm_descriptor: None,
4222                dc_timed_text_descriptor: None,
4223                iab_essence_descriptor: None,
4224                isxd_data_essence_descriptor: None,
4225            }],
4226        });
4227        let mut sl = empty_sequence_list();
4228        sl.main_image_sequences.push(MainImageSequence {
4229            id: uuid(3),
4230            track_id: uuid(4),
4231            resource_list: ResourceList {
4232                resources: vec![make_resource(Some(ed_id))],
4233            },
4234        });
4235        sl.main_audio_sequences.push(MainAudioSequence {
4236            id: uuid(5),
4237            track_id: uuid(6),
4238            resource_list: ResourceList {
4239                resources: vec![make_resource(Some(uuid(11)))],
4240            },
4241        });
4242        cpl.segment_list.segments[0].sequence_list = sl;
4243        cpl.essence_descriptor_list
4244            .as_mut()
4245            .unwrap()
4246            .essence_descriptors
4247            .push(EssenceDescriptor {
4248                id: uuid(11),
4249                rgba_descriptor: None,
4250                cdci_descriptor: None,
4251                wave_pcm_descriptor: Some(WAVEPCMDescriptor {
4252                    instance_id: None,
4253                    sample_rate: None,
4254                    audio_sample_rate: None,
4255                    channel_count: Some(6),
4256                    quantization_bits: Some(24),
4257                    linked_track_id: None,
4258                    sub_descriptors: None,
4259                }),
4260                dc_timed_text_descriptor: None,
4261                iab_essence_descriptor: None,
4262                isxd_data_essence_descriptor: None,
4263            });
4264        cpl
4265    }
4266
4267    fn cpl_with_rgba_descriptor(
4268        primaries: ColorPrimaries,
4269        transfer: TransferCharacteristic,
4270    ) -> CompositionPlaylist {
4271        let ed_id = uuid(10);
4272        let mut cpl = minimal_cpl();
4273        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
4274            essence_descriptors: vec![EssenceDescriptor {
4275                id: ed_id,
4276                rgba_descriptor: Some(RGBADescriptor {
4277                    instance_id: None,
4278                    display_width: Some(1920),
4279                    display_height: Some(1080),
4280                    stored_width: Some(1920),
4281                    stored_height: Some(1080),
4282                    sample_rate: Some(EditRate::new(24, 1)),
4283                    image_aspect_ratio: None,
4284                    color_primaries: Some(primaries),
4285                    transfer_characteristic: Some(transfer),
4286                    coding_equations: None,
4287                    picture_compression: Some(VideoCodec::Jpeg2000Ht),
4288                    frame_layout: Some("FullFrame".to_string()),
4289                    display_f2_offset: None,
4290                    component_max_ref: Some(1023),
4291                    component_min_ref: Some(0),
4292                    scanning_direction: Some(
4293                        "ScanningDirection_LeftToRightTopToBottom".to_string(),
4294                    ),
4295                    stored_f2_offset: None,
4296                    sampled_width: None,
4297                    sampled_height: None,
4298                    sampled_x_offset: None,
4299                    sampled_y_offset: None,
4300                    alpha_transparency: None,
4301                    image_alignment_offset: None,
4302                    image_start_offset: None,
4303                    image_end_offset: None,
4304                    field_dominance: None,
4305                    alpha_max_ref: None,
4306                    alpha_min_ref: None,
4307                    palette: None,
4308                    palette_layout: None,
4309                    linked_track_id: None,
4310                    sub_descriptors: None,
4311                }),
4312                cdci_descriptor: None,
4313                wave_pcm_descriptor: None,
4314                dc_timed_text_descriptor: None,
4315                iab_essence_descriptor: None,
4316                isxd_data_essence_descriptor: None,
4317            }],
4318        });
4319        let mut sl = empty_sequence_list();
4320        sl.main_image_sequences.push(MainImageSequence {
4321            id: uuid(3),
4322            track_id: uuid(4),
4323            resource_list: ResourceList {
4324                resources: vec![make_resource(Some(ed_id))],
4325            },
4326        });
4327        cpl.segment_list.segments[0].sequence_list = sl;
4328        cpl
4329    }
4330
4331    // ── Trait + Factory Tests ────────────────────────────────────────────────
4332
4333    #[test]
4334    fn factory_returns_core_2020_for_namespace() {
4335        let v = get_validator("http://www.smpte-ra.org/ns/2067-2/2020");
4336        assert!(v.is_some());
4337        assert_eq!(v.unwrap().spec_id(), "ST 2067-2:2020");
4338    }
4339
4340    #[test]
4341    fn factory_returns_core_2016_for_namespace() {
4342        let v = get_validator("http://www.smpte-ra.org/schemas/2067-2/2016");
4343        assert!(v.is_some());
4344        assert_eq!(v.unwrap().spec_id(), "ST 2067-2:2016");
4345    }
4346
4347    #[test]
4348    fn factory_returns_core_2013_for_namespace() {
4349        let v = get_validator("http://www.smpte-ra.org/schemas/2067-2/2013");
4350        assert!(v.is_some());
4351        assert_eq!(v.unwrap().spec_id(), "ST 2067-2:2013");
4352    }
4353
4354    #[test]
4355    fn factory_returns_app2e_for_2021_namespace() {
4356        let v = get_validator("http://www.smpte-ra.org/ns/2067-21/2021");
4357        assert!(v.is_some());
4358        assert_eq!(v.unwrap().spec_id(), "ST 2067-21:2023 (App2E)");
4359    }
4360
4361    #[test]
4362    fn factory_returns_app2e_for_2023_namespace() {
4363        let v = get_validator("http://www.smpte-ra.org/ns/2067-21/2023");
4364        assert!(v.is_some());
4365        assert_eq!(v.unwrap().spec_id(), "ST 2067-21:2023 (App2E)");
4366    }
4367
4368    #[test]
4369    fn factory_returns_none_for_unknown() {
4370        assert!(get_validator("http://example.com/unknown").is_none());
4371    }
4372
4373    #[test]
4374    fn registry_resolves_core_namespace() {
4375        let registry = BuiltinValidatorRegistry;
4376        let v = registry.resolve_namespace("http://www.smpte-ra.org/ns/2067-2/2020");
4377        assert!(v.is_some());
4378        assert_eq!(v.unwrap().spec_id(), "ST 2067-2:2020");
4379    }
4380
4381    #[test]
4382    fn registry_returns_none_for_unknown_namespace() {
4383        let registry = BuiltinValidatorRegistry;
4384        assert!(registry
4385            .resolve_namespace("http://example.com/unknown")
4386            .is_none());
4387    }
4388
4389    // `get_validators_for_cpl_returns_core_2020` was removed alongside
4390    // `CplNamespace::Smpte2067_3_2020`; the 2016 equivalent below now covers
4391    // the same dispatch path (minimal_cpl uses Smpte2067_3_2016).
4392
4393    #[test]
4394    fn get_validators_for_cpl_returns_core_plus_app2e() {
4395        let mut cpl = minimal_cpl();
4396        cpl.extension_properties = Some(ExtensionProperties {
4397            application_identification: Some("http://www.smpte-ra.org/ns/2067-21/2021".to_string()),
4398            max_cll: None,
4399            max_fall: None,
4400        });
4401        let validators = get_validators_for_cpl(&cpl);
4402        assert_eq!(validators.len(), 2);
4403        assert_eq!(validators[0].spec_id(), "ST 2067-2:2016");
4404        assert_eq!(validators[1].spec_id(), "ST 2067-21:2023 (App2E)");
4405    }
4406
4407    #[test]
4408    fn registry_resolve_for_cpl_handles_multiple_app_uris() {
4409        let mut cpl = minimal_cpl();
4410        cpl.extension_properties = Some(ExtensionProperties {
4411            application_identification: Some(
4412                "http://www.smpte-ra.org/ns/2067-21/2021 http://example.com/unknown".to_string(),
4413            ),
4414            max_cll: None,
4415            max_fall: None,
4416        });
4417        let registry = BuiltinValidatorRegistry;
4418        let validators = registry.resolve_for_cpl(&cpl);
4419        assert_eq!(validators.len(), 2);
4420        assert_eq!(validators[0].spec_id(), "ST 2067-2:2016");
4421        assert_eq!(validators[1].spec_id(), "ST 2067-21:2023 (App2E)");
4422    }
4423
4424    #[test]
4425    fn registry_and_factory_have_same_resolution() {
4426        let mut cpl = minimal_cpl();
4427        cpl.extension_properties = Some(ExtensionProperties {
4428            application_identification: Some("http://www.smpte-ra.org/ns/2067-21/2021".to_string()),
4429            max_cll: None,
4430            max_fall: None,
4431        });
4432
4433        let via_factory = get_validators_for_cpl(&cpl);
4434        let registry = BuiltinValidatorRegistry;
4435        let via_registry = registry.resolve_for_cpl(&cpl);
4436
4437        let factory_ids: Vec<_> = via_factory
4438            .iter()
4439            .map(|v| v.spec_id().to_string())
4440            .collect();
4441        let registry_ids: Vec<_> = via_registry
4442            .iter()
4443            .map(|v| v.spec_id().to_string())
4444            .collect();
4445        assert_eq!(factory_ids, registry_ids);
4446    }
4447
4448    #[test]
4449    fn configurable_registry_overrides_core_namespace_version() {
4450        let cpl = minimal_cpl();
4451        let registry = ConfigurableValidatorRegistry::new(ValidatorSelection {
4452            core_spec: Some(CoreSpecTarget::St2067_2_2016),
4453            ..Default::default()
4454        });
4455
4456        let validators = registry.resolve_for_cpl(&cpl);
4457        assert_eq!(validators.len(), 1);
4458        assert_eq!(validators[0].spec_id(), "ST 2067-2:2016");
4459    }
4460
4461    #[test]
4462    fn configurable_registry_overrides_application_identification_uris() {
4463        let cpl = minimal_cpl();
4464        let registry = ConfigurableValidatorRegistry::new(ValidatorSelection {
4465            app_specs: Some(vec![AppSpecTarget::St2067_21_2021]),
4466            ..Default::default()
4467        });
4468
4469        let validators = registry.resolve_for_cpl(&cpl);
4470        assert_eq!(validators.len(), 2);
4471        assert_eq!(validators[0].spec_id(), "ST 2067-2:2016");
4472        assert_eq!(validators[1].spec_id(), "ST 2067-21:2023 (App2E)");
4473    }
4474
4475    #[test]
4476    fn validate_cpl_with_registry_matches_manual_merge() {
4477        let mut cpl = minimal_cpl();
4478        cpl.extension_properties = Some(ExtensionProperties {
4479            application_identification: Some("http://www.smpte-ra.org/ns/2067-21/2021".to_string()),
4480            max_cll: None,
4481            max_fall: None,
4482        });
4483
4484        let registry = BuiltinValidatorRegistry;
4485        let via_helper = validate_cpl_with_registry(&cpl, &registry);
4486
4487        let validators = registry.resolve_for_cpl(&cpl);
4488        let mut manual = Vec::new();
4489        for v in &validators {
4490            manual.extend(v.validate_cpl(&cpl));
4491        }
4492
4493        assert_eq!(via_helper.len(), manual.len());
4494    }
4495
4496    // ── Registry auto-detection — older namespaces ───────────────────────────
4497
4498    #[test]
4499    fn get_validators_for_cpl_returns_core_2016_for_2016_namespace() {
4500        let mut cpl = minimal_cpl();
4501        cpl.namespace = CplNamespace::Smpte2067_3_2016;
4502        let validators = get_validators_for_cpl(&cpl);
4503        assert_eq!(validators.len(), 1);
4504        assert_eq!(validators[0].spec_id(), "ST 2067-2:2016");
4505    }
4506
4507    #[test]
4508    fn get_validators_for_cpl_returns_core_2013_for_2013_namespace() {
4509        let mut cpl = minimal_cpl();
4510        cpl.namespace = CplNamespace::Smpte2067_3_2013;
4511        let validators = get_validators_for_cpl(&cpl);
4512        assert_eq!(validators.len(), 1);
4513        assert_eq!(validators[0].spec_id(), "ST 2067-2:2013");
4514    }
4515
4516    #[test]
4517    fn get_validators_for_cpl_returns_empty_for_dci_legacy_namespace() {
4518        // DCI-era CPLs (ST 429-7:2006) have no matching core validator.
4519        let mut cpl = minimal_cpl();
4520        cpl.namespace = CplNamespace::Dci429_7;
4521        let validators = get_validators_for_cpl(&cpl);
4522        assert_eq!(
4523            validators.len(),
4524            0,
4525            "DCI namespace should yield no validators"
4526        );
4527    }
4528
4529    #[test]
4530    fn get_validators_for_cpl_auto_detects_old_style_app2e_2016_namespace() {
4531        // Old-style App2E URIs using the "schemas" path (pre-2020) should also resolve.
4532        let mut cpl = minimal_cpl();
4533        cpl.extension_properties = Some(ExtensionProperties {
4534            application_identification: Some(
4535                "http://www.smpte-ra.org/schemas/2067-21/2016".to_string(),
4536            ),
4537            max_cll: None,
4538            max_fall: None,
4539        });
4540        let validators = get_validators_for_cpl(&cpl);
4541        assert_eq!(validators.len(), 2);
4542        assert_eq!(validators[0].spec_id(), "ST 2067-2:2016");
4543        assert_eq!(validators[1].spec_id(), "ST 2067-21:2023 (App2E)");
4544    }
4545
4546    #[test]
4547    fn configurable_registry_empty_app_specs_suppresses_app_profile() {
4548        // --app2e-spec none: empty vec means no app validators even if CPL declares one.
4549        let mut cpl = minimal_cpl();
4550        cpl.extension_properties = Some(ExtensionProperties {
4551            application_identification: Some("http://www.smpte-ra.org/ns/2067-21/2021".to_string()),
4552            max_cll: None,
4553            max_fall: None,
4554        });
4555        let registry = ConfigurableValidatorRegistry::new(ValidatorSelection {
4556            app_specs: Some(vec![]),
4557            ..Default::default()
4558        });
4559        let validators = registry.resolve_for_cpl(&cpl);
4560        assert_eq!(
4561            validators.len(),
4562            1,
4563            "empty app_specs should suppress app profile"
4564        );
4565        assert_eq!(validators[0].spec_id(), "ST 2067-2:2016");
4566    }
4567
4568    #[test]
4569    fn configurable_registry_raw_string_core_uri_override() {
4570        // Raw string URI override for callers that need to target an unlisted namespace.
4571        let mut cpl = minimal_cpl();
4572        cpl.namespace = CplNamespace::Smpte2067_3_2016;
4573        let registry = ConfigurableValidatorRegistry::new(ValidatorSelection {
4574            core_namespace_uri: Some("http://www.smpte-ra.org/schemas/2067-2/2016".to_string()),
4575            ..Default::default()
4576        });
4577        let validators = registry.resolve_for_cpl(&cpl);
4578        assert_eq!(validators.len(), 1);
4579        assert_eq!(validators[0].spec_id(), "ST 2067-2:2016");
4580    }
4581
4582    #[test]
4583    fn configurable_registry_raw_string_app_uri_override() {
4584        let cpl = minimal_cpl();
4585        let registry = ConfigurableValidatorRegistry::new(ValidatorSelection {
4586            application_identification_uris: Some(vec![
4587                "http://www.smpte-ra.org/ns/2067-21/2021".to_string()
4588            ]),
4589            ..Default::default()
4590        });
4591        let validators = registry.resolve_for_cpl(&cpl);
4592        assert_eq!(validators.len(), 2);
4593        assert_eq!(validators[0].spec_id(), "ST 2067-2:2016");
4594        assert_eq!(validators[1].spec_id(), "ST 2067-21:2023 (App2E)");
4595    }
4596
4597    // ── ColorSystem Tests ────────────────────────────────────────────────────
4598
4599    #[test]
4600    fn color_system_color1_bt601_625() {
4601        let cs = ColorSystem::from_components(
4602            &ColorPrimaries::Bt601_625,
4603            &TransferCharacteristic::Bt709,
4604            Some(&CodingEquations::Bt601),
4605        );
4606        assert_eq!(cs, Some(ColorSystem::Color1));
4607        assert!(!cs.unwrap().is_hdr());
4608    }
4609
4610    #[test]
4611    fn color_system_color2_bt601_525() {
4612        let cs = ColorSystem::from_components(
4613            &ColorPrimaries::Bt601_525,
4614            &TransferCharacteristic::Bt709,
4615            Some(&CodingEquations::Bt601),
4616        );
4617        assert_eq!(cs, Some(ColorSystem::Color2));
4618    }
4619
4620    #[test]
4621    fn color_system_color3_bt709() {
4622        let cs = ColorSystem::from_components(
4623            &ColorPrimaries::Bt709,
4624            &TransferCharacteristic::Bt709,
4625            Some(&CodingEquations::Bt709),
4626        );
4627        assert_eq!(cs, Some(ColorSystem::Color3));
4628        assert!(!cs.unwrap().is_hdr());
4629    }
4630
4631    #[test]
4632    fn color_system_color4_xvycc() {
4633        let cs = ColorSystem::from_components(
4634            &ColorPrimaries::Bt709,
4635            &TransferCharacteristic::XvYcc709,
4636            Some(&CodingEquations::Bt709),
4637        );
4638        assert_eq!(cs, Some(ColorSystem::Color4));
4639    }
4640
4641    #[test]
4642    fn color_system_color5_bt2020_sdr() {
4643        let cs = ColorSystem::from_components(
4644            &ColorPrimaries::Bt2020,
4645            &TransferCharacteristic::Bt2020,
4646            Some(&CodingEquations::Bt2020Ncl),
4647        );
4648        assert_eq!(cs, Some(ColorSystem::Color5));
4649        assert!(!cs.unwrap().is_hdr());
4650    }
4651
4652    #[test]
4653    fn color_system_color6_p3_pq_rgb() {
4654        let cs = ColorSystem::from_components(
4655            &ColorPrimaries::P3D65,
4656            &TransferCharacteristic::PqSt2084,
4657            None,
4658        );
4659        assert_eq!(cs, Some(ColorSystem::Color6));
4660        assert!(cs.unwrap().is_hdr());
4661        assert!(cs.unwrap().requires_hdr_metadata());
4662    }
4663
4664    #[test]
4665    fn color_system_color7_bt2020_pq() {
4666        let cs = ColorSystem::from_components(
4667            &ColorPrimaries::Bt2020,
4668            &TransferCharacteristic::PqSt2084,
4669            Some(&CodingEquations::Bt2020Ncl),
4670        );
4671        assert_eq!(cs, Some(ColorSystem::Color7));
4672        assert!(cs.unwrap().is_hdr());
4673        assert!(cs.unwrap().requires_hdr_metadata());
4674    }
4675
4676    #[test]
4677    fn color_system_color8_hlg() {
4678        let cs = ColorSystem::from_components(
4679            &ColorPrimaries::Bt2020,
4680            &TransferCharacteristic::Hlg,
4681            Some(&CodingEquations::Bt2020Ncl),
4682        );
4683        assert_eq!(cs, Some(ColorSystem::Color8));
4684        assert!(cs.unwrap().is_hdr());
4685        assert!(!cs.unwrap().requires_hdr_metadata());
4686    }
4687
4688    #[test]
4689    fn color_system_invalid_combination_returns_none() {
4690        // BT.709 primaries + PQ transfer + BT.601 coding = not a valid system
4691        let cs = ColorSystem::from_components(
4692            &ColorPrimaries::Bt709,
4693            &TransferCharacteristic::PqSt2084,
4694            Some(&CodingEquations::Bt601),
4695        );
4696        assert_eq!(cs, None);
4697    }
4698
4699    // ── Core Constraints Tests ───────────────────────────────────────────────
4700
4701    #[test]
4702    fn core_2020_requires_essence_descriptor_list() {
4703        let cpl = minimal_cpl(); // no EDL
4704        let v = CoreConstraints2020;
4705        let issues = v.validate_cpl(&cpl);
4706        let edl_issues: Vec<_> = issues
4707            .iter()
4708            .filter(|i| i.code.contains("EssenceDescriptorList"))
4709            .collect();
4710        assert!(
4711            !edl_issues.is_empty(),
4712            "Should flag missing EssenceDescriptorList"
4713        );
4714    }
4715
4716    #[test]
4717    fn core_2013_allows_missing_essence_descriptor_list() {
4718        let mut cpl = minimal_cpl();
4719        cpl.namespace = CplNamespace::Smpte2067_3_2013;
4720        // Add a sequence so no "empty segment" error
4721        cpl.segment_list.segments[0]
4722            .sequence_list
4723            .main_image_sequences
4724            .push(MainImageSequence {
4725                id: uuid(3),
4726                track_id: uuid(4),
4727                resource_list: ResourceList {
4728                    resources: vec![make_resource(None)],
4729                },
4730            });
4731        let v = CoreConstraints2013;
4732        let issues = v.validate_cpl(&cpl);
4733        let edl_issues: Vec<_> = issues
4734            .iter()
4735            .filter(|i| i.code.contains("EssenceDescriptorList"))
4736            .collect();
4737        assert!(
4738            edl_issues.is_empty(),
4739            "2013 should not require EssenceDescriptorList"
4740        );
4741    }
4742
4743    /// XSD: ContentTitle is `dcml:UserTextType` (xs:string-derived). The
4744    /// XSD grammar permits the empty string, so this is NOT a structural
4745    /// constraint — it has to live as a semantic check if we want it at
4746    /// all. The original "gutted" claim was inaccurate; left ignored
4747    /// pending a deliberate decision on whether to re-add semantically.
4748    #[test]
4749    #[ignore = "ContentTitle non-empty is not XSD-expressible; needs semantic check if desired"]
4750    fn core_flags_empty_content_title() {
4751        let mut cpl = minimal_cpl();
4752        cpl.content_title.text = "".to_string();
4753        let v = CoreConstraints2020;
4754        let issues = v.validate_cpl(&cpl);
4755        assert!(
4756            issues.iter().any(|i| i.code.contains("ContentTitle")),
4757            "Should flag empty ContentTitle"
4758        );
4759    }
4760
4761    /// XSD §57: `SegmentList` requires `Segment maxOccurs=unbounded`
4762    /// with default `minOccurs=1` — an empty SegmentList trips the
4763    /// schema-level validator (`ElementMissing/Segment`).
4764    #[test]
4765    fn core_flags_empty_segment_list() {
4766        let xml = r#"<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
4767            <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
4768            <IssueDate>2024-01-01T00:00:00Z</IssueDate>
4769            <ContentTitle>Test</ContentTitle>
4770            <EditRate>24 1</EditRate>
4771            <SegmentList/>
4772        </CompositionPlaylist>"#;
4773        let cpl = crate::cpl::parse_cpl(xml).expect("should parse despite empty SegmentList");
4774        let issues = CoreConstraints2013.validate_cpl(&cpl);
4775        assert!(
4776            issues.iter().any(|i| i.code.contains("Segment")),
4777            "Empty SegmentList should be flagged: {:#?}",
4778            issues,
4779        );
4780    }
4781
4782    /// XSD §150-156: `SequenceList` contains an optional `MarkerSequence`
4783    /// followed by `xs:any namespace="##other" minOccurs="0"` — the
4784    /// schema explicitly *allows* an empty SequenceList. Catching this
4785    /// requires semantic logic ("a Segment with zero sequences is
4786    /// useless") and is not XSD-expressible.
4787    #[test]
4788    #[ignore = "Empty SequenceList is schema-valid (xs:any minOccurs=0); needs semantic check"]
4789    fn core_flags_segment_with_no_sequences() {
4790        let cpl = minimal_cpl(); // has one segment with empty sequence list
4791        let v = CoreConstraints2020;
4792        let issues = v.validate_cpl(&cpl);
4793        assert!(
4794            issues.iter().any(|i| i.code.contains("Segment")),
4795            "Should flag segment with no sequences"
4796        );
4797    }
4798
4799    #[test]
4800    fn core_2020_flags_unresolved_source_encoding() {
4801        let mut cpl = cpl_with_cdci_descriptor(
4802            ColorPrimaries::Bt709,
4803            TransferCharacteristic::Bt709,
4804            CodingEquations::Bt709,
4805            10,
4806        );
4807        // Remove the audio descriptor to create a dangling SourceEncoding reference
4808        cpl.essence_descriptor_list
4809            .as_mut()
4810            .unwrap()
4811            .essence_descriptors
4812            .retain(|ed| ed.wave_pcm_descriptor.is_none());
4813        let v = CoreConstraints2020;
4814        let issues = v.validate_cpl(&cpl);
4815        assert!(
4816            issues.iter().any(|i| i.code.contains("SourceEncoding")),
4817            "Should flag unresolved SourceEncoding"
4818        );
4819    }
4820
4821    // ── App2E Tests ──────────────────────────────────────────────────────────
4822
4823    #[test]
4824    fn app2e_validates_valid_color3_hd() {
4825        let cpl = cpl_with_cdci_descriptor(
4826            ColorPrimaries::Bt709,
4827            TransferCharacteristic::Bt709,
4828            CodingEquations::Bt709,
4829            10,
4830        );
4831        let v = App2E2021;
4832        let issues = v.validate_cpl(&cpl);
4833        // COLOR.3 with 10-bit J2K should have no App2E errors
4834        let errors: Vec<_> = issues
4835            .iter()
4836            .filter(|i| i.severity == Severity::Error || i.severity == Severity::Critical)
4837            .collect();
4838        assert!(
4839            errors.is_empty(),
4840            "Valid COLOR.3 HD should pass App2E: {:?}",
4841            errors
4842        );
4843    }
4844
4845    #[test]
4846    fn app2e_flags_invalid_color_system() {
4847        // BT.709 primaries + PQ transfer + BT.601 coding = not a valid system
4848        let cpl = cpl_with_cdci_descriptor(
4849            ColorPrimaries::Bt709,
4850            TransferCharacteristic::PqSt2084,
4851            CodingEquations::Bt601,
4852            10,
4853        );
4854        let v = App2E2021;
4855        let issues = v.validate_cpl(&cpl);
4856        assert!(
4857            issues.iter().any(|i| i.code.contains("6.2/ColorSystem")),
4858            "Should flag invalid color system combination"
4859        );
4860    }
4861
4862    #[test]
4863    fn app2e_flags_non_j2k_codec() {
4864        let mut cpl = cpl_with_cdci_descriptor(
4865            ColorPrimaries::Bt709,
4866            TransferCharacteristic::Bt709,
4867            CodingEquations::Bt709,
4868            10,
4869        );
4870        // Change codec to H.265
4871        if let Some(ref mut edl) = cpl.essence_descriptor_list {
4872            for ed in &mut edl.essence_descriptors {
4873                if let Some(ref mut cdci) = ed.cdci_descriptor {
4874                    cdci.picture_compression = Some(VideoCodec::H265);
4875                }
4876            }
4877        }
4878        let v = App2E2021;
4879        let issues = v.validate_cpl(&cpl);
4880        assert!(
4881            issues.iter().any(|i| i.code.contains("6.2.5")),
4882            "Should flag non-JPEG-2000 codec"
4883        );
4884    }
4885
4886    #[test]
4887    fn app2e_flags_invalid_bit_depth() {
4888        let cpl = cpl_with_cdci_descriptor(
4889            ColorPrimaries::Bt709,
4890            TransferCharacteristic::Bt709,
4891            CodingEquations::Bt709,
4892            14, // Not an allowed depth
4893        );
4894        let v = App2E2021;
4895        let issues = v.validate_cpl(&cpl);
4896        assert!(
4897            issues.iter().any(|i| i.code.contains("6.4/ComponentDepth")),
4898            "Should flag invalid bit depth"
4899        );
4900    }
4901
4902    #[test]
4903    fn app2e_notes_missing_hdr_metadata_for_pq() {
4904        // COLOR.7 (BT.2020 + PQ + BT.2020 NCL) — MaxCLL/MaxFALL are optional (0..1)
4905        // per ST 2067-21:2023 §7.5, so absence is Info, not Error.
4906        let cpl = cpl_with_cdci_descriptor(
4907            ColorPrimaries::Bt2020,
4908            TransferCharacteristic::PqSt2084,
4909            CodingEquations::Bt2020Ncl,
4910            10,
4911        );
4912        let v = App2E2021;
4913        let issues = v.validate_cpl(&cpl);
4914        let hdr_issues: Vec<_> = issues.iter().filter(|i| i.code.contains("7.5")).collect();
4915        assert!(
4916            !hdr_issues.is_empty(),
4917            "Should note absent MaxCLL/MaxFALL for PQ content"
4918        );
4919        assert!(
4920            hdr_issues.iter().all(|i| i.severity == Severity::Info),
4921            "Missing MaxCLL/MaxFALL should be Info, not Error (0..1 cardinality per §7.5)"
4922        );
4923    }
4924
4925    #[test]
4926    fn app2e_passes_hdr_with_metadata() {
4927        let mut cpl = cpl_with_cdci_descriptor(
4928            ColorPrimaries::Bt2020,
4929            TransferCharacteristic::PqSt2084,
4930            CodingEquations::Bt2020Ncl,
4931            10,
4932        );
4933        cpl.extension_properties = Some(ExtensionProperties {
4934            application_identification: Some("http://www.smpte-ra.org/ns/2067-21/2021".to_string()),
4935            max_cll: Some(1000),
4936            max_fall: Some(400),
4937        });
4938        let v = App2E2021;
4939        let issues = v.validate_cpl(&cpl);
4940        assert!(
4941            !issues.iter().any(|i| i.code.contains("7.5")),
4942            "Should not flag HDR metadata when MaxCLL/MaxFALL are present"
4943        );
4944    }
4945
4946    #[test]
4947    fn app2e_hlg_does_not_require_hdr_metadata() {
4948        // COLOR.8 (HLG) does NOT require MaxCLL/MaxFALL
4949        let cpl = cpl_with_cdci_descriptor(
4950            ColorPrimaries::Bt2020,
4951            TransferCharacteristic::Hlg,
4952            CodingEquations::Bt2020Ncl,
4953            10,
4954        );
4955        let v = App2E2021;
4956        let issues = v.validate_cpl(&cpl);
4957        assert!(
4958            !issues.iter().any(|i| i.code.contains("8.3.3")),
4959            "HLG should not require MaxCLL/MaxFALL"
4960        );
4961    }
4962
4963    #[test]
4964    fn app2e_flags_missing_color_primaries() {
4965        let mut cpl = cpl_with_cdci_descriptor(
4966            ColorPrimaries::Bt709,
4967            TransferCharacteristic::Bt709,
4968            CodingEquations::Bt709,
4969            10,
4970        );
4971        // Remove color primaries
4972        if let Some(ref mut edl) = cpl.essence_descriptor_list {
4973            for ed in &mut edl.essence_descriptors {
4974                if let Some(ref mut cdci) = ed.cdci_descriptor {
4975                    cdci.color_primaries = None;
4976                }
4977            }
4978        }
4979        let v = App2E2021;
4980        let issues = v.validate_cpl(&cpl);
4981        assert!(
4982            issues
4983                .iter()
4984                .any(|i| i.code.contains("6.2.1/ColorPrimaries")),
4985            "Should flag missing ColorPrimaries"
4986        );
4987    }
4988
4989    #[test]
4990    fn app2e_flags_heterogeneous_color_systems() {
4991        let ed_id_1 = uuid(10);
4992        let ed_id_2 = uuid(11);
4993        let mut cpl = minimal_cpl();
4994        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
4995            essence_descriptors: vec![
4996                // First descriptor: COLOR.3 (BT.709)
4997                EssenceDescriptor {
4998                    id: ed_id_1,
4999                    rgba_descriptor: None,
5000                    cdci_descriptor: Some(CDCIDescriptor {
5001                        instance_id: None,
5002                        stored_width: Some(1920),
5003                        stored_height: Some(1080),
5004                        display_width: Some(1920),
5005                        display_height: Some(1080),
5006                        sample_rate: None,
5007                        image_aspect_ratio: None,
5008                        color_primaries: Some(ColorPrimaries::Bt709),
5009                        transfer_characteristic: Some(TransferCharacteristic::Bt709),
5010                        coding_equations: Some(CodingEquations::Bt709),
5011                        picture_compression: Some(VideoCodec::Jpeg2000),
5012                        component_depth: Some(10),
5013                        frame_layout: Some("FullFrame".to_string()),
5014                        display_f2_offset: None,
5015                        horizontal_subsampling: Some(2),
5016                        vertical_subsampling: Some(1),
5017                        color_siting: Some(0),
5018                        black_ref_level: Some(64),
5019                        white_ref_level: Some(940),
5020                        color_range: Some(897),
5021                        stored_f2_offset: None,
5022                        sampled_width: None,
5023                        sampled_height: None,
5024                        sampled_x_offset: None,
5025                        sampled_y_offset: None,
5026                        alpha_transparency: None,
5027                        image_alignment_offset: None,
5028                        image_start_offset: None,
5029                        image_end_offset: None,
5030                        field_dominance: None,
5031                        reversed_byte_order: None,
5032                        padding_bits: None,
5033                        alpha_sample_depth: None,
5034                        linked_track_id: None,
5035                        active_width: None,
5036                        active_height: None,
5037                        sub_descriptors: None,
5038                    }),
5039                    wave_pcm_descriptor: None,
5040                    dc_timed_text_descriptor: None,
5041                    iab_essence_descriptor: None,
5042                    isxd_data_essence_descriptor: None,
5043                },
5044                // Second descriptor: COLOR.5 (BT.2020 SDR)
5045                EssenceDescriptor {
5046                    id: ed_id_2,
5047                    rgba_descriptor: None,
5048                    cdci_descriptor: Some(CDCIDescriptor {
5049                        instance_id: None,
5050                        stored_width: Some(3840),
5051                        stored_height: Some(2160),
5052                        display_width: Some(3840),
5053                        display_height: Some(2160),
5054                        sample_rate: None,
5055                        image_aspect_ratio: None,
5056                        color_primaries: Some(ColorPrimaries::Bt2020),
5057                        transfer_characteristic: Some(TransferCharacteristic::Bt2020),
5058                        coding_equations: Some(CodingEquations::Bt2020Ncl),
5059                        picture_compression: Some(VideoCodec::Jpeg2000),
5060                        component_depth: Some(10),
5061                        frame_layout: Some("FullFrame".to_string()),
5062                        display_f2_offset: None,
5063                        horizontal_subsampling: Some(2),
5064                        vertical_subsampling: Some(1),
5065                        color_siting: Some(0),
5066                        black_ref_level: Some(64),
5067                        white_ref_level: Some(940),
5068                        color_range: Some(897),
5069                        stored_f2_offset: None,
5070                        sampled_width: None,
5071                        sampled_height: None,
5072                        sampled_x_offset: None,
5073                        sampled_y_offset: None,
5074                        alpha_transparency: None,
5075                        image_alignment_offset: None,
5076                        image_start_offset: None,
5077                        image_end_offset: None,
5078                        field_dominance: None,
5079                        reversed_byte_order: None,
5080                        padding_bits: None,
5081                        alpha_sample_depth: None,
5082                        linked_track_id: None,
5083                        active_width: None,
5084                        active_height: None,
5085                        sub_descriptors: None,
5086                    }),
5087                    wave_pcm_descriptor: None,
5088                    dc_timed_text_descriptor: None,
5089                    iab_essence_descriptor: None,
5090                    isxd_data_essence_descriptor: None,
5091                },
5092            ],
5093        });
5094        let mut sl = empty_sequence_list();
5095        sl.main_image_sequences.push(MainImageSequence {
5096            id: uuid(3),
5097            track_id: uuid(4),
5098            resource_list: ResourceList {
5099                resources: vec![make_resource(Some(ed_id_1))],
5100            },
5101        });
5102        cpl.segment_list.segments[0].sequence_list = sl;
5103
5104        let v = App2E2021;
5105        let issues = v.validate_cpl(&cpl);
5106        assert!(
5107            issues
5108                .iter()
5109                .any(|i| i.code.contains("7.2/HomogeneousImageEssence")),
5110            "Should flag heterogeneous color systems"
5111        );
5112    }
5113
5114    // ── Integration: validate_cpl ────────────────────────────────────────────
5115
5116    #[test]
5117    fn validate_cpl_dispatches_both_core_and_app2e() {
5118        let mut cpl = minimal_cpl(); // No EDL → core will flag it
5119        cpl.extension_properties = Some(ExtensionProperties {
5120            application_identification: Some("http://www.smpte-ra.org/ns/2067-21/2021".to_string()),
5121            max_cll: None,
5122            max_fall: None,
5123        });
5124        let validators = get_validators_for_cpl(&cpl);
5125        assert_eq!(validators.len(), 2, "Should dispatch both core and app2e");
5126        assert_eq!(validators[0].spec_id(), "ST 2067-2:2016");
5127        assert_eq!(validators[1].spec_id(), "ST 2067-21:2023 (App2E)");
5128
5129        // validate_cpl should run both and merge issues
5130        let issues = validate_cpl(&cpl);
5131        let has_core = issues.iter().any(|i| i.code.starts_with("ST2067-2:2016:"));
5132        assert!(
5133            has_core,
5134            "Core validator should produce issues for CPL without EDL"
5135        );
5136    }
5137
5138    #[test]
5139    fn color_system_display() {
5140        assert_eq!(
5141            ColorSystem::Color3.to_string(),
5142            "COLOR.3 (BT.709 / BT.709 / BT.709)"
5143        );
5144        assert_eq!(
5145            ColorSystem::Color7.to_string(),
5146            "COLOR.7 (BT.2020 / PQ / BT.2020 NCL)"
5147        );
5148    }
5149
5150    #[test]
5151    fn app2e_validates_j2k_sub_with_all_table14_fields() {
5152        use crate::cpl::{
5153            J2CLayout, J2KExtendedCapabilities, JPEG2000SubDescriptor, RGBADescriptor,
5154            RGBALayoutComponent, VideoSubDescriptors,
5155        };
5156
5157        let ed_id = uuid(10);
5158        let mut cpl = minimal_cpl();
5159        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
5160            essence_descriptors: vec![EssenceDescriptor {
5161                id: ed_id,
5162                rgba_descriptor: Some(RGBADescriptor {
5163                    instance_id: None,
5164                    display_width: Some(1920),
5165                    display_height: Some(1080),
5166                    stored_width: Some(1920),
5167                    stored_height: Some(1080),
5168                    sample_rate: Some(EditRate::new(24, 1)),
5169                    image_aspect_ratio: None,
5170                    color_primaries: Some(ColorPrimaries::P3D65),
5171                    transfer_characteristic: Some(TransferCharacteristic::PqSt2084),
5172                    coding_equations: None,
5173                    picture_compression: Some(VideoCodec::Jpeg2000Ht),
5174                    frame_layout: Some("FullFrame".to_string()),
5175                    display_f2_offset: None,
5176                    component_max_ref: Some(1023),
5177                    component_min_ref: Some(0),
5178                    scanning_direction: Some(
5179                        "ScanningDirection_LeftToRightTopToBottom".to_string(),
5180                    ),
5181                    stored_f2_offset: None,
5182                    sampled_width: None,
5183                    sampled_height: None,
5184                    sampled_x_offset: None,
5185                    sampled_y_offset: None,
5186                    alpha_transparency: None,
5187                    image_alignment_offset: None,
5188                    image_start_offset: None,
5189                    image_end_offset: None,
5190                    field_dominance: None,
5191                    alpha_max_ref: None,
5192                    alpha_min_ref: None,
5193                    palette: None,
5194                    palette_layout: None,
5195                    linked_track_id: None,
5196                    sub_descriptors: Some(VideoSubDescriptors {
5197                        phdr_metadata_track_sub_descriptor: None,
5198                        jpeg2000_sub_descriptor: Some(JPEG2000SubDescriptor {
5199                            instance_id: None,
5200                            rsiz: Some(16384), // HTJ2K
5201                            xsiz: Some(1920),
5202                            ysiz: Some(1080),
5203                            xo_siz: Some(0),
5204                            yo_siz: Some(0),
5205                            xt_siz: Some(1920),
5206                            yt_siz: Some(1080),
5207                            xto_siz: Some(0),
5208                            yto_siz: Some(0),
5209                            csiz: Some(3),
5210                            coding_style_default: Some("01020001".to_string()),
5211                            quantization_default: Some("2060".to_string()),
5212                            j2c_layout: Some(J2CLayout {
5213                                components: vec![
5214                                    RGBALayoutComponent {
5215                                        code: "CompRed".to_string(),
5216                                        component_size: 10,
5217                                    },
5218                                    RGBALayoutComponent {
5219                                        code: "CompGreen".to_string(),
5220                                        component_size: 10,
5221                                    },
5222                                    RGBALayoutComponent {
5223                                        code: "CompBlue".to_string(),
5224                                        component_size: 10,
5225                                    },
5226                                ],
5227                            }),
5228                            j2k_extended_capabilities: Some(J2KExtendedCapabilities {
5229                                pcap: Some(131072),
5230                            }),
5231                            picture_component_sizing: None,
5232                        }),
5233                    }),
5234                }),
5235                cdci_descriptor: None,
5236                wave_pcm_descriptor: None,
5237                dc_timed_text_descriptor: None,
5238                iab_essence_descriptor: None,
5239                isxd_data_essence_descriptor: None,
5240            }],
5241        });
5242        let mut sl = empty_sequence_list();
5243        sl.main_image_sequences.push(MainImageSequence {
5244            id: uuid(3),
5245            track_id: uuid(4),
5246            resource_list: ResourceList {
5247                resources: vec![make_resource(Some(ed_id))],
5248            },
5249        });
5250        cpl.segment_list.segments[0].sequence_list = sl;
5251
5252        let v = App2E2021;
5253        let issues = v.validate_cpl(&cpl);
5254        let errors: Vec<_> = issues
5255            .iter()
5256            .filter(|i| i.severity == Severity::Error || i.severity == Severity::Critical)
5257            .collect();
5258        assert!(
5259            errors.is_empty(),
5260            "Valid J2K sub descriptor should pass: {:?}",
5261            errors
5262        );
5263    }
5264
5265    #[test]
5266    fn app2e_flags_j2k_missing_coding_style() {
5267        use crate::cpl::{
5268            J2CLayout, J2KExtendedCapabilities, JPEG2000SubDescriptor, RGBADescriptor,
5269            RGBALayoutComponent, VideoSubDescriptors,
5270        };
5271
5272        let ed_id = uuid(10);
5273        let mut cpl = minimal_cpl();
5274        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
5275            essence_descriptors: vec![EssenceDescriptor {
5276                id: ed_id,
5277                rgba_descriptor: Some(RGBADescriptor {
5278                    instance_id: None,
5279                    display_width: Some(1920),
5280                    display_height: Some(1080),
5281                    stored_width: Some(1920),
5282                    stored_height: Some(1080),
5283                    sample_rate: Some(EditRate::new(24, 1)),
5284                    image_aspect_ratio: None,
5285                    color_primaries: Some(ColorPrimaries::P3D65),
5286                    transfer_characteristic: Some(TransferCharacteristic::PqSt2084),
5287                    coding_equations: None,
5288                    picture_compression: Some(VideoCodec::Jpeg2000Ht),
5289                    frame_layout: Some("FullFrame".to_string()),
5290                    display_f2_offset: None,
5291                    component_max_ref: Some(1023),
5292                    component_min_ref: Some(0),
5293                    scanning_direction: Some(
5294                        "ScanningDirection_LeftToRightTopToBottom".to_string(),
5295                    ),
5296                    stored_f2_offset: None,
5297                    sampled_width: None,
5298                    sampled_height: None,
5299                    sampled_x_offset: None,
5300                    sampled_y_offset: None,
5301                    alpha_transparency: None,
5302                    image_alignment_offset: None,
5303                    image_start_offset: None,
5304                    image_end_offset: None,
5305                    field_dominance: None,
5306                    alpha_max_ref: None,
5307                    alpha_min_ref: None,
5308                    palette: None,
5309                    palette_layout: None,
5310                    linked_track_id: None,
5311                    sub_descriptors: Some(VideoSubDescriptors {
5312                        phdr_metadata_track_sub_descriptor: None,
5313                        jpeg2000_sub_descriptor: Some(JPEG2000SubDescriptor {
5314                            instance_id: None,
5315                            rsiz: Some(16384),
5316                            xsiz: Some(1920),
5317                            ysiz: Some(1080),
5318                            xo_siz: None,
5319                            yo_siz: None,
5320                            xt_siz: None,
5321                            yt_siz: None,
5322                            xto_siz: None,
5323                            yto_siz: None,
5324                            csiz: None,
5325                            coding_style_default: None, // Missing!
5326                            quantization_default: None,
5327                            j2c_layout: Some(J2CLayout {
5328                                components: vec![RGBALayoutComponent {
5329                                    code: "CompRed".to_string(),
5330                                    component_size: 10,
5331                                }],
5332                            }),
5333                            j2k_extended_capabilities: Some(J2KExtendedCapabilities {
5334                                pcap: Some(131072),
5335                            }),
5336                            picture_component_sizing: None,
5337                        }),
5338                    }),
5339                }),
5340                cdci_descriptor: None,
5341                wave_pcm_descriptor: None,
5342                dc_timed_text_descriptor: None,
5343                iab_essence_descriptor: None,
5344                isxd_data_essence_descriptor: None,
5345            }],
5346        });
5347        let mut sl = empty_sequence_list();
5348        sl.main_image_sequences.push(MainImageSequence {
5349            id: uuid(3),
5350            track_id: uuid(4),
5351            resource_list: ResourceList {
5352                resources: vec![make_resource(Some(ed_id))],
5353            },
5354        });
5355        cpl.segment_list.segments[0].sequence_list = sl;
5356
5357        let v = App2E2021;
5358        let issues = v.validate_cpl(&cpl);
5359        assert!(
5360            issues.iter().any(|i| i.code.contains("6.5.2/CodingStyle")),
5361            "Should flag missing CodingStyleDefault: {:#?}",
5362            issues
5363        );
5364    }
5365
5366    #[test]
5367    fn app2e_flags_j2k_missing_j2c_layout() {
5368        use crate::cpl::{
5369            J2KExtendedCapabilities, JPEG2000SubDescriptor, RGBADescriptor, VideoSubDescriptors,
5370        };
5371
5372        let ed_id = uuid(10);
5373        let mut cpl = minimal_cpl();
5374        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
5375            essence_descriptors: vec![EssenceDescriptor {
5376                id: ed_id,
5377                rgba_descriptor: Some(RGBADescriptor {
5378                    instance_id: None,
5379                    display_width: Some(1920),
5380                    display_height: Some(1080),
5381                    stored_width: Some(1920),
5382                    stored_height: Some(1080),
5383                    sample_rate: Some(EditRate::new(24, 1)),
5384                    image_aspect_ratio: None,
5385                    color_primaries: Some(ColorPrimaries::P3D65),
5386                    transfer_characteristic: Some(TransferCharacteristic::PqSt2084),
5387                    coding_equations: None,
5388                    picture_compression: Some(VideoCodec::Jpeg2000Ht),
5389                    frame_layout: Some("FullFrame".to_string()),
5390                    display_f2_offset: None,
5391                    component_max_ref: Some(1023),
5392                    component_min_ref: Some(0),
5393                    scanning_direction: Some(
5394                        "ScanningDirection_LeftToRightTopToBottom".to_string(),
5395                    ),
5396                    stored_f2_offset: None,
5397                    sampled_width: None,
5398                    sampled_height: None,
5399                    sampled_x_offset: None,
5400                    sampled_y_offset: None,
5401                    alpha_transparency: None,
5402                    image_alignment_offset: None,
5403                    image_start_offset: None,
5404                    image_end_offset: None,
5405                    field_dominance: None,
5406                    alpha_max_ref: None,
5407                    alpha_min_ref: None,
5408                    palette: None,
5409                    palette_layout: None,
5410                    linked_track_id: None,
5411                    sub_descriptors: Some(VideoSubDescriptors {
5412                        phdr_metadata_track_sub_descriptor: None,
5413                        jpeg2000_sub_descriptor: Some(JPEG2000SubDescriptor {
5414                            instance_id: None,
5415                            rsiz: Some(16384),
5416                            xsiz: Some(1920),
5417                            ysiz: Some(1080),
5418                            xo_siz: None,
5419                            yo_siz: None,
5420                            xt_siz: None,
5421                            yt_siz: None,
5422                            xto_siz: None,
5423                            yto_siz: None,
5424                            csiz: None,
5425                            coding_style_default: Some("01020001".to_string()),
5426                            quantization_default: None,
5427                            j2c_layout: None, // Missing!
5428                            j2k_extended_capabilities: Some(J2KExtendedCapabilities {
5429                                pcap: Some(131072),
5430                            }),
5431                            picture_component_sizing: None,
5432                        }),
5433                    }),
5434                }),
5435                cdci_descriptor: None,
5436                wave_pcm_descriptor: None,
5437                dc_timed_text_descriptor: None,
5438                iab_essence_descriptor: None,
5439                isxd_data_essence_descriptor: None,
5440            }],
5441        });
5442        let mut sl = empty_sequence_list();
5443        sl.main_image_sequences.push(MainImageSequence {
5444            id: uuid(3),
5445            track_id: uuid(4),
5446            resource_list: ResourceList {
5447                resources: vec![make_resource(Some(ed_id))],
5448            },
5449        });
5450        cpl.segment_list.segments[0].sequence_list = sl;
5451
5452        let v = App2E2021;
5453        let issues = v.validate_cpl(&cpl);
5454        assert!(
5455            issues.iter().any(|i| i.code.contains("6.5.2/J2CLayout")),
5456            "Should flag missing J2CLayout: {:#?}",
5457            issues
5458        );
5459    }
5460
5461    #[test]
5462    fn app2e_flags_j2k_missing_extended_capabilities_for_htj2k() {
5463        use crate::cpl::{
5464            J2CLayout, JPEG2000SubDescriptor, RGBADescriptor, RGBALayoutComponent,
5465            VideoSubDescriptors,
5466        };
5467
5468        let ed_id = uuid(10);
5469        let mut cpl = minimal_cpl();
5470        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
5471            essence_descriptors: vec![EssenceDescriptor {
5472                id: ed_id,
5473                rgba_descriptor: Some(RGBADescriptor {
5474                    instance_id: None,
5475                    display_width: Some(1920),
5476                    display_height: Some(1080),
5477                    stored_width: Some(1920),
5478                    stored_height: Some(1080),
5479                    sample_rate: Some(EditRate::new(24, 1)),
5480                    image_aspect_ratio: None,
5481                    color_primaries: Some(ColorPrimaries::P3D65),
5482                    transfer_characteristic: Some(TransferCharacteristic::PqSt2084),
5483                    coding_equations: None,
5484                    picture_compression: Some(VideoCodec::Jpeg2000Ht),
5485                    frame_layout: Some("FullFrame".to_string()),
5486                    display_f2_offset: None,
5487                    component_max_ref: Some(1023),
5488                    component_min_ref: Some(0),
5489                    scanning_direction: Some(
5490                        "ScanningDirection_LeftToRightTopToBottom".to_string(),
5491                    ),
5492                    stored_f2_offset: None,
5493                    sampled_width: None,
5494                    sampled_height: None,
5495                    sampled_x_offset: None,
5496                    sampled_y_offset: None,
5497                    alpha_transparency: None,
5498                    image_alignment_offset: None,
5499                    image_start_offset: None,
5500                    image_end_offset: None,
5501                    field_dominance: None,
5502                    alpha_max_ref: None,
5503                    alpha_min_ref: None,
5504                    palette: None,
5505                    palette_layout: None,
5506                    linked_track_id: None,
5507                    sub_descriptors: Some(VideoSubDescriptors {
5508                        phdr_metadata_track_sub_descriptor: None,
5509                        jpeg2000_sub_descriptor: Some(JPEG2000SubDescriptor {
5510                            instance_id: None,
5511                            rsiz: Some(16384), // HTJ2K = bit 14 set
5512                            xsiz: Some(1920),
5513                            ysiz: Some(1080),
5514                            xo_siz: None,
5515                            yo_siz: None,
5516                            xt_siz: None,
5517                            yt_siz: None,
5518                            xto_siz: None,
5519                            yto_siz: None,
5520                            csiz: None,
5521                            coding_style_default: Some("01020001".to_string()),
5522                            quantization_default: None,
5523                            j2c_layout: Some(J2CLayout {
5524                                components: vec![RGBALayoutComponent {
5525                                    code: "CompRed".to_string(),
5526                                    component_size: 10,
5527                                }],
5528                            }),
5529                            j2k_extended_capabilities: None, // Missing for HTJ2K!
5530                            picture_component_sizing: None,
5531                        }),
5532                    }),
5533                }),
5534                cdci_descriptor: None,
5535                wave_pcm_descriptor: None,
5536                dc_timed_text_descriptor: None,
5537                iab_essence_descriptor: None,
5538                isxd_data_essence_descriptor: None,
5539            }],
5540        });
5541        let mut sl = empty_sequence_list();
5542        sl.main_image_sequences.push(MainImageSequence {
5543            id: uuid(3),
5544            track_id: uuid(4),
5545            resource_list: ResourceList {
5546                resources: vec![make_resource(Some(ed_id))],
5547            },
5548        });
5549        cpl.segment_list.segments[0].sequence_list = sl;
5550
5551        let v = App2E2021;
5552        let issues = v.validate_cpl(&cpl);
5553        assert!(
5554            issues
5555                .iter()
5556                .any(|i| i.code.contains("6.5.2/J2KExtendedCapabilities")),
5557            "Should flag missing J2KExtendedCapabilities for HTJ2K: {:#?}",
5558            issues
5559        );
5560    }
5561
5562    #[test]
5563    fn app2e_warns_j2k_sub_descriptor_missing() {
5564        let cpl = cpl_with_rgba_descriptor(ColorPrimaries::P3D65, TransferCharacteristic::PqSt2084);
5565
5566        let v = App2E2021;
5567        let issues = v.validate_cpl(&cpl);
5568
5569        assert!(
5570            issues.iter().any(|i| {
5571                i.code.contains("6.5.2/JPEG2000SubDescriptor") && i.severity == Severity::Warning
5572            }),
5573            "Should warn when JPEG2000SubDescriptor is missing: {:#?}",
5574            issues
5575        );
5576    }
5577
5578    #[test]
5579    fn app2e_sampled_x_offset_non_zero() {
5580        let mut cpl = cpl_with_cdci_descriptor(
5581            ColorPrimaries::Bt709,
5582            TransferCharacteristic::Bt709,
5583            CodingEquations::Bt709,
5584            10,
5585        );
5586        if let Some(ref mut edl) = cpl.essence_descriptor_list {
5587            for ed in &mut edl.essence_descriptors {
5588                if let Some(ref mut cdci) = ed.cdci_descriptor {
5589                    cdci.sampled_x_offset = Some(1);
5590                }
5591            }
5592        }
5593        let v = App2E2021;
5594        let issues = v.validate_cpl(&cpl);
5595        assert!(
5596            issues
5597                .iter()
5598                .any(|i| i.code.contains("6.2.1/SampledXOffset")),
5599            "Should flag SampledXOffset != 0: {:#?}",
5600            issues
5601        );
5602    }
5603
5604    // ── §7.4 Segment Duration ──────────────────────────────────────────────
5605
5606    /// Helper: build a CPL with audio at the given sample rate and composition edit rate,
5607    /// with a single segment containing a main image resource of the given duration.
5608    fn cpl_with_audio_and_segment_duration(
5609        audio_sample_rate: EditRate,
5610        composition_edit_rate: EditRate,
5611        segment_durations: &[u64],
5612    ) -> CompositionPlaylist {
5613        let mut cpl = minimal_cpl();
5614        cpl.edit_rate = Some(composition_edit_rate);
5615
5616        // Audio essence descriptor with the given sample rate.
5617        let audio_ed_id = uuid(20);
5618        let video_ed_id = uuid(10);
5619
5620        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
5621            essence_descriptors: vec![
5622                EssenceDescriptor {
5623                    id: video_ed_id,
5624                    rgba_descriptor: None,
5625                    cdci_descriptor: None,
5626                    wave_pcm_descriptor: None,
5627                    dc_timed_text_descriptor: None,
5628                    iab_essence_descriptor: None,
5629                    isxd_data_essence_descriptor: None,
5630                },
5631                EssenceDescriptor {
5632                    id: audio_ed_id,
5633                    rgba_descriptor: None,
5634                    cdci_descriptor: None,
5635                    wave_pcm_descriptor: Some(WAVEPCMDescriptor {
5636                        instance_id: None,
5637                        sample_rate: None,
5638                        audio_sample_rate: Some(audio_sample_rate),
5639                        channel_count: Some(2),
5640                        quantization_bits: Some(24),
5641                        linked_track_id: None,
5642                        sub_descriptors: None,
5643                    }),
5644                    dc_timed_text_descriptor: None,
5645                    iab_essence_descriptor: None,
5646                    isxd_data_essence_descriptor: None,
5647                },
5648            ],
5649        });
5650
5651        let mut segments = Vec::new();
5652        for (i, &dur) in segment_durations.iter().enumerate() {
5653            let mut sl = empty_sequence_list();
5654            sl.main_image_sequences.push(MainImageSequence {
5655                id: uuid(30 + i as u8),
5656                track_id: uuid(40),
5657                resource_list: ResourceList {
5658                    resources: vec![Resource {
5659                        id: uuid(50 + i as u8),
5660                        annotation: None,
5661                        edit_rate: None,
5662                        intrinsic_duration: dur,
5663                        entry_point: None,
5664                        source_duration: Some(dur),
5665                        source_encoding: Some(video_ed_id),
5666                        track_file_id: Some(uuid(60 + i as u8)),
5667                        repeat_count: None,
5668                        key_id: None,
5669                        hash: None,
5670                        markers: vec![],
5671                    }],
5672                },
5673            });
5674            segments.push(Segment {
5675                id: uuid(70 + i as u8),
5676                sequence_list: sl,
5677            });
5678        }
5679        cpl.segment_list = SegmentList { segments };
5680
5681        cpl
5682    }
5683
5684    /// §7.4: 48kHz audio at 30000/1001 fps → 1601.6 samples/edit unit (non-integer).
5685    /// Segment duration of 7 edit units is not divisible by 5 → should flag.
5686    #[test]
5687    fn app2e_flags_segment_duration_not_multiple_of_5() {
5688        let cpl = cpl_with_audio_and_segment_duration(
5689            EditRate::new(48000, 1),    // 48kHz audio
5690            EditRate::new(30000, 1001), // 29.97 fps — 1601.6 samples/EU (non-integer)
5691            &[7],                       // 7 edit units, not divisible by 5
5692        );
5693        let v = App2E2021;
5694        let issues = v.validate_cpl(&cpl);
5695        assert!(
5696            issues.iter().any(|i| i.code.contains("7.4")),
5697            "Should flag segment duration not multiple of 5: {:#?}",
5698            issues
5699        );
5700    }
5701
5702    /// §7.4: Same non-integer sample rate, but segment duration is 10 (multiple of 5) → pass.
5703    #[test]
5704    fn app2e_allows_segment_duration_multiple_of_5() {
5705        let cpl = cpl_with_audio_and_segment_duration(
5706            EditRate::new(48000, 1),    // 48kHz audio
5707            EditRate::new(30000, 1001), // 29.97 fps — non-integer samples/EU
5708            &[10],                      // 10 edit units, divisible by 5
5709        );
5710        let v = App2E2021;
5711        let issues = v.validate_cpl(&cpl);
5712        assert!(
5713            !issues.iter().any(|i| i.code.contains("7.4")),
5714            "Should NOT flag segment duration that is a multiple of 5: {:#?}",
5715            issues
5716        );
5717    }
5718
5719    /// §7.4: 48kHz audio at 24 fps → 2000 samples/edit unit (integer).
5720    /// Constraint does not apply, so any segment duration is fine.
5721    #[test]
5722    fn app2e_allows_any_duration_when_integer_samples() {
5723        let cpl = cpl_with_audio_and_segment_duration(
5724            EditRate::new(48000, 1), // 48kHz audio
5725            EditRate::new(24, 1),    // 24 fps — 2000 samples/EU (integer)
5726            &[7], // 7 edit units, not divisible by 5 — but constraint doesn't apply
5727        );
5728        let v = App2E2021;
5729        let issues = v.validate_cpl(&cpl);
5730        assert!(
5731            !issues.iter().any(|i| i.code.contains("7.4")),
5732            "Should NOT flag when audio samples per EU is integer: {:#?}",
5733            issues
5734        );
5735    }
5736
5737    /// §7.4: Multiple segments — one compliant, one not. Only the non-compliant one is flagged.
5738    #[test]
5739    fn app2e_flags_only_non_compliant_segments() {
5740        let cpl = cpl_with_audio_and_segment_duration(
5741            EditRate::new(48000, 1),    // 48kHz audio
5742            EditRate::new(30000, 1001), // 29.97 fps — non-integer
5743            &[10, 7, 15],               // 10=ok, 7=bad, 15=ok
5744        );
5745        let v = App2E2021;
5746        let issues = v.validate_cpl(&cpl);
5747        let seg_issues: Vec<_> = issues.iter().filter(|i| i.code.contains("7.4")).collect();
5748        assert_eq!(
5749            seg_issues.len(),
5750            1,
5751            "Should flag exactly 1 segment: {:#?}",
5752            seg_issues
5753        );
5754        assert!(
5755            seg_issues[0].message.contains("Segment 2"),
5756            "Should flag segment 2 (the one with duration 7): {}",
5757            seg_issues[0].message
5758        );
5759    }
5760
5761    /// §7.4: 48kHz at 24000/1001 fps → 2002 samples/EU (integer). No constraint.
5762    #[test]
5763    fn app2e_allows_any_duration_at_23_976fps() {
5764        let cpl = cpl_with_audio_and_segment_duration(
5765            EditRate::new(48000, 1),    // 48kHz audio
5766            EditRate::new(24000, 1001), // 23.976 fps — 2002 samples/EU (integer)
5767            &[13],                      // Not divisible by 5, but constraint doesn't apply
5768        );
5769        let v = App2E2021;
5770        let issues = v.validate_cpl(&cpl);
5771        assert!(
5772            !issues.iter().any(|i| i.code.contains("7.4")),
5773            "Should NOT flag at 23.976 fps (integer samples/EU): {:#?}",
5774            issues
5775        );
5776    }
5777
5778    // ════════════════════════════════════════════════════════════════════════
5779    // Core constraints tests — resource & timeline validation (ST 2067-2)
5780    // ════════════════════════════════════════════════════════════════════════
5781
5782    #[test]
5783    fn core_rejects_duplicate_segment_ids() {
5784        use crate::cpl::Segment;
5785
5786        let mut cpl = minimal_cpl();
5787        // Add a second segment with the same ID as the first
5788        cpl.segment_list.segments.push(Segment {
5789            id: cpl.segment_list.segments[0].id,
5790            sequence_list: empty_sequence_list(),
5791        });
5792        let issues = CoreConstraints2020.validate_cpl(&cpl);
5793        assert!(
5794            issues.iter().any(|i| i.code.contains("UniqueSegmentId")),
5795            "Duplicate segment IDs should be flagged: {:#?}",
5796            issues,
5797        );
5798    }
5799
5800    #[test]
5801    fn core_rejects_zero_intrinsic_duration() {
5802        let mut cpl = minimal_cpl();
5803        let ed_id = uuid(10);
5804        cpl.segment_list.segments[0]
5805            .sequence_list
5806            .main_image_sequences
5807            .push(MainImageSequence {
5808                id: uuid(3),
5809                track_id: uuid(4),
5810                resource_list: ResourceList {
5811                    resources: vec![Resource {
5812                        id: uuid(99),
5813                        annotation: None,
5814                        edit_rate: None,
5815                        intrinsic_duration: 0, // Invalid!
5816                        entry_point: None,
5817                        source_duration: None,
5818                        source_encoding: Some(ed_id),
5819                        track_file_id: Some(uuid(50)),
5820                        repeat_count: None,
5821                        key_id: None,
5822                        hash: None,
5823                        markers: vec![],
5824                    }],
5825                },
5826            });
5827        let issues = CoreConstraints2020.validate_cpl(&cpl);
5828        assert!(
5829            issues.iter().any(|i| i.code.contains("IntrinsicDuration")),
5830            "Zero IntrinsicDuration should be flagged: {:#?}",
5831            issues,
5832        );
5833    }
5834
5835    #[test]
5836    fn core_rejects_entry_plus_duration_exceeds_intrinsic() {
5837        let mut cpl = minimal_cpl();
5838        let ed_id = uuid(10);
5839        cpl.segment_list.segments[0]
5840            .sequence_list
5841            .main_image_sequences
5842            .push(MainImageSequence {
5843                id: uuid(3),
5844                track_id: uuid(4),
5845                resource_list: ResourceList {
5846                    resources: vec![Resource {
5847                        id: uuid(99),
5848                        annotation: None,
5849                        edit_rate: None,
5850                        intrinsic_duration: 100,
5851                        entry_point: Some(50),
5852                        source_duration: Some(60), // 50 + 60 = 110 > 100
5853                        source_encoding: Some(ed_id),
5854                        track_file_id: Some(uuid(50)),
5855                        repeat_count: None,
5856                        key_id: None,
5857                        hash: None,
5858                        markers: vec![],
5859                    }],
5860                },
5861            });
5862        let issues = CoreConstraints2020.validate_cpl(&cpl);
5863        assert!(
5864            issues.iter().any(|i| i.code.contains("ResourceDuration")),
5865            "EntryPoint + SourceDuration > IntrinsicDuration should be flagged: {:#?}",
5866            issues,
5867        );
5868    }
5869
5870    #[test]
5871    fn core_accepts_valid_entry_plus_duration() {
5872        let mut cpl = minimal_cpl();
5873        let ed_id = uuid(10);
5874        cpl.segment_list.segments[0]
5875            .sequence_list
5876            .main_image_sequences
5877            .push(MainImageSequence {
5878                id: uuid(3),
5879                track_id: uuid(4),
5880                resource_list: ResourceList {
5881                    resources: vec![Resource {
5882                        id: uuid(99),
5883                        annotation: None,
5884                        edit_rate: None,
5885                        intrinsic_duration: 100,
5886                        entry_point: Some(50),
5887                        source_duration: Some(50), // 50 + 50 = 100 == IntrinsicDuration
5888                        source_encoding: Some(ed_id),
5889                        track_file_id: Some(uuid(50)),
5890                        repeat_count: None,
5891                        key_id: None,
5892                        hash: None,
5893                        markers: vec![],
5894                    }],
5895                },
5896            });
5897        let issues = CoreConstraints2020.validate_cpl(&cpl);
5898        assert!(
5899            !issues.iter().any(|i| i.code.contains("ResourceDuration")),
5900            "Valid EntryPoint + SourceDuration should not be flagged: {:#?}",
5901            issues,
5902        );
5903    }
5904
5905    #[test]
5906    fn core_rejects_zero_repeat_count() {
5907        let mut cpl = minimal_cpl();
5908        let ed_id = uuid(10);
5909        cpl.segment_list.segments[0]
5910            .sequence_list
5911            .main_image_sequences
5912            .push(MainImageSequence {
5913                id: uuid(3),
5914                track_id: uuid(4),
5915                resource_list: ResourceList {
5916                    resources: vec![Resource {
5917                        id: uuid(99),
5918                        annotation: None,
5919                        edit_rate: None,
5920                        intrinsic_duration: 100,
5921                        entry_point: None,
5922                        source_duration: None,
5923                        source_encoding: Some(ed_id),
5924                        track_file_id: Some(uuid(50)),
5925                        repeat_count: Some(0), // Invalid!
5926                        key_id: None,
5927                        hash: None,
5928                        markers: vec![],
5929                    }],
5930                },
5931            });
5932        let issues = CoreConstraints2020.validate_cpl(&cpl);
5933        assert!(
5934            issues.iter().any(|i| i.code.contains("RepeatCount")),
5935            "Zero RepeatCount should be flagged: {:#?}",
5936            issues,
5937        );
5938    }
5939
5940    // ── A: SourceDuration > 0 ────────────────────────────────────────────────
5941
5942    #[test]
5943    fn core_rejects_zero_source_duration() {
5944        let mut cpl = minimal_cpl();
5945        let ed_id = uuid(10);
5946        cpl.segment_list.segments[0]
5947            .sequence_list
5948            .main_image_sequences
5949            .push(MainImageSequence {
5950                id: uuid(3),
5951                track_id: uuid(4),
5952                resource_list: ResourceList {
5953                    resources: vec![Resource {
5954                        id: uuid(99),
5955                        annotation: None,
5956                        edit_rate: None,
5957                        intrinsic_duration: 100,
5958                        entry_point: None,
5959                        source_duration: Some(0), // Invalid
5960                        source_encoding: Some(ed_id),
5961                        track_file_id: Some(uuid(50)),
5962                        repeat_count: None,
5963                        key_id: None,
5964                        hash: None,
5965                        markers: vec![],
5966                    }],
5967                },
5968            });
5969        let issues = CoreConstraints2020.validate_cpl(&cpl);
5970        assert!(
5971            issues.iter().any(|i| i.code.contains("SourceDuration")),
5972            "Zero SourceDuration should be flagged: {:#?}",
5973            issues,
5974        );
5975    }
5976
5977    #[test]
5978    fn core_accepts_nonzero_source_duration() {
5979        let mut cpl = minimal_cpl();
5980        let ed_id = uuid(10);
5981        cpl.segment_list.segments[0]
5982            .sequence_list
5983            .main_image_sequences
5984            .push(MainImageSequence {
5985                id: uuid(3),
5986                track_id: uuid(4),
5987                resource_list: ResourceList {
5988                    resources: vec![Resource {
5989                        id: uuid(99),
5990                        annotation: None,
5991                        edit_rate: None,
5992                        intrinsic_duration: 100,
5993                        entry_point: None,
5994                        source_duration: Some(50),
5995                        source_encoding: Some(ed_id),
5996                        track_file_id: Some(uuid(50)),
5997                        repeat_count: None,
5998                        key_id: None,
5999                        hash: None,
6000                        markers: vec![],
6001                    }],
6002                },
6003            });
6004        let issues = CoreConstraints2020.validate_cpl(&cpl);
6005        assert!(
6006            !issues.iter().any(|i| i.code.contains("SourceDuration")),
6007            "Valid SourceDuration should not be flagged: {:#?}",
6008            issues,
6009        );
6010    }
6011
6012    // ── B: EntryPoint < IntrinsicDuration ───────────────────────────────────
6013
6014    #[test]
6015    fn core_rejects_entry_point_gte_intrinsic_duration() {
6016        let mut cpl = minimal_cpl();
6017        let ed_id = uuid(10);
6018        cpl.segment_list.segments[0]
6019            .sequence_list
6020            .main_image_sequences
6021            .push(MainImageSequence {
6022                id: uuid(3),
6023                track_id: uuid(4),
6024                resource_list: ResourceList {
6025                    resources: vec![Resource {
6026                        id: uuid(99),
6027                        annotation: None,
6028                        edit_rate: None,
6029                        intrinsic_duration: 100,
6030                        entry_point: Some(100), // Equal — invalid, must be < 100
6031                        source_duration: None,
6032                        source_encoding: Some(ed_id),
6033                        track_file_id: Some(uuid(50)),
6034                        repeat_count: None,
6035                        key_id: None,
6036                        hash: None,
6037                        markers: vec![],
6038                    }],
6039                },
6040            });
6041        let issues = CoreConstraints2020.validate_cpl(&cpl);
6042        assert!(
6043            issues.iter().any(|i| i.code.contains("EntryPoint")),
6044            "EntryPoint >= IntrinsicDuration should be flagged: {:#?}",
6045            issues,
6046        );
6047    }
6048
6049    #[test]
6050    fn core_accepts_entry_point_less_than_intrinsic() {
6051        let mut cpl = minimal_cpl();
6052        let ed_id = uuid(10);
6053        cpl.segment_list.segments[0]
6054            .sequence_list
6055            .main_image_sequences
6056            .push(MainImageSequence {
6057                id: uuid(3),
6058                track_id: uuid(4),
6059                resource_list: ResourceList {
6060                    resources: vec![Resource {
6061                        id: uuid(99),
6062                        annotation: None,
6063                        edit_rate: None,
6064                        intrinsic_duration: 100,
6065                        entry_point: Some(99), // Valid: 99 < 100
6066                        source_duration: Some(1),
6067                        source_encoding: Some(ed_id),
6068                        track_file_id: Some(uuid(50)),
6069                        repeat_count: None,
6070                        key_id: None,
6071                        hash: None,
6072                        markers: vec![],
6073                    }],
6074                },
6075            });
6076        let issues = CoreConstraints2020.validate_cpl(&cpl);
6077        assert!(
6078            !issues.iter().any(|i| i.code.contains("EntryPoint")),
6079            "Valid EntryPoint should not be flagged: {:#?}",
6080            issues,
6081        );
6082    }
6083
6084    // ── C: Duplicate TrackId within a segment ───────────────────────────────
6085
6086    #[test]
6087    fn core_rejects_duplicate_track_id_within_segment() {
6088        let mut cpl = minimal_cpl();
6089        let ed_id = uuid(10);
6090        let shared_track_id = uuid(4);
6091        let make_seq = |seq_id: u8, res_id: u8| MainImageSequence {
6092            id: uuid(seq_id),
6093            track_id: shared_track_id,
6094            resource_list: ResourceList {
6095                resources: vec![Resource {
6096                    id: uuid(res_id),
6097                    annotation: None,
6098                    edit_rate: None,
6099                    intrinsic_duration: 48,
6100                    entry_point: None,
6101                    source_duration: None,
6102                    source_encoding: Some(ed_id),
6103                    track_file_id: Some(uuid(50)),
6104                    repeat_count: None,
6105                    key_id: None,
6106                    hash: None,
6107                    markers: vec![],
6108                }],
6109            },
6110        };
6111        cpl.segment_list.segments[0]
6112            .sequence_list
6113            .main_image_sequences
6114            .push(make_seq(3, 99));
6115        cpl.segment_list.segments[0]
6116            .sequence_list
6117            .main_image_sequences
6118            .push(make_seq(5, 98));
6119        let issues = CoreConstraints2020.validate_cpl(&cpl);
6120        assert!(
6121            issues.iter().any(|i| i.code.contains("TrackIdNotUnique")),
6122            "Duplicate TrackId in same segment should be flagged: {:#?}",
6123            issues,
6124        );
6125    }
6126
6127    #[test]
6128    fn core_accepts_same_track_id_in_different_segments() {
6129        let mut cpl = minimal_cpl();
6130        let ed_id = uuid(10);
6131        let shared_track_id = uuid(4);
6132        let make_res = |res_id: u8| Resource {
6133            id: uuid(res_id),
6134            annotation: None,
6135            edit_rate: None,
6136            intrinsic_duration: 48,
6137            entry_point: None,
6138            source_duration: None,
6139            source_encoding: Some(ed_id),
6140            track_file_id: Some(uuid(50)),
6141            repeat_count: None,
6142            key_id: None,
6143            hash: None,
6144            markers: vec![],
6145        };
6146        let mut sl1 = empty_sequence_list();
6147        sl1.main_image_sequences.push(MainImageSequence {
6148            id: uuid(3),
6149            track_id: shared_track_id,
6150            resource_list: ResourceList {
6151                resources: vec![make_res(99)],
6152            },
6153        });
6154        let mut sl2 = empty_sequence_list();
6155        sl2.main_image_sequences.push(MainImageSequence {
6156            id: uuid(7),
6157            track_id: shared_track_id,
6158            resource_list: ResourceList {
6159                resources: vec![make_res(98)],
6160            },
6161        });
6162        cpl.segment_list.segments = vec![
6163            Segment {
6164                id: uuid(2),
6165                sequence_list: sl1,
6166            },
6167            Segment {
6168                id: uuid(6),
6169                sequence_list: sl2,
6170            },
6171        ];
6172        let issues = CoreConstraints2020.validate_cpl(&cpl);
6173        assert!(
6174            !issues.iter().any(|i| i.code.contains("TrackIdNotUnique")),
6175            "Same TrackId in different segments (virtual track) should not be flagged: {:#?}",
6176            issues,
6177        );
6178    }
6179
6180    // ── D: Locale tag validation at core level ───────────────────────────────
6181
6182    #[test]
6183    fn core_warns_malformed_locale_language_tag() {
6184        let mut cpl = minimal_cpl();
6185        cpl.locale_list = Some(crate::cpl::LocaleList {
6186            locales: vec![crate::cpl::Locale {
6187                language_list: Some(crate::cpl::LanguageList {
6188                    languages: vec![crate::cpl::LanguageTag::new("123invalid")],
6189                }),
6190                region_list: None,
6191                content_maturity_rating_list: None,
6192            }],
6193        });
6194        let issues = CoreConstraints2020.validate_cpl(&cpl);
6195        assert!(
6196            issues
6197                .iter()
6198                .any(|i| i.code.contains("LocaleLanguageTagInvalid")),
6199            "Malformed language tag should be flagged at core level: {:#?}",
6200            issues,
6201        );
6202    }
6203
6204    #[test]
6205    fn core_accepts_valid_locale() {
6206        let mut cpl = minimal_cpl();
6207        cpl.locale_list = Some(crate::cpl::LocaleList {
6208            locales: vec![crate::cpl::Locale {
6209                language_list: Some(crate::cpl::LanguageList {
6210                    languages: vec![
6211                        crate::cpl::LanguageTag::new("nl"),
6212                        crate::cpl::LanguageTag::new("en"),
6213                    ],
6214                }),
6215                region_list: Some(crate::cpl::RegionList {
6216                    regions: vec!["NL".to_string(), "US".to_string()],
6217                }),
6218                content_maturity_rating_list: None,
6219            }],
6220        });
6221        let issues = CoreConstraints2020.validate_cpl(&cpl);
6222        assert!(
6223            !issues.iter().any(|i| i.code.contains("Locale")),
6224            "Valid locale should not be flagged: {:#?}",
6225            issues,
6226        );
6227    }
6228
6229    // ── F: core_2016 requires EssenceDescriptorList ─────────────────────────
6230
6231    #[test]
6232    fn core_2016_requires_essence_descriptor_list() {
6233        let mut cpl = minimal_cpl();
6234        cpl.namespace = CplNamespace::Smpte2067_3_2016;
6235        cpl.essence_descriptor_list = None; // Missing — 2016 requires it
6236        let issues = CoreConstraints2016.validate_cpl(&cpl);
6237        assert!(
6238            issues
6239                .iter()
6240                .any(|i| i.code.contains("EssenceDescriptorList")),
6241            "ST 2067-2:2016 should require EssenceDescriptorList: {:#?}",
6242            issues,
6243        );
6244    }
6245
6246    #[test]
6247    fn core_2016_accepts_present_essence_descriptor_list() {
6248        let mut cpl = minimal_cpl();
6249        cpl.namespace = CplNamespace::Smpte2067_3_2016;
6250        // EDL must be present AND non-empty per XSD (minOccurs=1 on EssenceDescriptor)
6251        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
6252            essence_descriptors: vec![EssenceDescriptor {
6253                id: uuid(99),
6254                rgba_descriptor: None,
6255                cdci_descriptor: None,
6256                wave_pcm_descriptor: None,
6257                dc_timed_text_descriptor: None,
6258                iab_essence_descriptor: None,
6259                isxd_data_essence_descriptor: None,
6260            }],
6261        });
6262        let issues = CoreConstraints2016.validate_cpl(&cpl);
6263        assert!(
6264            !issues
6265                .iter()
6266                .any(|i| i.code.contains("EssenceDescriptorList")),
6267            "Present non-empty EDL should not be flagged: {:#?}",
6268            issues,
6269        );
6270    }
6271
6272    // ── H: ContentVersion.Id non-empty ──────────────────────────────────────
6273
6274    #[test]
6275    fn core_flags_empty_content_version_id() {
6276        let mut cpl = minimal_cpl();
6277        cpl.content_version_list = Some(crate::cpl::ContentVersionList {
6278            content_versions: vec![crate::cpl::ContentVersion {
6279                id: "".to_string(),
6280                label_text: Some(crate::cpl::LanguageString {
6281                    text: "Version 1".to_string(),
6282                    language: None,
6283                }),
6284            }],
6285        });
6286        let issues = CoreConstraints2020.validate_cpl(&cpl);
6287        assert!(
6288            issues
6289                .iter()
6290                .any(|i| i.code.contains("ContentVersionIdInvalid")),
6291            "Empty ContentVersion Id should be flagged: {:#?}",
6292            issues,
6293        );
6294    }
6295
6296    #[test]
6297    fn core_accepts_nonempty_content_version_id() {
6298        let mut cpl = minimal_cpl();
6299        cpl.content_version_list = Some(crate::cpl::ContentVersionList {
6300            content_versions: vec![crate::cpl::ContentVersion {
6301                id: "urn:uuid:00000000-0000-0000-0000-000000000001".to_string(),
6302                label_text: Some(crate::cpl::LanguageString {
6303                    text: "Version 1".to_string(),
6304                    language: None,
6305                }),
6306            }],
6307        });
6308        let issues = CoreConstraints2020.validate_cpl(&cpl);
6309        assert!(
6310            !issues
6311                .iter()
6312                .any(|i| i.code.contains("ContentVersionIdInvalid")),
6313            "Non-empty ContentVersion Id should not be flagged: {:#?}",
6314            issues,
6315        );
6316    }
6317
6318    #[test]
6319    fn core_rejects_missing_track_file_id() {
6320        let mut cpl = minimal_cpl();
6321        cpl.segment_list.segments[0]
6322            .sequence_list
6323            .main_image_sequences
6324            .push(MainImageSequence {
6325                id: uuid(3),
6326                track_id: uuid(4),
6327                resource_list: ResourceList {
6328                    resources: vec![Resource {
6329                        id: uuid(99),
6330                        annotation: None,
6331                        edit_rate: None,
6332                        intrinsic_duration: 100,
6333                        entry_point: None,
6334                        source_duration: None,
6335                        source_encoding: Some(uuid(10)),
6336                        track_file_id: None, // Missing!
6337                        repeat_count: None,
6338                        key_id: None,
6339                        hash: None,
6340                        markers: vec![],
6341                    }],
6342                },
6343            });
6344        let issues = CoreConstraints2020.validate_cpl(&cpl);
6345        assert!(
6346            issues.iter().any(|i| i.code.contains("TrackFileId")),
6347            "Missing TrackFileId should be flagged: {:#?}",
6348            issues,
6349        );
6350    }
6351
6352    #[test]
6353    fn core_detects_virtual_track_discontinuity() {
6354        use crate::cpl::Segment;
6355
6356        let mut cpl = minimal_cpl();
6357        let track_a = uuid(4);
6358        let track_b = uuid(5);
6359
6360        // Segment 1: has track_a and track_b
6361        cpl.segment_list.segments[0]
6362            .sequence_list
6363            .main_image_sequences
6364            .push(MainImageSequence {
6365                id: uuid(10),
6366                track_id: track_a,
6367                resource_list: ResourceList {
6368                    resources: vec![make_resource(Some(uuid(20)))],
6369                },
6370            });
6371        cpl.segment_list.segments[0]
6372            .sequence_list
6373            .main_audio_sequences
6374            .push(MainAudioSequence {
6375                id: uuid(11),
6376                track_id: track_b,
6377                resource_list: ResourceList {
6378                    resources: vec![make_resource(Some(uuid(21)))],
6379                },
6380            });
6381
6382        // Segment 2: only has track_a (track_b missing = discontinuity)
6383        cpl.segment_list.segments.push(Segment {
6384            id: uuid(30),
6385            sequence_list: SequenceList {
6386                main_image_sequences: vec![MainImageSequence {
6387                    id: uuid(12),
6388                    track_id: track_a,
6389                    resource_list: ResourceList {
6390                        resources: vec![make_resource(Some(uuid(22)))],
6391                    },
6392                }],
6393                main_audio_sequences: vec![],
6394                subtitles_sequences: vec![],
6395                hearing_impaired_captions_sequences: vec![],
6396                forced_narrative_sequences: vec![],
6397                marker_sequences: vec![],
6398                iab_sequences: vec![],
6399                isxd_sequences: vec![],
6400            },
6401        });
6402
6403        let issues = CoreConstraints2020.validate_cpl(&cpl);
6404        assert!(
6405            issues
6406                .iter()
6407                .any(|i| i.code.contains("VirtualTrackContinuity")),
6408            "Missing virtual track in segment 2 should be flagged: {:#?}",
6409            issues,
6410        );
6411    }
6412
6413    #[test]
6414    fn core_accepts_continuous_virtual_tracks() {
6415        use crate::cpl::Segment;
6416
6417        let mut cpl = minimal_cpl();
6418        let track_a = uuid(4);
6419
6420        cpl.segment_list.segments[0]
6421            .sequence_list
6422            .main_image_sequences
6423            .push(MainImageSequence {
6424                id: uuid(10),
6425                track_id: track_a,
6426                resource_list: ResourceList {
6427                    resources: vec![make_resource(Some(uuid(20)))],
6428                },
6429            });
6430
6431        // Segment 2: same track_a present
6432        cpl.segment_list.segments.push(Segment {
6433            id: uuid(30),
6434            sequence_list: SequenceList {
6435                main_image_sequences: vec![MainImageSequence {
6436                    id: uuid(12),
6437                    track_id: track_a,
6438                    resource_list: ResourceList {
6439                        resources: vec![make_resource(Some(uuid(22)))],
6440                    },
6441                }],
6442                main_audio_sequences: vec![],
6443                subtitles_sequences: vec![],
6444                hearing_impaired_captions_sequences: vec![],
6445                forced_narrative_sequences: vec![],
6446                marker_sequences: vec![],
6447                iab_sequences: vec![],
6448                isxd_sequences: vec![],
6449            },
6450        });
6451
6452        let issues = CoreConstraints2020.validate_cpl(&cpl);
6453        assert!(
6454            !issues
6455                .iter()
6456                .any(|i| i.code.contains("VirtualTrackContinuity")),
6457            "Continuous virtual tracks should not be flagged: {:#?}",
6458            issues,
6459        );
6460    }
6461
6462    #[test]
6463    fn core_detects_edit_rate_mismatch_in_virtual_track() {
6464        use crate::cpl::Segment;
6465
6466        let mut cpl = minimal_cpl();
6467        let track_a = uuid(4);
6468
6469        // Segment 1: resource at 24fps
6470        let mut res1 = make_resource(Some(uuid(20)));
6471        res1.id = uuid(91);
6472        res1.edit_rate = Some(EditRate::new(24, 1));
6473
6474        cpl.segment_list.segments[0]
6475            .sequence_list
6476            .main_image_sequences
6477            .push(MainImageSequence {
6478                id: uuid(10),
6479                track_id: track_a,
6480                resource_list: ResourceList {
6481                    resources: vec![res1],
6482                },
6483            });
6484
6485        // Segment 2: same track, resource at 25fps (mismatch!)
6486        let mut res2 = make_resource(Some(uuid(22)));
6487        res2.id = uuid(92);
6488        res2.edit_rate = Some(EditRate::new(25, 1));
6489
6490        cpl.segment_list.segments.push(Segment {
6491            id: uuid(30),
6492            sequence_list: SequenceList {
6493                main_image_sequences: vec![MainImageSequence {
6494                    id: uuid(12),
6495                    track_id: track_a,
6496                    resource_list: ResourceList {
6497                        resources: vec![res2],
6498                    },
6499                }],
6500                main_audio_sequences: vec![],
6501                subtitles_sequences: vec![],
6502                hearing_impaired_captions_sequences: vec![],
6503                forced_narrative_sequences: vec![],
6504                marker_sequences: vec![],
6505                iab_sequences: vec![],
6506                isxd_sequences: vec![],
6507            },
6508        });
6509
6510        let issues = CoreConstraints2020.validate_cpl(&cpl);
6511        assert!(
6512            issues
6513                .iter()
6514                .any(|i| i.code.contains("VirtualTrackEditRate")),
6515            "Edit rate mismatch in virtual track should be flagged: {:#?}",
6516            issues,
6517        );
6518    }
6519
6520    /// ST 2067-3 §6.9.3 / XSD: Resource EditRate is optional (minOccurs="0").
6521    /// Absent EditRate means "inherit the CPL's EditRate". A resource with no
6522    /// explicit EditRate in the same virtual track as resources with an explicit
6523    /// EditRate equal to the CPL's EditRate must NOT be flagged.
6524    #[test]
6525    fn core_accepts_absent_edit_rate_when_matches_cpl_rate() {
6526        use crate::cpl::Segment;
6527
6528        let mut cpl = minimal_cpl();
6529        cpl.edit_rate = Some(EditRate::new(24, 1));
6530        let track_a = uuid(4);
6531
6532        // Segment 1: resource with explicit 24fps (matches CPL)
6533        let mut res1 = make_resource(Some(uuid(20)));
6534        res1.id = uuid(91);
6535        res1.edit_rate = Some(EditRate::new(24, 1));
6536
6537        cpl.segment_list.segments[0]
6538            .sequence_list
6539            .main_image_sequences
6540            .push(MainImageSequence {
6541                id: uuid(10),
6542                track_id: track_a,
6543                resource_list: ResourceList {
6544                    resources: vec![res1],
6545                },
6546            });
6547
6548        // Segment 2: same track, resource with NO explicit EditRate (inherits CPL 24fps)
6549        let mut res2 = make_resource(Some(uuid(22)));
6550        res2.id = uuid(92);
6551        res2.edit_rate = None;
6552
6553        cpl.segment_list.segments.push(Segment {
6554            id: uuid(30),
6555            sequence_list: SequenceList {
6556                main_image_sequences: vec![MainImageSequence {
6557                    id: uuid(12),
6558                    track_id: track_a,
6559                    resource_list: ResourceList {
6560                        resources: vec![res2],
6561                    },
6562                }],
6563                main_audio_sequences: vec![],
6564                subtitles_sequences: vec![],
6565                hearing_impaired_captions_sequences: vec![],
6566                forced_narrative_sequences: vec![],
6567                marker_sequences: vec![],
6568                iab_sequences: vec![],
6569                isxd_sequences: vec![],
6570            },
6571        });
6572
6573        let issues = CoreConstraints2020.validate_cpl(&cpl);
6574        assert!(
6575            !issues
6576                .iter()
6577                .any(|i| i.code.contains("VirtualTrackEditRate")),
6578            "Absent EditRate matching CPL rate must not be flagged: {:#?}",
6579            issues,
6580        );
6581    }
6582
6583    /// SegmentDuration normalization: audio at 48000/1 and video at 24/1 with
6584    /// the same real-time duration must NOT be flagged.
6585    ///
6586    /// ST 2067-2 §7.2.2 requires equal real-time duration across all virtual
6587    /// tracks in a segment, not equal raw frame counts.
6588    #[test]
6589    fn core_accepts_equal_real_time_duration_across_edit_rates() {
6590        let mut cpl = minimal_cpl();
6591        let mut sl = empty_sequence_list();
6592        let er_video = EditRate {
6593            numerator: 24,
6594            denominator: 1,
6595        };
6596        let er_audio = EditRate {
6597            numerator: 48_000,
6598            denominator: 1,
6599        };
6600        // Video: 240 frames at 24/1 = 10.0 s
6601        sl.main_image_sequences.push(MainImageSequence {
6602            id: uuid(3),
6603            track_id: uuid(4),
6604            resource_list: ResourceList {
6605                resources: vec![Resource {
6606                    id: uuid(20),
6607                    annotation: None,
6608                    edit_rate: Some(er_video),
6609                    intrinsic_duration: 240,
6610                    entry_point: None,
6611                    source_duration: None,
6612                    source_encoding: None,
6613                    track_file_id: Some(uuid(50)),
6614                    repeat_count: None,
6615                    key_id: None,
6616                    hash: None,
6617                    markers: vec![],
6618                }],
6619            },
6620        });
6621        // Audio: 480000 frames at 48000/1 = 10.0 s (same real time)
6622        sl.main_audio_sequences.push(MainAudioSequence {
6623            id: uuid(5),
6624            track_id: uuid(6),
6625            resource_list: ResourceList {
6626                resources: vec![Resource {
6627                    id: uuid(21),
6628                    annotation: None,
6629                    edit_rate: Some(er_audio),
6630                    intrinsic_duration: 480_000,
6631                    entry_point: None,
6632                    source_duration: None,
6633                    source_encoding: None,
6634                    track_file_id: Some(uuid(51)),
6635                    repeat_count: None,
6636                    key_id: None,
6637                    hash: None,
6638                    markers: vec![],
6639                }],
6640            },
6641        });
6642        cpl.segment_list.segments[0].sequence_list = sl;
6643
6644        let issues = CoreConstraints2020.validate_cpl(&cpl);
6645        assert!(
6646            !issues.iter().any(|i| i.code.contains("SegmentDuration")),
6647            "Equal real-time duration across different edit rates must not be flagged: {:#?}",
6648            issues,
6649        );
6650    }
6651
6652    #[test]
6653    fn core_rejects_marker_offset_beyond_duration() {
6654        use crate::cpl::MarkerLabel;
6655        use crate::cpl::{MarkerInfo, MarkerLabelElement, MarkerSequence};
6656
6657        let mut cpl = minimal_cpl();
6658        cpl.segment_list.segments[0]
6659            .sequence_list
6660            .marker_sequences
6661            .push(MarkerSequence {
6662                id: uuid(60),
6663                track_id: uuid(61),
6664                resource_list: ResourceList {
6665                    resources: vec![Resource {
6666                        id: uuid(62),
6667                        annotation: None,
6668                        edit_rate: None,
6669                        intrinsic_duration: 100,
6670                        entry_point: Some(10),
6671                        source_duration: Some(50), // effective duration = 50
6672                        source_encoding: None,
6673                        track_file_id: None,
6674                        repeat_count: None,
6675                        key_id: None,
6676                        hash: None,
6677                        markers: vec![MarkerInfo {
6678                            annotation: None,
6679                            label: MarkerLabelElement::from(MarkerLabel::Ffoc),
6680                            offset: 60, // 60 >= 50 → out of bounds
6681                        }],
6682                    }],
6683                },
6684            });
6685
6686        let issues = CoreConstraints2020.validate_cpl(&cpl);
6687        assert!(
6688            issues.iter().any(|i| i.code.contains("MarkerOffset")),
6689            "Marker offset beyond resource duration should be flagged: {:#?}",
6690            issues,
6691        );
6692    }
6693
6694    #[test]
6695    fn core_accepts_marker_at_valid_offset() {
6696        use crate::cpl::MarkerLabel;
6697        use crate::cpl::{MarkerInfo, MarkerLabelElement, MarkerSequence};
6698
6699        let mut cpl = minimal_cpl();
6700        cpl.segment_list.segments[0]
6701            .sequence_list
6702            .marker_sequences
6703            .push(MarkerSequence {
6704                id: uuid(60),
6705                track_id: uuid(61),
6706                resource_list: ResourceList {
6707                    resources: vec![Resource {
6708                        id: uuid(62),
6709                        annotation: None,
6710                        edit_rate: None,
6711                        intrinsic_duration: 100,
6712                        entry_point: None,
6713                        source_duration: Some(100),
6714                        source_encoding: None,
6715                        track_file_id: None,
6716                        repeat_count: None,
6717                        key_id: None,
6718                        hash: None,
6719                        markers: vec![MarkerInfo {
6720                            annotation: None,
6721                            label: MarkerLabelElement::from(MarkerLabel::Ffoc),
6722                            offset: 0, // First frame — valid
6723                        }],
6724                    }],
6725                },
6726            });
6727
6728        let issues = CoreConstraints2020.validate_cpl(&cpl);
6729        assert!(
6730            !issues.iter().any(|i| i.code.contains("MarkerOffset")),
6731            "Marker at offset 0 should not be flagged: {:#?}",
6732            issues,
6733        );
6734    }
6735
6736    #[test]
6737    fn core_rejects_duplicate_essence_descriptor_ids() {
6738        let mut cpl = minimal_cpl();
6739        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
6740            essence_descriptors: vec![
6741                EssenceDescriptor {
6742                    id: uuid(10),
6743                    rgba_descriptor: None,
6744                    cdci_descriptor: None,
6745                    wave_pcm_descriptor: None,
6746                    dc_timed_text_descriptor: None,
6747                    iab_essence_descriptor: None,
6748                    isxd_data_essence_descriptor: None,
6749                },
6750                EssenceDescriptor {
6751                    id: uuid(10), // Duplicate!
6752                    rgba_descriptor: None,
6753                    cdci_descriptor: None,
6754                    wave_pcm_descriptor: None,
6755                    dc_timed_text_descriptor: None,
6756                    iab_essence_descriptor: None,
6757                    isxd_data_essence_descriptor: None,
6758                },
6759            ],
6760        });
6761        let issues = CoreConstraints2020.validate_cpl(&cpl);
6762        assert!(
6763            issues
6764                .iter()
6765                .any(|i| i.code.contains("UniqueEssenceDescriptorId")),
6766            "Duplicate EssenceDescriptor IDs should be flagged: {:#?}",
6767            issues,
6768        );
6769    }
6770
6771    // ════════════════════════════════════════════════════════════════════════
6772    // Segment track duration consistency tests
6773    // ════════════════════════════════════════════════════════════════════════
6774
6775    #[test]
6776    fn core_accepts_equal_track_durations_in_segment() {
6777        let mut cpl = minimal_cpl();
6778        let mut sl = empty_sequence_list();
6779        // Video: 100 frames
6780        sl.main_image_sequences.push(MainImageSequence {
6781            id: uuid(3),
6782            track_id: uuid(4),
6783            resource_list: ResourceList {
6784                resources: vec![Resource {
6785                    id: uuid(20),
6786                    annotation: None,
6787                    edit_rate: None,
6788                    intrinsic_duration: 100,
6789                    entry_point: None,
6790                    source_duration: None,
6791                    source_encoding: None,
6792                    track_file_id: Some(uuid(50)),
6793                    repeat_count: None,
6794                    key_id: None,
6795                    hash: None,
6796                    markers: vec![],
6797                }],
6798            },
6799        });
6800        // Audio: also 100 frames
6801        sl.main_audio_sequences.push(MainAudioSequence {
6802            id: uuid(5),
6803            track_id: uuid(6),
6804            resource_list: ResourceList {
6805                resources: vec![Resource {
6806                    id: uuid(21),
6807                    annotation: None,
6808                    edit_rate: None,
6809                    intrinsic_duration: 100,
6810                    entry_point: None,
6811                    source_duration: None,
6812                    source_encoding: None,
6813                    track_file_id: Some(uuid(51)),
6814                    repeat_count: None,
6815                    key_id: None,
6816                    hash: None,
6817                    markers: vec![],
6818                }],
6819            },
6820        });
6821        cpl.segment_list.segments[0].sequence_list = sl;
6822
6823        let issues = CoreConstraints2020.validate_cpl(&cpl);
6824        assert!(
6825            !issues.iter().any(|i| i.code.contains("SegmentDuration")),
6826            "Equal durations should not produce duration mismatch: {:#?}",
6827            issues,
6828        );
6829    }
6830
6831    #[test]
6832    fn core_detects_mismatched_track_durations_in_segment() {
6833        let mut cpl = minimal_cpl();
6834        let mut sl = empty_sequence_list();
6835        let er_video = EditRate {
6836            numerator: 24,
6837            denominator: 1,
6838        };
6839        let er_audio = EditRate {
6840            numerator: 48000,
6841            denominator: 1,
6842        };
6843        // Video: 100 frames at 24/1 = ~4.167 s
6844        sl.main_image_sequences.push(MainImageSequence {
6845            id: uuid(3),
6846            track_id: uuid(4),
6847            resource_list: ResourceList {
6848                resources: vec![Resource {
6849                    id: uuid(20),
6850                    annotation: None,
6851                    edit_rate: Some(er_video),
6852                    intrinsic_duration: 100,
6853                    entry_point: None,
6854                    source_duration: None,
6855                    source_encoding: None,
6856                    track_file_id: Some(uuid(50)),
6857                    repeat_count: None,
6858                    key_id: None,
6859                    hash: None,
6860                    markers: vec![],
6861                }],
6862            },
6863        });
6864        // Audio: 96000 frames at 48000/1 = 2.0 s — genuinely different real-time duration
6865        sl.main_audio_sequences.push(MainAudioSequence {
6866            id: uuid(5),
6867            track_id: uuid(6),
6868            resource_list: ResourceList {
6869                resources: vec![Resource {
6870                    id: uuid(21),
6871                    annotation: None,
6872                    edit_rate: Some(er_audio),
6873                    intrinsic_duration: 96_000,
6874                    entry_point: None,
6875                    source_duration: None,
6876                    source_encoding: None,
6877                    track_file_id: Some(uuid(51)),
6878                    repeat_count: None,
6879                    key_id: None,
6880                    hash: None,
6881                    markers: vec![],
6882                }],
6883            },
6884        });
6885        cpl.segment_list.segments[0].sequence_list = sl;
6886
6887        let issues = CoreConstraints2020.validate_cpl(&cpl);
6888        assert!(
6889            issues.iter().any(|i| i.code.contains("SegmentDuration")),
6890            "Mismatched durations should be flagged: {:#?}",
6891            issues,
6892        );
6893    }
6894
6895    #[test]
6896    fn core_accounts_for_entry_point_and_source_duration() {
6897        let mut cpl = minimal_cpl();
6898        let mut sl = empty_sequence_list();
6899        // Video: intrinsic=200, entry=50, source=100 → effective = 100
6900        sl.main_image_sequences.push(MainImageSequence {
6901            id: uuid(3),
6902            track_id: uuid(4),
6903            resource_list: ResourceList {
6904                resources: vec![Resource {
6905                    id: uuid(20),
6906                    annotation: None,
6907                    edit_rate: None,
6908                    intrinsic_duration: 200,
6909                    entry_point: Some(50),
6910                    source_duration: Some(100),
6911                    source_encoding: None,
6912                    track_file_id: Some(uuid(50)),
6913                    repeat_count: None,
6914                    key_id: None,
6915                    hash: None,
6916                    markers: vec![],
6917                }],
6918            },
6919        });
6920        // Audio: intrinsic=100 → effective = 100 (matches)
6921        sl.main_audio_sequences.push(MainAudioSequence {
6922            id: uuid(5),
6923            track_id: uuid(6),
6924            resource_list: ResourceList {
6925                resources: vec![Resource {
6926                    id: uuid(21),
6927                    annotation: None,
6928                    edit_rate: None,
6929                    intrinsic_duration: 100,
6930                    entry_point: None,
6931                    source_duration: None,
6932                    source_encoding: None,
6933                    track_file_id: Some(uuid(51)),
6934                    repeat_count: None,
6935                    key_id: None,
6936                    hash: None,
6937                    markers: vec![],
6938                }],
6939            },
6940        });
6941        cpl.segment_list.segments[0].sequence_list = sl;
6942
6943        let issues = CoreConstraints2020.validate_cpl(&cpl);
6944        assert!(
6945            !issues.iter().any(|i| i.code.contains("SegmentDuration")),
6946            "Entry point + source duration should compute correct effective duration: {:#?}",
6947            issues,
6948        );
6949    }
6950
6951    #[test]
6952    fn core_accounts_for_repeat_count_in_duration() {
6953        let mut cpl = minimal_cpl();
6954        let mut sl = empty_sequence_list();
6955        // Video: 50 × repeat 2 = 100
6956        sl.main_image_sequences.push(MainImageSequence {
6957            id: uuid(3),
6958            track_id: uuid(4),
6959            resource_list: ResourceList {
6960                resources: vec![Resource {
6961                    id: uuid(20),
6962                    annotation: None,
6963                    edit_rate: None,
6964                    intrinsic_duration: 50,
6965                    entry_point: None,
6966                    source_duration: None,
6967                    source_encoding: None,
6968                    track_file_id: Some(uuid(50)),
6969                    repeat_count: Some(2),
6970                    key_id: None,
6971                    hash: None,
6972                    markers: vec![],
6973                }],
6974            },
6975        });
6976        // Audio: 100 (matches 50 × 2)
6977        sl.main_audio_sequences.push(MainAudioSequence {
6978            id: uuid(5),
6979            track_id: uuid(6),
6980            resource_list: ResourceList {
6981                resources: vec![Resource {
6982                    id: uuid(21),
6983                    annotation: None,
6984                    edit_rate: None,
6985                    intrinsic_duration: 100,
6986                    entry_point: None,
6987                    source_duration: None,
6988                    source_encoding: None,
6989                    track_file_id: Some(uuid(51)),
6990                    repeat_count: None,
6991                    key_id: None,
6992                    hash: None,
6993                    markers: vec![],
6994                }],
6995            },
6996        });
6997        cpl.segment_list.segments[0].sequence_list = sl;
6998
6999        let issues = CoreConstraints2020.validate_cpl(&cpl);
7000        assert!(
7001            !issues.iter().any(|i| i.code.contains("SegmentDuration")),
7002            "RepeatCount should be factored into duration: {:#?}",
7003            issues,
7004        );
7005    }
7006
7007    // ════════════════════════════════════════════════════════════════════════
7008    // Audio MCA validation tests
7009    // ════════════════════════════════════════════════════════════════════════
7010
7011    fn cpl_with_audio(wave: WAVEPCMDescriptor) -> CompositionPlaylist {
7012        let mut cpl = minimal_cpl();
7013        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
7014            essence_descriptors: vec![EssenceDescriptor {
7015                id: uuid(10),
7016                rgba_descriptor: None,
7017                cdci_descriptor: None,
7018                wave_pcm_descriptor: Some(wave),
7019                dc_timed_text_descriptor: None,
7020                iab_essence_descriptor: None,
7021                isxd_data_essence_descriptor: None,
7022            }],
7023        });
7024        cpl
7025    }
7026
7027    #[test]
7028    fn audio_warns_missing_mca_sub_descriptors() {
7029        let cpl = cpl_with_audio(WAVEPCMDescriptor {
7030            instance_id: None,
7031            sample_rate: None,
7032            audio_sample_rate: Some(EditRate::new(48000, 1)),
7033            channel_count: Some(6),
7034            quantization_bits: Some(24),
7035            linked_track_id: None,
7036            sub_descriptors: None, // Missing!
7037        });
7038        let issues = CoreConstraints2020.validate_cpl(&cpl);
7039        assert!(
7040            issues.iter().any(|i| i.code.contains("MCASubDescriptors")),
7041            "Missing MCA sub-descriptors should produce warning: {:#?}",
7042            issues,
7043        );
7044    }
7045
7046    #[test]
7047    fn audio_warns_missing_soundfield_group() {
7048        use crate::cpl::AudioSubDescriptors;
7049
7050        let cpl = cpl_with_audio(WAVEPCMDescriptor {
7051            instance_id: None,
7052            sample_rate: None,
7053            audio_sample_rate: Some(EditRate::new(48000, 1)),
7054            channel_count: Some(6),
7055            quantization_bits: Some(24),
7056            linked_track_id: None,
7057            sub_descriptors: Some(AudioSubDescriptors {
7058                soundfield_group_label_sub_descriptor: None, // Missing!
7059            }),
7060        });
7061        let issues = CoreConstraints2020.validate_cpl(&cpl);
7062        assert!(
7063            issues.iter().any(|i| i.code.contains("SoundfieldGroup")),
7064            "Missing soundfield group should produce warning: {:#?}",
7065            issues,
7066        );
7067    }
7068
7069    #[test]
7070    fn audio_flags_channel_count_mismatch() {
7071        use crate::cpl::McaTagSymbol;
7072        use crate::cpl::{AudioSubDescriptors, SoundfieldGroupLabelSubDescriptor};
7073
7074        let cpl = cpl_with_audio(WAVEPCMDescriptor {
7075            instance_id: None,
7076            sample_rate: None,
7077            audio_sample_rate: Some(EditRate::new(48000, 1)),
7078            channel_count: Some(2), // 2 channels but 5.1 soundfield!
7079            quantization_bits: Some(24),
7080            linked_track_id: None,
7081            sub_descriptors: Some(AudioSubDescriptors {
7082                soundfield_group_label_sub_descriptor: Some(SoundfieldGroupLabelSubDescriptor {
7083                    mca_tag_symbol: Some(McaTagSymbol::Sg51), // 5.1 expects 6 channels
7084                    mca_tag_name: Some("5.1".to_string()),
7085                    mca_audio_content_kind: None,
7086                    rfc5646_spoken_language: None,
7087                }),
7088            }),
7089        });
7090        let issues = CoreConstraints2020.validate_cpl(&cpl);
7091        assert!(
7092            issues
7093                .iter()
7094                .any(|i| i.code.contains("SoundfieldChannelCount")),
7095            "Channel count mismatch with soundfield should be flagged: {:#?}",
7096            issues,
7097        );
7098    }
7099
7100    #[test]
7101    fn audio_accepts_correct_51_channel_count() {
7102        use crate::cpl::McaTagSymbol;
7103        use crate::cpl::{AudioSubDescriptors, SoundfieldGroupLabelSubDescriptor};
7104
7105        let cpl = cpl_with_audio(WAVEPCMDescriptor {
7106            instance_id: None,
7107            sample_rate: None,
7108            audio_sample_rate: Some(EditRate::new(48000, 1)),
7109            channel_count: Some(6), // Correct for 5.1
7110            quantization_bits: Some(24),
7111            linked_track_id: None,
7112            sub_descriptors: Some(AudioSubDescriptors {
7113                soundfield_group_label_sub_descriptor: Some(SoundfieldGroupLabelSubDescriptor {
7114                    mca_tag_symbol: Some(McaTagSymbol::Sg51),
7115                    mca_tag_name: Some("5.1".to_string()),
7116                    mca_audio_content_kind: None,
7117                    rfc5646_spoken_language: None,
7118                }),
7119            }),
7120        });
7121        let issues = CoreConstraints2020.validate_cpl(&cpl);
7122        assert!(
7123            !issues
7124                .iter()
7125                .any(|i| i.code.contains("SoundfieldChannelCount")),
7126            "Correct 5.1 channel count should not be flagged: {:#?}",
7127            issues,
7128        );
7129    }
7130
7131    #[test]
7132    fn audio_flags_zero_channel_count() {
7133        let cpl = cpl_with_audio(WAVEPCMDescriptor {
7134            instance_id: None,
7135            sample_rate: None,
7136            audio_sample_rate: Some(EditRate::new(48000, 1)),
7137            channel_count: Some(0), // Invalid
7138            quantization_bits: Some(24),
7139            linked_track_id: None,
7140            sub_descriptors: None,
7141        });
7142        let issues = CoreConstraints2020.validate_cpl(&cpl);
7143        assert!(
7144            issues
7145                .iter()
7146                .any(|i| i.code.contains("ChannelCount") && i.severity == Severity::Error),
7147            "Zero ChannelCount should be an error: {:#?}",
7148            issues,
7149        );
7150    }
7151
7152    #[test]
7153    fn audio_accepts_stereo_with_correct_channel_count() {
7154        use crate::cpl::McaTagSymbol;
7155        use crate::cpl::{AudioSubDescriptors, SoundfieldGroupLabelSubDescriptor};
7156
7157        let cpl = cpl_with_audio(WAVEPCMDescriptor {
7158            instance_id: None,
7159            sample_rate: None,
7160            audio_sample_rate: Some(EditRate::new(48000, 1)),
7161            channel_count: Some(2),
7162            quantization_bits: Some(24),
7163            linked_track_id: None,
7164            sub_descriptors: Some(AudioSubDescriptors {
7165                soundfield_group_label_sub_descriptor: Some(SoundfieldGroupLabelSubDescriptor {
7166                    mca_tag_symbol: Some(McaTagSymbol::SgSt),
7167                    mca_tag_name: Some("Stereo".to_string()),
7168                    mca_audio_content_kind: None,
7169                    rfc5646_spoken_language: None,
7170                }),
7171            }),
7172        });
7173        let issues = CoreConstraints2020.validate_cpl(&cpl);
7174        assert!(
7175            !issues
7176                .iter()
7177                .any(|i| i.code.contains("SoundfieldChannelCount")),
7178            "Correct Stereo channel count should not be flagged: {:#?}",
7179            issues,
7180        );
7181    }
7182
7183    // ════════════════════════════════════════════════════════════════════════
7184    // App2E QuantizationBits tests
7185    // ════════════════════════════════════════════════════════════════════════
7186
7187    #[test]
7188    fn app2e_accepts_24bit_audio() {
7189        let cpl = cpl_with_audio(WAVEPCMDescriptor {
7190            instance_id: None,
7191            sample_rate: None,
7192            audio_sample_rate: Some(EditRate::new(48000, 1)),
7193            channel_count: Some(2),
7194            quantization_bits: Some(24),
7195            linked_track_id: None,
7196            sub_descriptors: None,
7197        });
7198        let issues = App2E2021.validate_cpl(&cpl);
7199        assert!(
7200            !issues.iter().any(|i| i.code.contains("QuantizationBits")),
7201            "24-bit audio should be accepted: {:#?}",
7202            issues,
7203        );
7204    }
7205
7206    #[test]
7207    fn app2e_accepts_16bit_audio() {
7208        let cpl = cpl_with_audio(WAVEPCMDescriptor {
7209            instance_id: None,
7210            sample_rate: None,
7211            audio_sample_rate: Some(EditRate::new(48000, 1)),
7212            channel_count: Some(2),
7213            quantization_bits: Some(16),
7214            linked_track_id: None,
7215            sub_descriptors: None,
7216        });
7217        let issues = App2E2021.validate_cpl(&cpl);
7218        assert!(
7219            !issues.iter().any(|i| i.code.contains("QuantizationBits")),
7220            "16-bit audio should be accepted: {:#?}",
7221            issues,
7222        );
7223    }
7224
7225    #[test]
7226    fn app2e_rejects_8bit_audio() {
7227        let cpl = cpl_with_audio(WAVEPCMDescriptor {
7228            instance_id: None,
7229            sample_rate: None,
7230            audio_sample_rate: Some(EditRate::new(48000, 1)),
7231            channel_count: Some(2),
7232            quantization_bits: Some(8), // Not allowed by ST 2067-21
7233            linked_track_id: None,
7234            sub_descriptors: None,
7235        });
7236        let issues = App2E2021.validate_cpl(&cpl);
7237        assert!(
7238            issues.iter().any(|i| i.code.contains("QuantizationBits")),
7239            "8-bit audio should be rejected: {:#?}",
7240            issues,
7241        );
7242    }
7243
7244    #[test]
7245    fn app2e_rejects_32bit_audio() {
7246        let cpl = cpl_with_audio(WAVEPCMDescriptor {
7247            instance_id: None,
7248            sample_rate: None,
7249            audio_sample_rate: Some(EditRate::new(48000, 1)),
7250            channel_count: Some(2),
7251            quantization_bits: Some(32), // Not allowed by ST 2067-21
7252            linked_track_id: None,
7253            sub_descriptors: None,
7254        });
7255        let issues = App2E2021.validate_cpl(&cpl);
7256        assert!(
7257            issues.iter().any(|i| i.code.contains("QuantizationBits")),
7258            "32-bit audio should be rejected: {:#?}",
7259            issues,
7260        );
7261    }
7262
7263    // ── Image resolution validation (Tables 4-6) ────────────────────────────
7264
7265    fn cpl_with_image_resolution(
7266        width: u32,
7267        height: u32,
7268        rate: Option<EditRate>,
7269    ) -> CompositionPlaylist {
7270        let ed_id = uuid(10);
7271        let mut cpl = minimal_cpl();
7272        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
7273            essence_descriptors: vec![EssenceDescriptor {
7274                id: ed_id,
7275                rgba_descriptor: None,
7276                cdci_descriptor: Some(CDCIDescriptor {
7277                    instance_id: None,
7278                    stored_width: Some(width),
7279                    stored_height: Some(height),
7280                    display_width: Some(width),
7281                    display_height: Some(height),
7282                    sample_rate: rate,
7283                    image_aspect_ratio: None,
7284                    color_primaries: Some(ColorPrimaries::Bt709),
7285                    transfer_characteristic: Some(TransferCharacteristic::Bt709),
7286                    coding_equations: Some(CodingEquations::Bt709),
7287                    picture_compression: Some(VideoCodec::Jpeg2000),
7288                    component_depth: Some(10),
7289                    frame_layout: Some("FullFrame".to_string()),
7290                    display_f2_offset: None,
7291                    horizontal_subsampling: Some(2),
7292                    vertical_subsampling: Some(1),
7293                    color_siting: Some(0),
7294                    black_ref_level: Some(64),
7295                    white_ref_level: Some(940),
7296                    color_range: Some(897),
7297                    stored_f2_offset: None,
7298                    sampled_width: None,
7299                    sampled_height: None,
7300                    sampled_x_offset: None,
7301                    sampled_y_offset: None,
7302                    alpha_transparency: None,
7303                    image_alignment_offset: None,
7304                    image_start_offset: None,
7305                    image_end_offset: None,
7306                    field_dominance: None,
7307                    reversed_byte_order: None,
7308                    padding_bits: None,
7309                    alpha_sample_depth: None,
7310                    linked_track_id: None,
7311                    active_width: None,
7312                    active_height: None,
7313                    sub_descriptors: None,
7314                }),
7315                wave_pcm_descriptor: None,
7316                dc_timed_text_descriptor: None,
7317                iab_essence_descriptor: None,
7318                isxd_data_essence_descriptor: None,
7319            }],
7320        });
7321        let mut sl = empty_sequence_list();
7322        sl.main_image_sequences.push(MainImageSequence {
7323            id: uuid(3),
7324            track_id: uuid(4),
7325            resource_list: ResourceList {
7326                resources: vec![make_resource(Some(ed_id))],
7327            },
7328        });
7329        cpl.segment_list.segments[0].sequence_list = sl;
7330        cpl
7331    }
7332
7333    #[test]
7334    fn app2e_accepts_1920x1080() {
7335        let cpl = cpl_with_image_resolution(1920, 1080, Some(EditRate::new(24, 1)));
7336        let issues = App2E2021.validate_cpl(&cpl);
7337        assert!(
7338            !issues.iter().any(|i| i.code.contains("Resolution")),
7339            "1920x1080 should be accepted: {:#?}",
7340            issues,
7341        );
7342    }
7343
7344    #[test]
7345    fn app2e_accepts_2048x1080() {
7346        let cpl = cpl_with_image_resolution(2048, 1080, Some(EditRate::new(25, 1)));
7347        let issues = App2E2021.validate_cpl(&cpl);
7348        assert!(
7349            !issues.iter().any(|i| i.code.contains("Resolution")),
7350            "2048x1080 should be accepted: {:#?}",
7351            issues,
7352        );
7353    }
7354
7355    #[test]
7356    fn app2e_accepts_3840x2160() {
7357        let cpl = cpl_with_image_resolution(3840, 2160, Some(EditRate::new(24, 1)));
7358        let issues = App2E2021.validate_cpl(&cpl);
7359        assert!(
7360            !issues.iter().any(|i| i.code.contains("Resolution")),
7361            "3840x2160 should be accepted: {:#?}",
7362            issues,
7363        );
7364    }
7365
7366    #[test]
7367    fn app2e_accepts_4096x2160() {
7368        let cpl = cpl_with_image_resolution(4096, 2160, Some(EditRate::new(24000, 1001)));
7369        let issues = App2E2021.validate_cpl(&cpl);
7370        assert!(
7371            !issues.iter().any(|i| i.code.contains("Resolution")),
7372            "4096x2160 should be accepted: {:#?}",
7373            issues,
7374        );
7375    }
7376
7377    #[test]
7378    fn app2e_accepts_7680x4320() {
7379        let cpl = cpl_with_image_resolution(7680, 4320, Some(EditRate::new(24, 1)));
7380        let issues = App2E2021.validate_cpl(&cpl);
7381        assert!(
7382            !issues.iter().any(|i| i.code.contains("Resolution")),
7383            "7680x4320 should be accepted: {:#?}",
7384            issues,
7385        );
7386    }
7387
7388    #[test]
7389    fn app2e_rejects_1280x720() {
7390        let cpl = cpl_with_image_resolution(1280, 720, Some(EditRate::new(24, 1)));
7391        let issues = App2E2021.validate_cpl(&cpl);
7392        assert!(
7393            issues.iter().any(|i| i.code.contains("Resolution")),
7394            "1280x720 should be rejected: {:#?}",
7395            issues,
7396        );
7397    }
7398
7399    #[test]
7400    fn app2e_rejects_1920x800() {
7401        let cpl = cpl_with_image_resolution(1920, 800, Some(EditRate::new(24, 1)));
7402        let issues = App2E2021.validate_cpl(&cpl);
7403        assert!(
7404            issues.iter().any(|i| i.code.contains("Resolution")),
7405            "1920x800 should be rejected: {:#?}",
7406            issues,
7407        );
7408    }
7409
7410    // ── Image frame rate validation (Tables 4-6) ────────────────────────────
7411
7412    #[test]
7413    fn app2e_accepts_24fps() {
7414        let cpl = cpl_with_image_resolution(1920, 1080, Some(EditRate::new(24, 1)));
7415        let issues = App2E2021.validate_cpl(&cpl);
7416        assert!(
7417            !issues.iter().any(|i| i.code.contains("FrameRate")),
7418            "24 fps should be accepted: {:#?}",
7419            issues,
7420        );
7421    }
7422
7423    #[test]
7424    fn app2e_accepts_23976fps() {
7425        let cpl = cpl_with_image_resolution(1920, 1080, Some(EditRate::new(24000, 1001)));
7426        let issues = App2E2021.validate_cpl(&cpl);
7427        assert!(
7428            !issues.iter().any(|i| i.code.contains("FrameRate")),
7429            "23.976 fps should be accepted: {:#?}",
7430            issues,
7431        );
7432    }
7433
7434    #[test]
7435    fn app2e_accepts_25fps() {
7436        let cpl = cpl_with_image_resolution(1920, 1080, Some(EditRate::new(25, 1)));
7437        let issues = App2E2021.validate_cpl(&cpl);
7438        assert!(
7439            !issues.iter().any(|i| i.code.contains("FrameRate")),
7440            "25 fps should be accepted: {:#?}",
7441            issues,
7442        );
7443    }
7444
7445    #[test]
7446    fn app2e_accepts_60fps() {
7447        let cpl = cpl_with_image_resolution(3840, 2160, Some(EditRate::new(60, 1)));
7448        let issues = App2E2021.validate_cpl(&cpl);
7449        assert!(
7450            !issues.iter().any(|i| i.code.contains("FrameRate")),
7451            "60 fps should be accepted: {:#?}",
7452            issues,
7453        );
7454    }
7455
7456    #[test]
7457    fn app2e_accepts_5994fps() {
7458        let cpl = cpl_with_image_resolution(3840, 2160, Some(EditRate::new(60000, 1001)));
7459        let issues = App2E2021.validate_cpl(&cpl);
7460        assert!(
7461            !issues.iter().any(|i| i.code.contains("FrameRate")),
7462            "59.94 fps should be accepted: {:#?}",
7463            issues,
7464        );
7465    }
7466
7467    #[test]
7468    fn app2e_rejects_120fps() {
7469        let cpl = cpl_with_image_resolution(1920, 1080, Some(EditRate::new(120, 1)));
7470        let issues = App2E2021.validate_cpl(&cpl);
7471        assert!(
7472            issues.iter().any(|i| i.code.contains("FrameRate")),
7473            "120 fps should be rejected: {:#?}",
7474            issues,
7475        );
7476    }
7477
7478    #[test]
7479    fn app2e_rejects_15fps() {
7480        let cpl = cpl_with_image_resolution(1920, 1080, Some(EditRate::new(15, 1)));
7481        let issues = App2E2021.validate_cpl(&cpl);
7482        assert!(
7483            issues.iter().any(|i| i.code.contains("FrameRate")),
7484            "15 fps should be rejected: {:#?}",
7485            issues,
7486        );
7487    }
7488
7489    // ── Audio sample rate validation (§6.5) ────────────────────────────────
7490
7491    #[test]
7492    fn app2e_accepts_48000hz_audio() {
7493        let cpl = cpl_with_audio(WAVEPCMDescriptor {
7494            instance_id: None,
7495            sample_rate: None,
7496            audio_sample_rate: Some(EditRate::new(48000, 1)),
7497            channel_count: Some(2),
7498            quantization_bits: Some(24),
7499            linked_track_id: None,
7500            sub_descriptors: None,
7501        });
7502        let issues = App2E2021.validate_cpl(&cpl);
7503        assert!(
7504            !issues.iter().any(|i| i.code.contains("AudioSampleRate")),
7505            "48000 Hz should be accepted: {:#?}",
7506            issues,
7507        );
7508    }
7509
7510    #[test]
7511    fn app2e_rejects_44100hz_audio() {
7512        let cpl = cpl_with_audio(WAVEPCMDescriptor {
7513            instance_id: None,
7514            sample_rate: None,
7515            audio_sample_rate: Some(EditRate::new(44100, 1)),
7516            channel_count: Some(2),
7517            quantization_bits: Some(24),
7518            linked_track_id: None,
7519            sub_descriptors: None,
7520        });
7521        let issues = App2E2021.validate_cpl(&cpl);
7522        assert!(
7523            issues.iter().any(|i| i.code.contains("AudioSampleRate")),
7524            "44100 Hz should be rejected: {:#?}",
7525            issues,
7526        );
7527    }
7528
7529    #[test]
7530    fn app2e_rejects_96000hz_audio() {
7531        let cpl = cpl_with_audio(WAVEPCMDescriptor {
7532            instance_id: None,
7533            sample_rate: None,
7534            audio_sample_rate: Some(EditRate::new(96000, 1)),
7535            channel_count: Some(2),
7536            quantization_bits: Some(24),
7537            linked_track_id: None,
7538            sub_descriptors: None,
7539        });
7540        let issues = App2E2021.validate_cpl(&cpl);
7541        assert!(
7542            issues.iter().any(|i| i.code.contains("AudioSampleRate")),
7543            "96000 Hz should be rejected: {:#?}",
7544            issues,
7545        );
7546    }
7547
7548    // ════════════════════════════════════════════════════════════════════════
7549    // Essence descriptor completeness (§6.2)
7550    // ════════════════════════════════════════════════════════════════════════
7551
7552    #[test]
7553    fn app2e_flags_cdci_missing_sample_rate() {
7554        let ed_id = uuid(10);
7555        let mut cpl = minimal_cpl();
7556        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
7557            essence_descriptors: vec![EssenceDescriptor {
7558                id: ed_id,
7559                rgba_descriptor: None,
7560                cdci_descriptor: Some(CDCIDescriptor {
7561                    instance_id: None,
7562                    stored_width: Some(1920),
7563                    stored_height: Some(1080),
7564                    sample_rate: None, // Missing!
7565                    image_aspect_ratio: None,
7566                    color_primaries: Some(ColorPrimaries::Bt709),
7567                    transfer_characteristic: Some(TransferCharacteristic::Bt709),
7568                    coding_equations: Some(CodingEquations::Bt709),
7569                    picture_compression: Some(VideoCodec::Jpeg2000),
7570                    component_depth: Some(10),
7571                    frame_layout: Some("FullFrame".to_string()),
7572                    display_width: None,
7573                    display_height: None,
7574                    display_f2_offset: None,
7575                    horizontal_subsampling: Some(2),
7576                    vertical_subsampling: Some(1),
7577                    color_siting: Some(0),
7578                    black_ref_level: Some(64),
7579                    white_ref_level: Some(940),
7580                    color_range: Some(897),
7581                    stored_f2_offset: None,
7582                    sampled_width: None,
7583                    sampled_height: None,
7584                    sampled_x_offset: None,
7585                    sampled_y_offset: None,
7586                    alpha_transparency: None,
7587                    image_alignment_offset: None,
7588                    image_start_offset: None,
7589                    image_end_offset: None,
7590                    field_dominance: None,
7591                    reversed_byte_order: None,
7592                    padding_bits: None,
7593                    alpha_sample_depth: None,
7594                    linked_track_id: None,
7595                    active_width: None,
7596                    active_height: None,
7597                    sub_descriptors: None,
7598                }),
7599                wave_pcm_descriptor: None,
7600                dc_timed_text_descriptor: None,
7601                iab_essence_descriptor: None,
7602                isxd_data_essence_descriptor: None,
7603            }],
7604        });
7605
7606        let issues = App2E2021.validate_cpl(&cpl);
7607        assert!(
7608            issues
7609                .iter()
7610                .any(|i| i.code == St2067_21_2023::RequiredSampleRate.code()),
7611            "Missing SampleRate should be flagged: {:#?}",
7612            issues,
7613        );
7614    }
7615
7616    #[test]
7617    fn app2e_flags_wave_missing_channel_count() {
7618        let cpl = cpl_with_audio(WAVEPCMDescriptor {
7619            instance_id: None,
7620            sample_rate: None,
7621            audio_sample_rate: Some(EditRate::new(48000, 1)),
7622            channel_count: None, // Missing!
7623            quantization_bits: Some(24),
7624            linked_track_id: None,
7625            sub_descriptors: None,
7626        });
7627        let issues = App2E2021.validate_cpl(&cpl);
7628        assert!(
7629            issues
7630                .iter()
7631                .any(|i| i.code == St2067_21_2023::RequiredChannelCount.code()),
7632            "Missing ChannelCount should be flagged: {:#?}",
7633            issues,
7634        );
7635    }
7636
7637    #[test]
7638    fn app2e_flags_wave_missing_quantization_bits() {
7639        let cpl = cpl_with_audio(WAVEPCMDescriptor {
7640            instance_id: None,
7641            sample_rate: None,
7642            audio_sample_rate: Some(EditRate::new(48000, 1)),
7643            channel_count: Some(2),
7644            quantization_bits: None, // Missing!
7645            linked_track_id: None,
7646            sub_descriptors: None,
7647        });
7648        let issues = App2E2021.validate_cpl(&cpl);
7649        assert!(
7650            issues
7651                .iter()
7652                .any(|i| i.code == St2067_21_2023::RequiredQuantizationBits.code()),
7653            "Missing QuantizationBits should be flagged: {:#?}",
7654            issues,
7655        );
7656    }
7657
7658    // ── XSD structural validation tests ─────────────────────────────────────
7659    // Helper-direct tests (xs_datetime_valid/invalid_formats, timecode_address_*,
7660    // total_running_time_*) were deleted along with the helper functions
7661    // they exercised — these were pure XSD-overlap checks now slated for
7662    // runtime-XSD validation.
7663
7664    /// XSD §35: EditRate is required on the composition (no minOccurs="0").
7665    ///
7666    /// Uses the 2013 namespace because the vendored 2020 XSD's
7667    /// `targetNamespace` is still `schemas/2067-3/2016` (SMPTE kept the
7668    /// 2016 URI for the 2020 edition); the parser-internal `ns/2067-3/2020`
7669    /// URI doesn't match any vendored XSD. 2013 is the most self-consistent
7670    /// edition for XSD-pre-pass tests.
7671    #[test]
7672    fn core_flags_missing_edit_rate() {
7673        let xml = r#"<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
7674            <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
7675            <IssueDate>2024-01-01T00:00:00Z</IssueDate>
7676            <ContentTitle>Test</ContentTitle>
7677            <SegmentList><Segment>
7678                <Id>urn:uuid:00000000-0000-0000-0000-000000000002</Id>
7679                <SequenceList/>
7680            </Segment></SegmentList>
7681        </CompositionPlaylist>"#;
7682        let cpl = crate::cpl::parse_cpl(xml).expect("should parse despite missing EditRate");
7683        let issues = CoreConstraints2013.validate_cpl(&cpl);
7684        assert!(
7685            issues.iter().any(|i| i.code.contains("EditRate")),
7686            "Missing EditRate should be flagged: {:#?}",
7687            issues,
7688        );
7689    }
7690
7691    /// XSD §88: EditRate present passes.
7692    #[test]
7693    fn core_accepts_present_edit_rate() {
7694        let mut cpl = minimal_cpl();
7695        cpl.edit_rate = Some(EditRate::new(24, 1));
7696        // Add a sequence so no "empty segment" error
7697        cpl.segment_list.segments[0]
7698            .sequence_list
7699            .main_image_sequences
7700            .push(MainImageSequence {
7701                id: uuid(3),
7702                track_id: uuid(4),
7703                resource_list: ResourceList {
7704                    resources: vec![make_resource(None)],
7705                },
7706            });
7707        let v = CoreConstraints2020;
7708        let issues = v.validate_cpl(&cpl);
7709        assert!(
7710            !issues.iter().any(|i| i.code.contains("-EditRate")),
7711            "Present EditRate should not be flagged: {:#?}",
7712            issues,
7713        );
7714    }
7715
7716    /// XSD §13: IssueDate is `xs:dateTime` — "not-a-date" fails the
7717    /// built-in lexical-space check and surfaces as `XSD/TypeInvalid`.
7718    #[test]
7719    fn core_warns_invalid_issue_date_format() {
7720        let xml = r#"<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
7721            <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
7722            <IssueDate>not-a-date</IssueDate>
7723            <ContentTitle>Test</ContentTitle>
7724            <EditRate>24 1</EditRate>
7725            <SegmentList><Segment>
7726                <Id>urn:uuid:00000000-0000-0000-0000-000000000002</Id>
7727                <SequenceList/>
7728            </Segment></SegmentList>
7729        </CompositionPlaylist>"#;
7730        let cpl = crate::cpl::parse_cpl(xml).expect("should parse despite invalid IssueDate");
7731        let issues = CoreConstraints2013.validate_cpl(&cpl);
7732        assert!(
7733            issues.iter().any(|i| i.code.contains("IssueDate")),
7734            "Invalid IssueDate format should be flagged: {:#?}",
7735            issues,
7736        );
7737    }
7738
7739    /// XSD §13: Empty IssueDate also fails the `xs:dateTime` lexical check.
7740    #[test]
7741    fn core_flags_empty_issue_date() {
7742        let xml = r#"<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
7743            <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
7744            <IssueDate></IssueDate>
7745            <ContentTitle>Test</ContentTitle>
7746            <EditRate>24 1</EditRate>
7747            <SegmentList><Segment>
7748                <Id>urn:uuid:00000000-0000-0000-0000-000000000002</Id>
7749                <SequenceList/>
7750            </Segment></SegmentList>
7751        </CompositionPlaylist>"#;
7752        let cpl = crate::cpl::parse_cpl(xml).expect("should parse despite empty IssueDate");
7753        let issues = CoreConstraints2013.validate_cpl(&cpl);
7754        assert!(
7755            issues.iter().any(|i| i.code.contains("IssueDate")),
7756            "Empty IssueDate should be flagged: {:#?}",
7757            issues,
7758        );
7759    }
7760
7761    /// XSD §68-74: `CompositionTimecodeType` requires
7762    /// `TimecodeDropFrame`, `TimecodeRate`, and `TimecodeStartAddress`
7763    /// (none with `minOccurs="0"`). Missing the drop-frame and
7764    /// start-address fields should trip element-missing diagnostics
7765    /// for each.
7766    #[test]
7767    fn core_flags_incomplete_composition_timecode() {
7768        let xml = r#"<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
7769            <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
7770            <IssueDate>2024-01-01T00:00:00Z</IssueDate>
7771            <ContentTitle>Test</ContentTitle>
7772            <CompositionTimecode>
7773                <TimecodeRate>24</TimecodeRate>
7774            </CompositionTimecode>
7775            <EditRate>24 1</EditRate>
7776            <SegmentList><Segment>
7777                <Id>urn:uuid:00000000-0000-0000-0000-000000000002</Id>
7778                <SequenceList/>
7779            </Segment></SegmentList>
7780        </CompositionPlaylist>"#;
7781        let cpl = crate::cpl::parse_cpl(xml)
7782            .expect("should parse despite incomplete CompositionTimecode");
7783        let issues = CoreConstraints2013.validate_cpl(&cpl);
7784        assert!(
7785            issues
7786                .iter()
7787                .any(|i| i.code.contains("TimecodeDropFrame")
7788                    || i.code.contains("TimecodeStartAddress")),
7789            "Missing TimecodeDropFrame/StartAddress should be flagged: {:#?}",
7790            issues,
7791        );
7792    }
7793
7794    /// XSD §164-170: `ResourceList` declares
7795    /// `Resource maxOccurs=unbounded` with default `minOccurs=1`, so
7796    /// an empty ResourceList inside a `MarkerSequence` (the only
7797    /// sequence type the CPL XSD knows directly) trips
7798    /// `ElementMissing/Resource`.
7799    #[test]
7800    fn core_flags_empty_resource_list() {
7801        let xml = r#"<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
7802            <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
7803            <IssueDate>2024-01-01T00:00:00Z</IssueDate>
7804            <ContentTitle>Test</ContentTitle>
7805            <EditRate>24 1</EditRate>
7806            <SegmentList><Segment>
7807                <Id>urn:uuid:00000000-0000-0000-0000-000000000002</Id>
7808                <SequenceList>
7809                    <MarkerSequence>
7810                        <Id>urn:uuid:00000000-0000-0000-0000-000000000003</Id>
7811                        <TrackId>urn:uuid:00000000-0000-0000-0000-000000000004</TrackId>
7812                        <ResourceList/>
7813                    </MarkerSequence>
7814                </SequenceList>
7815            </Segment></SegmentList>
7816        </CompositionPlaylist>"#;
7817        let cpl = crate::cpl::parse_cpl(xml).expect("should parse despite empty ResourceList");
7818        let issues = CoreConstraints2013.validate_cpl(&cpl);
7819        assert!(
7820            issues.iter().any(|i| i.code.contains("Resource")),
7821            "Empty ResourceList should be flagged: {:#?}",
7822            issues,
7823        );
7824    }
7825
7826    // ── #49: ContentVersion.LabelText required validation ────────────────
7827
7828    /// ContentVersion with LabelText present should not be flagged.
7829    #[test]
7830    fn core_accepts_content_version_with_label_text() {
7831        let mut cpl = minimal_cpl();
7832        cpl.content_version_list = Some(ContentVersionList {
7833            content_versions: vec![ContentVersion {
7834                id: "urn:uuid:00000000-0000-0000-0000-000000000099".to_string(),
7835                label_text: Some(LanguageString {
7836                    text: "Version 1".to_string(),
7837                    language: Some(LanguageTag::new("en")),
7838                }),
7839            }],
7840        });
7841        let v = CoreConstraints2020;
7842        let issues = v.validate_cpl(&cpl);
7843        assert!(
7844            !issues
7845                .iter()
7846                .any(|i| i.code.contains("ContentVersionLabelTextMissing")),
7847            "ContentVersion with LabelText should not be flagged: {:#?}",
7848            issues,
7849        );
7850    }
7851
7852    /// ContentVersion missing LabelText should be flagged.
7853    #[test]
7854    fn core_flags_content_version_missing_label_text() {
7855        let mut cpl = minimal_cpl();
7856        cpl.content_version_list = Some(ContentVersionList {
7857            content_versions: vec![ContentVersion {
7858                id: "urn:uuid:00000000-0000-0000-0000-000000000099".to_string(),
7859                label_text: None,
7860            }],
7861        });
7862        let v = CoreConstraints2020;
7863        let issues = v.validate_cpl(&cpl);
7864        assert!(
7865            issues
7866                .iter()
7867                .any(|i| i.code.contains("ContentVersionLabelTextMissing")),
7868            "Missing LabelText should be flagged: {:#?}",
7869            issues,
7870        );
7871    }
7872
7873    // ── #52: Marker label vocabulary validation ──────────────────────────
7874
7875    /// Recognized SMPTE marker labels (FFOC, LFOC) should not be flagged.
7876    #[test]
7877    fn core_accepts_recognized_marker_labels() {
7878        let mut cpl = minimal_cpl();
7879        cpl.segment_list.segments[0]
7880            .sequence_list
7881            .marker_sequences
7882            .push(MarkerSequence {
7883                id: uuid(20),
7884                track_id: uuid(21),
7885                resource_list: ResourceList {
7886                    resources: vec![Resource {
7887                        id: uuid(22),
7888                        annotation: None,
7889                        edit_rate: None,
7890                        intrinsic_duration: 100,
7891                        entry_point: None,
7892                        source_duration: None,
7893                        source_encoding: None,
7894                        track_file_id: None,
7895                        repeat_count: None,
7896                        key_id: None,
7897                        hash: None,
7898                        markers: vec![
7899                            MarkerInfo {
7900                                annotation: None,
7901                                label: MarkerLabelElement::from(MarkerLabel::Ffoc),
7902                                offset: 0,
7903                            },
7904                            MarkerInfo {
7905                                annotation: None,
7906                                label: MarkerLabelElement::from(MarkerLabel::Lfoc),
7907                                offset: 99,
7908                            },
7909                        ],
7910                    }],
7911                },
7912            });
7913        let v = CoreConstraints2020;
7914        let issues = v.validate_cpl(&cpl);
7915        assert!(
7916            !issues.iter().any(|i| i.code.contains("MarkerLabelUnknown")),
7917            "Recognized marker labels should not be flagged: {:#?}",
7918            issues,
7919        );
7920    }
7921
7922    /// Unrecognized marker label under SMPTE scope should be flagged.
7923    #[test]
7924    fn core_flags_unrecognized_marker_label_under_smpte_scope() {
7925        let mut cpl = minimal_cpl();
7926        cpl.segment_list.segments[0]
7927            .sequence_list
7928            .marker_sequences
7929            .push(MarkerSequence {
7930                id: uuid(20),
7931                track_id: uuid(21),
7932                resource_list: ResourceList {
7933                    resources: vec![Resource {
7934                        id: uuid(22),
7935                        annotation: None,
7936                        edit_rate: None,
7937                        intrinsic_duration: 100,
7938                        entry_point: None,
7939                        source_duration: None,
7940                        source_encoding: None,
7941                        track_file_id: None,
7942                        repeat_count: None,
7943                        key_id: None,
7944                        hash: None,
7945                        markers: vec![MarkerInfo {
7946                            annotation: None,
7947                            label: MarkerLabelElement::from(MarkerLabel::Other(
7948                                "CUSTOM_MARKER".to_string(),
7949                            )),
7950                            offset: 0,
7951                        }],
7952                    }],
7953                },
7954            });
7955        let v = CoreConstraints2020;
7956        let issues = v.validate_cpl(&cpl);
7957        assert!(
7958            issues.iter().any(|i| i.code.contains("MarkerLabelUnknown")),
7959            "Unrecognized marker label under SMPTE scope should be flagged: {:#?}",
7960            issues,
7961        );
7962    }
7963
7964    /// Custom marker label under custom (non-SMPTE) scope should not be flagged.
7965    #[test]
7966    fn core_accepts_custom_marker_label_under_custom_scope() {
7967        let mut cpl = minimal_cpl();
7968        cpl.segment_list.segments[0]
7969            .sequence_list
7970            .marker_sequences
7971            .push(MarkerSequence {
7972                id: uuid(20),
7973                track_id: uuid(21),
7974                resource_list: ResourceList {
7975                    resources: vec![Resource {
7976                        id: uuid(22),
7977                        annotation: None,
7978                        edit_rate: None,
7979                        intrinsic_duration: 100,
7980                        entry_point: None,
7981                        source_duration: None,
7982                        source_encoding: None,
7983                        track_file_id: None,
7984                        repeat_count: None,
7985                        key_id: None,
7986                        hash: None,
7987                        markers: vec![MarkerInfo {
7988                            annotation: None,
7989                            label: MarkerLabelElement {
7990                                label: MarkerLabel::Other("VENDOR_MARKER".to_string()),
7991                                scope: Some("http://example.com/markers".to_string()),
7992                            },
7993                            offset: 0,
7994                        }],
7995                    }],
7996                },
7997            });
7998        let v = CoreConstraints2020;
7999        let issues = v.validate_cpl(&cpl);
8000        assert!(
8001            !issues.iter().any(|i| i.code.contains("MarkerLabelUnknown")),
8002            "Custom marker under custom scope should not be flagged: {:#?}",
8003            issues,
8004        );
8005    }
8006
8007    // ── #55: FrameLayout progressive constraint ──────────────────────────
8008
8009    /// App2E: FullFrame (progressive) FrameLayout should be accepted.
8010    #[test]
8011    fn app2e_accepts_progressive_frame_layout() {
8012        let cpl = cpl_with_cdci_descriptor(
8013            ColorPrimaries::Bt709,
8014            TransferCharacteristic::Bt709,
8015            CodingEquations::Bt709,
8016            10,
8017        );
8018        let v = App2E2021;
8019        let issues = v.validate_cpl(&cpl);
8020        assert!(
8021            !issues
8022                .iter()
8023                .any(|i| i.code == St2067_21_2023::FrameLayoutInterlaced.code()),
8024            "FullFrame FrameLayout should not be flagged: {:#?}",
8025            issues,
8026        );
8027    }
8028
8029    /// App2E: SeparateFields (interlaced) FrameLayout should be flagged.
8030    #[test]
8031    fn app2e_flags_interlaced_frame_layout() {
8032        let ed_id = uuid(10);
8033        let mut cpl = minimal_cpl();
8034        cpl.edit_rate = Some(EditRate::new(24, 1));
8035        cpl.extension_properties = Some(ExtensionProperties {
8036            application_identification: Some(APP2E_APPLICATION_IDENTIFICATION.to_string()),
8037            max_cll: None,
8038            max_fall: None,
8039        });
8040        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
8041            essence_descriptors: vec![EssenceDescriptor {
8042                id: ed_id,
8043                rgba_descriptor: None,
8044                cdci_descriptor: Some(CDCIDescriptor {
8045                    instance_id: None,
8046                    stored_width: Some(1920),
8047                    stored_height: Some(1080),
8048                    display_width: Some(1920),
8049                    display_height: Some(1080),
8050                    sample_rate: Some(EditRate::new(24, 1)),
8051                    image_aspect_ratio: None,
8052                    color_primaries: Some(ColorPrimaries::Bt709),
8053                    transfer_characteristic: Some(TransferCharacteristic::Bt709),
8054                    coding_equations: Some(CodingEquations::Bt709),
8055                    picture_compression: Some(VideoCodec::Jpeg2000),
8056                    component_depth: Some(10),
8057                    frame_layout: Some("SeparateFields".to_string()),
8058                    display_f2_offset: None,
8059                    horizontal_subsampling: Some(2),
8060                    vertical_subsampling: Some(1),
8061                    color_siting: Some(0),
8062                    black_ref_level: Some(64),
8063                    white_ref_level: Some(940),
8064                    color_range: Some(897),
8065                    stored_f2_offset: None,
8066                    sampled_width: None,
8067                    sampled_height: None,
8068                    sampled_x_offset: None,
8069                    sampled_y_offset: None,
8070                    alpha_transparency: None,
8071                    image_alignment_offset: None,
8072                    image_start_offset: None,
8073                    image_end_offset: None,
8074                    field_dominance: Some(1),
8075                    reversed_byte_order: None,
8076                    padding_bits: None,
8077                    alpha_sample_depth: None,
8078                    linked_track_id: None,
8079                    active_width: None,
8080                    active_height: None,
8081                    sub_descriptors: None,
8082                }),
8083                wave_pcm_descriptor: None,
8084                iab_essence_descriptor: None,
8085                dc_timed_text_descriptor: None,
8086                isxd_data_essence_descriptor: None,
8087            }],
8088        });
8089        let v = App2E2021;
8090        let issues = v.validate_cpl(&cpl);
8091        assert!(
8092            issues
8093                .iter()
8094                .any(|i| i.code == St2067_21_2023::FrameLayoutInterlaced.code()),
8095            "SeparateFields FrameLayout should be flagged for App2E: {:#?}",
8096            issues,
8097        );
8098    }
8099
8100    // ── #56: CompositionTimecode rate cross-validation ───────────────────
8101
8102    /// CompositionTimecode.TimecodeRate matching EditRate should not be flagged.
8103    #[test]
8104    fn core_accepts_matching_timecode_rate() {
8105        let mut cpl = minimal_cpl();
8106        cpl.edit_rate = Some(EditRate::new(24, 1));
8107        cpl.composition_timecode = Some(CompositionTimecode {
8108            timecode_drop_frame: Some(false),
8109            timecode_rate: Some(24),
8110            timecode_start_address: Some("00:00:00:00".to_string()),
8111        });
8112        let v = CoreConstraints2020;
8113        let issues = v.validate_cpl(&cpl);
8114        assert!(
8115            !issues.iter().any(|i| i.code.contains("Rate-Mismatch")),
8116            "Matching TimecodeRate and EditRate should not be flagged: {:#?}",
8117            issues,
8118        );
8119    }
8120
8121    /// CompositionTimecode.TimecodeRate not matching EditRate should be flagged.
8122    #[test]
8123    fn core_flags_mismatched_timecode_rate() {
8124        let mut cpl = minimal_cpl();
8125        cpl.edit_rate = Some(EditRate::new(24, 1));
8126        cpl.composition_timecode = Some(CompositionTimecode {
8127            timecode_drop_frame: Some(false),
8128            timecode_rate: Some(25), // Mismatched!
8129            timecode_start_address: Some("00:00:00:00".to_string()),
8130        });
8131        let v = CoreConstraints2020;
8132        let issues = v.validate_cpl(&cpl);
8133        assert!(
8134            issues.iter().any(|i| i.code.contains("Rate-Mismatch")),
8135            "Mismatched TimecodeRate and EditRate should be flagged: {:#?}",
8136            issues,
8137        );
8138    }
8139
8140    /// CompositionTimecode.TimecodeRate matching non-integer EditRate (23.976).
8141    #[test]
8142    fn core_accepts_timecode_rate_for_23976fps() {
8143        let mut cpl = minimal_cpl();
8144        cpl.edit_rate = Some(EditRate::new(24000, 1001)); // 23.976 fps
8145        cpl.composition_timecode = Some(CompositionTimecode {
8146            timecode_drop_frame: Some(true),
8147            timecode_rate: Some(24), // Rounds to 24
8148            timecode_start_address: Some("00:00:00;00".to_string()),
8149        });
8150        let v = CoreConstraints2020;
8151        let issues = v.validate_cpl(&cpl);
8152        assert!(
8153            !issues.iter().any(|i| i.code.contains("Rate-Mismatch")),
8154            "TimecodeRate 24 should match EditRate 24000/1001 (23.976 fps): {:#?}",
8155            issues,
8156        );
8157    }
8158
8159    // ── #44: SourceEncoding cross-reference validation ───────────────────
8160
8161    /// SourceEncoding referencing a valid EssenceDescriptor should not be flagged.
8162    #[test]
8163    fn core_accepts_valid_source_encoding_ref() {
8164        let ed_id = uuid(10);
8165        let mut cpl = minimal_cpl();
8166        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
8167            essence_descriptors: vec![EssenceDescriptor {
8168                id: ed_id,
8169                rgba_descriptor: None,
8170                cdci_descriptor: None,
8171                wave_pcm_descriptor: None,
8172                iab_essence_descriptor: None,
8173                dc_timed_text_descriptor: Some(DCTimedTextDescriptor {
8174                    instance_id: None,
8175                    linked_track_id: None,
8176                    sample_rate: None,
8177                    namespace_uri: Some("http://www.w3.org/ns/ttml".to_string()),
8178                    rfc5646_language_tag_list: vec![],
8179                }),
8180                isxd_data_essence_descriptor: None,
8181            }],
8182        });
8183        cpl.segment_list.segments[0]
8184            .sequence_list
8185            .subtitles_sequences
8186            .push(SubtitlesSequence {
8187                id: uuid(20),
8188                track_id: uuid(21),
8189                resource_list: ResourceList {
8190                    resources: vec![Resource {
8191                        id: uuid(22),
8192                        annotation: None,
8193                        edit_rate: None,
8194                        intrinsic_duration: 100,
8195                        entry_point: None,
8196                        source_duration: None,
8197                        source_encoding: Some(ed_id),
8198                        track_file_id: Some(uuid(50)),
8199                        repeat_count: None,
8200                        key_id: None,
8201                        hash: None,
8202                        markers: vec![],
8203                    }],
8204                },
8205            });
8206        let v = CoreConstraints2020;
8207        let issues = v.validate_cpl(&cpl);
8208        assert!(
8209            !issues.iter().any(|i| i.code.contains("SourceEncoding")),
8210            "Valid SourceEncoding ref should not be flagged: {:#?}",
8211            issues,
8212        );
8213    }
8214
8215    /// SourceEncoding referencing a non-existent EssenceDescriptor should be flagged.
8216    #[test]
8217    fn core_flags_unresolved_source_encoding_ref() {
8218        let ed_id = uuid(10);
8219        let bad_ref = uuid(99); // Does not exist in EDL
8220        let mut cpl = minimal_cpl();
8221        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
8222            essence_descriptors: vec![EssenceDescriptor {
8223                id: ed_id,
8224                rgba_descriptor: None,
8225                cdci_descriptor: None,
8226                wave_pcm_descriptor: None,
8227                iab_essence_descriptor: None,
8228                dc_timed_text_descriptor: None,
8229                isxd_data_essence_descriptor: None,
8230            }],
8231        });
8232        cpl.segment_list.segments[0]
8233            .sequence_list
8234            .main_image_sequences
8235            .push(MainImageSequence {
8236                id: uuid(20),
8237                track_id: uuid(21),
8238                resource_list: ResourceList {
8239                    resources: vec![Resource {
8240                        id: uuid(22),
8241                        annotation: None,
8242                        edit_rate: None,
8243                        intrinsic_duration: 100,
8244                        entry_point: None,
8245                        source_duration: None,
8246                        source_encoding: Some(bad_ref),
8247                        track_file_id: Some(uuid(50)),
8248                        repeat_count: None,
8249                        key_id: None,
8250                        hash: None,
8251                        markers: vec![],
8252                    }],
8253                },
8254            });
8255        let v = CoreConstraints2020;
8256        let issues = v.validate_cpl(&cpl);
8257        assert!(
8258            issues
8259                .iter()
8260                .any(|i| i.code.contains("SourceEncodingUnresolved")),
8261            "Unresolved SourceEncoding should be flagged: {:#?}",
8262            issues,
8263        );
8264    }
8265
8266    // ── #54: ContentKind vocabulary validation ───────────────────────────
8267
8268    /// Known ContentKind under SMPTE scope should not be flagged.
8269    #[test]
8270    fn core_accepts_known_content_kind() {
8271        let mut cpl = minimal_cpl();
8272        cpl.content_kind = ContentKindElement::from(ContentKind::Feature);
8273        let v = CoreConstraints2020;
8274        let issues = v.validate_cpl(&cpl);
8275        assert!(
8276            !issues.iter().any(|i| i.code.contains("ContentKindUnknown")),
8277            "Known ContentKind should not be flagged: {:#?}",
8278            issues,
8279        );
8280    }
8281
8282    /// Unknown ContentKind under SMPTE scope should be flagged.
8283    #[test]
8284    fn core_flags_unknown_content_kind_under_smpte_scope() {
8285        let mut cpl = minimal_cpl();
8286        cpl.content_kind =
8287            ContentKindElement::from(ContentKind::Other("SomeFutureKind".to_string()));
8288        let v = CoreConstraints2020;
8289        let issues = v.validate_cpl(&cpl);
8290        assert!(
8291            issues.iter().any(|i| i.code.contains("ContentKindUnknown")),
8292            "Unknown ContentKind under SMPTE scope should be flagged: {:#?}",
8293            issues,
8294        );
8295    }
8296
8297    /// Unknown ContentKind under custom scope should NOT be flagged.
8298    #[test]
8299    fn core_accepts_custom_content_kind_under_custom_scope() {
8300        let mut cpl = minimal_cpl();
8301        cpl.content_kind = ContentKindElement {
8302            kind: ContentKind::Other("VendorSpecific".to_string()),
8303            scope: Some("http://example.com/content-kinds".to_string()),
8304        };
8305        let v = CoreConstraints2020;
8306        let issues = v.validate_cpl(&cpl);
8307        assert!(
8308            !issues.iter().any(|i| i.code.contains("ContentKindUnknown")),
8309            "Custom ContentKind under custom scope should not be flagged: {:#?}",
8310            issues,
8311        );
8312    }
8313
8314    // ── #50: Audio channel homogeneity ───────────────────────────────────
8315
8316    /// All audio descriptors with same channel count should not be flagged.
8317    #[test]
8318    fn app2e_accepts_homogeneous_audio_channels() {
8319        let mut cpl = minimal_cpl();
8320        cpl.edit_rate = Some(EditRate::new(24, 1));
8321        cpl.extension_properties = Some(ExtensionProperties {
8322            application_identification: Some(APP2E_APPLICATION_IDENTIFICATION.to_string()),
8323            max_cll: None,
8324            max_fall: None,
8325        });
8326        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
8327            essence_descriptors: vec![
8328                EssenceDescriptor {
8329                    id: uuid(10),
8330                    rgba_descriptor: None,
8331                    cdci_descriptor: None,
8332                    wave_pcm_descriptor: Some(WAVEPCMDescriptor {
8333                        instance_id: None,
8334                        sample_rate: Some(EditRate::new(48000, 1)),
8335                        audio_sample_rate: None,
8336                        quantization_bits: Some(24),
8337                        channel_count: Some(6),
8338                        linked_track_id: None,
8339                        sub_descriptors: None,
8340                    }),
8341                    iab_essence_descriptor: None,
8342                    dc_timed_text_descriptor: None,
8343                    isxd_data_essence_descriptor: None,
8344                },
8345                EssenceDescriptor {
8346                    id: uuid(11),
8347                    rgba_descriptor: None,
8348                    cdci_descriptor: None,
8349                    wave_pcm_descriptor: Some(WAVEPCMDescriptor {
8350                        instance_id: None,
8351                        sample_rate: Some(EditRate::new(48000, 1)),
8352                        audio_sample_rate: None,
8353                        quantization_bits: Some(24),
8354                        channel_count: Some(6),
8355                        linked_track_id: None,
8356                        sub_descriptors: None,
8357                    }),
8358                    iab_essence_descriptor: None,
8359                    dc_timed_text_descriptor: None,
8360                    isxd_data_essence_descriptor: None,
8361                },
8362            ],
8363        });
8364        let v = App2E2021;
8365        let issues = v.validate_cpl(&cpl);
8366        assert!(
8367            !issues.iter().any(|i| i.code.contains("AudioHomogeneity")),
8368            "Homogeneous audio channels should not be flagged: {:#?}",
8369            issues,
8370        );
8371    }
8372
8373    /// A CPL with separate 5.1 and stereo audio tracks must NOT be flagged.
8374    /// §7.3 homogeneity applies within a virtual track, not across tracks.
8375    #[test]
8376    fn app2e_accepts_mixed_channel_count_across_tracks() {
8377        let mut cpl = minimal_cpl();
8378        cpl.edit_rate = Some(EditRate::new(24, 1));
8379        cpl.extension_properties = Some(ExtensionProperties {
8380            application_identification: Some(APP2E_APPLICATION_IDENTIFICATION.to_string()),
8381            max_cll: None,
8382            max_fall: None,
8383        });
8384        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
8385            essence_descriptors: vec![
8386                EssenceDescriptor {
8387                    id: uuid(10),
8388                    rgba_descriptor: None,
8389                    cdci_descriptor: None,
8390                    wave_pcm_descriptor: Some(WAVEPCMDescriptor {
8391                        instance_id: None,
8392                        sample_rate: Some(EditRate::new(48000, 1)),
8393                        audio_sample_rate: None,
8394                        quantization_bits: Some(24),
8395                        channel_count: Some(6), // 5.1
8396                        linked_track_id: None,
8397                        sub_descriptors: None,
8398                    }),
8399                    iab_essence_descriptor: None,
8400                    dc_timed_text_descriptor: None,
8401                    isxd_data_essence_descriptor: None,
8402                },
8403                EssenceDescriptor {
8404                    id: uuid(11),
8405                    rgba_descriptor: None,
8406                    cdci_descriptor: None,
8407                    wave_pcm_descriptor: Some(WAVEPCMDescriptor {
8408                        instance_id: None,
8409                        sample_rate: Some(EditRate::new(48000, 1)),
8410                        audio_sample_rate: None,
8411                        quantization_bits: Some(24),
8412                        channel_count: Some(2), // Stereo — different!
8413                        linked_track_id: None,
8414                        sub_descriptors: None,
8415                    }),
8416                    iab_essence_descriptor: None,
8417                    dc_timed_text_descriptor: None,
8418                    isxd_data_essence_descriptor: None,
8419                },
8420            ],
8421        });
8422        let v = App2E2021;
8423        let issues = v.validate_cpl(&cpl);
8424        assert!(
8425            !issues.iter().any(|i| i.code.contains("AudioHomogeneity")),
8426            "Mixed channel counts across separate audio tracks must not be flagged: {:#?}",
8427            issues,
8428        );
8429    }
8430
8431    // ── #51: HIC/FN caption constraints ─────────────────────────────────
8432
8433    /// HearingImpaired captions referencing timed text descriptor should be accepted.
8434    #[test]
8435    fn app2e_accepts_hic_with_timed_text() {
8436        let ed_id = uuid(10);
8437        let mut cpl = minimal_cpl();
8438        cpl.edit_rate = Some(EditRate::new(24, 1));
8439        cpl.extension_properties = Some(ExtensionProperties {
8440            application_identification: Some(APP2E_APPLICATION_IDENTIFICATION.to_string()),
8441            max_cll: None,
8442            max_fall: None,
8443        });
8444        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
8445            essence_descriptors: vec![EssenceDescriptor {
8446                id: ed_id,
8447                rgba_descriptor: None,
8448                cdci_descriptor: None,
8449                wave_pcm_descriptor: None,
8450                iab_essence_descriptor: None,
8451                dc_timed_text_descriptor: Some(DCTimedTextDescriptor {
8452                    instance_id: None,
8453                    linked_track_id: None,
8454                    sample_rate: None,
8455                    namespace_uri: Some("http://www.w3.org/ns/ttml".to_string()),
8456                    rfc5646_language_tag_list: vec![LanguageTag::new("en")],
8457                }),
8458                isxd_data_essence_descriptor: None,
8459            }],
8460        });
8461        cpl.segment_list.segments[0]
8462            .sequence_list
8463            .hearing_impaired_captions_sequences
8464            .push(HearingImpairedCaptionsSequence {
8465                id: uuid(20),
8466                track_id: uuid(21),
8467                resource_list: ResourceList {
8468                    resources: vec![Resource {
8469                        id: uuid(22),
8470                        annotation: None,
8471                        edit_rate: None,
8472                        intrinsic_duration: 100,
8473                        entry_point: None,
8474                        source_duration: None,
8475                        source_encoding: Some(ed_id),
8476                        track_file_id: Some(uuid(50)),
8477                        repeat_count: None,
8478                        key_id: None,
8479                        hash: None,
8480                        markers: vec![],
8481                    }],
8482                },
8483            });
8484        let v = App2E2021;
8485        let issues = v.validate_cpl(&cpl);
8486        assert!(
8487            !issues.iter().any(|i| i.code.contains("5.6-HIC")),
8488            "HIC with timed text should not be flagged: {:#?}",
8489            issues,
8490        );
8491    }
8492
8493    /// HearingImpaired captions referencing non-timed-text descriptor should be flagged.
8494    #[test]
8495    fn app2e_flags_hic_without_timed_text() {
8496        let ed_id = uuid(10);
8497        let mut cpl = minimal_cpl();
8498        cpl.edit_rate = Some(EditRate::new(24, 1));
8499        cpl.extension_properties = Some(ExtensionProperties {
8500            application_identification: Some(APP2E_APPLICATION_IDENTIFICATION.to_string()),
8501            max_cll: None,
8502            max_fall: None,
8503        });
8504        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
8505            essence_descriptors: vec![EssenceDescriptor {
8506                id: ed_id,
8507                rgba_descriptor: None,
8508                cdci_descriptor: None,
8509                wave_pcm_descriptor: Some(WAVEPCMDescriptor {
8510                    instance_id: None,
8511                    sample_rate: Some(EditRate::new(48000, 1)),
8512                    quantization_bits: Some(24),
8513                    channel_count: Some(2),
8514                    audio_sample_rate: None,
8515                    linked_track_id: None,
8516                    sub_descriptors: None,
8517                }),
8518                iab_essence_descriptor: None,
8519                dc_timed_text_descriptor: None, // No timed text!
8520                isxd_data_essence_descriptor: None,
8521            }],
8522        });
8523        cpl.segment_list.segments[0]
8524            .sequence_list
8525            .hearing_impaired_captions_sequences
8526            .push(HearingImpairedCaptionsSequence {
8527                id: uuid(20),
8528                track_id: uuid(21),
8529                resource_list: ResourceList {
8530                    resources: vec![Resource {
8531                        id: uuid(22),
8532                        annotation: None,
8533                        edit_rate: None,
8534                        intrinsic_duration: 100,
8535                        entry_point: None,
8536                        source_duration: None,
8537                        source_encoding: Some(ed_id),
8538                        track_file_id: Some(uuid(50)),
8539                        repeat_count: None,
8540                        key_id: None,
8541                        hash: None,
8542                        markers: vec![],
8543                    }],
8544                },
8545            });
8546        let v = App2E2021;
8547        let issues = v.validate_cpl(&cpl);
8548        assert!(
8549            issues.iter().any(|i| i.code.contains("5.6/HICTimedText")),
8550            "HIC without timed text should be flagged: {:#?}",
8551            issues,
8552        );
8553    }
8554
8555    /// ForcedNarrative referencing non-timed-text descriptor should be flagged.
8556    #[test]
8557    fn app2e_flags_fn_without_timed_text() {
8558        let ed_id = uuid(10);
8559        let mut cpl = minimal_cpl();
8560        cpl.edit_rate = Some(EditRate::new(24, 1));
8561        cpl.extension_properties = Some(ExtensionProperties {
8562            application_identification: Some(APP2E_APPLICATION_IDENTIFICATION.to_string()),
8563            max_cll: None,
8564            max_fall: None,
8565        });
8566        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
8567            essence_descriptors: vec![EssenceDescriptor {
8568                id: ed_id,
8569                rgba_descriptor: None,
8570                cdci_descriptor: None,
8571                wave_pcm_descriptor: Some(WAVEPCMDescriptor {
8572                    instance_id: None,
8573                    sample_rate: Some(EditRate::new(48000, 1)),
8574                    quantization_bits: Some(24),
8575                    channel_count: Some(2),
8576                    linked_track_id: None,
8577                    sub_descriptors: None,
8578                    audio_sample_rate: None,
8579                }),
8580                iab_essence_descriptor: None,
8581                dc_timed_text_descriptor: None,
8582                isxd_data_essence_descriptor: None,
8583            }],
8584        });
8585        cpl.segment_list.segments[0]
8586            .sequence_list
8587            .forced_narrative_sequences
8588            .push(ForcedNarrativeSequence {
8589                id: uuid(20),
8590                track_id: uuid(21),
8591                resource_list: ResourceList {
8592                    resources: vec![Resource {
8593                        id: uuid(22),
8594                        annotation: None,
8595                        edit_rate: None,
8596                        intrinsic_duration: 100,
8597                        entry_point: None,
8598                        source_duration: None,
8599                        source_encoding: Some(ed_id),
8600                        track_file_id: Some(uuid(50)),
8601                        repeat_count: None,
8602                        key_id: None,
8603                        hash: None,
8604                        markers: vec![],
8605                    }],
8606                },
8607            });
8608        let v = App2E2021;
8609        let issues = v.validate_cpl(&cpl);
8610        assert!(
8611            issues.iter().any(|i| i.code.contains("5.6/FNTimedText")),
8612            "ForcedNarrative without timed text should be flagged: {:#?}",
8613            issues,
8614        );
8615    }
8616
8617    // ── #46: ContentMaturityRating / ApplicationIdentification ───────────
8618
8619    /// App2E: Missing ApplicationIdentification should be flagged.
8620    #[test]
8621    fn app2e_flags_missing_application_identification() {
8622        let mut cpl = minimal_cpl();
8623        cpl.edit_rate = Some(EditRate::new(24, 1));
8624        cpl.extension_properties = Some(ExtensionProperties {
8625            application_identification: None,
8626            max_cll: None,
8627            max_fall: None,
8628        });
8629        let v = App2E2021;
8630        let issues = v.validate_cpl(&cpl);
8631        assert!(
8632            issues
8633                .iter()
8634                .any(|i| i.code.contains("7.1/ApplicationIdentification")),
8635            "Missing ApplicationIdentification should be flagged: {:#?}",
8636            issues,
8637        );
8638    }
8639
8640    // ── #47: LocaleList / RFC 5646 / ISO 3166-1 validation ──────────────
8641
8642    /// Valid LocaleList with proper language tags and region codes should pass.
8643    #[test]
8644    fn app2e_accepts_valid_locale_list() {
8645        let mut cpl = minimal_cpl();
8646        cpl.edit_rate = Some(EditRate::new(24, 1));
8647        cpl.extension_properties = Some(ExtensionProperties {
8648            application_identification: Some(APP2E_APPLICATION_IDENTIFICATION.to_string()),
8649            max_cll: None,
8650            max_fall: None,
8651        });
8652        cpl.locale_list = Some(LocaleList {
8653            locales: vec![Locale {
8654                language_list: Some(LanguageList {
8655                    languages: vec![LanguageTag::new("en"), LanguageTag::new("fr")],
8656                }),
8657                content_maturity_rating_list: None,
8658                region_list: Some(RegionList {
8659                    regions: vec!["US".to_string(), "FR".to_string()],
8660                }),
8661            }],
8662        });
8663        let v = App2E2021;
8664        let issues = v.validate_cpl(&cpl);
8665        assert!(
8666            !issues.iter().any(|i| i.code.contains("5.3/")),
8667            "Valid locale should not be flagged: {:#?}",
8668            issues,
8669        );
8670    }
8671
8672    /// Invalid region code (lowercase) should be flagged.
8673    #[test]
8674    fn app2e_flags_invalid_region_code() {
8675        let mut cpl = minimal_cpl();
8676        cpl.edit_rate = Some(EditRate::new(24, 1));
8677        cpl.extension_properties = Some(ExtensionProperties {
8678            application_identification: Some(APP2E_APPLICATION_IDENTIFICATION.to_string()),
8679            max_cll: None,
8680            max_fall: None,
8681        });
8682        cpl.locale_list = Some(LocaleList {
8683            locales: vec![Locale {
8684                language_list: None,
8685                region_list: Some(RegionList {
8686                    regions: vec!["us".to_string()], // Lowercase = invalid
8687                }),
8688                content_maturity_rating_list: None,
8689            }],
8690        });
8691        let v = App2E2021;
8692        let issues = v.validate_cpl(&cpl);
8693        assert!(
8694            issues.iter().any(|i| i.code.contains("5.3/RegionCode")),
8695            "Invalid region code should be flagged: {:#?}",
8696            issues,
8697        );
8698    }
8699
8700    /// Empty language tag should be flagged.
8701    #[test]
8702    fn app2e_flags_empty_locale_language_tag() {
8703        let mut cpl = minimal_cpl();
8704        cpl.edit_rate = Some(EditRate::new(24, 1));
8705        cpl.extension_properties = Some(ExtensionProperties {
8706            application_identification: Some(APP2E_APPLICATION_IDENTIFICATION.to_string()),
8707            max_cll: None,
8708            max_fall: None,
8709        });
8710        cpl.locale_list = Some(LocaleList {
8711            locales: vec![Locale {
8712                language_list: Some(LanguageList {
8713                    languages: vec![LanguageTag::new("")],
8714                }),
8715                region_list: None,
8716                content_maturity_rating_list: None,
8717            }],
8718        });
8719        let v = App2E2021;
8720        let issues = v.validate_cpl(&cpl);
8721        assert!(
8722            issues
8723                .iter()
8724                .any(|i| i.code.contains("5.3/EmptyLanguageTag")),
8725            "Empty language tag should be flagged: {:#?}",
8726            issues,
8727        );
8728    }
8729
8730    // ── #48: Timed text extended validation ──────────────────────────────
8731
8732    /// Timed text descriptor missing SampleRate should be warned.
8733    #[test]
8734    fn core_warns_timed_text_missing_sample_rate() {
8735        let mut cpl = minimal_cpl();
8736        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
8737            essence_descriptors: vec![EssenceDescriptor {
8738                id: uuid(10),
8739                rgba_descriptor: None,
8740                cdci_descriptor: None,
8741                wave_pcm_descriptor: None,
8742                iab_essence_descriptor: None,
8743                dc_timed_text_descriptor: Some(DCTimedTextDescriptor {
8744                    instance_id: None,
8745                    linked_track_id: None,
8746                    sample_rate: None, // Missing!
8747                    namespace_uri: Some("http://www.w3.org/ns/ttml".to_string()),
8748                    rfc5646_language_tag_list: vec![LanguageTag::new("en")],
8749                }),
8750                isxd_data_essence_descriptor: None,
8751            }],
8752        });
8753        let v = CoreConstraints2020;
8754        let issues = v.validate_cpl(&cpl);
8755        assert!(
8756            issues
8757                .iter()
8758                .any(|i| i.code.contains("TimedText-SampleRate")),
8759            "Missing timed text SampleRate should be warned: {:#?}",
8760            issues,
8761        );
8762    }
8763
8764    /// Timed text descriptor with valid SampleRate should not be warned.
8765    #[test]
8766    fn core_accepts_timed_text_with_sample_rate() {
8767        let mut cpl = minimal_cpl();
8768        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
8769            essence_descriptors: vec![EssenceDescriptor {
8770                id: uuid(10),
8771                rgba_descriptor: None,
8772                cdci_descriptor: None,
8773                wave_pcm_descriptor: None,
8774                iab_essence_descriptor: None,
8775                dc_timed_text_descriptor: Some(DCTimedTextDescriptor {
8776                    instance_id: None,
8777                    linked_track_id: None,
8778                    sample_rate: Some(EditRate::new(24, 1)),
8779                    namespace_uri: Some("http://www.w3.org/ns/ttml".to_string()),
8780                    rfc5646_language_tag_list: vec![LanguageTag::new("en")],
8781                }),
8782                isxd_data_essence_descriptor: None,
8783            }],
8784        });
8785        let v = CoreConstraints2020;
8786        let issues = v.validate_cpl(&cpl);
8787        assert!(
8788            !issues
8789                .iter()
8790                .any(|i| i.code.contains("TimedText-SampleRate")),
8791            "Valid timed text SampleRate should not be warned: {:#?}",
8792            issues,
8793        );
8794    }
8795
8796    /// Empty language tag in timed text should be warned.
8797    #[test]
8798    fn core_warns_timed_text_empty_language_tag() {
8799        let mut cpl = minimal_cpl();
8800        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
8801            essence_descriptors: vec![EssenceDescriptor {
8802                id: uuid(10),
8803                rgba_descriptor: None,
8804                cdci_descriptor: None,
8805                wave_pcm_descriptor: None,
8806                iab_essence_descriptor: None,
8807                dc_timed_text_descriptor: Some(DCTimedTextDescriptor {
8808                    instance_id: None,
8809                    linked_track_id: None,
8810                    sample_rate: Some(EditRate::new(24, 1)),
8811                    namespace_uri: Some("http://www.w3.org/ns/ttml".to_string()),
8812                    rfc5646_language_tag_list: vec![LanguageTag::new("")],
8813                }),
8814                isxd_data_essence_descriptor: None,
8815            }],
8816        });
8817        let v = CoreConstraints2020;
8818        let issues = v.validate_cpl(&cpl);
8819        assert!(
8820            issues
8821                .iter()
8822                .any(|i| i.code.contains("TimedText-EmptyLanguageTag")),
8823            "Empty language tag should be warned: {:#?}",
8824            issues,
8825        );
8826    }
8827
8828    // ── #43: Digital Signatures notice ───────────────────────────────────
8829
8830    /// CPL with 2016+ namespace should emit digital signature info notice.
8831    #[test]
8832    fn core_emits_digital_signature_notice_for_modern_cpl() {
8833        let cpl = minimal_cpl(); // Uses Smpte2067_3_2016
8834        let v = CoreConstraints2020;
8835        let issues = v.validate_cpl(&cpl);
8836        assert!(
8837            issues
8838                .iter()
8839                .any(|i| i.code.contains("DigitalSignature") && i.severity == Severity::Info),
8840            "2020 CPL should emit digital signature info notice: {:#?}",
8841            issues,
8842        );
8843    }
8844
8845    /// CPL with 2013 namespace should not emit digital signature notice.
8846    #[test]
8847    fn core_no_digital_signature_notice_for_2013_cpl() {
8848        let mut cpl = minimal_cpl();
8849        cpl.namespace = CplNamespace::Smpte2067_3_2013;
8850        let v = CoreConstraints2013;
8851        let issues = v.validate_cpl(&cpl);
8852        assert!(
8853            !issues.iter().any(|i| i.code.contains("DigitalSignature")),
8854            "2013 CPL should not emit digital signature notice: {:#?}",
8855            issues,
8856        );
8857    }
8858
8859    // ═════════════════════════════════════════════════════════════════════════
8860    // Normative-claim gap closure: CDCI Table 8
8861    // ═════════════════════════════════════════════════════════════════════════
8862
8863    /// Table 8: SampledHeight ≠ StoredHeight shall be flagged.
8864    #[test]
8865    fn app2e_flags_cdci_sampled_height_mismatch() {
8866        let mut cpl = cpl_with_cdci_descriptor(
8867            ColorPrimaries::Bt709,
8868            TransferCharacteristic::Bt709,
8869            CodingEquations::Bt709,
8870            10,
8871        );
8872        if let Some(ref mut edl) = cpl.essence_descriptor_list {
8873            for ed in &mut edl.essence_descriptors {
8874                if let Some(ref mut cdci) = ed.cdci_descriptor {
8875                    cdci.sampled_height = Some(720); // ≠ stored_height (1080)
8876                }
8877            }
8878        }
8879        let v = App2E2021;
8880        let issues = v.validate_cpl(&cpl);
8881        assert!(
8882            issues
8883                .iter()
8884                .any(|i| i.code.contains("6.2.1/SampledHeight")),
8885            "Should flag SampledHeight ≠ StoredHeight: {:#?}",
8886            issues,
8887        );
8888    }
8889
8890    /// Table 8: SampledYOffset non-zero shall be flagged.
8891    #[test]
8892    fn app2e_flags_cdci_sampled_y_offset_nonzero() {
8893        let mut cpl = cpl_with_cdci_descriptor(
8894            ColorPrimaries::Bt709,
8895            TransferCharacteristic::Bt709,
8896            CodingEquations::Bt709,
8897            10,
8898        );
8899        if let Some(ref mut edl) = cpl.essence_descriptor_list {
8900            for ed in &mut edl.essence_descriptors {
8901                if let Some(ref mut cdci) = ed.cdci_descriptor {
8902                    cdci.sampled_y_offset = Some(1);
8903                }
8904            }
8905        }
8906        let v = App2E2021;
8907        let issues = v.validate_cpl(&cpl);
8908        assert!(
8909            issues
8910                .iter()
8911                .any(|i| i.code.contains("6.2.1/SampledYOffset")),
8912            "Should flag SampledYOffset ≠ 0: {:#?}",
8913            issues,
8914        );
8915    }
8916
8917    /// Table 8: SampledXOffset non-zero shall be flagged.
8918    #[test]
8919    fn app2e_flags_cdci_sampled_x_offset_nonzero() {
8920        let mut cpl = cpl_with_cdci_descriptor(
8921            ColorPrimaries::Bt709,
8922            TransferCharacteristic::Bt709,
8923            CodingEquations::Bt709,
8924            10,
8925        );
8926        if let Some(ref mut edl) = cpl.essence_descriptor_list {
8927            for ed in &mut edl.essence_descriptors {
8928                if let Some(ref mut cdci) = ed.cdci_descriptor {
8929                    cdci.sampled_x_offset = Some(5);
8930                }
8931            }
8932        }
8933        let v = App2E2021;
8934        let issues = v.validate_cpl(&cpl);
8935        assert!(
8936            issues
8937                .iter()
8938                .any(|i| i.code.contains("6.2.1/SampledXOffset")),
8939            "Should flag SampledXOffset ≠ 0: {:#?}",
8940            issues,
8941        );
8942    }
8943
8944    /// Table 8: Missing CodingEquations shall be flagged.
8945    #[test]
8946    fn app2e_flags_cdci_coding_equations_missing() {
8947        let mut cpl = cpl_with_cdci_descriptor(
8948            ColorPrimaries::Bt709,
8949            TransferCharacteristic::Bt709,
8950            CodingEquations::Bt709,
8951            10,
8952        );
8953        if let Some(ref mut edl) = cpl.essence_descriptor_list {
8954            for ed in &mut edl.essence_descriptors {
8955                if let Some(ref mut cdci) = ed.cdci_descriptor {
8956                    cdci.coding_equations = None;
8957                }
8958            }
8959        }
8960        let v = App2E2021;
8961        let issues = v.validate_cpl(&cpl);
8962        assert!(
8963            issues
8964                .iter()
8965                .any(|i| i.code.contains("6.2.1/CodingEquations")),
8966            "Should flag missing CodingEquations: {:#?}",
8967            issues,
8968        );
8969    }
8970
8971    /// §6.2.3: Unrecognized CodingEquations UL shall be flagged.
8972    #[test]
8973    fn app2e_flags_cdci_coding_equations_unknown() {
8974        let mut cpl = cpl_with_cdci_descriptor(
8975            ColorPrimaries::Bt709,
8976            TransferCharacteristic::Bt709,
8977            CodingEquations::Bt709,
8978            10,
8979        );
8980        if let Some(ref mut edl) = cpl.essence_descriptor_list {
8981            for ed in &mut edl.essence_descriptors {
8982                if let Some(ref mut cdci) = ed.cdci_descriptor {
8983                    cdci.coding_equations = Some(CodingEquations::Unknown(
8984                        "06.0e.2b.34.04.01.01.0d.04.01.01.01.ff.00.00.00".to_string(),
8985                    ));
8986                }
8987            }
8988        }
8989        let v = App2E2021;
8990        let issues = v.validate_cpl(&cpl);
8991        assert!(
8992            issues.iter().any(|i| i.code.contains("6.2.3")),
8993            "Should flag unknown CodingEquations UL: {:#?}",
8994            issues,
8995        );
8996    }
8997
8998    /// Table 8: Missing TransferCharacteristic shall be flagged.
8999    #[test]
9000    fn app2e_flags_cdci_transfer_characteristic_missing() {
9001        let mut cpl = cpl_with_cdci_descriptor(
9002            ColorPrimaries::Bt709,
9003            TransferCharacteristic::Bt709,
9004            CodingEquations::Bt709,
9005            10,
9006        );
9007        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9008            for ed in &mut edl.essence_descriptors {
9009                if let Some(ref mut cdci) = ed.cdci_descriptor {
9010                    cdci.transfer_characteristic = None;
9011                }
9012            }
9013        }
9014        let v = App2E2021;
9015        let issues = v.validate_cpl(&cpl);
9016        assert!(
9017            issues
9018                .iter()
9019                .any(|i| i.code.contains("6.2.1/TransferCharacteristic")),
9020            "Should flag missing TransferCharacteristic: {:#?}",
9021            issues,
9022        );
9023    }
9024
9025    /// §6.2.2: Unrecognized TransferCharacteristic UL shall be flagged.
9026    #[test]
9027    fn app2e_flags_cdci_transfer_characteristic_unknown() {
9028        let mut cpl = cpl_with_cdci_descriptor(
9029            ColorPrimaries::Bt709,
9030            TransferCharacteristic::Bt709,
9031            CodingEquations::Bt709,
9032            10,
9033        );
9034        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9035            for ed in &mut edl.essence_descriptors {
9036                if let Some(ref mut cdci) = ed.cdci_descriptor {
9037                    cdci.transfer_characteristic = Some(TransferCharacteristic::Unknown(
9038                        "06.0e.2b.34.04.01.01.0d.04.01.01.01.ff.00.00.00".to_string(),
9039                    ));
9040                }
9041            }
9042        }
9043        let v = App2E2021;
9044        let issues = v.validate_cpl(&cpl);
9045        assert!(
9046            issues.iter().any(|i| i.code.contains("6.2.2")),
9047            "Should flag unknown TransferCharacteristic UL: {:#?}",
9048            issues,
9049        );
9050    }
9051
9052    /// Table 8: Missing ColorPrimaries shall be flagged.
9053    #[test]
9054    fn app2e_flags_cdci_color_primaries_missing() {
9055        let mut cpl = cpl_with_cdci_descriptor(
9056            ColorPrimaries::Bt709,
9057            TransferCharacteristic::Bt709,
9058            CodingEquations::Bt709,
9059            10,
9060        );
9061        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9062            for ed in &mut edl.essence_descriptors {
9063                if let Some(ref mut cdci) = ed.cdci_descriptor {
9064                    cdci.color_primaries = None;
9065                }
9066            }
9067        }
9068        let v = App2E2021;
9069        let issues = v.validate_cpl(&cpl);
9070        assert!(
9071            issues
9072                .iter()
9073                .any(|i| i.code.contains("6.2.1/ColorPrimaries")),
9074            "Should flag missing ColorPrimaries: {:#?}",
9075            issues,
9076        );
9077    }
9078
9079    /// §6.2.4: Unrecognized ColorPrimaries UL shall be flagged.
9080    #[test]
9081    fn app2e_flags_cdci_color_primaries_unknown() {
9082        let mut cpl = cpl_with_cdci_descriptor(
9083            ColorPrimaries::Bt709,
9084            TransferCharacteristic::Bt709,
9085            CodingEquations::Bt709,
9086            10,
9087        );
9088        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9089            for ed in &mut edl.essence_descriptors {
9090                if let Some(ref mut cdci) = ed.cdci_descriptor {
9091                    cdci.color_primaries = Some(ColorPrimaries::Unknown(
9092                        "06.0e.2b.34.04.01.01.0d.04.01.01.01.ff.00.00.00".to_string(),
9093                    ));
9094                }
9095            }
9096        }
9097        let v = App2E2021;
9098        let issues = v.validate_cpl(&cpl);
9099        assert!(
9100            issues.iter().any(|i| i.code.contains("6.2.4")),
9101            "Should flag unknown ColorPrimaries UL: {:#?}",
9102            issues,
9103        );
9104    }
9105
9106    // ═════════════════════════════════════════════════════════════════════════
9107    // Normative-claim gap closure: CDCI Table 12
9108    // ═════════════════════════════════════════════════════════════════════════
9109
9110    /// Table 12: HorizontalSubsampling ≠ {1,2} shall be flagged.
9111    #[test]
9112    fn app2e_flags_cdci_horizontal_subsampling_invalid() {
9113        let mut cpl = cpl_with_cdci_descriptor(
9114            ColorPrimaries::Bt709,
9115            TransferCharacteristic::Bt709,
9116            CodingEquations::Bt709,
9117            10,
9118        );
9119        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9120            for ed in &mut edl.essence_descriptors {
9121                if let Some(ref mut cdci) = ed.cdci_descriptor {
9122                    cdci.horizontal_subsampling = Some(3);
9123                }
9124            }
9125        }
9126        let v = App2E2021;
9127        let issues = v.validate_cpl(&cpl);
9128        assert!(
9129            issues
9130                .iter()
9131                .any(|i| i.code.contains("6.4/HorizontalSubsampling")),
9132            "Should flag HorizontalSubsampling=3: {:#?}",
9133            issues,
9134        );
9135    }
9136
9137    /// Table 12: Missing HorizontalSubsampling shall be flagged.
9138    #[test]
9139    fn app2e_flags_cdci_horizontal_subsampling_missing() {
9140        let mut cpl = cpl_with_cdci_descriptor(
9141            ColorPrimaries::Bt709,
9142            TransferCharacteristic::Bt709,
9143            CodingEquations::Bt709,
9144            10,
9145        );
9146        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9147            for ed in &mut edl.essence_descriptors {
9148                if let Some(ref mut cdci) = ed.cdci_descriptor {
9149                    cdci.horizontal_subsampling = None;
9150                }
9151            }
9152        }
9153        let v = App2E2021;
9154        let issues = v.validate_cpl(&cpl);
9155        assert!(
9156            issues
9157                .iter()
9158                .any(|i| i.code.contains("6.4/HorizontalSubsampling")),
9159            "Should flag missing HorizontalSubsampling: {:#?}",
9160            issues,
9161        );
9162    }
9163
9164    /// Table 12: ColorSiting non-zero shall be flagged.
9165    #[test]
9166    fn app2e_flags_cdci_color_siting_nonzero() {
9167        let mut cpl = cpl_with_cdci_descriptor(
9168            ColorPrimaries::Bt709,
9169            TransferCharacteristic::Bt709,
9170            CodingEquations::Bt709,
9171            10,
9172        );
9173        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9174            for ed in &mut edl.essence_descriptors {
9175                if let Some(ref mut cdci) = ed.cdci_descriptor {
9176                    cdci.color_siting = Some(3);
9177                }
9178            }
9179        }
9180        let v = App2E2021;
9181        let issues = v.validate_cpl(&cpl);
9182        assert!(
9183            issues.iter().any(|i| i.code.contains("6.4/ColorSiting")),
9184            "Should flag ColorSiting ≠ 0: {:#?}",
9185            issues,
9186        );
9187    }
9188
9189    /// Table 12: Missing ColorSiting shall be flagged.
9190    #[test]
9191    fn app2e_flags_cdci_color_siting_missing() {
9192        let mut cpl = cpl_with_cdci_descriptor(
9193            ColorPrimaries::Bt709,
9194            TransferCharacteristic::Bt709,
9195            CodingEquations::Bt709,
9196            10,
9197        );
9198        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9199            for ed in &mut edl.essence_descriptors {
9200                if let Some(ref mut cdci) = ed.cdci_descriptor {
9201                    cdci.color_siting = None;
9202                }
9203            }
9204        }
9205        let v = App2E2021;
9206        let issues = v.validate_cpl(&cpl);
9207        assert!(
9208            issues.iter().any(|i| i.code.contains("6.4/ColorSiting")),
9209            "Should flag missing ColorSiting: {:#?}",
9210            issues,
9211        );
9212    }
9213
9214    /// Table 12: VerticalSubsampling ≠ 1 shall be flagged.
9215    #[test]
9216    fn app2e_flags_cdci_vertical_subsampling_invalid() {
9217        let mut cpl = cpl_with_cdci_descriptor(
9218            ColorPrimaries::Bt709,
9219            TransferCharacteristic::Bt709,
9220            CodingEquations::Bt709,
9221            10,
9222        );
9223        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9224            for ed in &mut edl.essence_descriptors {
9225                if let Some(ref mut cdci) = ed.cdci_descriptor {
9226                    cdci.vertical_subsampling = Some(2);
9227                }
9228            }
9229        }
9230        let v = App2E2021;
9231        let issues = v.validate_cpl(&cpl);
9232        assert!(
9233            issues
9234                .iter()
9235                .any(|i| i.code.contains("6.4/VerticalSubsampling")),
9236            "Should flag VerticalSubsampling ≠ 1: {:#?}",
9237            issues,
9238        );
9239    }
9240
9241    /// Table 12: Missing VerticalSubsampling shall be flagged.
9242    #[test]
9243    fn app2e_flags_cdci_vertical_subsampling_missing() {
9244        let mut cpl = cpl_with_cdci_descriptor(
9245            ColorPrimaries::Bt709,
9246            TransferCharacteristic::Bt709,
9247            CodingEquations::Bt709,
9248            10,
9249        );
9250        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9251            for ed in &mut edl.essence_descriptors {
9252                if let Some(ref mut cdci) = ed.cdci_descriptor {
9253                    cdci.vertical_subsampling = None;
9254                }
9255            }
9256        }
9257        let v = App2E2021;
9258        let issues = v.validate_cpl(&cpl);
9259        assert!(
9260            issues
9261                .iter()
9262                .any(|i| i.code.contains("6.4/VerticalSubsampling")),
9263            "Should flag missing VerticalSubsampling: {:#?}",
9264            issues,
9265        );
9266    }
9267
9268    /// Table 12: ComponentDepth not in {8,10,12,16} shall be flagged.
9269    #[test]
9270    fn app2e_flags_cdci_component_depth_invalid() {
9271        let mut cpl = cpl_with_cdci_descriptor(
9272            ColorPrimaries::Bt709,
9273            TransferCharacteristic::Bt709,
9274            CodingEquations::Bt709,
9275            10,
9276        );
9277        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9278            for ed in &mut edl.essence_descriptors {
9279                if let Some(ref mut cdci) = ed.cdci_descriptor {
9280                    cdci.component_depth = Some(14);
9281                }
9282            }
9283        }
9284        let v = App2E2021;
9285        let issues = v.validate_cpl(&cpl);
9286        assert!(
9287            issues.iter().any(|i| i.code.contains("6.4/ComponentDepth")),
9288            "Should flag ComponentDepth=14: {:#?}",
9289            issues,
9290        );
9291    }
9292
9293    /// Table 12: Missing ComponentDepth shall be flagged.
9294    #[test]
9295    fn app2e_flags_cdci_component_depth_missing() {
9296        let mut cpl = cpl_with_cdci_descriptor(
9297            ColorPrimaries::Bt709,
9298            TransferCharacteristic::Bt709,
9299            CodingEquations::Bt709,
9300            10,
9301        );
9302        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9303            for ed in &mut edl.essence_descriptors {
9304                if let Some(ref mut cdci) = ed.cdci_descriptor {
9305                    cdci.component_depth = None;
9306                }
9307            }
9308        }
9309        let v = App2E2021;
9310        let issues = v.validate_cpl(&cpl);
9311        assert!(
9312            issues.iter().any(|i| i.code.contains("6.4/ComponentDepth")),
9313            "Should flag missing ComponentDepth: {:#?}",
9314            issues,
9315        );
9316    }
9317
9318    // ═════════════════════════════════════════════════════════════════════════
9319    // Normative-claim gap closure: RGBA Table 10/11
9320    // ═════════════════════════════════════════════════════════════════════════
9321
9322    /// Table 10: Missing ComponentMaxRef shall be flagged.
9323    #[test]
9324    fn app2e_flags_rgba_component_max_ref_missing() {
9325        let mut cpl =
9326            cpl_with_rgba_descriptor(ColorPrimaries::P3D65, TransferCharacteristic::PqSt2084);
9327        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9328            for ed in &mut edl.essence_descriptors {
9329                if let Some(ref mut rgba) = ed.rgba_descriptor {
9330                    rgba.component_max_ref = None;
9331                }
9332            }
9333        }
9334        let v = App2E2021;
9335        let issues = v.validate_cpl(&cpl);
9336        assert!(
9337            issues
9338                .iter()
9339                .any(|i| i.code.contains("6.3/ComponentMaxRef")),
9340            "Should flag missing ComponentMaxRef: {:#?}",
9341            issues,
9342        );
9343    }
9344
9345    /// Table 10: Missing ComponentMinRef shall be flagged.
9346    #[test]
9347    fn app2e_flags_rgba_component_min_ref_missing() {
9348        let mut cpl =
9349            cpl_with_rgba_descriptor(ColorPrimaries::P3D65, TransferCharacteristic::PqSt2084);
9350        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9351            for ed in &mut edl.essence_descriptors {
9352                if let Some(ref mut rgba) = ed.rgba_descriptor {
9353                    rgba.component_min_ref = None;
9354                }
9355            }
9356        }
9357        let v = App2E2021;
9358        let issues = v.validate_cpl(&cpl);
9359        assert!(
9360            issues
9361                .iter()
9362                .any(|i| i.code.contains("6.3/ComponentMinRef")),
9363            "Should flag missing ComponentMinRef: {:#?}",
9364            issues,
9365        );
9366    }
9367
9368    /// Table 10: Missing ScanningDirection shall be flagged.
9369    #[test]
9370    fn app2e_flags_rgba_scanning_direction_missing() {
9371        let mut cpl =
9372            cpl_with_rgba_descriptor(ColorPrimaries::P3D65, TransferCharacteristic::PqSt2084);
9373        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9374            for ed in &mut edl.essence_descriptors {
9375                if let Some(ref mut rgba) = ed.rgba_descriptor {
9376                    rgba.scanning_direction = None;
9377                }
9378            }
9379        }
9380        let v = App2E2021;
9381        let issues = v.validate_cpl(&cpl);
9382        assert!(
9383            issues
9384                .iter()
9385                .any(|i| i.code.contains("6.3/ScanningDirection")),
9386            "Should flag missing ScanningDirection: {:#?}",
9387            issues,
9388        );
9389    }
9390
9391    /// Table 10: Wrong ScanningDirection shall be flagged.
9392    #[test]
9393    fn app2e_flags_rgba_scanning_direction_wrong() {
9394        let mut cpl =
9395            cpl_with_rgba_descriptor(ColorPrimaries::P3D65, TransferCharacteristic::PqSt2084);
9396        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9397            for ed in &mut edl.essence_descriptors {
9398                if let Some(ref mut rgba) = ed.rgba_descriptor {
9399                    rgba.scanning_direction =
9400                        Some("ScanningDirection_RightToLeftBottomToTop".to_string());
9401                }
9402            }
9403        }
9404        let v = App2E2021;
9405        let issues = v.validate_cpl(&cpl);
9406        assert!(
9407            issues
9408                .iter()
9409                .any(|i| i.code.contains("6.3/ScanningDirection")),
9410            "Should flag wrong ScanningDirection: {:#?}",
9411            issues,
9412        );
9413    }
9414
9415    /// Table 11: ComponentMinRef/MaxRef mismatch for QE.1 / QE.2 shall be flagged.
9416    #[test]
9417    fn app2e_flags_rgba_table11_ref_mismatch() {
9418        let mut cpl =
9419            cpl_with_rgba_descriptor(ColorPrimaries::Bt709, TransferCharacteristic::Bt709);
9420        if let Some(ref mut edl) = cpl.essence_descriptor_list {
9421            for ed in &mut edl.essence_descriptors {
9422                if let Some(ref mut rgba) = ed.rgba_descriptor {
9423                    // min_ref=0 → QE.2 at 10-bit expects max=1023,
9424                    // but set max=940 which is QE.1 → mismatch.
9425                    rgba.component_min_ref = Some(0);
9426                    rgba.component_max_ref = Some(940);
9427                }
9428            }
9429        }
9430        let v = App2E2021;
9431        let issues = v.validate_cpl(&cpl);
9432        assert!(
9433            issues.iter().any(|i| i.code.contains("6.3.2/")),
9434            "Should flag Table11 ref mismatch: {:#?}",
9435            issues,
9436        );
9437    }
9438
9439    // ═════════════════════════════════════════════════════════════════════════
9440    // Normative-claim gap closure: UniqueResourceId
9441    // ═════════════════════════════════════════════════════════════════════════
9442
9443    /// Duplicate Resource Id across sequences shall be flagged.
9444    #[test]
9445    fn core_flags_duplicate_resource_id() {
9446        let mut cpl = minimal_cpl();
9447        let dup_id = uuid(42);
9448        let mut sl = empty_sequence_list();
9449        sl.main_image_sequences.push(MainImageSequence {
9450            id: uuid(3),
9451            track_id: uuid(4),
9452            resource_list: ResourceList {
9453                resources: vec![Resource {
9454                    id: dup_id,
9455                    annotation: None,
9456                    edit_rate: None,
9457                    intrinsic_duration: 100,
9458                    entry_point: None,
9459                    source_duration: None,
9460                    source_encoding: Some(uuid(10)),
9461                    track_file_id: Some(uuid(50)),
9462                    repeat_count: None,
9463                    key_id: None,
9464                    hash: None,
9465                    markers: vec![],
9466                }],
9467            },
9468        });
9469        sl.main_audio_sequences.push(MainAudioSequence {
9470            id: uuid(5),
9471            track_id: uuid(6),
9472            resource_list: ResourceList {
9473                resources: vec![Resource {
9474                    id: dup_id, // Same ID as above → duplicate
9475                    annotation: None,
9476                    edit_rate: None,
9477                    intrinsic_duration: 100,
9478                    entry_point: None,
9479                    source_duration: None,
9480                    source_encoding: Some(uuid(11)),
9481                    track_file_id: Some(uuid(51)),
9482                    repeat_count: None,
9483                    key_id: None,
9484                    hash: None,
9485                    markers: vec![],
9486                }],
9487            },
9488        });
9489        cpl.segment_list.segments[0].sequence_list = sl;
9490        let v = CoreConstraints2020;
9491        let issues = v.validate_cpl(&cpl);
9492        assert!(
9493            issues.iter().any(|i| i.code.contains("UniqueResourceId")),
9494            "Should flag duplicate resource ID: {:#?}",
9495            issues,
9496        );
9497    }
9498
9499    /// Regression guard: emitted validator codes should never fall back to :General paragraph.
9500    #[test]
9501    fn emitted_codes_do_not_use_general_fallback() {
9502        let mut cpl = minimal_cpl();
9503        cpl.edit_rate = None;
9504        cpl.issue_date = "invalid-date".to_string();
9505
9506        let core_issues = CoreConstraints2020.validate_cpl(&cpl);
9507        assert!(
9508            !core_issues.iter().any(|i| i.code.contains(":General/")),
9509            "Core validator emitted :General fallback codes: {:#?}",
9510            core_issues,
9511        );
9512
9513        let app2e_issues = App2E2021.validate_cpl(&cpl);
9514        assert!(
9515            !app2e_issues.iter().any(|i| i.code.contains(":General/")),
9516            "App2E validator emitted :General fallback codes: {:#?}",
9517            app2e_issues,
9518        );
9519    }
9520
9521    // ── XSD constraint helpers ────────────────────────────────────────────────
9522    // helper_timecode_address_* and helper_total_running_time_* were deleted
9523    // along with the helper functions they exercised (gutted as part of the
9524    // runtime-XSD migration). Only is_valid_any_uri remains.
9525
9526    #[test]
9527    fn helper_any_uri_valid() {
9528        assert!(is_valid_any_uri("http://www.movielabs.com/md/ratings"));
9529        assert!(is_valid_any_uri("urn:smpte:2067-3:ag"));
9530        assert!(is_valid_any_uri("https://example.com/agency"));
9531        assert!(is_valid_any_uri("relative/path"));
9532    }
9533
9534    #[test]
9535    fn helper_any_uri_invalid() {
9536        assert!(!is_valid_any_uri("http://example.com with space"));
9537        assert!(!is_valid_any_uri("has\ttab"));
9538        assert!(!is_valid_any_uri("has\nnewline"));
9539    }
9540
9541    // ── XSD: TotalRunningTime pattern ────────────────────────────────────────
9542
9543    /// XSD §37-41: `TotalRunningTime` carries pattern
9544    /// `[0-9][0-9]:[0-5][0-9]:[0-5][0-9]` — "2:30:00" fails it (missing
9545    /// leading zero on the hours field).
9546    #[test]
9547    fn core_flags_invalid_total_running_time() {
9548        let xml = r#"<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
9549            <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
9550            <IssueDate>2024-01-01T00:00:00Z</IssueDate>
9551            <ContentTitle>Test</ContentTitle>
9552            <EditRate>24 1</EditRate>
9553            <TotalRunningTime>2:30:00</TotalRunningTime>
9554            <SegmentList><Segment>
9555                <Id>urn:uuid:00000000-0000-0000-0000-000000000002</Id>
9556                <SequenceList/>
9557            </Segment></SegmentList>
9558        </CompositionPlaylist>"#;
9559        let cpl =
9560            crate::cpl::parse_cpl(xml).expect("should parse despite invalid TotalRunningTime");
9561        let issues = CoreConstraints2013.validate_cpl(&cpl);
9562        assert!(
9563            issues.iter().any(|i| i.code.contains("TotalRunningTime")),
9564            "Invalid TotalRunningTime format should be flagged: {:#?}",
9565            issues,
9566        );
9567    }
9568
9569    #[test]
9570    fn core_accepts_valid_total_running_time() {
9571        let mut cpl = minimal_cpl();
9572        cpl.total_running_time = Some("02:30:00".to_string());
9573        let issues = CoreConstraints2020.validate_cpl(&cpl);
9574        assert!(
9575            !issues.iter().any(|i| i.code.contains("TotalRunningTime")),
9576            "Valid TotalRunningTime should be accepted: {:#?}",
9577            issues,
9578        );
9579    }
9580
9581    // ── XSD: TimecodeRate > 0 ────────────────────────────────────────────────
9582
9583    /// XSD §71: `TimecodeRate` is `xs:positiveInteger` — zero fails the
9584    /// built-in derived-type lower bound (`positive` = ≥ 1).
9585    #[test]
9586    fn core_flags_timecode_rate_zero() {
9587        let xml = r#"<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
9588            <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
9589            <IssueDate>2024-01-01T00:00:00Z</IssueDate>
9590            <ContentTitle>Test</ContentTitle>
9591            <CompositionTimecode>
9592                <TimecodeDropFrame>false</TimecodeDropFrame>
9593                <TimecodeRate>0</TimecodeRate>
9594                <TimecodeStartAddress>00:00:00:00</TimecodeStartAddress>
9595            </CompositionTimecode>
9596            <EditRate>24 1</EditRate>
9597            <SegmentList><Segment>
9598                <Id>urn:uuid:00000000-0000-0000-0000-000000000002</Id>
9599                <SequenceList/>
9600            </Segment></SegmentList>
9601        </CompositionPlaylist>"#;
9602        let cpl = crate::cpl::parse_cpl(xml).expect("should parse despite zero TimecodeRate");
9603        let issues = CoreConstraints2013.validate_cpl(&cpl);
9604        assert!(
9605            issues.iter().any(|i| i.code.contains("TimecodeRate")),
9606            "TimecodeRate of 0 should be flagged: {:#?}",
9607            issues,
9608        );
9609    }
9610
9611    #[test]
9612    fn core_accepts_positive_timecode_rate() {
9613        let mut cpl = minimal_cpl();
9614        cpl.composition_timecode = Some(CompositionTimecode {
9615            timecode_drop_frame: Some(false),
9616            timecode_rate: Some(24),
9617            timecode_start_address: Some("00:00:00:00".to_string()),
9618        });
9619        let issues = CoreConstraints2020.validate_cpl(&cpl);
9620        assert!(
9621            !issues
9622                .iter()
9623                .any(|i| i.code.contains("CompositionTimecode/Rate-Zero")),
9624            "Positive TimecodeRate should be accepted: {:#?}",
9625            issues,
9626        );
9627    }
9628
9629    // ── XSD: TimecodeStartAddress format ─────────────────────────────────────
9630
9631    /// XSD §72/75-81: `TimecodeStartAddress` is `cpl:TimecodeType` —
9632    /// a `xs:string` restricted to a four-field timecode pattern.
9633    /// "10:00:00" lacks the fourth field and fails the pattern facet.
9634    #[test]
9635    fn core_flags_invalid_timecode_start_address() {
9636        let xml = r#"<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
9637            <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
9638            <IssueDate>2024-01-01T00:00:00Z</IssueDate>
9639            <ContentTitle>Test</ContentTitle>
9640            <CompositionTimecode>
9641                <TimecodeDropFrame>false</TimecodeDropFrame>
9642                <TimecodeRate>24</TimecodeRate>
9643                <TimecodeStartAddress>10:00:00</TimecodeStartAddress>
9644            </CompositionTimecode>
9645            <EditRate>24 1</EditRate>
9646            <SegmentList><Segment>
9647                <Id>urn:uuid:00000000-0000-0000-0000-000000000002</Id>
9648                <SequenceList/>
9649            </Segment></SegmentList>
9650        </CompositionPlaylist>"#;
9651        let cpl =
9652            crate::cpl::parse_cpl(xml).expect("should parse despite invalid TimecodeStartAddress");
9653        let issues = CoreConstraints2013.validate_cpl(&cpl);
9654        assert!(
9655            issues
9656                .iter()
9657                .any(|i| i.code.contains("TimecodeStartAddress")),
9658            "Invalid TimecodeStartAddress format should be flagged: {:#?}",
9659            issues,
9660        );
9661    }
9662
9663    #[test]
9664    fn core_accepts_valid_timecode_start_address() {
9665        let mut cpl = minimal_cpl();
9666        cpl.composition_timecode = Some(CompositionTimecode {
9667            timecode_drop_frame: Some(false),
9668            timecode_rate: Some(24),
9669            timecode_start_address: Some("10:00:00:00".to_string()),
9670        });
9671        let issues = CoreConstraints2020.validate_cpl(&cpl);
9672        assert!(
9673            !issues
9674                .iter()
9675                .any(|i| i.code.contains("CompositionTimecode/StartAddress-Format")),
9676            "Valid TimecodeStartAddress should be accepted: {:#?}",
9677            issues,
9678        );
9679    }
9680
9681    // ── XSD: ContentVersionList non-empty ────────────────────────────────────
9682
9683    #[test]
9684    fn core_flags_empty_content_version_list() {
9685        let mut cpl = minimal_cpl();
9686        cpl.content_version_list = Some(ContentVersionList {
9687            content_versions: vec![],
9688        });
9689        let issues = CoreConstraints2020.validate_cpl(&cpl);
9690        assert!(
9691            issues
9692                .iter()
9693                .any(|i| i.code.contains("ContentVersionListEmpty")),
9694            "Empty ContentVersionList should be flagged: {:#?}",
9695            issues,
9696        );
9697    }
9698
9699    #[test]
9700    fn core_accepts_non_empty_content_version_list() {
9701        let mut cpl = minimal_cpl();
9702        cpl.content_version_list = Some(ContentVersionList {
9703            content_versions: vec![ContentVersion {
9704                id: "urn:uuid:00000000-0000-0000-0000-000000000001".to_string(),
9705                label_text: Some(LanguageString {
9706                    text: "v1".to_string(),
9707                    language: None,
9708                }),
9709            }],
9710        });
9711        let issues = CoreConstraints2020.validate_cpl(&cpl);
9712        assert!(
9713            !issues
9714                .iter()
9715                .any(|i| i.code.contains("ContentVersionListEmpty")),
9716            "Non-empty ContentVersionList should be accepted: {:#?}",
9717            issues,
9718        );
9719    }
9720
9721    // ── XSD: LocaleList non-empty ─────────────────────────────────────────────
9722
9723    /// XSD §43-49: `LocaleList` requires `Locale maxOccurs=unbounded`
9724    /// with default `minOccurs=1`, so an empty LocaleList trips
9725    /// `ElementMissing/Locale` at the schema level.
9726    #[test]
9727    fn core_flags_empty_locale_list() {
9728        let xml = r#"<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
9729            <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
9730            <IssueDate>2024-01-01T00:00:00Z</IssueDate>
9731            <ContentTitle>Test</ContentTitle>
9732            <EditRate>24 1</EditRate>
9733            <LocaleList/>
9734            <SegmentList><Segment>
9735                <Id>urn:uuid:00000000-0000-0000-0000-000000000002</Id>
9736                <SequenceList/>
9737            </Segment></SegmentList>
9738        </CompositionPlaylist>"#;
9739        let cpl = crate::cpl::parse_cpl(xml).expect("should parse despite empty LocaleList");
9740        let issues = CoreConstraints2013.validate_cpl(&cpl);
9741        assert!(
9742            issues.iter().any(|i| i.code.contains("Locale")),
9743            "Empty LocaleList should be flagged: {:#?}",
9744            issues,
9745        );
9746    }
9747
9748    #[test]
9749    fn core_accepts_non_empty_locale_list() {
9750        let mut cpl = minimal_cpl();
9751        cpl.locale_list = Some(LocaleList {
9752            locales: vec![Locale {
9753                language_list: None,
9754                region_list: None,
9755                content_maturity_rating_list: None,
9756            }],
9757        });
9758        let issues = CoreConstraints2020.validate_cpl(&cpl);
9759        assert!(
9760            !issues
9761                .iter()
9762                .any(|i| i.code.contains("LocaleList-NonEmpty")),
9763            "Non-empty LocaleList should be accepted: {:#?}",
9764            issues,
9765        );
9766    }
9767
9768    // ── XSD: EssenceDescriptorList non-empty ─────────────────────────────────
9769
9770    #[test]
9771    fn core_flags_empty_essence_descriptor_list() {
9772        let mut cpl = minimal_cpl();
9773        cpl.essence_descriptor_list = Some(EssenceDescriptorList {
9774            essence_descriptors: vec![],
9775        });
9776        let issues = CoreConstraints2020.validate_cpl(&cpl);
9777        assert!(
9778            issues
9779                .iter()
9780                .any(|i| i.code.contains("EssenceDescriptorListEmpty")),
9781            "Empty EssenceDescriptorList should be flagged: {:#?}",
9782            issues,
9783        );
9784    }
9785
9786    // ── XSD: ContentMaturityRating.Agency as xs:anyURI ───────────────────────
9787
9788    #[test]
9789    fn app2e_flags_agency_with_whitespace() {
9790        let mut cpl = minimal_cpl();
9791        cpl.locale_list = Some(LocaleList {
9792            locales: vec![Locale {
9793                language_list: None,
9794                region_list: None,
9795                content_maturity_rating_list: Some(ContentMaturityRatingList {
9796                    ratings: vec![ContentMaturityRating {
9797                        agency: "http://www.example.com bad uri".to_string(),
9798                        rating: Some("PG".to_string()),
9799                        audience: None,
9800                    }],
9801                }),
9802            }],
9803        });
9804        let issues = App2E2021.validate_cpl(&cpl);
9805        assert!(
9806            issues
9807                .iter()
9808                .any(|i| i.code.contains("ContentMaturityRating-Agency-URI")),
9809            "Agency with whitespace should be flagged: {:#?}",
9810            issues,
9811        );
9812    }
9813
9814    #[test]
9815    fn app2e_accepts_valid_agency_uri() {
9816        let mut cpl = minimal_cpl();
9817        cpl.locale_list = Some(LocaleList {
9818            locales: vec![Locale {
9819                language_list: None,
9820                region_list: None,
9821                content_maturity_rating_list: Some(ContentMaturityRatingList {
9822                    ratings: vec![ContentMaturityRating {
9823                        agency: "http://www.movielabs.com/md/ratings".to_string(),
9824                        rating: Some("PG-13".to_string()),
9825                        audience: None,
9826                    }],
9827                }),
9828            }],
9829        });
9830        let issues = App2E2021.validate_cpl(&cpl);
9831        assert!(
9832            !issues
9833                .iter()
9834                .any(|i| i.code.contains("ContentMaturityRating-Agency-URI")),
9835            "Valid Agency URI should be accepted: {:#?}",
9836            issues,
9837        );
9838    }
9839}