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