Skip to main content

zencodec_testkit/
lib.rs

1//! Conformance test harness for [`zencodec`] codec implementations.
2//!
3//! A codec crate adds this as a `dev-dependency` and runs the `check_*`
4//! functions against its own [`EncoderConfig`] / [`DecoderConfig`] to verify it
5//! honors the shared contract — especially the parts that are easy to get
6//! subtly wrong and expensive to ship wrong:
7//!
8//! - [`check_metadata_no_leak`] — a [`MetadataPolicy`] must never leak what it
9//!   discards. The privacy guarantee.
10//! - [`check_cross_path_pixel_equivalence`] — every still encode/decode path
11//!   (one-shot, `push_rows` + pull `encode_from` encode; one-shot, push-sink,
12//!   streaming decode) must produce identical pixels.
13//! - [`check_animation_cross_path_equivalence`] — every animation decode path
14//!   (borrowed, owned, push-sink) must yield identical frames, matching the input.
15//! - [`check_orientation_roundtrip`] — an orientation survives a keeping policy
16//!   exactly once (no loss, no double-application).
17//! - [`check_color_context_consistency`] — whatever `ColorContext` a decoder
18//!   attaches to its output buffers is class-valid (an ICC only rides a layout
19//!   its device class describes) and identical across the one-shot, streaming,
20//!   and animation (borrowed + owned) paths — strip/scratch buffers are where a
21//!   context silently dies. Lenient about *whether* one is attached;
22//!   [`check_color_context_attached`] is the strict, opt-in positive direction
23//!   (a decoder that read colour back must attach it).
24//! - [`check_color_authority_spec`] — the `ColorAuthority` a decoder names in
25//!   `source_color` is the one its format's spec assigns for the colour fields it
26//!   read back (PNG/JXL/HEIC: CICP outranks ICC; AVIF: MIAF order; the rest:
27//!   ICC). [`check_source_color_authority`] is the unit form for a codec's own
28//!   fixtures, [`expected_color_authority`] the answer key.
29//! - [`check_native_hdr_roundtrip`] — when both ends declare `hdr`, BT.2100 PQ
30//!   and HLG survive a `PreserveExact` round trip: the CICP, the HDR envelope
31//!   (content light level + mastering display), 16-bit pixels where both ends
32//!   are natively 16-bit, and the decoded buffer is *labelled* PQ/HLG rather
33//!   than sRGB. Phase 0 of the gain-map/HDR delivery scope.
34//! - [`check_gain_map_roundtrip`] — the gain-map encode/decode contract: an
35//!   undeclared encoder rejects `with_gain_map_pixels` loudly with
36//!   `UnsupportedOperation::GainMapEncode`; a declared one embeds 1- and
37//!   3-channel maps (forward and backward direction) that a declared decoder
38//!   reports from `probe()`, surfaces only on request (`Components` /
39//!   `with_extract_gain_map`), never under the default `BaseOnly`, falls back
40//!   honestly on `ReconstructHdr` without `reconstructs_hdr`, and that survive
41//!   a decode → `with_gain_map_pixels` transcode. Phases 1, 2 (test matrix) and
42//!   4 (the testkit cross-path check) of that scope.
43//! - [`check_fidelity_honesty`] — `resolved_target_fidelity()` tells the truth:
44//!   a declared `lossless` honours a `Lossless` request byte-exactly, a codec
45//!   without `lossy` never reports `Lossy` (it promotes and says so), a codec
46//!   with `lossy` never answers a lossy request with `Lossless`, and the legacy
47//!   `is_lossless()` agrees. The cross-codec `Fidelity` contract.
48//! - [`check_inventory`] — a declared structural inventory covers every byte,
49//!   reports appended junk as unconsumed, and survives every truncation.
50//! - [`check_capability_honesty`] — every declared capability works and every
51//!   undeclared optional path cleanly returns
52//!   [`UnsupportedOperation`]. Both directions for
53//!   the structural paths and (where the decoder can observe them) the metadata
54//!   channels, so a codec can't claim a feature it lacks *or* hide one it has; see
55//!   the fn docs for the exact per-flag scope.
56//! - [`check_decode_error_envelope`] / [`assert_uses_codec_error_envelope`] — a
57//!   codec's [`ErrorCategory`] and codec name survive
58//!   dyn-dispatch type erasure (the `At<CodecError>` envelope contract). Opt-in,
59//!   and *not* in [`check_all`]: only for codecs that return the envelope
60//!   `type Error` (the testkit's own [`reference`](mod@reference) is a Pattern-A
61//!   foil that deliberately fails it).
62//! - [`check_decode_truncation_series`] — a truncated (incomplete) input must
63//!   categorize as an *incomplete-input* category (never an `Internal` 5xx, an
64//!   `OutOfMemory`, an `Io` error, or a caller-fault), and must never panic or
65//!   silently decode a truncated header. Enforces the one part of the taxonomy
66//!   that is broadly distinguishable. Opt-in, *not* in [`check_all`]; on the
67//!   dyn-erased path a Pattern-A codec fails it the same way the envelope check
68//!   does (the allowed/denied category policy is documented on
69//!   [`check_decode_truncation_series`]).
70//!
71//! [`check_all`] runs them all with default inputs — the one-call entry point.
72//!
73//! The [`reference`](mod@reference) module ships a faithful codec (declares and
74//! honors every capability) the harness is validated against — it doubles as a
75//! worked example; an internal `minimal` codec (every optional capability
76//! declared false) validates the false-direction branches.
77//!
78//! [`EncoderConfig`]: zencodec::encode::EncoderConfig
79//! [`DecoderConfig`]: zencodec::decode::DecoderConfig
80
81use std::borrow::Cow;
82use std::sync::Arc;
83
84use whereat::At;
85use zencodec::CodecErrorExt;
86use zencodec::decode::{
87    AnimationFrameDecoder, Decode, DecodeJob, DecodeOutput, DecodeRowSink, DecoderConfig,
88    DynDecoderConfig, SinkError, StreamingDecode,
89};
90use zencodec::encode::{AnimationFrameEncoder, EncodeJob, Encoder, EncoderConfig, Fidelity};
91use zencodec::exif::Exif;
92use zencodec::gainmap::{
93    DecodedGainMap, GainMapChannel, GainMapInfo, GainMapParams, GainMapPresence, GainMapRender,
94    GainMapSource,
95};
96use zencodec::{
97    Cicp, CodecError, ColorAuthority, ContentLightLevel, ErrorCategory, ImageFormat,
98    MasteringDisplay, Metadata, MetadataFields, MetadataPolicy, Orientation, SourceColor,
99    UnsupportedOperation,
100};
101use zenpixels::{
102    ColorContext, PixelBuffer, PixelDescriptor, PixelSlice, PixelSliceMut, TransferFunction,
103};
104
105mod adversarial;
106mod contracts;
107pub use adversarial::{
108    ExpectedError, PrefixCase, PrefixOutcome, TimingCase, check_animation_cases,
109    check_decode_prefix_cases,
110};
111pub(crate) mod fixtures;
112/// The false-direction exemplar codec — consumed only by this crate's own
113/// tests, so it is compiled only with them.
114#[cfg(test)]
115pub(crate) mod minimal;
116pub mod reference;
117pub use contracts::{
118    check_animation_durations, check_animation_timing, check_animation_timing_unsupported,
119    check_decode_policy_admission, check_encode_policy_admission,
120    check_incremental_capability_honesty, check_incremental_contract, check_incremental_decoder,
121    check_inventory_input_limit, check_metadata_rewrite, check_read_at_contract,
122    check_seekable_capability_honesty,
123};
124
125#[cfg(test)]
126pub(crate) use minimal::{MinimalDecoderConfig, MinimalEncoderConfig};
127pub use reference::{
128    RefError, ReferenceDecoderConfig, ReferenceEncoderConfig, ReferenceZcrDecoderConfig, ZCR_FORMAT,
129};
130
131// ===========================================================================
132// Result types
133// ===========================================================================
134
135/// A conformance-check failure, naming the check and a human-readable detail.
136#[derive(Debug, Clone)]
137pub struct Failure {
138    /// The check that failed (e.g. `"metadata_no_leak"`).
139    pub check: &'static str,
140    /// What went wrong, with enough context to act on.
141    pub detail: String,
142}
143
144impl std::fmt::Display for Failure {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        write!(f, "[{}] {}", self.check, self.detail)
147    }
148}
149
150impl std::error::Error for Failure {}
151
152/// The result of a conformance check.
153pub type Conformance = Result<(), Failure>;
154
155fn fail(check: &'static str, detail: impl Into<String>) -> Failure {
156    Failure {
157        check,
158        detail: detail.into(),
159    }
160}
161
162// ===========================================================================
163// Test image
164// ===========================================================================
165
166/// A deterministic test image with contiguous (tightly-packed) rows.
167pub struct TestImage {
168    width: u32,
169    height: u32,
170    desc: PixelDescriptor,
171    data: Vec<u8>,
172}
173
174impl TestImage {
175    /// An RGBA8 image whose channels vary with position, so any row/column
176    /// transposition shows up as a pixel diff.
177    pub fn rgba8_gradient(width: u32, height: u32) -> Self {
178        Self::gradient(width, height, PixelDescriptor::RGBA8_SRGB, 4, 0)
179    }
180
181    /// An RGB8 image with the same gradient pattern.
182    pub fn rgb8_gradient(width: u32, height: u32) -> Self {
183        Self::gradient(width, height, PixelDescriptor::RGB8_SRGB, 3, 0)
184    }
185
186    /// An RGBA8 gradient offset by `seed`, so distinct same-size frames (for
187    /// animation tests) differ in content and a frame-ordering bug is visible.
188    pub fn rgba8_gradient_seeded(width: u32, height: u32, seed: u8) -> Self {
189        Self::gradient(width, height, PixelDescriptor::RGBA8_SRGB, 4, seed)
190    }
191
192    /// An opaque RGB8 gradient offset by `seed`: distinct frames for codecs
193    /// whose animations carry no partial transparency (GIF).
194    pub fn rgb8_gradient_seeded(width: u32, height: u32, seed: u8) -> Self {
195        Self::gradient(width, height, PixelDescriptor::RGB8_SRGB, 3, seed)
196    }
197
198    /// A 16-bit RGB gradient carrying `desc`'s colour labelling (a U16 RGB
199    /// descriptor such as `RGB16_BT2100_PQ`). Samples span the full 16-bit range
200    /// with distinct high and low bytes, so a byte-order bug or an 8-bit
201    /// truncation shows up as a pixel diff.
202    pub(crate) fn rgb16_gradient(width: u32, height: u32, desc: PixelDescriptor) -> Self {
203        assert!(width > 0 && height > 0, "test image must be non-empty");
204        assert_eq!(
205            desc.bytes_per_pixel(),
206            6,
207            "rgb16_gradient needs a U16 RGB descriptor"
208        );
209        let mut data = vec![0u8; width as usize * height as usize * 6];
210        for y in 0..height as usize {
211            for x in 0..width as usize {
212                let p = (y * width as usize + x) * 6;
213                let r = (x * 1031 + y * 517) as u16;
214                let g = (x * 257 + y * 3079) as u16;
215                let b = ((x ^ y) * 4111 + 12345) as u16;
216                data[p..p + 2].copy_from_slice(&r.to_ne_bytes());
217                data[p + 2..p + 4].copy_from_slice(&g.to_ne_bytes());
218                data[p + 4..p + 6].copy_from_slice(&b.to_ne_bytes());
219            }
220        }
221        Self {
222            width,
223            height,
224            desc,
225            data,
226        }
227    }
228
229    fn gradient(width: u32, height: u32, desc: PixelDescriptor, bpp: usize, seed: u8) -> Self {
230        assert!(width > 0 && height > 0, "test image must be non-empty");
231        let s = seed as usize;
232        let mut data = vec![0u8; width as usize * height as usize * bpp];
233        for y in 0..height as usize {
234            for x in 0..width as usize {
235                let p = (y * width as usize + x) * bpp;
236                data[p] = (x * 7 + y * 3 + s) as u8; // R
237                data[p + 1] = (x * 3 + y * 11 + s * 2) as u8; // G
238                data[p + 2] = ((x ^ y) + s) as u8; // B
239                if bpp == 4 {
240                    data[p + 3] = 255 - (x + y + s) as u8; // A
241                }
242            }
243        }
244        Self {
245            width,
246            height,
247            desc,
248            data,
249        }
250    }
251
252    fn row_bytes(&self) -> usize {
253        self.width as usize * self.desc.bytes_per_pixel()
254    }
255
256    /// Borrow the whole image as a [`PixelSlice`].
257    pub fn as_slice(&self) -> PixelSlice<'_> {
258        PixelSlice::new(
259            &self.data,
260            self.width,
261            self.height,
262            self.row_bytes(),
263            self.desc,
264        )
265        .expect("test image dimensions are valid")
266    }
267
268    fn strip(&self, y: u32, h: u32) -> PixelSlice<'_> {
269        let rb = self.row_bytes();
270        let bytes = &self.data[y as usize * rb..(y as usize + h as usize) * rb];
271        PixelSlice::new(bytes, self.width, h, rb, self.desc).expect("strip dimensions are valid")
272    }
273
274    fn pixels(&self) -> Pixels {
275        grab(self.as_slice())
276    }
277}
278
279// ===========================================================================
280// Pixel comparison
281// ===========================================================================
282
283/// A decoded image flattened to contiguous rows for byte-exact comparison.
284#[derive(PartialEq, Eq)]
285struct Pixels {
286    width: u32,
287    rows: u32,
288    desc: PixelDescriptor,
289    bytes: Vec<u8>,
290}
291
292impl std::fmt::Debug for Pixels {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        write!(
295            f,
296            "Pixels {{ {}x{} {:?}, {} bytes }}",
297            self.width,
298            self.rows,
299            self.desc,
300            self.bytes.len()
301        )
302    }
303}
304
305fn grab(ps: PixelSlice<'_>) -> Pixels {
306    grab_ref(&ps)
307}
308
309/// Apply an EXIF `orientation` to `p`, producing the image a conformant reader
310/// would *display* — orientation is a rendering transform, not a label. Uses
311/// zenpixels' canonical [`Orientation::forward_map`] / `output_dimensions`, so
312/// the conventions match production exactly.
313fn render(p: &Pixels, orientation: Orientation) -> Pixels {
314    let bpp = p.desc.bytes_per_pixel();
315    let (w, h) = (p.width, p.rows);
316    let (ow, oh) = orientation.output_dimensions(w, h);
317    let in_rb = w as usize * bpp;
318    let out_rb = ow as usize * bpp;
319    let mut bytes = vec![0u8; oh as usize * out_rb];
320    for sy in 0..h {
321        for sx in 0..w {
322            let (dx, dy) = orientation.forward_map(sx, sy, w, h);
323            let si = sy as usize * in_rb + sx as usize * bpp;
324            let di = dy as usize * out_rb + dx as usize * bpp;
325            bytes[di..di + bpp].copy_from_slice(&p.bytes[si..si + bpp]);
326        }
327    }
328    Pixels {
329        width: ow,
330        rows: oh,
331        desc: p.desc,
332        bytes,
333    }
334}
335
336/// `grab` for a borrowed slice — e.g. [`AnimationFrame::pixels`], which returns a
337/// reference into the decoder's canvas.
338fn grab_ref(ps: &PixelSlice<'_>) -> Pixels {
339    let rb = ps.width() as usize * ps.descriptor().bytes_per_pixel();
340    let mut bytes = Vec::with_capacity(rb * ps.rows() as usize);
341    for y in 0..ps.rows() {
342        bytes.extend_from_slice(&ps.row(y)[..rb]);
343    }
344    Pixels {
345        width: ps.width(),
346        rows: ps.rows(),
347        desc: ps.descriptor(),
348        bytes,
349    }
350}
351
352// ===========================================================================
353// Collecting decode sink
354// ===========================================================================
355
356/// A [`DecodeRowSink`] that gathers all strips into one contiguous buffer.
357#[derive(Default)]
358struct CollectSink {
359    width: u32,
360    rows: u32,
361    desc: Option<PixelDescriptor>,
362    buf: Vec<u8>,
363}
364
365impl DecodeRowSink for CollectSink {
366    fn begin(
367        &mut self,
368        width: u32,
369        height: u32,
370        descriptor: PixelDescriptor,
371    ) -> Result<(), SinkError> {
372        self.width = width;
373        self.rows = height;
374        self.desc = Some(descriptor);
375        self.buf = vec![0u8; width as usize * height as usize * descriptor.bytes_per_pixel()];
376        Ok(())
377    }
378
379    fn provide_next_buffer(
380        &mut self,
381        y: u32,
382        height: u32,
383        width: u32,
384        descriptor: PixelDescriptor,
385    ) -> Result<PixelSliceMut<'_>, SinkError> {
386        let stride = width as usize * descriptor.bytes_per_pixel();
387        let end = (y as usize + height as usize) * stride;
388        if self.buf.len() < end {
389            self.buf.resize(end, 0);
390        }
391        self.width = width;
392        self.desc = Some(descriptor);
393        self.rows = self.rows.max(y + height);
394        let off = y as usize * stride;
395        // Dimensions and span are exact by construction, so this never fails.
396        Ok(
397            PixelSliceMut::new(&mut self.buf[off..end], width, height, stride, descriptor)
398                .expect("collect sink buffer dimensions are valid"),
399        )
400    }
401}
402
403impl CollectSink {
404    fn into_pixels(self) -> Result<Pixels, String> {
405        let desc = self.desc.ok_or("sink received no buffers")?;
406        Ok(Pixels {
407            width: self.width,
408            rows: self.rows,
409            desc,
410            bytes: self.buf,
411        })
412    }
413}
414
415// ===========================================================================
416// Encode / decode path runners (generic over codec config)
417// ===========================================================================
418
419fn enc_oneshot<E>(
420    cfg: &E,
421    img: &TestImage,
422    meta: Metadata,
423    policy: MetadataPolicy,
424) -> Result<Vec<u8>, String>
425where
426    E: EncoderConfig,
427    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
428{
429    let enc = cfg
430        .clone()
431        .job()
432        .with_metadata_policy(meta, policy)
433        .encoder()
434        .map_err(|e| e.to_string())?;
435    Ok(enc
436        .encode(img.as_slice())
437        .map_err(|e| e.to_string())?
438        .into_vec())
439}
440
441fn enc_push_rows<E>(cfg: &E, img: &TestImage) -> Result<Vec<u8>, String>
442where
443    E: EncoderConfig,
444    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
445{
446    let mut enc = cfg
447        .clone()
448        .job()
449        .with_metadata_policy(Metadata::none(), MetadataPolicy::PreserveExact)
450        .encoder()
451        .map_err(|e| e.to_string())?;
452    let strip = enc.preferred_strip_height().max(1);
453    let mut y = 0;
454    while y < img.height {
455        let h = strip.min(img.height - y);
456        enc.push_rows(img.strip(y, h)).map_err(|e| e.to_string())?;
457        y += h;
458    }
459    Ok(enc.finish().map_err(|e| e.to_string())?.into_vec())
460}
461
462fn dec_oneshot<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<(Pixels, Metadata), String> {
463    let out = cfg
464        .clone()
465        .job()
466        .decoder(Cow::Borrowed(bytes), &[])
467        .map_err(|e| e.to_string())?
468        .decode()
469        .map_err(|e| e.to_string())?;
470    Ok((grab(out.pixels()), out.metadata()))
471}
472
473fn dec_streaming<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<Pixels, String> {
474    let mut sd = cfg
475        .clone()
476        .job()
477        .streaming_decoder(Cow::Borrowed(bytes), &[])
478        .map_err(|e| e.to_string())?;
479    let mut width = 0;
480    let mut rows = 0;
481    let mut desc = None;
482    let mut bytes_out = Vec::new();
483    while let Some((_, strip)) = sd.next_batch().map_err(|e| e.to_string())? {
484        let p = grab(strip);
485        if desc.is_none() {
486            width = p.width;
487            desc = Some(p.desc);
488        }
489        rows += p.rows;
490        bytes_out.extend_from_slice(&p.bytes);
491    }
492    Ok(Pixels {
493        width,
494        rows,
495        desc: desc.ok_or("streaming decoder yielded no strips")?,
496        bytes: bytes_out,
497    })
498}
499
500fn dec_push<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<Pixels, String> {
501    let mut sink = CollectSink::default();
502    cfg.clone()
503        .job()
504        .push_decoder(Cow::Borrowed(bytes), &mut sink, &[])
505        .map_err(|e| e.to_string())?;
506    sink.into_pixels()
507}
508
509// ===========================================================================
510// Conformance checks
511// ===========================================================================
512
513/// A round trip through one-shot encode → one-shot decode reproduces the input
514/// pixels exactly. The smallest sanity check; a failure here means nothing else
515/// is trustworthy.
516pub fn check_pixel_roundtrip<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
517where
518    E: EncoderConfig,
519    D: DecoderConfig,
520    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
521{
522    const CHECK: &str = "pixel_roundtrip";
523    let bytes = enc_oneshot(&enc, img, Metadata::none(), MetadataPolicy::PreserveExact)
524        .map_err(|e| fail(CHECK, format!("encode: {e}")))?;
525    let (got, _) = dec_oneshot(&dec, &bytes).map_err(|e| fail(CHECK, format!("decode: {e}")))?;
526    if got != img.pixels() {
527        return Err(fail(
528            CHECK,
529            format!(
530                "decoded pixels differ from the {}x{} input",
531                img.width, img.height
532            ),
533        ));
534    }
535    Ok(())
536}
537
538/// Verify the [`OutputTiming`](zencodec::encode::OutputTiming) an encoder's
539/// [`begin`](Encoder::begin) reports, and
540/// [`EncodeCapabilities::streaming_output`](zencodec::encode::EncodeCapabilities::streaming_output)
541/// behind it.
542///
543/// Run it with a configuration that streams as well as the default: a
544/// configuration that reports [`OutputTiming::AT_FINISH`](zencodec::encode::OutputTiming::AT_FINISH)
545/// passes without exercising the streaming path. To verify a lag of `L`
546/// rows the image must be taller than `L + 2 x strip`, where `strip` is the
547/// encoder's [`preferred_strip_height`](Encoder::preferred_strip_height)
548/// (at most a quarter of the image); a shorter image fails the check rather
549/// than passing it unverified. [`check_all`] picks a tall enough image.
550///
551/// - Without `push_rows` the codec must not declare `streaming_output`.
552/// - `begin` may reject the declaration as
553///   [`UnsupportedOperation::RowLevelEncode`] (this configuration cannot
554///   encode from rows); any other error fails the check.
555/// - A streaming timing needs `streaming_output` declared and a lag below
556///   the image height.
557/// - `AT_FINISH`: `pending_output` stays empty until `finish`.
558/// - Streaming with lag `L`: between any two points of the encode `L` or
559///   more rows apart (after `begin` and after each strip but the last),
560///   bytes become pending. That catches an understated lag and an encoder
561///   that stalls after its first strip.
562/// - Draining all pending bytes after every strip, and separately draining
563///   uneven prefixes (one byte, a few, all but one, and once more than is
564///   pending, which clamps), each yield, with `finish`'s remainder, exactly
565///   the bytes of the same encode left undrained. Those bytes decode to the
566///   pixels of the one-shot [`encode`](Encoder::encode). (Not to a plain
567///   `push_rows` encode: an encoder may need the image size before its first
568///   row, through `begin` or `with_canvas_size`.)
569/// - Draining with [`take_output`](Encoder::take_output), into an empty
570///   vector and into a non-empty one, yields the same bytes and leaves
571///   nothing pending.
572/// - After a streaming `begin`, a second `begin` and a one-shot `encode` are
573///   errors.
574/// - `begin` with a zero width or height is accepted exactly when `encode`
575///   of that size succeeds (or declines as `RowLevelEncode`).
576/// - A refused `begin` leaves the encoder unchanged: after the first such
577///   zero-size refusal, `begin` with the real size on the same encoder
578///   reports the same timing and streams the same file.
579/// - Every run reports the same timing.
580pub fn check_streaming_output_honesty<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
581where
582    E: EncoderConfig,
583    D: DecoderConfig,
584    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
585{
586    use zencodec::encode::OutputTiming;
587    const CHECK: &str = "streaming_output_honesty";
588    let caps = E::capabilities();
589    if !caps.push_rows() {
590        if caps.streaming_output() {
591            return Err(fail(CHECK, "declares streaming_output without push_rows"));
592        }
593        return Ok(());
594    }
595    let desc = img.strip(0, 1).descriptor();
596    /// How a run drains `pending_output`.
597    #[derive(Clone, Copy, PartialEq)]
598    enum Drain {
599        /// Never: everything arrives from `finish`.
600        Never,
601        /// All of it after every strip.
602        All,
603        /// Uneven prefixes, cycling through these choices, one of which asks
604        /// for more than is pending.
605        Partial,
606        /// `take_output`, into an empty vector and into one already holding
607        /// a byte, alternately.
608        Take,
609    }
610    struct Run {
611        timing: OutputTiming,
612        file: Vec<u8>,
613        /// Rows pushed and bytes available (drained plus pending) after
614        /// `begin` and after each strip.
615        points: Vec<(u32, usize)>,
616    }
617    let encoder = || {
618        enc.clone()
619            .job()
620            .with_metadata_policy(Metadata::none(), MetadataPolicy::PreserveExact)
621            .encoder()
622            .map_err(|e| e.to_string())
623    };
624    // Declare the image on `e`, push every strip draining as `drain` says,
625    // and finish. `Ok(None)`: begin declined row-level encode for this
626    // configuration.
627    let run_on = |mut e: <E::Job as EncodeJob>::Enc, drain: Drain| -> Result<Option<Run>, String> {
628        let timing = match e.begin(img.width, img.height, desc) {
629            Ok(t) => t,
630            Err(err)
631                if err.unsupported_operation() == Some(&UnsupportedOperation::RowLevelEncode) =>
632            {
633                return Ok(None);
634            }
635            Err(err) => return Err(format!("begin: {err}")),
636        };
637        let mut out = Vec::new();
638        let mut cycle = 0usize;
639        let mut take =
640            |e: &mut <E::Job as EncodeJob>::Enc, out: &mut Vec<u8>| -> Result<(), String> {
641                if drain == Drain::Take {
642                    cycle += 1;
643                    let mut chunk = if cycle.is_multiple_of(2) {
644                        Vec::new()
645                    } else {
646                        vec![0xA5]
647                    };
648                    let kept = chunk.len();
649                    let len = e.pending_output().len();
650                    e.take_output(&mut chunk);
651                    if chunk.len() != kept + len || chunk[..kept] != [0xA5][..kept] {
652                        return Err(format!(
653                            "take_output of {len} pending bytes into a vector of {kept} \
654                             left it {} long, or changed what it held",
655                            chunk.len()
656                        ));
657                    }
658                    if !e.pending_output().is_empty() {
659                        return Err("take_output left bytes pending".into());
660                    }
661                    out.extend_from_slice(&chunk[kept..]);
662                    return Ok(());
663                }
664                let pending = e.pending_output();
665                let len = pending.len();
666                let n = match drain {
667                    Drain::Never => return Ok(()),
668                    Drain::Take => unreachable!("handled above"),
669                    Drain::All => len,
670                    Drain::Partial => {
671                        cycle += 1;
672                        match cycle % 5 {
673                            0 => 1.min(len),
674                            1 => 7.min(len),
675                            2 => len.saturating_sub(1),
676                            3 => len + 5, // more than pending: clamps
677                            _ => len / 2,
678                        }
679                    }
680                };
681                out.extend_from_slice(&pending[..n.min(len)]);
682                e.consume_output(n);
683                let left = e.pending_output().len();
684                if left != len - n.min(len) {
685                    return Err(format!(
686                        "consume_output({n}) of {len} pending left {left} (want {})",
687                        len - n.min(len)
688                    ));
689                }
690                Ok(())
691            };
692        let mut points = vec![(0, out.len() + e.pending_output().len())];
693        take(&mut e, &mut out)?;
694        let strip = e
695            .preferred_strip_height()
696            .max(1)
697            .min(img.height.div_ceil(4).max(1));
698        let mut y = 0;
699        while y < img.height {
700            let h = strip.min(img.height - y);
701            e.push_rows(img.strip(y, h)).map_err(|e| e.to_string())?;
702            y += h;
703            points.push((y, out.len() + e.pending_output().len()));
704            take(&mut e, &mut out)?;
705        }
706        // Whatever is still pending after a partial drain precedes `finish`'s
707        // remainder.
708        out.extend_from_slice(e.pending_output());
709        let n = e.pending_output().len();
710        e.consume_output(n);
711        out.extend_from_slice(&e.finish().map_err(|e| e.to_string())?.into_vec());
712        Ok(Some(Run {
713            timing,
714            file: out,
715            points,
716        }))
717    };
718    let run = |drain: Drain| -> Result<Option<Run>, String> { run_on(encoder()?, drain) };
719    let Some(drained) = run(Drain::All).map_err(|e| fail(CHECK, format!("drained encode: {e}")))?
720    else {
721        return Ok(());
722    };
723    let timing = drained.timing;
724    if timing.streams() && !caps.streaming_output() {
725        return Err(fail(
726            CHECK,
727            "begin reported streaming output but streaming_output is not declared",
728        ));
729    }
730    let Some(lag) = timing.lag_rows() else {
731        if drained.points.iter().any(|&(_, n)| n > 0) {
732            return Err(fail(
733                CHECK,
734                "pending_output returned bytes but begin reported AT_FINISH",
735            ));
736        }
737        return Ok(());
738    };
739    if lag >= img.height {
740        return Err(fail(
741            CHECK,
742            format!(
743                "begin reported streaming with a lag of {lag} rows on a {}-row image: \
744                 an encoder that holds every row reports AT_FINISH",
745                img.height
746            ),
747        ));
748    }
749    let strip = drained.points.get(1).map_or(1, |p| p.0);
750    if img.height <= lag + 2 * strip {
751        return Err(fail(
752            CHECK,
753            format!(
754                "a {}-row image cannot verify a lag of {lag} rows in strips of {strip}: \
755                 use an image taller than {} rows",
756                img.height,
757                lag + 2 * strip
758            ),
759        ));
760    }
761    // Between any two points at least `lag` rows apart, the later one has
762    // more bytes: every row more than `lag` back is encoded by then. The
763    // last strip's bytes may wait for `finish`.
764    let points = &drained.points[..drained.points.len() - 1];
765    for (i, &(rows, bytes)) in points.iter().enumerate() {
766        if let Some(&(later, more)) = points[i + 1..].iter().find(|&&(r, _)| r > rows + lag)
767            && more <= bytes
768        {
769            return Err(fail(
770                CHECK,
771                format!(
772                    "begin reported a lag of {lag} rows, but from {rows} to {later} rows \
773                     pushed no bytes became pending ({bytes} available at both)"
774                ),
775            ));
776        }
777    }
778    let whole = run(Drain::Never)
779        .map_err(|e| fail(CHECK, format!("undrained encode: {e}")))?
780        .ok_or_else(|| fail(CHECK, "begin declined row-level encode on the second run"))?;
781    let partial = run(Drain::Partial)
782        .map_err(|e| fail(CHECK, format!("partially drained encode: {e}")))?
783        .ok_or_else(|| fail(CHECK, "begin declined row-level encode on the third run"))?;
784    let taken = run(Drain::Take)
785        .map_err(|e| fail(CHECK, format!("encode drained with take_output: {e}")))?
786        .ok_or_else(|| fail(CHECK, "begin declined row-level encode on the fourth run"))?;
787    if whole.timing != timing || partial.timing != timing || taken.timing != timing {
788        return Err(fail(
789            CHECK,
790            "begin reported a different timing on another run",
791        ));
792    }
793    if drained.file != whole.file {
794        return Err(fail(
795            CHECK,
796            "draining pending_output changed the encoded bytes",
797        ));
798    }
799    if partial.file != whole.file {
800        return Err(fail(
801            CHECK,
802            "draining uneven prefixes of pending_output changed the encoded bytes",
803        ));
804    }
805    if taken.file != whole.file {
806        return Err(fail(
807            CHECK,
808            "draining with take_output changed the encoded bytes",
809        ));
810    }
811    // Protocol: one declaration per encode, and no one-shot encode after it.
812    let mut e = encoder().map_err(|e| fail(CHECK, e))?;
813    e.begin(img.width, img.height, desc)
814        .map_err(|e| fail(CHECK, format!("begin: {e}")))?;
815    if e.begin(img.width, img.height, desc).is_ok() {
816        return Err(fail(CHECK, "a second begin was accepted"));
817    }
818    let mut e = encoder().map_err(|e| fail(CHECK, e))?;
819    e.begin(img.width, img.height, desc)
820        .map_err(|e| fail(CHECK, format!("begin: {e}")))?;
821    if e.encode(img.strip(0, img.height)).is_ok() {
822        return Err(fail(CHECK, "encode after a streaming begin was accepted"));
823    }
824    // A zero width or height: `begin` answers as `encode` of that size does.
825    // A refused `begin` leaves the encoder unchanged, so the real declaration
826    // that follows on the same encoder streams the same file.
827    let bpp = desc.bytes_per_pixel();
828    let mut refusal_checked = false;
829    for (w, h) in [(0, img.height), (img.width, 0)] {
830        let empty = vec![0u8; w as usize * bpp * h as usize];
831        let slice = PixelSlice::new(&empty, w, h, w as usize * bpp, desc)
832            .map_err(|e| fail(CHECK, format!("{w}x{h} slice: {e}")))?;
833        let encoded = encoder().map_err(|e| fail(CHECK, e))?.encode(slice).is_ok();
834        let mut e = encoder().map_err(|e| fail(CHECK, e))?;
835        let begun = e.begin(w, h, desc);
836        if let Err(err) = &begun
837            && err.unsupported_operation() == Some(&UnsupportedOperation::RowLevelEncode)
838        {
839            continue;
840        }
841        if begun.is_ok() != encoded {
842            return Err(fail(
843                CHECK,
844                format!(
845                    "begin({w}, {h}) {} but encode of a {w}x{h} image {}",
846                    if begun.is_ok() {
847                        "was accepted"
848                    } else {
849                        "was refused"
850                    },
851                    if encoded { "succeeds" } else { "fails" }
852                ),
853            ));
854        }
855        if begun.is_err() && !refusal_checked {
856            refusal_checked = true;
857            let after = run_on(e, Drain::All)
858                .map_err(|m| {
859                    fail(
860                        CHECK,
861                        format!(
862                            "after a refused begin({w}, {h}), the encode of the real \
863                             {}x{} image on the same encoder failed: {m}",
864                            img.width, img.height
865                        ),
866                    )
867                })?
868                .ok_or_else(|| {
869                    fail(
870                        CHECK,
871                        format!("after a refused begin({w}, {h}), begin declined row-level encode"),
872                    )
873                })?;
874            if after.timing != timing || after.file != whole.file {
875                return Err(fail(
876                    CHECK,
877                    format!(
878                        "a refused begin({w}, {h}) changed the encode that followed on \
879                         the same encoder"
880                    ),
881                ));
882            }
883        }
884    }
885    let one = enc_oneshot(&enc, img, Metadata::none(), MetadataPolicy::PreserveExact)
886        .map_err(|e| fail(CHECK, format!("one-shot encode: {e}")))?;
887    let (want, _) = dec_oneshot(&dec, &one).map_err(|e| fail(CHECK, format!("decode: {e}")))?;
888    let (got, _) = dec_oneshot(&dec, &drained.file)
889        .map_err(|e| fail(CHECK, format!("decode streamed: {e}")))?;
890    if got != want {
891        return Err(fail(
892            CHECK,
893            "streamed encode decodes to different pixels than the one-shot encode",
894        ));
895    }
896    Ok(())
897}
898
899/// The image [`check_all`] gives [`check_streaming_output_honesty`]: 40 wide,
900/// and tall enough to verify the lag the default configuration's `begin`
901/// reports (the lag may grow with the height, so ask again).
902fn streaming_check_image<E>(enc: &E) -> TestImage
903where
904    E: EncoderConfig,
905    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
906{
907    let mut height = 24u32;
908    for _ in 0..4 {
909        let Ok(mut e) = enc.clone().job().encoder() else {
910            break;
911        };
912        let Ok(timing) = e.begin(40, height, PixelDescriptor::RGBA8_SRGB) else {
913            break;
914        };
915        let Some(lag) = timing.lag_rows() else {
916            break;
917        };
918        let strip = e
919            .preferred_strip_height()
920            .max(1)
921            .min(height.div_ceil(4).max(1));
922        if height > lag + 2 * strip {
923            break;
924        }
925        height = lag + 2 * strip.max(e.preferred_strip_height().max(1)) + 8;
926    }
927    TestImage::rgba8_gradient(40, height)
928}
929
930/// Every advertised feeding mode produces identical pixels.
931///
932/// Encode paths: one-shot, plus incremental `push_rows` when the encoder's
933/// capabilities advertise it. Decode paths: one-shot, push-sink, plus streaming
934/// when advertised. All decoded results must equal each other *and* the input.
935pub fn check_cross_path_pixel_equivalence<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
936where
937    E: EncoderConfig,
938    D: DecoderConfig,
939    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
940{
941    const CHECK: &str = "cross_path_pixel_equivalence";
942    let want = img.pixels();
943
944    // --- decode paths over a canonical one-shot encode ---
945    let canonical = enc_oneshot(&enc, img, Metadata::none(), MetadataPolicy::PreserveExact)
946        .map_err(|e| fail(CHECK, format!("canonical encode: {e}")))?;
947
948    let mut decoded: Vec<(&str, Pixels)> = Vec::new();
949    decoded.push((
950        "decode",
951        dec_oneshot(&dec, &canonical)
952            .map_err(|e| fail(CHECK, format!("one-shot decode: {e}")))?
953            .0,
954    ));
955    decoded.push((
956        "push_decoder",
957        dec_push(&dec, &canonical).map_err(|e| fail(CHECK, format!("push decode: {e}")))?,
958    ));
959    if D::capabilities().streaming() {
960        decoded.push((
961            "streaming",
962            dec_streaming(&dec, &canonical)
963                .map_err(|e| fail(CHECK, format!("streaming decode: {e}")))?,
964        ));
965    }
966
967    for (name, px) in &decoded {
968        if *px != want {
969            return Err(fail(
970                CHECK,
971                format!("decode path `{name}` diverged from the input image"),
972            ));
973        }
974    }
975
976    // --- encode paths must all decode back to the input ---
977    if E::capabilities().push_rows() {
978        let pr =
979            enc_push_rows(&enc, img).map_err(|e| fail(CHECK, format!("push_rows encode: {e}")))?;
980        let (got, _) = dec_oneshot(&dec, &pr)
981            .map_err(|e| fail(CHECK, format!("decode push_rows output: {e}")))?;
982        if got != want {
983            return Err(fail(
984                CHECK,
985                "push_rows encode produced different pixels than one-shot encode".to_string(),
986            ));
987        }
988    }
989
990    if E::capabilities().encode_from() {
991        let ef = run_encode_from(&enc, img)
992            .map_err(|e| fail(CHECK, format!("encode_from encode: {e}")))?;
993        let (got, _) = dec_oneshot(&dec, &ef)
994            .map_err(|e| fail(CHECK, format!("decode encode_from output: {e}")))?;
995        if got != want {
996            return Err(fail(
997                CHECK,
998                "encode_from (pull-source) produced different pixels than one-shot encode"
999                    .to_string(),
1000            ));
1001        }
1002    }
1003
1004    Ok(())
1005}
1006
1007fn encode_animation<E>(cfg: &E, frames: &[TestImage], meta: Metadata) -> Result<Vec<u8>, String>
1008where
1009    E: EncoderConfig,
1010    <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
1011{
1012    let mut a = cfg
1013        .clone()
1014        .job()
1015        .with_metadata_policy(meta, MetadataPolicy::PreserveExact)
1016        .with_loop_count(Some(0))
1017        .animation_frame_encoder()
1018        .map_err(|e| e.to_string())?;
1019    for (i, f) in frames.iter().enumerate() {
1020        a.push_frame(f.as_slice(), 40 + i as u32 * 10, None)
1021            .map_err(|e| e.to_string())?;
1022    }
1023    Ok(a.finish(None).map_err(|e| e.to_string())?.into_vec())
1024}
1025
1026fn decode_anim_borrowed<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<Vec<Pixels>, String> {
1027    let mut d = cfg
1028        .clone()
1029        .job()
1030        .animation_frame_decoder(Cow::Borrowed(bytes), &[])
1031        .map_err(|e| e.to_string())?;
1032    let mut out = Vec::new();
1033    // The borrowed frame is invalidated by the next call, so copy before looping.
1034    while let Some(frame) = d.render_next_frame(None).map_err(|e| e.to_string())? {
1035        out.push(grab_ref(frame.pixels()));
1036    }
1037    Ok(out)
1038}
1039
1040fn decode_anim_owned<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<Vec<Pixels>, String> {
1041    let mut d = cfg
1042        .clone()
1043        .job()
1044        .animation_frame_decoder(Cow::Borrowed(bytes), &[])
1045        .map_err(|e| e.to_string())?;
1046    let mut out = Vec::new();
1047    while let Some(frame) = d.render_next_frame_owned(None).map_err(|e| e.to_string())? {
1048        out.push(grab(frame.pixels()));
1049    }
1050    Ok(out)
1051}
1052
1053fn decode_anim_sink<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<Vec<Pixels>, String> {
1054    let mut d = cfg
1055        .clone()
1056        .job()
1057        .animation_frame_decoder(Cow::Borrowed(bytes), &[])
1058        .map_err(|e| e.to_string())?;
1059    let mut out = Vec::new();
1060    loop {
1061        let mut sink = CollectSink::default();
1062        match d
1063            .render_next_frame_to_sink(None, &mut sink)
1064            .map_err(|e| e.to_string())?
1065        {
1066            Some(_info) => out.push(sink.into_pixels()?),
1067            None => break,
1068        }
1069    }
1070    Ok(out)
1071}
1072
1073/// Every animation decode path yields identical frames, matching the input.
1074///
1075/// Encodes the frames via the animation encoder, then decodes the result three
1076/// ways — [`render_next_frame`](zencodec::decode::AnimationFrameDecoder::render_next_frame)
1077/// (borrowed canvas), `render_next_frame_owned`, and `render_next_frame_to_sink`
1078/// (push model) — and asserts all three produce the same frame count and the same
1079/// per-frame pixels as the input. The borrowed path is the usual source of bugs:
1080/// its frame aliases the decoder's canvas and is invalidated by the next call, so
1081/// a codec that composites in place can leak the wrong frame.
1082///
1083/// Skipped (returns `Ok`) when either end does not advertise animation.
1084pub fn check_animation_cross_path_equivalence<E, D>(
1085    enc: E,
1086    dec: D,
1087    frames: &[TestImage],
1088) -> Conformance
1089where
1090    E: EncoderConfig,
1091    D: DecoderConfig,
1092    <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
1093{
1094    const CHECK: &str = "animation_cross_path_equivalence";
1095    if !E::capabilities().animation() || !D::capabilities().animation() {
1096        return Ok(()); // not applicable to a still-only codec
1097    }
1098    if frames.is_empty() {
1099        return Err(fail(CHECK, "no frames supplied"));
1100    }
1101
1102    let bytes = encode_animation(&enc, frames, Metadata::none())
1103        .map_err(|e| fail(CHECK, format!("encode: {e}")))?;
1104    let want: Vec<Pixels> = frames.iter().map(|f| f.pixels()).collect();
1105
1106    // Keep timing in the published one-call suite too, without adding the
1107    // static bounds needed by erased-dispatch conformance checks.
1108    let mut borrowed = dec
1109        .clone()
1110        .job()
1111        .animation_frame_decoder(Cow::Borrowed(&bytes), &[])
1112        .map_err(|e| fail(CHECK, e.to_string()))?;
1113    let mut owned = dec
1114        .clone()
1115        .job()
1116        .animation_frame_decoder(Cow::Borrowed(&bytes), &[])
1117        .map_err(|e| fail(CHECK, e.to_string()))?;
1118    for index in 0..frames.len() {
1119        let expected = zencodec::animation::FrameDuration::from_millis(40 + index as u32 * 10);
1120        let a = borrowed
1121            .render_next_frame(None)
1122            .map_err(|e| fail(CHECK, e.to_string()))?
1123            .ok_or_else(|| fail(CHECK, "missing borrowed frame"))?;
1124        let b = owned
1125            .render_next_frame_owned(None)
1126            .map_err(|e| fail(CHECK, e.to_string()))?
1127            .ok_or_else(|| fail(CHECK, "missing owned frame"))?;
1128        if a.duration() != expected || b.duration() != expected {
1129            return Err(fail(
1130                CHECK,
1131                format!(
1132                    "frame {index} lost timing: {:?}/{:?}, expected {expected:?}",
1133                    a.duration(),
1134                    b.duration()
1135                ),
1136            ));
1137        }
1138    }
1139
1140    let paths = [
1141        ("render_next_frame", decode_anim_borrowed(&dec, &bytes)),
1142        ("render_next_frame_owned", decode_anim_owned(&dec, &bytes)),
1143        ("render_next_frame_to_sink", decode_anim_sink(&dec, &bytes)),
1144    ];
1145    for (name, res) in paths {
1146        let got = res.map_err(|e| fail(CHECK, format!("{name}: {e}")))?;
1147        if got.len() != want.len() {
1148            return Err(fail(
1149                CHECK,
1150                format!(
1151                    "{name} produced {} frames, expected {}",
1152                    got.len(),
1153                    want.len()
1154                ),
1155            ));
1156        }
1157        for (i, (g, w)) in got.iter().zip(&want).enumerate() {
1158            if g != w {
1159                return Err(fail(
1160                    CHECK,
1161                    format!("{name} frame {i} pixels differ from the input"),
1162                ));
1163            }
1164        }
1165    }
1166    Ok(())
1167}
1168
1169/// Verify the [`AnimationOutputTiming`](zencodec::encode::AnimationOutputTiming)
1170/// an animation encoder's [`begin`](AnimationFrameEncoder::begin) reports.
1171///
1172/// Runs `begin` with the frame count declared and without it. For each
1173/// declaration that streams:
1174///
1175/// - With the count declared, the lag is below the frame count.
1176/// - Between any two points `lag` or more frames apart (after `begin` and
1177///   after each frame but the last), bytes become pending. To verify a lag
1178///   of `L` frames, supply more than `L + 1` frames; fewer fails the check.
1179/// - Draining everything after each frame, draining with
1180///   [`take_output`](AnimationFrameEncoder::take_output), and not draining
1181///   all produce the same bytes, which decode to the same frames and
1182///   durations as the encode without `begin` (so lossy codecs can run it).
1183/// - A second `begin` is an error, and so is finishing after fewer frames
1184///   than declared.
1185/// - A refused `begin` leaves the encoder unchanged: when `begin` refuses a
1186///   zero-width canvas, `begin` with the real canvas on the same encoder
1187///   reports the same timing and streams the same file.
1188///
1189/// Skipped (returns `Ok`) when the encoder does not advertise animation.
1190/// `begin` may decline with an [`UnsupportedOperation`]; any other error
1191/// fails the check.
1192pub fn check_animation_streaming_output_honesty<E, D>(
1193    enc: E,
1194    dec: D,
1195    frames: &[TestImage],
1196) -> Conformance
1197where
1198    E: EncoderConfig,
1199    D: DecoderConfig,
1200    <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
1201{
1202    use zencodec::encode::AnimationOutputTiming;
1203    const CHECK: &str = "animation_streaming_output_honesty";
1204    if !E::capabilities().animation() {
1205        return Ok(());
1206    }
1207    let Some(first) = frames.first() else {
1208        return Err(fail(CHECK, "no frames supplied"));
1209    };
1210    let (width, height) = (first.width, first.height);
1211    let desc = first.strip(0, 1).descriptor();
1212    let count = frames.len() as u32;
1213    let encoder = || {
1214        enc.clone()
1215            .job()
1216            .with_metadata_policy(Metadata::none(), MetadataPolicy::PreserveExact)
1217            .with_loop_count(Some(0))
1218            .animation_frame_encoder()
1219            .map_err(|e| e.to_string())
1220    };
1221    #[derive(Clone, Copy, PartialEq)]
1222    enum Drain {
1223        Never,
1224        All,
1225        Take,
1226    }
1227    struct Run {
1228        timing: AnimationOutputTiming,
1229        file: Vec<u8>,
1230        points: Vec<(u32, usize)>,
1231    }
1232    // Declare the animation on `a`, push every frame draining as `drain`
1233    // says, and finish. `Ok(None)`: begin declined.
1234    let run_on = |mut a: <E::Job as EncodeJob>::AnimationFrameEnc,
1235                  declare: Option<u32>,
1236                  drain: Drain|
1237     -> Result<Option<Run>, String> {
1238        let timing = match a.begin(width, height, desc, declare) {
1239            Ok(t) => t,
1240            Err(err) if err.unsupported_operation().is_some() => return Ok(None),
1241            Err(err) => return Err(format!("begin: {err}")),
1242        };
1243        let mut out = Vec::new();
1244        let take = |a: &mut <E::Job as EncodeJob>::AnimationFrameEnc, out: &mut Vec<u8>| match drain
1245        {
1246            Drain::Never => {}
1247            Drain::All => {
1248                let n = a.pending_output().len();
1249                out.extend_from_slice(a.pending_output());
1250                a.consume_output(n);
1251            }
1252            Drain::Take => {
1253                let mut chunk = Vec::new();
1254                a.take_output(&mut chunk);
1255                out.extend_from_slice(&chunk);
1256            }
1257        };
1258        let mut points = vec![(0, a.pending_output().len())];
1259        take(&mut a, &mut out);
1260        for (i, f) in frames.iter().enumerate() {
1261            a.push_frame(f.as_slice(), 40 + i as u32 * 10, None)
1262                .map_err(|e| format!("push_frame {i}: {e}"))?;
1263            points.push((i as u32 + 1, out.len() + a.pending_output().len()));
1264            take(&mut a, &mut out);
1265        }
1266        if !a.pending_output().is_empty() && drain != Drain::Never {
1267            return Err("bytes pending right after a full drain".into());
1268        }
1269        // `finish` returns what is still pending along with the rest.
1270        out.extend_from_slice(&a.finish(None).map_err(|e| e.to_string())?.into_vec());
1271        Ok(Some(Run {
1272            timing,
1273            file: out,
1274            points,
1275        }))
1276    };
1277    let run = |declare: Option<u32>, drain: Drain| -> Result<Option<Run>, String> {
1278        run_on(encoder()?, declare, drain)
1279    };
1280    // `begin` may change framing, never pixels or timing: the streamed file
1281    // must decode exactly as the same encode without `begin` does (not as
1282    // the input, which a lossy codec need not reproduce).
1283    let plain = encode_animation(&enc, frames, Metadata::none())
1284        .map_err(|e| fail(CHECK, format!("encode without begin: {e}")))?;
1285    let decode_timed =
1286        |bytes: &[u8]| -> Result<Vec<(Pixels, zencodec::animation::FrameDuration)>, String> {
1287            let mut d = dec
1288                .clone()
1289                .job()
1290                .animation_frame_decoder(Cow::Borrowed(bytes), &[])
1291                .map_err(|e| e.to_string())?;
1292            let mut out = Vec::new();
1293            while let Some(f) = d.render_next_frame(None).map_err(|e| e.to_string())? {
1294                out.push((grab_ref(f.pixels()), f.duration()));
1295            }
1296            Ok(out)
1297        };
1298    let want = decode_timed(&plain).map_err(|e| fail(CHECK, format!("decode: {e}")))?;
1299    for declare in [Some(count), None] {
1300        let label = if declare.is_some() {
1301            "with the frame count"
1302        } else {
1303            "without the frame count"
1304        };
1305        let Some(drained) =
1306            run(declare, Drain::All).map_err(|e| fail(CHECK, format!("{label}: {e}")))?
1307        else {
1308            continue;
1309        };
1310        let Some(lag) = drained.timing.lag_frames() else {
1311            if drained.points.iter().any(|&(_, n)| n > 0) {
1312                return Err(fail(
1313                    CHECK,
1314                    format!("{label}: pending_output returned bytes but begin reported AT_FINISH"),
1315                ));
1316            }
1317            continue;
1318        };
1319        if declare.is_some() && lag >= count {
1320            return Err(fail(
1321                CHECK,
1322                format!(
1323                    "{label}: begin reported a lag of {lag} frames for {count} frames: \
1324                     an encoder that holds every frame reports AT_FINISH"
1325                ),
1326            ));
1327        }
1328        if count <= lag + 1 {
1329            return Err(fail(
1330                CHECK,
1331                format!(
1332                    "{count} frames cannot verify a lag of {lag} frames: supply more than {}",
1333                    lag + 1
1334                ),
1335            ));
1336        }
1337        let points = &drained.points[..drained.points.len() - 1];
1338        for (i, &(n, bytes)) in points.iter().enumerate() {
1339            if let Some(&(later, more)) = points[i + 1..].iter().find(|&&(m, _)| m > n + lag)
1340                && more <= bytes
1341            {
1342                return Err(fail(
1343                    CHECK,
1344                    format!(
1345                        "{label}: begin reported a lag of {lag} frames, but from {n} to \
1346                         {later} frames pushed no bytes became pending"
1347                    ),
1348                ));
1349            }
1350        }
1351        for (name, drain) in [("undrained", Drain::Never), ("take_output", Drain::Take)] {
1352            let other = run(declare, drain)
1353                .map_err(|e| fail(CHECK, format!("{label}, {name}: {e}")))?
1354                .ok_or_else(|| fail(CHECK, format!("{label}: begin declined on another run")))?;
1355            if other.timing != drained.timing {
1356                return Err(fail(
1357                    CHECK,
1358                    format!("{label}: begin reported a different timing on another run"),
1359                ));
1360            }
1361            if other.file != drained.file {
1362                return Err(fail(
1363                    CHECK,
1364                    format!("{label}: the {name} encode differs from the drained one"),
1365                ));
1366            }
1367        }
1368        let got = decode_timed(&drained.file)
1369            .map_err(|e| fail(CHECK, format!("{label}: decode: {e}")))?;
1370        if got.len() != want.len() {
1371            return Err(fail(
1372                CHECK,
1373                format!(
1374                    "{label}: the streamed animation has {} frames, the encode without \
1375                     begin {}",
1376                    got.len(),
1377                    want.len()
1378                ),
1379            ));
1380        }
1381        for (index, ((gp, gd), (wp, wd))) in got.iter().zip(&want).enumerate() {
1382            if gp != wp {
1383                return Err(fail(
1384                    CHECK,
1385                    format!(
1386                        "{label}: frame {index} of the streamed animation decodes to \
1387                         different pixels than the encode without begin"
1388                    ),
1389                ));
1390            }
1391            if gd != wd {
1392                return Err(fail(
1393                    CHECK,
1394                    format!("{label}: frame {index} lasts {gd:?} streamed, {wd:?} without begin"),
1395                ));
1396            }
1397        }
1398        let mut a = encoder().map_err(|e| fail(CHECK, e))?;
1399        a.begin(width, height, desc, declare)
1400            .map_err(|e| fail(CHECK, format!("begin: {e}")))?;
1401        if a.begin(width, height, desc, declare).is_ok() {
1402            return Err(fail(CHECK, format!("{label}: a second begin was accepted")));
1403        }
1404        // A refused `begin` leaves the encoder unchanged.
1405        let mut a = encoder().map_err(|e| fail(CHECK, e))?;
1406        if let Err(err) = a.begin(0, height, desc, declare)
1407            && err.unsupported_operation().is_none()
1408        {
1409            let after = run_on(a, declare, Drain::All)
1410                .map_err(|e| {
1411                    fail(
1412                        CHECK,
1413                        format!(
1414                            "{label}: after a refused begin(0, {height}), the encode on the \
1415                             same encoder failed: {e}"
1416                        ),
1417                    )
1418                })?
1419                .ok_or_else(|| {
1420                    fail(
1421                        CHECK,
1422                        format!("{label}: after a refused begin(0, {height}), begin declined"),
1423                    )
1424                })?;
1425            if after.timing != drained.timing || after.file != drained.file {
1426                return Err(fail(
1427                    CHECK,
1428                    format!(
1429                        "{label}: a refused begin(0, {height}) changed the encode that \
1430                         followed on the same encoder"
1431                    ),
1432                ));
1433            }
1434        }
1435        if declare.is_some() {
1436            let mut a = encoder().map_err(|e| fail(CHECK, e))?;
1437            a.begin(width, height, desc, declare)
1438                .map_err(|e| fail(CHECK, format!("begin: {e}")))?;
1439            for (i, f) in frames[..frames.len() - 1].iter().enumerate() {
1440                a.push_frame(f.as_slice(), 40 + i as u32 * 10, None)
1441                    .map_err(|e| fail(CHECK, format!("push_frame {i}: {e}")))?;
1442            }
1443            if a.finish(None).is_ok() {
1444                return Err(fail(
1445                    CHECK,
1446                    format!(
1447                        "finish after {} of {count} declared frames was accepted",
1448                        count - 1
1449                    ),
1450                ));
1451            }
1452        }
1453    }
1454    Ok(())
1455}
1456
1457/// Frames for [`check_all`]'s [`check_animation_streaming_output_honesty`]:
1458/// enough to verify the lag the encoder reports with the count declared.
1459fn animation_streaming_frames<E>(enc: &E) -> Vec<TestImage>
1460where
1461    E: EncoderConfig,
1462    <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
1463{
1464    let mut count = 6u32;
1465    for _ in 0..3 {
1466        let lag = enc
1467            .clone()
1468            .job()
1469            .with_loop_count(Some(0))
1470            .animation_frame_encoder()
1471            .ok()
1472            .and_then(|mut a| {
1473                a.begin(24, 16, PixelDescriptor::RGBA8_SRGB, Some(count))
1474                    .ok()
1475            })
1476            .and_then(|t| t.lag_frames());
1477        match lag {
1478            Some(lag) if count <= lag + 1 => count = (lag + 4).min(200),
1479            _ => break,
1480        }
1481    }
1482    (0..count)
1483        .map(|i| TestImage::rgba8_gradient_seeded(24, 16, (i * 60 % 256) as u8))
1484        .collect()
1485}
1486
1487/// A retention policy never leaks what it discards.
1488///
1489/// Encodes the image with rich metadata (GPS + thumbnail + camera + copyright +
1490/// XMP + ICC + CICP) under several policies, decodes, and asserts the output
1491/// metadata is a *subset* of what the policy keeps. A subset (not equality)
1492/// check, so a codec that supports fewer channels still passes — it can drop
1493/// more, never add back. Discarded ICC/CICP may match a clean encode of the
1494/// same pixels and policy exactly: that is native/default color rather than
1495/// retained source metadata. Other discarded fields reappearing are a leak.
1496///
1497/// What's directly asserted on the decoded output: ICC, XMP, CICP, HDR
1498/// (content-light-level / mastering-display), and the EXIF sub-categories the
1499/// public [`Exif`] API can introspect — GPS, thumbnail, rights (copyright/artist),
1500/// **camera/device identity** ([`Exif::has_camera`], Make/Model/MakerNote/serials/…)
1501/// and **capture timestamps** ([`Exif::has_datetimes`]). Device IDs, camera owner,
1502/// image identity and UTC offsets are checked separately when their descriptive
1503/// siblings are retained. An emitted EXIF blob that
1504/// fails to re-parse is also a failure (a mangled blob can hide raw GPS/camera
1505/// bytes a lenient reader scrapes, and its drops can't be verified).
1506pub fn check_metadata_no_leak<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
1507where
1508    E: EncoderConfig,
1509    D: DecoderConfig,
1510    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
1511{
1512    const CHECK: &str = "metadata_no_leak";
1513    let rich = Metadata::none()
1514        .with_exif(fixtures::rich_exif_le())
1515        .with_xmp(fixtures::sample_xmp())
1516        .with_icc(fixtures::sample_icc())
1517        .with_cicp(Cicp::SRGB);
1518
1519    let policies = [
1520        ("Web", MetadataPolicy::Web),
1521        ("ColorAndRotation", MetadataPolicy::ColorAndRotation),
1522        (
1523            "Web + camera/timestamps",
1524            MetadataPolicy::Custom(
1525                MetadataPolicy::Web.fields().with_exif(
1526                    zencodec::ExifPolicy::ATTRIBUTED_ORIENTATION
1527                        .with_camera(zencodec::Retention::Keep)
1528                        .with_datetimes(zencodec::Retention::Keep),
1529                ),
1530            ),
1531        ),
1532        ("PreserveExact", MetadataPolicy::PreserveExact),
1533        (
1534            "Custom(DISCARD_ALL)",
1535            MetadataPolicy::Custom(MetadataFields::DISCARD_ALL),
1536        ),
1537    ];
1538
1539    for (name, policy) in policies {
1540        #[allow(deprecated)] // The published codec wrapper still uses this compatibility path.
1541        let expected = rich.clone().filtered(&policy);
1542        let bytes = enc_oneshot(&enc, img, rich.clone(), policy)
1543            .map_err(|e| fail(CHECK, format!("[{name}] encode: {e}")))?;
1544        let (_, decoded) =
1545            dec_oneshot(&dec, &bytes).map_err(|e| fail(CHECK, format!("[{name}] decode: {e}")))?;
1546        // Native/default color may be reported even when no optional metadata
1547        // was supplied. Compare the same pixels and policy against that clean
1548        // encode; only an exact intrinsic color match is exempt from leakage.
1549        let clean = enc_oneshot(&enc, img, Metadata::none(), policy)
1550            .map_err(|e| fail(CHECK, format!("[{name}] clean encode: {e}")))?;
1551        let (_, intrinsic) = dec_oneshot(&dec, &clean)
1552            .map_err(|e| fail(CHECK, format!("[{name}] clean decode: {e}")))?;
1553        assert_no_leak(CHECK, name, &decoded, &expected, &intrinsic)?;
1554    }
1555    Ok(())
1556}
1557
1558fn assert_no_leak(
1559    check: &'static str,
1560    policy: &str,
1561    decoded: &Metadata,
1562    expected: &Metadata,
1563    intrinsic: &Metadata,
1564) -> Conformance {
1565    if decoded.icc_profile.is_some()
1566        && expected.icc_profile.is_none()
1567        && decoded.icc_profile != intrinsic.icc_profile
1568    {
1569        return Err(fail(
1570            check,
1571            format!("[{policy}] ICC profile in output but the policy dropped it"),
1572        ));
1573    }
1574    if decoded.xmp.is_some() && expected.xmp.is_none() {
1575        return Err(fail(
1576            check,
1577            format!("[{policy}] XMP in output but the policy dropped it"),
1578        ));
1579    }
1580    if decoded.cicp.is_some() && expected.cicp.is_none() && decoded.cicp != intrinsic.cicp {
1581        return Err(fail(
1582            check,
1583            format!("[{policy}] CICP color signaling in output but the policy dropped it"),
1584        ));
1585    }
1586    if decoded.content_light_level.is_some() && expected.content_light_level.is_none() {
1587        return Err(fail(
1588            check,
1589            format!("[{policy}] HDR content-light-level in output but the policy dropped it"),
1590        ));
1591    }
1592    if decoded.mastering_display.is_some() && expected.mastering_display.is_none() {
1593        return Err(fail(
1594            check,
1595            format!("[{policy}] HDR mastering-display in output but the policy dropped it"),
1596        ));
1597    }
1598
1599    match &decoded.exif {
1600        None => {} // nothing embedded is always safe
1601        Some(d) => {
1602            if expected.exif.is_none() {
1603                return Err(fail(
1604                    check,
1605                    format!("[{policy}] EXIF in output but the policy dropped the whole blob"),
1606                ));
1607            }
1608            let want = expected.exif.as_deref().and_then(Exif::parse);
1609            if let Some(dx) = Exif::parse(d.as_ref()) {
1610                let want_gps = want.as_ref().is_some_and(Exif::has_gps);
1611                if dx.has_gps() && !want_gps {
1612                    return Err(fail(
1613                        check,
1614                        format!(
1615                            "[{policy}] GPS data in output EXIF but the policy dropped it (privacy leak)"
1616                        ),
1617                    ));
1618                }
1619                let want_thumb = want.as_ref().is_some_and(Exif::has_thumbnail);
1620                if dx.has_thumbnail() && !want_thumb {
1621                    return Err(fail(
1622                        check,
1623                        format!(
1624                            "[{policy}] thumbnail in output EXIF but the policy dropped it (privacy leak)"
1625                        ),
1626                    ));
1627                }
1628                let want_rights = want
1629                    .as_ref()
1630                    .is_some_and(|w| w.copyright().is_some() || w.artist().is_some());
1631                if (dx.copyright().is_some() || dx.artist().is_some()) && !want_rights {
1632                    return Err(fail(
1633                        check,
1634                        format!(
1635                            "[{policy}] rights tags in output EXIF but the policy dropped them"
1636                        ),
1637                    ));
1638                }
1639                let want_camera = want.as_ref().is_some_and(Exif::has_camera);
1640                if dx.has_camera() && !want_camera {
1641                    return Err(fail(
1642                        check,
1643                        format!(
1644                            "[{policy}] camera-identity tags (Make/Model/MakerNote/serial/…) in output EXIF but the policy dropped them (privacy leak)"
1645                        ),
1646                    ));
1647                }
1648                // A broad camera/date check is insufficient when descriptive
1649                // camera tags or timestamps are retained but identifiers are not.
1650                for (name, present, wanted) in [
1651                    (
1652                        "device identifiers",
1653                        dx.has_device_ids(),
1654                        want.as_ref().is_some_and(Exif::has_device_ids),
1655                    ),
1656                    (
1657                        "camera owner",
1658                        dx.has_camera_owner(),
1659                        want.as_ref().is_some_and(Exif::has_camera_owner),
1660                    ),
1661                    (
1662                        "image identity",
1663                        dx.has_image_unique_id(),
1664                        want.as_ref().is_some_and(Exif::has_image_unique_id),
1665                    ),
1666                    (
1667                        "UTC offsets",
1668                        dx.has_time_offsets(),
1669                        want.as_ref().is_some_and(Exif::has_time_offsets),
1670                    ),
1671                ] {
1672                    if present && !wanted {
1673                        return Err(fail(
1674                            check,
1675                            format!(
1676                                "[{policy}] {name} in output EXIF but the policy dropped it (privacy leak)"
1677                            ),
1678                        ));
1679                    }
1680                }
1681                let want_datetimes = want.as_ref().is_some_and(Exif::has_datetimes);
1682                if dx.has_datetimes() && !want_datetimes {
1683                    return Err(fail(
1684                        check,
1685                        format!(
1686                            "[{policy}] capture-timestamp tags in output EXIF but the policy dropped them (privacy leak)"
1687                        ),
1688                    ));
1689                }
1690            } else {
1691                // The output carries an EXIF blob the policy meant to keep (in part),
1692                // but it does not parse. A mangled blob can still embed raw GPS /
1693                // camera bytes a lenient reader scrapes, and the drops can't be
1694                // verified — treat an unparseable emitted blob as a failure.
1695                return Err(fail(
1696                    check,
1697                    format!(
1698                        "[{policy}] output EXIF is unparseable — cannot verify the policy's drops, and it may hide raw GPS/camera bytes"
1699                    ),
1700                ));
1701            }
1702        }
1703    }
1704    Ok(())
1705}
1706
1707/// The *displayed* image is preserved through a policy that keeps orientation.
1708///
1709/// EXIF orientation is a rendering transform the reader applies, not a label, so
1710/// the invariant is on the displayed pixels — `render(pixels, orientation)` — not
1711/// the stored buffer. For each non-identity orientation under each keeping policy
1712/// (`Web`, `ColorAndRotation`, `PreserveExact`), this asserts
1713/// `render(decoded_pixels, decoded_orientation) == render(input, requested)`.
1714///
1715/// That one invariant is correct for every valid storage strategy and catches
1716/// every failure mode:
1717/// - **Carry** (stored pixels as-authored + tag) — passes.
1718/// - **Bake** (rotated pixels + `Identity` tag, the only option for a
1719///   metadata-free format) — also passes; the displayed image is identical.
1720/// - **Double-application** (rotated pixels *and* the tag) — caught: applying the
1721///   tag to already-rotated pixels rotates twice, so the render differs.
1722/// - **Loss** (tag dropped to `Identity`, pixels untouched) — caught: the render
1723///   is the un-rotated image.
1724pub fn check_orientation_roundtrip<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
1725where
1726    E: EncoderConfig,
1727    D: DecoderConfig,
1728    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
1729{
1730    const CHECK: &str = "orientation_roundtrip";
1731    let orientations = [
1732        Orientation::Rotate90,
1733        Orientation::Rotate180,
1734        Orientation::Rotate270,
1735        Orientation::FlipH,
1736        Orientation::FlipV,
1737    ];
1738    let policies = [
1739        ("Web", MetadataPolicy::Web),
1740        ("ColorAndRotation", MetadataPolicy::ColorAndRotation),
1741        ("PreserveExact", MetadataPolicy::PreserveExact),
1742    ];
1743    let input = img.pixels();
1744    for ori in orientations {
1745        // What a reader should display for an image authored with this orientation.
1746        let want = render(&input, ori);
1747        for (name, policy) in policies {
1748            let meta = Metadata::none().with_orientation(ori);
1749            let bytes = enc_oneshot(&enc, img, meta, policy)
1750                .map_err(|e| fail(CHECK, format!("[{name}] encode {ori:?}: {e}")))?;
1751            let (px, decoded) = dec_oneshot(&dec, &bytes)
1752                .map_err(|e| fail(CHECK, format!("[{name}] decode {ori:?}: {e}")))?;
1753            if render(&px, decoded.orientation) != want {
1754                return Err(fail(
1755                    CHECK,
1756                    format!(
1757                        "[{name}] orientation {ori:?}: displayed image not preserved (decoded orientation = {:?}; \
1758                         a reader applying the tag would show loss, or a double-rotation if the codec also baked it)",
1759                        decoded.orientation
1760                    ),
1761                ));
1762            }
1763        }
1764    }
1765    Ok(())
1766}
1767
1768// ===========================================================================
1769// Native HDR conformance (zencodec#24, Phase 0)
1770// ===========================================================================
1771
1772/// The HDR envelope the native-HDR check encodes with — values a dropped or
1773/// zeroed field can't be mistaken for.
1774fn hdr_envelope() -> (ContentLightLevel, MasteringDisplay) {
1775    (
1776        ContentLightLevel::new(1000, 400),
1777        MasteringDisplay::HDR10_REFERENCE,
1778    )
1779}
1780
1781/// Container fixed-point tolerance: |Δ| ≤ 2e-5 for sub-unit values (xy
1782/// chromaticities are stored in 1/50000 units by AVIF/HEIF, luminances in
1783/// 1/10000), relative 1e-5 above 1.0 (peak luminance).
1784fn hdr_close(got: f32, want: f32) -> bool {
1785    let tol = if want.abs() < 1.0 {
1786        2e-5
1787    } else {
1788        want.abs() * 1e-5
1789    };
1790    (got - want).abs() <= tol
1791}
1792
1793fn mastering_display_matches(got: &MasteringDisplay, want: &MasteringDisplay) -> bool {
1794    got.primaries_xy
1795        .iter()
1796        .zip(&want.primaries_xy)
1797        .all(|(g, w)| hdr_close(g[0], w[0]) && hdr_close(g[1], w[1]))
1798        && hdr_close(got.white_point_xy[0], want.white_point_xy[0])
1799        && hdr_close(got.white_point_xy[1], want.white_point_xy[1])
1800        && hdr_close(got.max_luminance, want.max_luminance)
1801        && hdr_close(got.min_luminance, want.min_luminance)
1802}
1803
1804/// Native HDR survives a round trip: for BT.2100 **PQ** and **HLG**, the CICP,
1805/// the HDR envelope (content light level + mastering display), the pixels, and
1806/// the decoded buffer's HDR *labelling* all come back from a `PreserveExact`
1807/// encode → decode.
1808///
1809/// Skipped (`Ok`) unless **both** ends declare
1810/// [`hdr`](zencodec::encode::EncodeCapabilities::hdr). For each transfer:
1811///
1812/// - encodes `Cicp::BT2100_PQ` / `Cicp::BT2100_HLG` with `ContentLightLevel(1000, 400)`
1813///   and `MasteringDisplay::HDR10_REFERENCE`, using a 16-bit RGB gradient when both
1814///   ends declare `native_16bit` (the 8-bit RGB gradient otherwise — the
1815///   signaling path is exercised either way);
1816/// - the decoded pixels must equal the input byte-for-byte;
1817/// - the decoded descriptor must carry the HDR transfer (`Pq` / `Hlg`) — a PQ
1818///   decode labelled sRGB is exactly the mislabel the descriptor exists to
1819///   prevent — and on the 16-bit path must equal `RGB16_BT2100_PQ` / `_HLG`;
1820/// - when both ends declare the `cicp` channel, `Metadata.cicp` must equal the
1821///   input;
1822/// - `content_light_level` must survive exactly and `mastering_display` within
1823///   container fixed-point precision (2e-5 on xy, 1e-5 relative on luminance).
1824///   `hdr` on both ends means the envelope reaches the encoder and comes back —
1825///   silently dropping it is the "gain-map → native-HDR transcode loses the
1826///   envelope" hazard in `docs/correctness-model.md`.
1827///
1828/// This is Phase 0 of the gain-map / HDR delivery scope
1829/// (`docs/gainmap-pipeline-scope-2026-06-08.md`): prove native HDR works with
1830/// zero new API before any gain-map encode surface is added. Part of
1831/// [`check_all`].
1832pub fn check_native_hdr_roundtrip<E, D>(enc: E, dec: D) -> Conformance
1833where
1834    E: EncoderConfig,
1835    D: DecoderConfig,
1836    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
1837{
1838    const CHECK: &str = "native_hdr_roundtrip";
1839    let (ec, dc) = (E::capabilities(), D::capabilities());
1840    if !ec.hdr() || !dc.hdr() {
1841        return Ok(()); // not applicable to an SDR-only codec
1842    }
1843    let sixteen = ec.native_16bit() && dc.native_16bit();
1844    let (cll, md) = hdr_envelope();
1845    let cases = [
1846        (
1847            "PQ",
1848            Cicp::BT2100_PQ,
1849            PixelDescriptor::RGB16_BT2100_PQ,
1850            TransferFunction::Pq,
1851        ),
1852        (
1853            "HLG",
1854            Cicp::BT2100_HLG,
1855            PixelDescriptor::RGB16_BT2100_HLG,
1856            TransferFunction::Hlg,
1857        ),
1858    ];
1859    for (name, cicp, desc16, transfer) in cases {
1860        let img = if sixteen {
1861            TestImage::rgb16_gradient(20, 14, desc16)
1862        } else {
1863            TestImage::rgb8_gradient(20, 14)
1864        };
1865        let meta = Metadata::none()
1866            .with_cicp(cicp)
1867            .with_content_light_level(cll)
1868            .with_mastering_display(md);
1869        let bytes = enc_oneshot(&enc, &img, meta, MetadataPolicy::PreserveExact)
1870            .map_err(|e| fail(CHECK, format!("[{name}] encode: {e}")))?;
1871        let out =
1872            dec_output(&dec, &bytes).map_err(|e| fail(CHECK, format!("[{name}] decode: {e}")))?;
1873
1874        let got_px = grab(out.pixels());
1875        let want_px = img.pixels();
1876        if (got_px.width, got_px.rows) != (want_px.width, want_px.rows)
1877            || got_px.bytes != want_px.bytes
1878        {
1879            return Err(fail(
1880                CHECK,
1881                format!(
1882                    "[{name}] decoded {}-bit pixels differ from the input",
1883                    if sixteen { 16 } else { 8 }
1884                ),
1885            ));
1886        }
1887        let desc = got_px.desc;
1888        if desc.transfer() != transfer {
1889            return Err(fail(
1890                CHECK,
1891                format!(
1892                    "[{name}] the decoded buffer is labelled {:?}, not {transfer:?} — the descriptor \
1893                     must describe the current pixels; stamp it from the file's CICP",
1894                    desc.transfer()
1895                ),
1896            ));
1897        }
1898        if sixteen && desc != desc16 {
1899            return Err(fail(
1900                CHECK,
1901                format!("[{name}] 16-bit decode descriptor is {desc:?}, expected {desc16:?}"),
1902            ));
1903        }
1904
1905        let got = out.metadata();
1906        if ec.cicp() && dc.cicp() && got.cicp != Some(cicp) {
1907            return Err(fail(
1908                CHECK,
1909                format!(
1910                    "[{name}] CICP {cicp:?} did not survive PreserveExact (decoded {:?})",
1911                    got.cicp
1912                ),
1913            ));
1914        }
1915        match got.content_light_level {
1916            Some(c) if c == cll => {}
1917            other => {
1918                return Err(fail(
1919                    CHECK,
1920                    format!(
1921                        "[{name}] content_light_level {cll:?} did not survive PreserveExact \
1922                         (decoded {other:?}) — hdr is declared on both ends, so the HDR envelope \
1923                         must reach the encoder and come back"
1924                    ),
1925                ));
1926            }
1927        }
1928        match got.mastering_display {
1929            Some(m) if mastering_display_matches(&m, &md) => {}
1930            other => {
1931                return Err(fail(
1932                    CHECK,
1933                    format!(
1934                        "[{name}] mastering_display {md:?} did not survive PreserveExact within \
1935                         container precision (decoded {other:?})"
1936                    ),
1937                ));
1938            }
1939        }
1940    }
1941    Ok(())
1942}
1943
1944// ===========================================================================
1945// Gain-map encode / decode conformance (zencodec#24, Phases 1/2/4)
1946// ===========================================================================
1947
1948/// One cell of the gain-map test matrix.
1949#[derive(Clone, Copy)]
1950struct GainMapCase {
1951    name: &'static str,
1952    channels: u8,
1953    /// ISO 21496-1 backward direction: HDR base + SDR gain map.
1954    backward: bool,
1955}
1956
1957/// The matrix the scope audit asked for: 1-channel (the UltraHDR norm),
1958/// 3-channel (iOS 18 mainstream), and a backward-direction file (Android 16
1959/// ships HDR-base + SDR-gain-map).
1960const GAIN_MAP_CASES: [GainMapCase; 3] = [
1961    GainMapCase {
1962        name: "1ch forward",
1963        channels: 1,
1964        backward: false,
1965    },
1966    GainMapCase {
1967        name: "3ch forward",
1968        channels: 3,
1969        backward: false,
1970    },
1971    GainMapCase {
1972        name: "1ch backward",
1973        channels: 1,
1974        backward: true,
1975    },
1976];
1977
1978/// Gain-map dimensions: deliberately *not* the base's (20×14) — sub-resolution
1979/// maps are the ecosystem norm, and a codec that assumes base geometry breaks.
1980const GAIN_MAP_W: u32 = 10;
1981const GAIN_MAP_H: u32 = 7;
1982
1983/// The ISO 21496-1 parameters every case carries. All values are dyadic
1984/// (exactly representable in the wire format's fractions), so the decoded
1985/// params must equal the input *exactly* — no tolerance hides a mis-serialized
1986/// field. Per-channel values differ for the 3-channel case so a channel swap
1987/// is visible.
1988fn gain_map_params(case: GainMapCase) -> GainMapParams {
1989    let ch = |i: usize| GainMapChannel {
1990        min: -1.0 + i as f64 * 0.25,
1991        max: 2.0 + i as f64 * 0.5,
1992        gamma: 1.0 + i as f64 * 0.5,
1993        base_offset: 1.0 / 64.0,
1994        alternate_offset: 1.0 / 32.0,
1995    };
1996    let mut p = GainMapParams::default();
1997    p.channels = if case.channels == 1 {
1998        [ch(0); 3]
1999    } else {
2000        [ch(0), ch(1), ch(2)]
2001    };
2002    // Forward: SDR base (0 stops), HDR alternate (+2 stops). Backward flips the
2003    // roles — and sets the authoritative flag, not just the headrooms.
2004    if case.backward {
2005        p.base_hdr_headroom = 2.0;
2006        p.alternate_hdr_headroom = 0.0;
2007        p.backward_direction = true;
2008    } else {
2009        p.base_hdr_headroom = 0.0;
2010        p.alternate_hdr_headroom = 2.0;
2011    }
2012    p
2013}
2014
2015/// Deterministic 8-bit gain-map pixels: a smooth ramp per channel (a gain map
2016/// is a low-frequency control signal), offset per channel so channel order is
2017/// observable.
2018fn gain_map_pixels(case: GainMapCase) -> Pixels {
2019    let c = case.channels as usize;
2020    let mut bytes = vec![0u8; GAIN_MAP_W as usize * GAIN_MAP_H as usize * c];
2021    for y in 0..GAIN_MAP_H as usize {
2022        for x in 0..GAIN_MAP_W as usize {
2023            for k in 0..c {
2024                bytes[(y * GAIN_MAP_W as usize + x) * c + k] =
2025                    (16 + x * 13 + y * 17 + k * 40) as u8;
2026            }
2027        }
2028    }
2029    Pixels {
2030        width: GAIN_MAP_W,
2031        rows: GAIN_MAP_H,
2032        desc: if c == 1 {
2033            PixelDescriptor::GRAY8
2034        } else {
2035            PixelDescriptor::RGB8
2036        },
2037        bytes,
2038    }
2039}
2040
2041/// The alternate rendition's colour: PQ, as an UltraHDR/AVIF map usually says.
2042const GAIN_MAP_ALTERNATE_CICP: Cicp = Cicp::BT2100_PQ;
2043
2044/// Build the encode-input fixture for one case. Built fresh each time
2045/// ([`DecodedGainMap`] is consumed by `with_gain_map_pixels`).
2046fn gain_map_fixture(case: GainMapCase) -> DecodedGainMap {
2047    let px = gain_map_pixels(case);
2048    let buf = PixelBuffer::from_vec(px.bytes, px.width, px.rows, px.desc)
2049        .expect("gain-map fixture dimensions are valid");
2050    let info = GainMapInfo::new(gain_map_params(case), GAIN_MAP_W, GAIN_MAP_H, case.channels)
2051        .with_alternate_cicp(GAIN_MAP_ALTERNATE_CICP);
2052    DecodedGainMap::new(buf, info)
2053}
2054
2055/// A format that is not the encoder's own, for the mismatched-`format`
2056/// rejection on `with_gain_map_encoded`.
2057fn foreign_format<E: EncoderConfig>() -> ImageFormat {
2058    [ImageFormat::Bmp, ImageFormat::Pnm, ImageFormat::Qoi]
2059        .into_iter()
2060        .find(|f| *f != E::format())
2061        .expect("three candidates cannot all equal one format")
2062}
2063
2064/// Encode `img` with `gm` attached via `with_gain_map_pixels` (one-shot path).
2065fn enc_with_gain_map<E>(cfg: &E, img: &TestImage, gm: DecodedGainMap) -> Result<Vec<u8>, String>
2066where
2067    E: EncoderConfig,
2068    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
2069{
2070    let enc = cfg
2071        .clone()
2072        .job()
2073        .with_metadata_policy(Metadata::none(), MetadataPolicy::PreserveExact)
2074        .with_gain_map_pixels(gm)
2075        .map_err(|e| format!("with_gain_map_pixels: {e}"))?
2076        .encoder()
2077        .map_err(|e| e.to_string())?;
2078    Ok(enc
2079        .encode(img.as_slice())
2080        .map_err(|e| e.to_string())?
2081        .into_vec())
2082}
2083
2084/// Encode `img` with `gm` attached, through the incremental `push_rows` +
2085/// `finish` path — a separate code path in real codecs, where a job-level
2086/// setting is easy to leave behind.
2087fn enc_push_rows_with_gain_map<E>(
2088    cfg: &E,
2089    img: &TestImage,
2090    gm: DecodedGainMap,
2091) -> Result<Vec<u8>, String>
2092where
2093    E: EncoderConfig,
2094    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
2095{
2096    let mut enc = cfg
2097        .clone()
2098        .job()
2099        .with_metadata_policy(Metadata::none(), MetadataPolicy::PreserveExact)
2100        .with_gain_map_pixels(gm)
2101        .map_err(|e| format!("with_gain_map_pixels: {e}"))?
2102        .encoder()
2103        .map_err(|e| e.to_string())?;
2104    let strip = enc.preferred_strip_height().max(1);
2105    let mut y = 0;
2106    while y < img.height {
2107        let h = strip.min(img.height - y);
2108        enc.push_rows(img.strip(y, h)).map_err(|e| e.to_string())?;
2109        y += h;
2110    }
2111    Ok(enc.finish().map_err(|e| e.to_string())?.into_vec())
2112}
2113
2114/// Decode with a specific [`GainMapRender`] intent.
2115fn dec_render<D: DecoderConfig>(
2116    cfg: &D,
2117    bytes: &[u8],
2118    render: GainMapRender,
2119) -> Result<DecodeOutput, D::Error> {
2120    cfg.clone()
2121        .job()
2122        .with_gain_map_render(render)
2123        .decoder(Cow::Borrowed(bytes), &[])?
2124        .decode()
2125}
2126
2127/// What the check compares of a surfaced gain map.
2128struct GainMapObserved {
2129    pixels: Pixels,
2130    info: GainMapInfo,
2131}
2132
2133impl GainMapObserved {
2134    fn of(gm: &DecodedGainMap) -> Self {
2135        Self {
2136            pixels: grab(gm.pixels.as_slice()),
2137            info: gm.metadata.clone(),
2138        }
2139    }
2140}
2141
2142/// Mean absolute sample error between two same-geometry 8-bit maps.
2143fn mean_abs_error(a: &[u8], b: &[u8]) -> f64 {
2144    if a.is_empty() || a.len() != b.len() {
2145        return f64::INFINITY;
2146    }
2147    let sum: u64 = a
2148        .iter()
2149        .zip(b)
2150        .map(|(x, y)| u64::from(x.abs_diff(*y)))
2151        .sum();
2152    sum as f64 / a.len() as f64
2153}
2154
2155/// Sanity bound for a *lossy* gain-map codec, in 8-bit sample units: the
2156/// smooth 10×7 ramp fixture re-encoded at the codec's default must come back
2157/// within this mean absolute error. This is a "not blank, not garbled, not
2158/// another channel" guard, **not** a fidelity claim — how well a lossy codec
2159/// preserves a gain map is that codec's own rate–distortion test (see the
2160/// q90 knee note on `with_gain_map_pixels`).
2161const LOSSY_GAIN_MAP_MAE_BOUND: f64 = 24.0;
2162
2163/// Compare a surfaced gain map with what was embedded: geometry, channel
2164/// count, params (exactly), the alternate CICP, and the pixels — byte-exact
2165/// when the encoder is lossless, else within [`LOSSY_GAIN_MAP_MAE_BOUND`].
2166fn compare_gain_map(
2167    got: &GainMapObserved,
2168    want_px: &Pixels,
2169    want_info: &GainMapInfo,
2170    lossless: bool,
2171    what: &str,
2172) -> Result<(), String> {
2173    if (got.pixels.width, got.pixels.rows) != (want_px.width, want_px.rows) {
2174        return Err(format!(
2175            "{what}: gain-map geometry is {}x{}, expected {}x{}",
2176            got.pixels.width, got.pixels.rows, want_px.width, want_px.rows
2177        ));
2178    }
2179    let got_ch = got.pixels.desc.channels() as u8;
2180    if got_ch != want_info.channels {
2181        return Err(format!(
2182            "{what}: gain map has {got_ch} channels ({:?}), expected {}",
2183            got.pixels.desc, want_info.channels
2184        ));
2185    }
2186    if got.info.width != want_px.width || got.info.height != want_px.rows {
2187        return Err(format!(
2188            "{what}: GainMapInfo says {}x{} but the pixels are {}x{}",
2189            got.info.width, got.info.height, want_px.width, want_px.rows
2190        ));
2191    }
2192    if got.info.channels != want_info.channels {
2193        return Err(format!(
2194            "{what}: GainMapInfo.channels is {}, expected {}",
2195            got.info.channels, want_info.channels
2196        ));
2197    }
2198    if got.info.params != want_info.params {
2199        return Err(format!(
2200            "{what}: ISO 21496-1 params did not survive exactly (all fixture values are \
2201             dyadic, so this is a serialization/parse defect, not rounding):\n  got  {:?}\n  want {:?}",
2202            got.info.params, want_info.params
2203        ));
2204    }
2205    if got.info.alternate_cicp != want_info.alternate_cicp {
2206        return Err(format!(
2207            "{what}: alternate_cicp is {:?}, expected {:?}",
2208            got.info.alternate_cicp, want_info.alternate_cicp
2209        ));
2210    }
2211    if got.pixels.desc.bytes_per_pixel() != want_px.desc.bytes_per_pixel() {
2212        return Err(format!(
2213            "{what}: gain map came back as {:?}, expected an 8-bit {}-channel layout",
2214            got.pixels.desc, want_info.channels
2215        ));
2216    }
2217    if lossless {
2218        if got.pixels.bytes != want_px.bytes {
2219            return Err(format!(
2220                "{what}: gain-map pixels differ from the input on a lossless encoder"
2221            ));
2222        }
2223    } else {
2224        let mae = mean_abs_error(&got.pixels.bytes, &want_px.bytes);
2225        if mae > LOSSY_GAIN_MAP_MAE_BOUND {
2226            return Err(format!(
2227                "{what}: gain-map pixels came back with mean abs error {mae:.1} (bound \
2228                 {LOSSY_GAIN_MAP_MAE_BOUND}) — blank, garbled, or the wrong map"
2229            ));
2230        }
2231    }
2232    Ok(())
2233}
2234
2235/// The gain-map encode/decode contract (`docs/gainmap-pipeline-scope-2026-06-08.md`
2236/// Phases 1, 2 and 4, tracked in zencodec#24), for every declared/undeclared
2237/// combination of [`EncodeCapabilities::gain_map`](zencodec::encode::EncodeCapabilities::gain_map)
2238/// and [`DecodeCapabilities::gain_map`](zencodec::decode::DecodeCapabilities::gain_map):
2239///
2240/// **Encoder undeclared** ⇒ `with_gain_map_pixels` and `with_gain_map_encoded`
2241/// must fail with [`UnsupportedOperation::GainMapEncode`] (the trait default) —
2242/// a dropped gain map is lost HDR, so silence is the one forbidden outcome.
2243///
2244/// **Encoder declared** ⇒ for each case of the test matrix (1-channel forward,
2245/// 3-channel forward, 1-channel backward-direction — sub-resolution 10×7 maps
2246/// on a 20×14 RGB8 base, ISO 21496-1 params with only dyadic values, PQ
2247/// alternate CICP):
2248///
2249/// - `with_gain_map_pixels` accepts the map and the base still encodes;
2250///   `with_gain_map_encoded` with a *foreign* `format` is rejected (the
2251///   documented mismatch error — any error, it is the codec's own).
2252/// - **Decoder declared** ⇒ `probe()` reports the map (`gain_map` presence not
2253///   `Absent`, and when `Available` its geometry/channels/params match;
2254///   `supplements.gain_map` set); the default `BaseOnly` decode carries **no**
2255///   [`DecodedGainMap`] extra (opt-in only) and its base pixels equal the input
2256///   when the encoder is lossless; `Components` surfaces a `DecodedGainMap`
2257///   whose geometry, channels, params (exact), alternate CICP and pixels
2258///   (byte-exact when lossless, else within a mean-abs-error sanity bound)
2259///   match; `with_extract_gain_map(true)` yields the identical map;
2260///   `ReconstructHdr { None }` without `reconstructs_hdr` either surfaces
2261///   the components with the base still labelled SDR, or fails with
2262///   `UnsupportedOperation` — never an SDR buffer labelled PQ/HLG; with
2263///   `reconstructs_hdr` it must succeed with an HDR-labelled buffer and the
2264///   envelope (`mastering_display` + `content_light_level`) on `source_color`.
2265/// - **Transcode (Phase 4)**: the `Components` map is fed back through a fresh
2266///   `with_gain_map_pixels`, decoded again, and must match the first
2267///   generation (metadata exactly; pixels byte-exact when lossless).
2268/// - **`push_rows` path** (when the encoder declares it): the same map
2269///   attached to an incremental `push_rows` + `finish` encode surfaces
2270///   identically — the streaming encoder is a separate code path in real
2271///   codecs, and a job-level setting is easy to leave behind there.
2272/// - **Decoder undeclared** ⇒ a `Components` decode of the same file either
2273///   decodes the base with no `DecodedGainMap` extra (a hidden capability is
2274///   a lie) or fails with `UnsupportedOperation`.
2275///
2276/// Skipped (`Ok`) only for the undeclared/undeclared pair once the loud
2277/// rejection is verified. All violations across the matrix are collected and
2278/// reported together. Not covered: the own-format `with_gain_map_encoded`
2279/// fast path (the payload shape is container-specific — a bare AV1 OBU vs a
2280/// whole JPEG — so a codec tests it with its own fixture), 10/12-bit maps, and
2281/// the positive `reconstructs_hdr` branch against the testkit's own codecs
2282/// (the reference declines to reconstruct, as zencodec carries no HDR math).
2283/// Part of [`check_all`].
2284pub fn check_gain_map_roundtrip<E, D>(enc: E, dec: D) -> Conformance
2285where
2286    E: EncoderConfig,
2287    D: DecoderConfig,
2288    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
2289{
2290    const CHECK: &str = "gain_map_roundtrip";
2291    let (ec, dc) = (E::capabilities(), D::capabilities());
2292    let mut v: Vec<String> = Vec::new();
2293
2294    // --- undeclared encoder: loud rejection, both entry points ---
2295    if !ec.gain_map() {
2296        let case = GAIN_MAP_CASES[0];
2297        match enc
2298            .clone()
2299            .job()
2300            .with_gain_map_pixels(gain_map_fixture(case))
2301        {
2302            Ok(_) => v.push(
2303                "encoder does not declare gain_map, yet with_gain_map_pixels succeeded (hidden \
2304                 capability, or a silently dropped gain map)"
2305                    .into(),
2306            ),
2307            Err(e) if e.unsupported_operation() == Some(&UnsupportedOperation::GainMapEncode) => {}
2308            Err(e) => v.push(format!(
2309                "encoder does not declare gain_map: with_gain_map_pixels must fail with \
2310                 UnsupportedOperation::GainMapEncode, got: {e}"
2311            )),
2312        }
2313        let src = GainMapSource::new(
2314            vec![0u8; 16],
2315            E::format(),
2316            GainMapInfo::new(gain_map_params(case), GAIN_MAP_W, GAIN_MAP_H, 1),
2317        );
2318        match enc.clone().job().with_gain_map_encoded(src) {
2319            Ok(_) => v.push(
2320                "encoder does not declare gain_map, yet with_gain_map_encoded succeeded".into(),
2321            ),
2322            Err(e) if e.unsupported_operation() == Some(&UnsupportedOperation::GainMapEncode) => {}
2323            Err(e) => v.push(format!(
2324                "encoder does not declare gain_map: with_gain_map_encoded must fail with \
2325                 UnsupportedOperation::GainMapEncode, got: {e}"
2326            )),
2327        }
2328        return if v.is_empty() {
2329            Ok(())
2330        } else {
2331            Err(fail(CHECK, v.join("; ")))
2332        };
2333    }
2334
2335    // --- declared encoder ---
2336    let base = TestImage::rgb8_gradient(20, 14);
2337    let lossless = enc.is_lossless() == Some(true);
2338
2339    // Foreign-format encoded map: the documented mismatch rejection.
2340    let foreign = GainMapSource::new(
2341        vec![0u8; 16],
2342        foreign_format::<E>(),
2343        GainMapInfo::new(
2344            gain_map_params(GAIN_MAP_CASES[0]),
2345            GAIN_MAP_W,
2346            GAIN_MAP_H,
2347            1,
2348        ),
2349    );
2350    if enc.clone().job().with_gain_map_encoded(foreign).is_ok() {
2351        v.push(format!(
2352            "with_gain_map_encoded accepted a gain map in a foreign format ({:?}) — the \
2353             contract is to reject a format mismatch so the caller decodes to pixels",
2354            foreign_format::<E>()
2355        ));
2356    }
2357
2358    for case in GAIN_MAP_CASES {
2359        let name = case.name;
2360        let want_px = gain_map_pixels(case);
2361        let want_info = gain_map_fixture(case).metadata;
2362
2363        let bytes = match enc_with_gain_map(&enc, &base, gain_map_fixture(case)) {
2364            Ok(b) => b,
2365            Err(e) => {
2366                v.push(format!("[{name}] encode with gain map: {e}"));
2367                continue;
2368            }
2369        };
2370
2371        if !dc.gain_map() {
2372            // A decoder without the capability must not surface one anyway.
2373            match dec_render(&dec, &bytes, GainMapRender::Components) {
2374                Ok(out) if out.extras::<DecodedGainMap>().is_some() => v.push(format!(
2375                    "[{name}] decoder does not declare gain_map, yet Components surfaced a \
2376                     DecodedGainMap (hidden capability)"
2377                )),
2378                Ok(_) => {}
2379                Err(e) if e.unsupported_operation().is_some() => {}
2380                Err(e) => v.push(format!(
2381                    "[{name}] decoder does not declare gain_map: a Components decode must \
2382                     decode the base or fail with UnsupportedOperation, got: {e}"
2383                )),
2384            }
2385            continue;
2386        }
2387
2388        // probe(): the map is reported.
2389        match dec.clone().job().probe(&bytes) {
2390            Err(e) => v.push(format!("[{name}] probe: {e}")),
2391            Ok(info) => {
2392                if !info.supplements.gain_map {
2393                    v.push(format!(
2394                        "[{name}] probe: supplements.gain_map is false on a file with a gain map"
2395                    ));
2396                }
2397                match &info.gain_map {
2398                    GainMapPresence::Absent => v.push(format!(
2399                        "[{name}] probe: gain_map presence is Absent on a file with a gain map"
2400                    )),
2401                    GainMapPresence::Available(i) => {
2402                        if (i.width, i.height, i.channels)
2403                            != (GAIN_MAP_W, GAIN_MAP_H, case.channels)
2404                        {
2405                            v.push(format!(
2406                                "[{name}] probe: GainMapInfo is {}x{}x{}, expected {}x{}x{}",
2407                                i.width,
2408                                i.height,
2409                                i.channels,
2410                                GAIN_MAP_W,
2411                                GAIN_MAP_H,
2412                                case.channels
2413                            ));
2414                        }
2415                        if i.params != want_info.params {
2416                            v.push(format!(
2417                                "[{name}] probe: ISO 21496-1 params differ from the input:\n  got  {:?}\n  want {:?}",
2418                                i.params, want_info.params
2419                            ));
2420                        }
2421                    }
2422                    _ => {} // Unknown is honest for a cheap probe
2423                }
2424            }
2425        }
2426
2427        // Default render: base only, no surfaced map.
2428        match dec_render(&dec, &bytes, GainMapRender::BaseOnly) {
2429            Err(e) => v.push(format!("[{name}] BaseOnly decode: {e}")),
2430            Ok(out) => {
2431                if out.extras::<DecodedGainMap>().is_some() {
2432                    v.push(format!(
2433                        "[{name}] BaseOnly (the default) surfaced a DecodedGainMap — gain-map \
2434                         decode is opt-in"
2435                    ));
2436                }
2437                let px = grab(out.pixels());
2438                if (px.width, px.rows) != (base.width, base.height) {
2439                    v.push(format!(
2440                        "[{name}] BaseOnly decoded {}x{}, expected the {}x{} base",
2441                        px.width, px.rows, base.width, base.height
2442                    ));
2443                } else if lossless && px.bytes != base.pixels().bytes {
2444                    v.push(format!(
2445                        "[{name}] BaseOnly base pixels differ from the input on a lossless \
2446                         encoder — attaching a gain map must not disturb the base"
2447                    ));
2448                }
2449            }
2450        }
2451
2452        // Components: the map is surfaced and matches.
2453        let first = match dec_render(&dec, &bytes, GainMapRender::Components) {
2454            Err(e) => {
2455                v.push(format!("[{name}] Components decode: {e}"));
2456                None
2457            }
2458            Ok(mut out) => match out.take_extras::<DecodedGainMap>() {
2459                None => {
2460                    v.push(format!(
2461                        "[{name}] decoder declares gain_map, but Components surfaced no \
2462                         DecodedGainMap"
2463                    ));
2464                    None
2465                }
2466                Some(gm) => {
2467                    let obs = GainMapObserved::of(&gm);
2468                    if let Err(e) =
2469                        compare_gain_map(&obs, &want_px, &want_info, lossless, "Components")
2470                    {
2471                        v.push(format!("[{name}] {e}"));
2472                    }
2473                    Some((gm, obs))
2474                }
2475            },
2476        };
2477
2478        // with_extract_gain_map(true) is the same request as Components.
2479        if let Some((_, first_obs)) = &first {
2480            match dec
2481                .clone()
2482                .job()
2483                .with_extract_gain_map(true)
2484                .decoder(Cow::Borrowed(&bytes), &[])
2485                .and_then(|d| d.decode())
2486            {
2487                Err(e) => v.push(format!("[{name}] with_extract_gain_map(true) decode: {e}")),
2488                Ok(out) => match out.extras::<DecodedGainMap>() {
2489                    None => v.push(format!(
2490                        "[{name}] with_extract_gain_map(true) surfaced no DecodedGainMap, but \
2491                         Components did — the two must be equivalent"
2492                    )),
2493                    Some(gm) => {
2494                        let obs = GainMapObserved::of(gm);
2495                        if obs.pixels != first_obs.pixels || obs.info != first_obs.info {
2496                            v.push(format!(
2497                                "[{name}] with_extract_gain_map(true) and Components surfaced \
2498                                 different gain maps"
2499                            ));
2500                        }
2501                    }
2502                },
2503            }
2504        }
2505
2506        // ReconstructHdr: honoured only with reconstructs_hdr; otherwise an
2507        // honest fallback, never an SDR buffer wearing an HDR label.
2508        let recon = GainMapRender::ReconstructHdr {
2509            target_headroom: None,
2510        };
2511        match dec_render(&dec, &bytes, recon) {
2512            Ok(out) => {
2513                let desc = out.pixels().descriptor();
2514                let hdr_labelled = matches!(
2515                    desc.transfer(),
2516                    TransferFunction::Pq | TransferFunction::Hlg | TransferFunction::Linear
2517                );
2518                if dc.reconstructs_hdr() {
2519                    if !hdr_labelled {
2520                        v.push(format!(
2521                            "[{name}] reconstructs_hdr is declared, but ReconstructHdr produced a \
2522                             buffer labelled {:?} — the output must be an HDR pixel format",
2523                            desc.transfer()
2524                        ));
2525                    }
2526                    let sc = &out.info().source_color;
2527                    if sc.mastering_display.is_none() || sc.content_light_level.is_none() {
2528                        v.push(format!(
2529                            "[{name}] reconstructs_hdr: the reconstructed output must carry the \
2530                             luminance envelope (mastering_display + content_light_level) on \
2531                             source_color — without it a native-HDR transcode loses it"
2532                        ));
2533                    }
2534                } else if matches!(
2535                    desc.transfer(),
2536                    TransferFunction::Pq | TransferFunction::Hlg
2537                ) {
2538                    v.push(format!(
2539                        "[{name}] reconstructs_hdr is NOT declared, yet ReconstructHdr returned a \
2540                         buffer labelled {:?} — an SDR base must not be relabelled HDR; surface \
2541                         Components or fail with UnsupportedOperation",
2542                        desc.transfer()
2543                    ));
2544                }
2545            }
2546            Err(e) if dc.reconstructs_hdr() => v.push(format!(
2547                "[{name}] reconstructs_hdr is declared, but ReconstructHdr failed: {e}"
2548            )),
2549            Err(e) if e.unsupported_operation().is_some() => {}
2550            Err(e) => v.push(format!(
2551                "[{name}] ReconstructHdr without reconstructs_hdr must surface Components or \
2552                 fail with UnsupportedOperation, got: {e}"
2553            )),
2554        }
2555
2556        // Phase 4: the surfaced map transcodes through with_gain_map_pixels.
2557        if let Some((gm, first_obs)) = first {
2558            let base2 = TestImage::rgb8_gradient(20, 14);
2559            match enc_with_gain_map(&enc, &base2, gm).and_then(|b| {
2560                dec_render(&dec, &b, GainMapRender::Components).map_err(|e| e.to_string())
2561            }) {
2562                Err(e) => v.push(format!(
2563                    "[{name}] transcode (decode → with_gain_map_pixels): {e}"
2564                )),
2565                Ok(out) => match out.extras::<DecodedGainMap>() {
2566                    None => v.push(format!(
2567                        "[{name}] transcode: the re-encoded file surfaced no DecodedGainMap"
2568                    )),
2569                    Some(gm2) => {
2570                        let obs2 = GainMapObserved::of(gm2);
2571                        if let Err(e) = compare_gain_map(
2572                            &obs2,
2573                            &first_obs.pixels,
2574                            &first_obs.info,
2575                            lossless,
2576                            "transcode generation 2",
2577                        ) {
2578                            v.push(format!("[{name}] {e}"));
2579                        }
2580                    }
2581                },
2582            }
2583        }
2584
2585        // push_rows: the streaming encoder carries the map too.
2586        if ec.push_rows() {
2587            match enc_push_rows_with_gain_map(&enc, &base, gain_map_fixture(case)).and_then(|b| {
2588                dec_render(&dec, &b, GainMapRender::Components).map_err(|e| e.to_string())
2589            }) {
2590                Err(e) => v.push(format!("[{name}] push_rows encode with gain map: {e}")),
2591                Ok(out) => match out.extras::<DecodedGainMap>() {
2592                    None => v.push(format!(
2593                        "[{name}] push_rows: the incrementally encoded file surfaced no \
2594                         DecodedGainMap — the gain map was lost on the push_rows path"
2595                    )),
2596                    Some(gm) => {
2597                        if let Err(e) = compare_gain_map(
2598                            &GainMapObserved::of(gm),
2599                            &want_px,
2600                            &want_info,
2601                            lossless,
2602                            "push_rows",
2603                        ) {
2604                            v.push(format!("[{name}] {e}"));
2605                        }
2606                    }
2607                },
2608            }
2609        }
2610    }
2611
2612    if v.is_empty() {
2613        Ok(())
2614    } else {
2615        Err(fail(CHECK, v.join("; ")))
2616    }
2617}
2618
2619// ===========================================================================
2620// Buffer colour-context conformance (zencodec#25)
2621// ===========================================================================
2622
2623/// The colour every context check encodes with: an RGB-class ICC (the test
2624/// images are RGB/RGBA, so the profile is class-valid for the decoded layout)
2625/// plus sRGB CICP. `SourceColor`'s default authority is ICC, so a faithful
2626/// decoder's drop-dupe context carries the ICC alone.
2627fn color_context_metadata() -> Metadata {
2628    Metadata::none()
2629        .with_icc(fixtures::sample_icc())
2630        .with_cicp(Cicp::SRGB)
2631}
2632
2633fn dec_output<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<DecodeOutput, String> {
2634    cfg.clone()
2635        .job()
2636        .decoder(Cow::Borrowed(bytes), &[])
2637        .map_err(|e| e.to_string())?
2638        .decode()
2639        .map_err(|e| e.to_string())
2640}
2641
2642fn describe_ctx(ctx: Option<&ColorContext>) -> String {
2643    match ctx {
2644        None => "no ColorContext".into(),
2645        Some(c) => format!(
2646            "ColorContext {{ icc: {}, cicp: {:?} }}",
2647            c.icc
2648                .as_ref()
2649                .map_or("none".to_string(), |i| format!("{} bytes", i.len())),
2650            c.cicp
2651        ),
2652    }
2653}
2654
2655fn same_ctx(a: Option<&Arc<ColorContext>>, b: Option<&Arc<ColorContext>>) -> bool {
2656    a.map(|x| &**x) == b.map(|x| &**x)
2657}
2658
2659/// The two rules every *attached* context must satisfy, for one emitted
2660/// buffer or slice. `None` (nothing attached) is always acceptable here.
2661///
2662/// 1. **Non-empty** — a context with neither ICC nor CICP is noise; attach
2663///    nothing instead.
2664/// 2. **Class gate** — an ICC rides a buffer only when its device class (header
2665///    bytes 16..20) matches the buffer's colour model: `RGB ` ↔ Rgb/Rgba/Bgra,
2666///    `GRAY` ↔ Gray/GrayAlpha, `CMYK` ↔ Cmyk. Crosswise pairing is invalid
2667///    signaling (libpng rejects it); an unreadable class is not valid for any
2668///    layout.
2669fn validate_buffer_context(
2670    ctx: Option<&ColorContext>,
2671    desc: PixelDescriptor,
2672) -> Result<(), String> {
2673    let Some(ctx) = ctx else {
2674        return Ok(());
2675    };
2676    if ctx.icc.is_none() && ctx.cicp.is_none() {
2677        return Err(
2678            "an attached ColorContext carries neither ICC nor CICP — attach nothing rather \
2679             than an empty description"
2680                .into(),
2681        );
2682    }
2683    if let Some(icc) = &ctx.icc {
2684        let model = desc.color_model();
2685        match zenpixels::icc::profile_color_space(icc) {
2686            Some(class) if class == model => {}
2687            Some(class) => {
2688                return Err(format!(
2689                    "an ICC of device class {class:?} is attached to a {model:?} buffer ({desc:?}) — \
2690                     an ICC only rides a layout its class describes (RGB ↔ Rgb/Rgba/Bgra, GRAY ↔ \
2691                     Gray/GrayAlpha); derive its CICP and carry that alone, or emit a layout the \
2692                     profile describes"
2693                ));
2694            }
2695            None => {
2696                return Err(format!(
2697                    "an ICC whose device class (header bytes 16..20) is unreadable is attached to a \
2698                     {model:?} buffer — it is not class-valid for any layout; do not attach it"
2699                ));
2700            }
2701        }
2702    }
2703    Ok(())
2704}
2705
2706/// Whatever [`ColorContext`] a decoder attaches to its output pixels is
2707/// class-valid and the same on every decode path.
2708///
2709/// The decoded-buffer convention (zencodec `docs/IMPLEMENTING.md`, "Colour on the
2710/// decoded buffer"): a decoder SHOULD attach `SourceColor::to_color_context()` to
2711/// the buffers it emits, class-gated so an ICC only rides a layout its device
2712/// class describes. This check enforces the two invariants that hold whether or
2713/// not a codec has adopted the convention yet:
2714///
2715/// - **Class-valid and non-empty** — see the rules on the ICC device class in the
2716///   module docs; an attached context must describe *these* pixels.
2717/// - **Path equivalence** — the one-shot buffer, every streaming strip (when
2718///   streaming is advertised), and every animation frame on both the borrowed and
2719///   the owned path (when animation is advertised) carry *identical* contexts. Strip
2720///   and scratch buffers are rebuilt per batch and are where a context silently
2721///   dies; a probe-era vs frame-era CICP divergence between paths shows up here too.
2722///
2723/// It does **not** require a context to be attached — a codec that attaches
2724/// nothing passes (the pixels stay described by `ImageInfo.source_color`). Run
2725/// [`check_color_context_attached`] for the strict positive direction once the
2726/// codec has adopted the convention. Part of [`check_all`].
2727pub fn check_color_context_consistency<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
2728where
2729    E: EncoderConfig,
2730    D: DecoderConfig,
2731    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
2732    <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
2733{
2734    const CHECK: &str = "color_context_consistency";
2735    let meta = color_context_metadata();
2736    let bytes = enc_oneshot(&enc, img, meta.clone(), MetadataPolicy::PreserveExact)
2737        .map_err(|e| fail(CHECK, format!("encode: {e}")))?;
2738    let out = dec_output(&dec, &bytes).map_err(|e| fail(CHECK, format!("decode: {e}")))?;
2739    let desc = out.pixels().descriptor();
2740    let oneshot = out.into_buffer().color_context().cloned();
2741    validate_buffer_context(oneshot.as_deref(), desc)
2742        .map_err(|e| fail(CHECK, format!("one-shot decode: {e}")))?;
2743
2744    if D::capabilities().streaming() {
2745        let mut sd = dec
2746            .clone()
2747            .job()
2748            .streaming_decoder(Cow::Borrowed(&bytes), &[])
2749            .map_err(|e| fail(CHECK, format!("streaming decoder: {e}")))?;
2750        while let Some((y, strip)) = sd
2751            .next_batch()
2752            .map_err(|e| fail(CHECK, format!("streaming next_batch: {e}")))?
2753        {
2754            validate_buffer_context(strip.color_context().map(|c| &**c), strip.descriptor())
2755                .map_err(|e| fail(CHECK, format!("streaming strip at y={y}: {e}")))?;
2756            if !same_ctx(strip.color_context(), oneshot.as_ref()) {
2757                return Err(fail(
2758                    CHECK,
2759                    format!(
2760                        "streaming strip at y={y} carries {} but the one-shot decode carries {} — \
2761                         strip/scratch buffers must re-attach the context at emission",
2762                        describe_ctx(strip.color_context().map(|c| &**c)),
2763                        describe_ctx(oneshot.as_deref())
2764                    ),
2765                ));
2766            }
2767        }
2768    }
2769
2770    if E::capabilities().animation() && D::capabilities().animation() {
2771        let frames = [
2772            TestImage::rgba8_gradient_seeded(img.width, img.height, 0),
2773            TestImage::rgba8_gradient_seeded(img.width, img.height, 90),
2774        ];
2775        let anim = encode_animation(&enc, &frames, meta)
2776            .map_err(|e| fail(CHECK, format!("animation encode: {e}")))?;
2777        let primary = dec_output(&dec, &anim)
2778            .map_err(|e| fail(CHECK, format!("one-shot decode of the animation: {e}")))?;
2779        let pdesc = primary.pixels().descriptor();
2780        let primary_ctx = primary.into_buffer().color_context().cloned();
2781        validate_buffer_context(primary_ctx.as_deref(), pdesc)
2782            .map_err(|e| fail(CHECK, format!("one-shot decode of the animation: {e}")))?;
2783
2784        let mismatch = |path: &str, i: u32, got: Option<&Arc<ColorContext>>| {
2785            fail(
2786                CHECK,
2787                format!(
2788                    "{path} frame {i} carries {} but the one-shot decode of the same file carries {} — \
2789                     every frame must carry the context its pixels are described by",
2790                    describe_ctx(got.map(|c| &**c)),
2791                    describe_ctx(primary_ctx.as_deref())
2792                ),
2793            )
2794        };
2795
2796        let mut d = dec
2797            .clone()
2798            .job()
2799            .animation_frame_decoder(Cow::Borrowed(&anim), &[])
2800            .map_err(|e| fail(CHECK, format!("animation decoder: {e}")))?;
2801        while let Some(frame) = d
2802            .render_next_frame(None)
2803            .map_err(|e| fail(CHECK, format!("render_next_frame: {e}")))?
2804        {
2805            let i = frame.frame_index();
2806            let px = frame.pixels();
2807            validate_buffer_context(px.color_context().map(|c| &**c), px.descriptor())
2808                .map_err(|e| fail(CHECK, format!("render_next_frame frame {i}: {e}")))?;
2809            if !same_ctx(px.color_context(), primary_ctx.as_ref()) {
2810                return Err(mismatch("render_next_frame", i, px.color_context()));
2811            }
2812        }
2813
2814        let mut d = dec
2815            .clone()
2816            .job()
2817            .animation_frame_decoder(Cow::Borrowed(&anim), &[])
2818            .map_err(|e| fail(CHECK, format!("animation decoder: {e}")))?;
2819        while let Some(frame) = d
2820            .render_next_frame_owned(None)
2821            .map_err(|e| fail(CHECK, format!("render_next_frame_owned: {e}")))?
2822        {
2823            let i = frame.frame_index();
2824            let px = frame.pixels();
2825            validate_buffer_context(px.color_context().map(|c| &**c), px.descriptor())
2826                .map_err(|e| fail(CHECK, format!("render_next_frame_owned frame {i}: {e}")))?;
2827            if !same_ctx(px.color_context(), primary_ctx.as_ref()) {
2828                return Err(mismatch("render_next_frame_owned", i, px.color_context()));
2829            }
2830        }
2831    }
2832    Ok(())
2833}
2834
2835/// A decoder that read colour back attaches it to the output buffer — the
2836/// strict, positive direction of the decoded-buffer convention.
2837///
2838/// Encodes with an RGB-class ICC + sRGB CICP under `PreserveExact`, decodes, and
2839/// looks at what the decoder reported in `ImageInfo.source_color`. If it read
2840/// back neither ICC nor CICP there is nothing to attach and the check passes
2841/// (whether it *should* have read them is [`check_capability_honesty`]'s job).
2842/// Otherwise the output buffer must carry a [`ColorContext`], and it must carry
2843/// the **authoritative** field: under [`ColorAuthority::Icc`] with a class-valid
2844/// profile, the ICC bytes ride as-is (rank 1 of the convention); under
2845/// [`ColorAuthority::Cicp`], the CICP. A class-invalid ICC only has to be
2846/// replaced by *some* description (the derived-CICP fallback), which the
2847/// class-gate rules in [`check_color_context_consistency`] verify. When streaming
2848/// is advertised, the first strip must carry a context too.
2849///
2850/// Opt-in — **not** part of [`check_all`] — because the convention is a SHOULD
2851/// that codecs adopt one at a time; run it once yours does.
2852pub fn check_color_context_attached<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
2853where
2854    E: EncoderConfig,
2855    D: DecoderConfig,
2856    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
2857{
2858    const CHECK: &str = "color_context_attached";
2859    let bytes = enc_oneshot(
2860        &enc,
2861        img,
2862        color_context_metadata(),
2863        MetadataPolicy::PreserveExact,
2864    )
2865    .map_err(|e| fail(CHECK, format!("encode: {e}")))?;
2866    let out = dec_output(&dec, &bytes).map_err(|e| fail(CHECK, format!("decode: {e}")))?;
2867    let src = out.info().source_color.clone();
2868    let desc = out.pixels().descriptor();
2869    let ctx = out.into_buffer().color_context().cloned();
2870
2871    if src.icc_profile.is_none() && src.cicp.is_none() {
2872        return Ok(()); // the decoder read no colour back — nothing to attach
2873    }
2874    let Some(ctx) = ctx else {
2875        return Err(fail(
2876            CHECK,
2877            format!(
2878                "the decoder read colour back into ImageInfo.source_color (icc: {}, cicp: {:?}) \
2879                 but attached no ColorContext to the output buffer — attach \
2880                 SourceColor::to_color_context(), class-gated, after the descriptor is final",
2881                src.icc_profile
2882                    .as_ref()
2883                    .map_or("none".to_string(), |i| format!("{} bytes", i.len())),
2884                src.cicp
2885            ),
2886        ));
2887    };
2888    validate_buffer_context(Some(&ctx), desc)
2889        .map_err(|e| fail(CHECK, format!("one-shot decode: {e}")))?;
2890
2891    let src_icc_class_valid = src
2892        .icc_profile
2893        .as_deref()
2894        .is_some_and(|icc| zenpixels::icc::profile_color_space(icc) == Some(desc.color_model()));
2895    match src.color_authority {
2896        ColorAuthority::Icc if src_icc_class_valid => {
2897            if ctx.icc.as_deref() != src.icc_profile.as_deref() {
2898                return Err(fail(
2899                    CHECK,
2900                    format!(
2901                        "ICC is the authority and the source profile is class-valid for the {:?} \
2902                         output, so the buffer context must carry those ICC bytes as-is; it carries {}",
2903                        desc.color_model(),
2904                        describe_ctx(Some(&ctx))
2905                    ),
2906                ));
2907            }
2908        }
2909        ColorAuthority::Cicp if src.cicp.is_some() => {
2910            if ctx.cicp != src.cicp {
2911                return Err(fail(
2912                    CHECK,
2913                    format!(
2914                        "CICP is the authority ({:?}) but the buffer context carries {}",
2915                        src.cicp,
2916                        describe_ctx(Some(&ctx))
2917                    ),
2918                ));
2919            }
2920        }
2921        // Authority field absent, or an ICC the output layout can't carry: any
2922        // non-empty, class-valid description (already validated) is acceptable.
2923        ColorAuthority::Icc | ColorAuthority::Cicp => {}
2924    }
2925
2926    if D::capabilities().streaming() {
2927        let mut sd = dec
2928            .clone()
2929            .job()
2930            .streaming_decoder(Cow::Borrowed(&bytes), &[])
2931            .map_err(|e| fail(CHECK, format!("streaming decoder: {e}")))?;
2932        let first = sd
2933            .next_batch()
2934            .map_err(|e| fail(CHECK, format!("streaming next_batch: {e}")))?;
2935        if let Some((y, strip)) = first
2936            && strip.color_context().is_none()
2937        {
2938            return Err(fail(
2939                CHECK,
2940                format!(
2941                    "the one-shot buffer carries a ColorContext but the streaming strip at y={y} \
2942                     carries none — re-attach the context on every emitted strip"
2943                ),
2944            ));
2945        }
2946    }
2947    Ok(())
2948}
2949
2950// ===========================================================================
2951// Colour authority — the per-format spec table (issue #11, Table 4)
2952// ===========================================================================
2953
2954/// The [`ColorAuthority`] a format's specification assigns, given which colour
2955/// fields the decoder actually read back.
2956///
2957/// This is the shared answer key behind [`check_source_color_authority`] — the
2958/// "what does the spec say" half of the audit in zencodec issue #11 (Table 4),
2959/// so every codec crate asserts the same rule instead of re-deriving it:
2960///
2961/// | format | rule |
2962/// |---|---|
2963/// | PNG | `cICP` outranks `iCCP` (PNG 3rd ed.): `Cicp` when CICP was read, else `Icc` |
2964/// | JXL | codestream colour encoding outranks an embedded ICC: `Cicp` when read, else `Icc` |
2965/// | HEIC | `nclx` is the primary colour authority (ISO 23008-12): `Cicp` when read, else `Icc` |
2966/// | AVIF | MIAF order: an ICC `colr` outranks `nclx` — `Icc` when an ICC was read, else `Cicp` when CICP was read, else `Icc` |
2967/// | Radiance HDR | scene-linear BT.709 has no ICC carrier: `Cicp` when the codec expressed it as CICP, else `Icc` |
2968/// | JPEG, WebP, GIF, TIFF, BMP, ICO, PNM, Farbfeld, QOI, TGA, DNG, RAW, PDF | ICC (or an sRGB assumption): always `Icc` |
2969///
2970/// Returns `None` where no rule is recorded — [`Custom`](ImageFormat::Custom),
2971/// [`Unknown`](ImageFormat::Unknown), and the formats the audit did not cover
2972/// (EXR, JPEG 2000, SVG) — so a check over those passes rather than inventing a
2973/// rule. `Cicp` is never expected when no CICP was read (the enum's own
2974/// invariant: codecs only set `Cicp` when `cicp` is populated).
2975pub fn expected_color_authority(
2976    format: ImageFormat,
2977    has_cicp: bool,
2978    has_icc: bool,
2979) -> Option<ColorAuthority> {
2980    use ImageFormat::*;
2981    let cicp_first = if has_cicp {
2982        ColorAuthority::Cicp
2983    } else {
2984        ColorAuthority::Icc
2985    };
2986    Some(match format {
2987        Png | Jxl | Heic | Hdr => cicp_first,
2988        Avif => {
2989            if has_icc {
2990                ColorAuthority::Icc
2991            } else {
2992                cicp_first
2993            }
2994        }
2995        Jpeg | WebP | Gif | Tiff | Bmp | Ico | Pnm | Farbfeld | Qoi | Tga | Dng | Raw | Pdf => {
2996            ColorAuthority::Icc
2997        }
2998        // EXR / JPEG 2000 / SVG were not covered by the audit; `Custom` /
2999        // `Unknown` (and any future `#[non_exhaustive]` variant) have no rule.
3000        _ => return None,
3001    })
3002}
3003
3004/// A decoded [`SourceColor`] names the authority its format's spec assigns.
3005///
3006/// The unit form of the check — run it on the `source_color` a decoder produced
3007/// for a known input. The expected authority comes from
3008/// [`expected_color_authority`] applied to the fields the decoder *read back*
3009/// (so a decoder that reads no CICP is not expected to name it). Passes when the
3010/// format has no recorded rule.
3011///
3012/// This is the "shared test helper" the issue #11 audit asked for: the
3013/// authority-mismatch class of bug (HEIC naming `Icc` for an `nclx`-only file,
3014/// so the CICP was dropped by `SourceColor::to_color_context()` and the pixels
3015/// fell back to sRGB) is invisible to a pixel round trip and only shows up
3016/// downstream in the CMS.
3017pub fn check_source_color_authority(format: ImageFormat, sc: &SourceColor) -> Conformance {
3018    const CHECK: &str = "source_color_authority";
3019    let Some(want) = expected_color_authority(format, sc.cicp.is_some(), sc.icc_profile.is_some())
3020    else {
3021        return Ok(());
3022    };
3023    if sc.color_authority == want {
3024        return Ok(());
3025    }
3026    Err(fail(
3027        CHECK,
3028        format!(
3029            "{format:?} read cicp: {}, icc: {} — the format spec makes {want:?} authoritative, \
3030             but source_color.color_authority is {:?}",
3031            sc.cicp.map_or("none".to_string(), |c| format!("{c:?}")),
3032            sc.icc_profile
3033                .as_ref()
3034                .map_or("none".to_string(), |i| format!("{} bytes", i.len())),
3035            sc.color_authority
3036        ),
3037    ))
3038}
3039
3040/// Decoded colour authority follows the format spec on every metadata mix.
3041///
3042/// Encodes `img` with an ICC only, a CICP only, both, and neither (each
3043/// channel only when the encoder declares it), then runs
3044/// [`check_source_color_authority`] on the `source_color` of the one-shot
3045/// decode *and* of `probe()`, under the encoder's declared
3046/// [`format()`](EncoderConfig::format). Passes trivially for formats without a
3047/// recorded rule (see [`expected_color_authority`]).
3048pub fn check_color_authority_spec<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
3049where
3050    E: EncoderConfig,
3051    D: DecoderConfig,
3052    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
3053{
3054    const CHECK: &str = "color_authority_spec";
3055    let ec = E::capabilities();
3056    let format = E::format();
3057    let mut mixes: Vec<(&str, Metadata)> = vec![("no colour metadata", Metadata::none())];
3058    if ec.icc() {
3059        mixes.push((
3060            "icc only",
3061            Metadata::none().with_icc(fixtures::sample_icc()),
3062        ));
3063    }
3064    if ec.cicp() {
3065        mixes.push(("cicp only", Metadata::none().with_cicp(Cicp::SRGB)));
3066    }
3067    if ec.icc() && ec.cicp() {
3068        mixes.push((
3069            "icc + cicp",
3070            Metadata::none()
3071                .with_icc(fixtures::sample_icc())
3072                .with_cicp(Cicp::SRGB),
3073        ));
3074    }
3075    let mut v: Vec<String> = Vec::new();
3076    for (name, meta) in mixes {
3077        let bytes = match enc_oneshot(&enc, img, meta, MetadataPolicy::PreserveExact) {
3078            Ok(b) => b,
3079            Err(e) => {
3080                v.push(format!("{name}: encode failed: {e}"));
3081                continue;
3082            }
3083        };
3084        match dec_output(&dec, &bytes) {
3085            Err(e) => v.push(format!("{name}: decode failed: {e}")),
3086            Ok(out) => {
3087                if let Err(f) = check_source_color_authority(format, &out.info().source_color) {
3088                    v.push(format!("{name}, decode: {}", f.detail));
3089                }
3090            }
3091        }
3092        match dec.clone().job().probe(&bytes) {
3093            Err(e) => v.push(format!("{name}: probe failed: {e}")),
3094            Ok(info) => {
3095                if let Err(f) = check_source_color_authority(format, &info.source_color) {
3096                    v.push(format!("{name}, probe: {}", f.detail));
3097                }
3098            }
3099        }
3100    }
3101    if v.is_empty() {
3102        Ok(())
3103    } else {
3104        Err(fail(CHECK, v.join("; ")))
3105    }
3106}
3107
3108/// Classify one structural capability: declared support must match observed
3109/// behavior. Declared + works = fine; declared + failed = lying (missing impl);
3110/// undeclared + worked = lying (hidden support); undeclared + failed with
3111/// anything other than `UnsupportedOperation` = wrong error for an absent
3112/// feature.
3113fn classify<T, Er>(name: &str, declared: bool, res: Result<T, Er>, v: &mut Vec<String>)
3114where
3115    Er: std::error::Error + 'static,
3116{
3117    match (declared, res) {
3118        (true, Ok(_)) => {}
3119        (true, Err(e)) => v.push(format!(
3120            "{name}: declared supported, but the operation failed: {e}"
3121        )),
3122        (false, Ok(_)) => v.push(format!(
3123            "{name}: not declared, but the operation succeeded (hidden capability)"
3124        )),
3125        (false, Err(e)) => {
3126            if e.unsupported_operation().is_none() {
3127                v.push(format!(
3128                    "{name}: not declared; expected UnsupportedOperation when used, got a different error: {e}"
3129                ));
3130            }
3131        }
3132    }
3133}
3134
3135fn run_push_rows<E>(cfg: &E, img: &TestImage) -> Result<Vec<u8>, E::Error>
3136where
3137    E: EncoderConfig,
3138    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
3139{
3140    let mut e = cfg
3141        .clone()
3142        .job()
3143        .with_metadata_policy(Metadata::none(), MetadataPolicy::PreserveExact)
3144        .encoder()?;
3145    let strip = e.preferred_strip_height().max(1);
3146    let mut y = 0;
3147    while y < img.height {
3148        let h = strip.min(img.height - y);
3149        e.push_rows(img.strip(y, h))?;
3150        y += h;
3151    }
3152    Ok(e.finish()?.into_vec())
3153}
3154
3155fn run_encode_from<E>(cfg: &E, img: &TestImage) -> Result<Vec<u8>, E::Error>
3156where
3157    E: EncoderConfig,
3158    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
3159{
3160    let e = cfg
3161        .clone()
3162        .job()
3163        .with_metadata_policy(Metadata::none(), MetadataPolicy::PreserveExact)
3164        .encoder()?;
3165    let rb = img.row_bytes();
3166    let mut next = 0u32;
3167    let mut src = |_y: u32, mut buf: PixelSliceMut<'_>| -> usize {
3168        if next >= img.height {
3169            return 0;
3170        }
3171        let want = buf.rows().min(img.height - next);
3172        for r in 0..want {
3173            let s = (next + r) as usize * rb;
3174            let dst = buf.row_mut(r);
3175            let n = dst.len().min(rb);
3176            dst[..n].copy_from_slice(&img.data[s..s + n]);
3177        }
3178        next += want;
3179        want as usize
3180    };
3181    Ok(e.encode_from(&mut src)?.into_vec())
3182}
3183
3184/// Capability-honesty for the animation encode path, handled separately from
3185/// [`classify`] because the frame encoder's `Error` can differ from the codec's
3186/// (`type AnimationFrameEnc = ()` has `Error = UnsupportedOperation`, not the
3187/// codec's error). `animation_frame_encoder()` returns the *job's* error
3188/// (`E::Error`, inspectable for `UnsupportedOperation`); the per-frame error is
3189/// only stringified, so no `Error = E::Error` unification is required.
3190fn check_animation_encode_honesty<E>(cfg: &E, img: &TestImage, declared: bool, v: &mut Vec<String>)
3191where
3192    E: EncoderConfig,
3193    <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
3194{
3195    match cfg
3196        .clone()
3197        .job()
3198        .with_loop_count(Some(0))
3199        .animation_frame_encoder()
3200    {
3201        Ok(mut a) => {
3202            // The encoder was created, so the codec supports animation.
3203            let frames: Result<(), String> = (|| {
3204                a.push_frame(img.as_slice(), 100, None)
3205                    .map_err(|e| e.to_string())?;
3206                a.push_frame(img.as_slice(), 100, None)
3207                    .map_err(|e| e.to_string())?;
3208                a.finish(None).map_err(|e| e.to_string())?;
3209                Ok(())
3210            })();
3211            match (declared, frames) {
3212                (true, Ok(())) => {}
3213                (true, Err(e)) => v.push(format!("encode animation: declared supported, but the operation failed: {e}")),
3214                (false, _) => v.push(
3215                    "encode animation: not declared, but animation_frame_encoder() succeeded (hidden capability)".into(),
3216                ),
3217            }
3218        }
3219        Err(e) => {
3220            if declared {
3221                v.push(format!("encode animation: declared supported, but animation_frame_encoder() failed: {e}"));
3222            } else if e.unsupported_operation().is_none() {
3223                v.push(format!(
3224                    "encode animation: not declared; expected UnsupportedOperation when used, got a different error: {e}"
3225                ));
3226            }
3227        }
3228    }
3229}
3230
3231fn run_streaming<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<(), D::Error> {
3232    let mut sd = cfg
3233        .clone()
3234        .job()
3235        .streaming_decoder(Cow::Borrowed(bytes), &[])?;
3236    while sd.next_batch()?.is_some() {}
3237    Ok(())
3238}
3239
3240fn run_animation_decode<D: DecoderConfig>(cfg: &D, bytes: &[u8]) -> Result<(), D::Error> {
3241    let mut ad = cfg
3242        .clone()
3243        .job()
3244        .animation_frame_decoder(Cow::Borrowed(bytes), &[])?;
3245    while ad.render_next_frame(None)?.is_some() {}
3246    Ok(())
3247}
3248
3249/// Declared capabilities match real behavior.
3250///
3251/// For the encode paths (`push_rows`, `encode_from`, animation), the decode paths
3252/// (streaming, animation), the `lossless` knob, and `cheap_probe`, **both
3253/// directions** are checked: every declared capability is exercised, and every
3254/// *undeclared* optional path must decline with
3255/// [`UnsupportedOperation`] — a codec can't claim a
3256/// feature it lacks *or* hide one it has. The metadata channels
3257/// (`icc`/`exif`/`xmp`/`cicp`) are checked bidirectionally **where the decoder can
3258/// observe them**: each channel is supplied separately, and a declared channel
3259/// must survive a `PreserveExact` round trip, and
3260/// an undeclared one must *not* (a hidden write capability); the
3261/// encoder-writes-but-decoder-doesn't-read quadrant isn't observable through decode.
3262/// Separate probes allow formats with mutually exclusive ICC/native-color carriers;
3263/// mixed-carrier precedence remains covered by [`check_color_authority_spec`].
3264/// `native_alpha` is forward-only — a declared RGBA8 round trip must preserve alpha;
3265/// the no-alpha direction isn't cleanly assertable (a codec may legitimately reject
3266/// or flatten RGBA input).
3267///
3268/// All violations are collected and reported together, so one run names every
3269/// dishonest flag.
3270///
3271/// Not covered: cooperative cancellation (`stop`) — whether a codec honors a
3272/// triggered token is timing-dependent on small inputs and can't be asserted
3273/// reliably here; the `lossy` flag, whose effect isn't observable from the
3274/// bitstream alone; and the pixel-format / resource / tuning flags (`native_gray`,
3275/// `native_16bit`, `native_f32`, `enforces_max_pixels` / `enforces_max_memory`,
3276/// the CICP-carrier flags, and the `effort` / `quality` / `threads` ranges),
3277/// whose honesty needs format-specific fixtures a generic harness can't supply.
3278/// `hdr` and `gain_map` have their own checks ([`check_native_hdr_roundtrip`],
3279/// [`check_gain_map_roundtrip`]).
3280pub fn check_capability_honesty<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
3281where
3282    E: EncoderConfig,
3283    D: DecoderConfig,
3284    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
3285    <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
3286{
3287    const CHECK: &str = "capability_honesty";
3288    let ec = E::capabilities();
3289    let dc = D::capabilities();
3290    let mut v: Vec<String> = Vec::new();
3291
3292    // --- structural encode paths (both directions) ---
3293    classify(
3294        "encode push_rows",
3295        ec.push_rows(),
3296        run_push_rows(&enc, img),
3297        &mut v,
3298    );
3299    classify(
3300        "encode encode_from",
3301        ec.encode_from(),
3302        run_encode_from(&enc, img),
3303        &mut v,
3304    );
3305    check_animation_encode_honesty(&enc, img, ec.animation(), &mut v);
3306
3307    // --- lossless config-knob honesty ---
3308    // Declared => with_lossless(true) must surface via is_lossless(); undeclared
3309    // => the no-op default leaves is_lossless() == None.
3310    let toggled = enc.clone().with_lossless(true).is_lossless();
3311    match (ec.lossless(), toggled) {
3312        (true, Some(true)) => {}
3313        (true, other) => v.push(format!(
3314            "encode lossless: declared, but with_lossless(true) gives is_lossless() = {other:?} (expected Some(true))"
3315        )),
3316        (false, None) => {}
3317        (false, other) => v.push(format!(
3318            "encode lossless: not declared, but with_lossless(true) gives is_lossless() = {other:?} (expected None)"
3319        )),
3320    }
3321
3322    // --- a canonical encode for the decode-side checks ---
3323    match enc_oneshot(&enc, img, Metadata::none(), MetadataPolicy::PreserveExact) {
3324        Err(e) => v.push(format!(
3325            "could not produce a canonical encode for decode checks: {e}"
3326        )),
3327        Ok(canonical) => {
3328            classify(
3329                "decode streaming",
3330                dc.streaming(),
3331                run_streaming(&dec, &canonical),
3332                &mut v,
3333            );
3334            classify(
3335                "decode animation",
3336                dc.animation(),
3337                run_animation_decode(&dec, &canonical),
3338                &mut v,
3339            );
3340            if dc.cheap_probe()
3341                && let Err(e) = dec.clone().job().probe(&canonical)
3342            {
3343                v.push(format!(
3344                    "decode cheap_probe: declared, but probe() failed: {e}"
3345                ));
3346            }
3347        }
3348    }
3349
3350    // --- metadata-channel honesty (bidirectional): for each channel the decoder
3351    //     can read back, a declared encoder channel must survive a PreserveExact
3352    //     round trip, AND an *undeclared* encoder channel must NOT (a codec can't
3353    //     hide a write capability it claims not to have). The "encoder writes,
3354    //     decoder doesn't read" quadrant isn't observable through decode, so it's
3355    //     left to the codec's own tests. ---
3356    check_metadata_channel_honesty(
3357        ec,
3358        dc,
3359        |metadata| {
3360            enc_oneshot(&enc, img, metadata, MetadataPolicy::PreserveExact)
3361                .and_then(|bytes| dec_oneshot(&dec, &bytes))
3362                .map(|(_, metadata)| metadata)
3363        },
3364        &mut v,
3365    );
3366
3367    // --- native_alpha honesty: RGBA8 alpha survives when both ends claim it ---
3368    if ec.native_alpha() && dc.native_alpha() {
3369        let rgba = TestImage::rgba8_gradient(img.width.max(2), img.height.max(2));
3370        match enc_oneshot(&enc, &rgba, Metadata::none(), MetadataPolicy::PreserveExact)
3371            .and_then(|b| dec_oneshot(&dec, &b))
3372        {
3373            Err(e) => v.push(format!(
3374                "native_alpha: declared, but an RGBA8 round trip failed: {e}"
3375            )),
3376            Ok((px, _)) => {
3377                if px != rgba.pixels() {
3378                    v.push("native_alpha: declared, but RGBA8 pixels (alpha included) did not round-trip".into());
3379                }
3380            }
3381        }
3382    }
3383
3384    if v.is_empty() {
3385        Ok(())
3386    } else {
3387        Err(fail(CHECK, v.join("; ")))
3388    }
3389}
3390
3391fn check_metadata_channel_honesty(
3392    ec: &zencodec::encode::EncodeCapabilities,
3393    dc: &zencodec::decode::DecodeCapabilities,
3394    mut roundtrip: impl FnMut(Metadata) -> Result<Metadata, String>,
3395    violations: &mut Vec<String>,
3396) {
3397    let channels = [
3398        (
3399            "icc",
3400            ec.icc(),
3401            dc.icc(),
3402            Metadata::none().with_icc(fixtures::sample_icc()),
3403            (|m: &Metadata| m.icc_profile.is_some()) as fn(&Metadata) -> bool,
3404        ),
3405        (
3406            "exif",
3407            ec.exif(),
3408            dc.exif(),
3409            Metadata::none().with_exif(fixtures::rich_exif_le()),
3410            |m: &Metadata| m.exif.is_some(),
3411        ),
3412        (
3413            "xmp",
3414            ec.xmp(),
3415            dc.xmp(),
3416            Metadata::none().with_xmp(fixtures::sample_xmp()),
3417            |m: &Metadata| m.xmp.is_some(),
3418        ),
3419        (
3420            "cicp",
3421            ec.cicp(),
3422            dc.cicp(),
3423            Metadata::none().with_cicp(Cicp::SRGB),
3424            |m: &Metadata| m.cicp.is_some(),
3425        ),
3426    ];
3427    for (name, enc_writes, dec_reads, metadata, present) in channels {
3428        if !dec_reads {
3429            continue; // not observable through this decoder
3430        }
3431        match roundtrip(metadata) {
3432            Err(e) => violations.push(format!("{name}: metadata-channel round trip failed: {e}")),
3433            Ok(actual) if enc_writes && !present(&actual) => violations.push(format!(
3434                "{name}: declared by encoder+decoder, but did not survive a PreserveExact round trip"
3435            )),
3436            Ok(actual) if !enc_writes && present(&actual) => violations.push(format!(
3437                "{name}: encoder declared it does NOT support this channel, yet it survived a round trip (hidden capability)"
3438            )),
3439            Ok(_) => {}
3440        }
3441    }
3442}
3443
3444// ===========================================================================
3445// Fidelity honesty (issue #26 — the per-codec `Fidelity` contract)
3446// ===========================================================================
3447
3448/// The resolved [`Fidelity`] report is honest against the declared capabilities
3449/// and against the pixels that actually come back.
3450///
3451/// [`with_fidelity`](EncoderConfig::with_fidelity) is best-effort and
3452/// infallible, so the whole cross-codec contract rests on
3453/// [`resolved_target_fidelity`](EncoderConfig::resolved_target_fidelity) telling
3454/// the truth. For each request — `Lossless`, then every [`LossyTarget`](zencodec::encode::LossyTarget) arm
3455/// (`CodecSpecificQuality` at the middle of the declared `quality_range`,
3456/// `ApproxSsim2`, `ApproxButteraugli`, `ApproxZensimB`), each applied on top of a
3457/// prior `Lossless` request so a stale setting cannot leak through — the
3458/// check asserts:
3459///
3460/// - **`lossless` declared** ⇒ a `Lossless` request resolves to
3461///   `Some(Lossless)` **and** the decoded pixels are byte-identical to the
3462///   input. Undeclared ⇒ it must not resolve to `Some(Lossless)` (a codec
3463///   cannot claim a mode it does not declare).
3464/// - **`lossy` declared** ⇒ a lossy request never resolves to `Lossless` (a
3465///   codec with a lossy mode must use it when asked), and — when it also
3466///   declares a `quality_range` — resolves to `Some(Lossy(_))` rather than
3467///   `None`. Undeclared ⇒ it must not resolve to `Some(Lossy(_))`: the honest
3468///   outcomes are promotion to `Some(Lossless)` (as the
3469///   [`reference`](mod@reference) codec does) or `None`.
3470/// - Every request still encodes and decodes, and whenever the codec *reports*
3471///   `Lossless` the pixels are exact — whatever was asked for.
3472/// - The legacy getter agrees: when both `is_lossless()` and the resolved
3473///   fidelity are `Some`, `is_lossless()` equals `resolved.is_lossless()`.
3474///
3475/// All violations are collected and reported together. Not covered: whether a
3476/// metric target was *hit* (needs the metric), and the deferred
3477/// `LosslessMode`/near-lossless arm (not in the shipped enum).
3478pub fn check_fidelity_honesty<E, D>(enc: E, dec: D, img: &TestImage) -> Conformance
3479where
3480    E: EncoderConfig,
3481    D: DecoderConfig,
3482    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
3483{
3484    const CHECK: &str = "fidelity_honesty";
3485    let ec = E::capabilities();
3486    let mut v: Vec<String> = Vec::new();
3487
3488    // Round-trip a configured encoder; on success, report whether the pixels
3489    // came back exact.
3490    let exact = |cfg: &E| -> Result<bool, String> {
3491        let bytes = enc_oneshot(cfg, img, Metadata::none(), MetadataPolicy::PreserveExact)?;
3492        let (px, _) = dec_oneshot(&dec, &bytes)?;
3493        Ok(px == img.pixels())
3494    };
3495    let legacy_agrees = |name: &str, cfg: &E, resolved: Option<Fidelity>, v: &mut Vec<String>| {
3496        if let (Some(legacy), Some(f)) = (cfg.is_lossless(), resolved)
3497            && legacy != f.is_lossless()
3498        {
3499            v.push(format!(
3500                "{name}: is_lossless() = Some({legacy}) disagrees with resolved_target_fidelity() = {f:?}"
3501            ));
3502        }
3503    };
3504
3505    // --- Lossless ---
3506    let ll = enc.clone().with_fidelity(Fidelity::Lossless);
3507    let r = ll.resolved_target_fidelity();
3508    if ec.lossless() {
3509        if r != Some(Fidelity::Lossless) {
3510            v.push(format!(
3511                "Lossless: `lossless` is declared, but the request resolved to {r:?} (expected Some(Lossless))"
3512            ));
3513        }
3514    } else if r == Some(Fidelity::Lossless) {
3515        v.push(
3516            "Lossless: `lossless` is NOT declared, yet the request resolved to Some(Lossless)"
3517                .into(),
3518        );
3519    }
3520    legacy_agrees("Lossless", &ll, r, &mut v);
3521    match exact(&ll) {
3522        Err(e) => v.push(format!("Lossless: round trip failed: {e}")),
3523        Ok(false) if r == Some(Fidelity::Lossless) => v.push(
3524            "Lossless: resolved to Lossless but the decoded pixels differ from the input".into(),
3525        ),
3526        Ok(_) => {}
3527    }
3528
3529    // --- every lossy arm, applied after a Lossless request (last write wins) ---
3530    let q = ec.quality_range().map_or(75.0, |[lo, hi]| (lo + hi) / 2.0);
3531    let requests = [
3532        ("Lossy(CodecSpecificQuality)", Fidelity::codec_quality(q)),
3533        ("Lossy(ApproxSsim2)", Fidelity::ssim2(80.0)),
3534        ("Lossy(ApproxButteraugli)", Fidelity::butteraugli(1.5)),
3535        ("Lossy(ApproxZensimB)", Fidelity::zensim_b(80.0)),
3536    ];
3537    for (name, req) in requests {
3538        let cfg = enc
3539            .clone()
3540            .with_fidelity(Fidelity::Lossless)
3541            .with_fidelity(req);
3542        let r = cfg.resolved_target_fidelity();
3543        if ec.lossy() {
3544            match r {
3545                Some(Fidelity::Lossy(_)) => {}
3546                Some(Fidelity::Lossless) => v.push(format!(
3547                    "{name}: `lossy` is declared, but the request resolved to Lossless (a prior Lossless request leaked, or lossy is not honored)"
3548                )),
3549                None if ec.quality_range().is_some() => v.push(format!(
3550                    "{name}: `lossy` + a quality_range are declared, but the request resolved to None (report the target it mapped to)"
3551                )),
3552                _ => {}
3553            }
3554        } else if let Some(Fidelity::Lossy(t)) = r {
3555            v.push(format!(
3556                "{name}: `lossy` is NOT declared, yet the request resolved to Lossy({t:?}) — promote to Lossless (and report it) or report None"
3557            ));
3558        }
3559        legacy_agrees(name, &cfg, r, &mut v);
3560        match exact(&cfg) {
3561            Err(e) => v.push(format!("{name}: round trip failed: {e}")),
3562            Ok(false) if r == Some(Fidelity::Lossless) => v.push(format!(
3563                "{name}: resolved to Lossless but the decoded pixels differ from the input"
3564            )),
3565            Ok(_) => {}
3566        }
3567    }
3568
3569    if v.is_empty() {
3570        Ok(())
3571    } else {
3572        Err(fail(CHECK, v.join("; ")))
3573    }
3574}
3575
3576// ===========================================================================
3577// Error-envelope conformance (the `At<CodecError>` Pattern-B contract)
3578// ===========================================================================
3579
3580/// Statically assert a codec returns the shared **`At<CodecError>` envelope**
3581/// from every encode/decode trait boundary — the Pattern-B error contract.
3582///
3583/// A zero-cost compile-time gate: it takes no arguments and runs no code. A codec
3584/// invokes it once — `assert_uses_codec_error_envelope::<MyEncoderConfig, MyDecoderConfig>()`
3585/// — and the bounds below make it a **compile error** for any codec whose
3586/// `type Error` is its own native enum instead of `At<CodecError>` (Pattern A).
3587///
3588/// Why it matters: a native-enum `type Error` classifies only on the *typed*
3589/// path. The moment it is erased — the `BoxedError` every `Dyn*` dispatch method
3590/// produces, an `anyhow::Error`, a mapped wrapper — all you hold is a `dyn Error`,
3591/// and you cannot downcast that to a `dyn CategorizedError`; the
3592/// [`ErrorCategory`] and codec name are gone.
3593/// `At<CodecError>` is one concrete type, so it survives any erasure by a
3594/// downcast. [`check_decode_error_envelope`] is the runtime companion that proves
3595/// the category actually *flows* through erasure.
3596///
3597/// It bounds the config, job, and leaf executor on both sides. The optional stub
3598/// associated types (`AnimationFrameEnc`, `StreamDec`, `AnimationFrameDec`) are
3599/// intentionally *not* bound: a still-only codec legitimately uses `()` /
3600/// [`Unsupported`](zencodec::Unsupported), whose `Error` is
3601/// [`UnsupportedOperation`], not the envelope.
3602///
3603/// Not part of [`check_all`] — the testkit's own [`reference`](mod@reference)
3604/// codec is a deliberate Pattern-A foil, so this is opt-in for codecs that have
3605/// adopted the envelope.
3606pub fn assert_uses_codec_error_envelope<E, D>()
3607where
3608    E: EncoderConfig<Error = At<CodecError>>,
3609    E::Job: EncodeJob<Error = At<CodecError>>,
3610    <E::Job as EncodeJob>::Enc: Encoder<Error = At<CodecError>>,
3611    D: DecoderConfig<Error = At<CodecError>>,
3612    for<'a> D::Job<'a>: DecodeJob<'a, Error = At<CodecError>>,
3613    for<'a> <D::Job<'a> as DecodeJob<'a>>::Dec: Decode<Error = At<CodecError>>,
3614{
3615}
3616
3617/// A codec's [`ErrorCategory`] **and** originating codec
3618/// name survive dyn-dispatch type erasure — the runtime half of the Pattern-B
3619/// contract.
3620///
3621/// Drives the decoder through the dyn boundary (`&dyn DynDecoderConfig` →
3622/// [`dyn_job`](zencodec::decode::DynDecoderConfig::dyn_job) → `probe`) on input
3623/// the codec rejects, so the typed `At<CodecError>` is erased to the `BoxedError`
3624/// a generic pipeline actually holds. It then recovers the envelope from that
3625/// `Box<dyn Error>` and asserts both
3626/// [`error_category`](zencodec::CodecErrorExt::error_category) and the
3627/// [`codec`](zencodec::CodecError::codec) name come back.
3628///
3629/// A Pattern-A codec (native-enum `type Error`) **fails** here: its category may
3630/// exist on the typed value, but it is unrecoverable once erased — which is the
3631/// whole point of the envelope, and what this check exists to catch.
3632/// [`assert_uses_codec_error_envelope`] is the compile-time companion; this proves
3633/// the category genuinely propagates. Not part of [`check_all`] (see that note).
3634///
3635/// `malformed` must be bytes this codec rejects — any non-decodable input, a short
3636/// garbage buffer is usually enough. If the codec *accepts* them, the check says so
3637/// rather than passing silently.
3638pub fn check_decode_error_envelope<D>(dec: D, malformed: &[u8]) -> Conformance
3639where
3640    D: DecoderConfig + 'static,
3641{
3642    const CHECK: &str = "decode_error_envelope";
3643    let dyn_cfg: &dyn DynDecoderConfig = &dec;
3644    let erased = match dyn_cfg.dyn_job().probe(malformed) {
3645        Err(e) => e,
3646        Ok(_) => {
3647            return Err(fail(
3648                CHECK,
3649                "probe() accepted the supplied `malformed` input — pass bytes this codec rejects, \
3650                 so an error is actually produced to inspect",
3651            ));
3652        }
3653    };
3654    if erased.error_category().is_none() {
3655        return Err(fail(
3656            CHECK,
3657            format!(
3658                "ErrorCategory did not survive dyn-dispatch erasure: the decoder's `type Error` is \
3659                 not `At<CodecError>` (Pattern A — a native error enum erases to a bare `dyn Error`, \
3660                 which cannot be downcast to recover the category). Switch the zencodec trait impls \
3661                 to `type Error = At<CodecError>`. Erased error was: {erased}"
3662            ),
3663        ));
3664    }
3665    if erased.codec_error().and_then(CodecError::codec).is_none() {
3666        return Err(fail(
3667            CHECK,
3668            "the CodecError envelope survived erasure but carries no codec name — make the native \
3669             error's `CategorizedError::codec_name()` return `Some(\"<codec>\")`",
3670        ));
3671    }
3672    Ok(())
3673}
3674
3675// ===========================================================================
3676// EOF / truncation-series conformance
3677// ===========================================================================
3678
3679/// Whether `cat` is an acceptable category for a truncated / incomplete input.
3680///
3681/// TRUE for the whole image-bytes-origin cluster — [`ErrorCategory::Image`] with any
3682/// [`ImageError`](zencodec::ImageError): [`UnexpectedEof`](zencodec::ImageError::UnexpectedEof)
3683/// (the ideal), [`Malformed`](zencodec::ImageError::Malformed), or
3684/// [`Unsupported`](zencodec::ImageError::Unsupported). A truncated prefix legitimately
3685/// reads as *client-supplied incomplete data* (an HTTP 4xx): this early it may look
3686/// cut-short, corrupt, or unrecognizable, and a codec that can't yet tell those apart
3687/// is still safe as long as it stays inside the `Image` arm.
3688///
3689/// FALSE for every other origin, each of which MISATTRIBUTES a client-side truncation:
3690/// [`Internal`](ErrorCategory::Internal) (a codec bug / 5xx),
3691/// [`Resource`](ErrorCategory::Resource) (OOM from an unvalidated length in truncated
3692/// data, or a limit), [`Io`](ErrorCategory::Io) (there is no I/O on an in-memory slice),
3693/// the caller-fault [`Request`](ErrorCategory::Request) set, the operation
3694/// [`Stopped`](ErrorCategory::Stopped) set, and [`Policy`](ErrorCategory::Policy).
3695pub(crate) fn is_incomplete_input_category(cat: ErrorCategory) -> bool {
3696    // Default-DENY: `ErrorCategory` is `#[non_exhaustive]`, so any *future* variant
3697    // falls through to `false` and is conservatively flagged for review rather than
3698    // silently accepted as a valid truncation category. The image-bytes-origin arm
3699    // (`Image(_)`) IS exactly the incomplete-input-tolerable set — a truncated prefix
3700    // is always a client-supplied-data fault, never a codec bug or caller-request fault.
3701    matches!(cat, ErrorCategory::Image(_))
3702}
3703
3704/// Deterministic series of truncation lengths spanning a `len`-byte bitstream: the
3705/// small absolute sizes `{0,1,2,3,4,8,16}` (header region) unioned with the
3706/// fractions `{1/8,1/4,3/8,1/2,5/8,3/4,7/8, len-1}` of `len`. Deduped, sorted
3707/// ascending, and clamped to `n < len` (a full-length "truncation" is the original
3708/// image, not a truncation). `0` — the empty input — is a legitimate truncation.
3709fn truncation_lengths(len: usize) -> Vec<usize> {
3710    let mut lens: Vec<usize> = vec![0, 1, 2, 3, 4, 8, 16];
3711    for (num, den) in [(1, 8), (1, 4), (3, 8), (1, 2), (5, 8), (3, 4), (7, 8)] {
3712        lens.push(len * num / den);
3713    }
3714    lens.push(len.saturating_sub(1));
3715    lens.retain(|&n| n < len);
3716    lens.sort_unstable();
3717    lens.dedup();
3718    lens
3719}
3720
3721/// A truncated (incomplete) input is categorized as *incomplete client data* —
3722/// never mis-attributed as an internal bug, an OOM, an I/O error, or a caller
3723/// fault — and never panics or silently decodes.
3724///
3725/// This enforces the one part of the [`ErrorCategory`] taxonomy that IS broadly
3726/// distinguishable: whatever a codec's exact error, cutting a known-good image
3727/// short must land in the *incomplete-input* set
3728/// ([`UnexpectedEof`](zencodec::ImageError::UnexpectedEof)
3729/// is ideal, but the rest of the image-bytes-origin [`Image`](ErrorCategory::Image) arm
3730/// ([`Malformed`](zencodec::ImageError::Malformed) / [`Unsupported`](zencodec::ImageError::Unsupported))
3731/// is tolerated because a truncated prefix can genuinely look malformed). It catches the real bug class
3732/// where a codec reads a length field out of truncated data and OOMs, or funnels a
3733/// truncation into [`Internal`](ErrorCategory::Internal) — a 5xx for a 4xx-class
3734/// client error.
3735///
3736/// `valid` is a KNOWN-GOOD encoded image the codec supplies (at least a few bytes).
3737/// The check builds a deterministic series of prefixes and, for each, runs a **full
3738/// decode through the dyn-erased boundary** —
3739/// [`DynDecodeJob::push_decode`](zencodec::decode::DynDecodeJob::push_decode) into a
3740/// throwaway sink. That is the dyn full-decode path, so the pixel **body** is
3741/// actually read and *body* truncation is exercised, not just the header a `probe`
3742/// would parse. Each decode is wrapped in [`catch_unwind`](std::panic::catch_unwind)
3743/// so a codec panic on truncated input becomes a named failure, not a process abort.
3744/// Every offending offset is collected and reported together.
3745///
3746/// Per offset `n` of `len`:
3747/// - **panic** → failure (truncated input must never panic).
3748/// - **`Ok`** (decode succeeded) when `n <= len/4` (header/early region) → failure:
3749///   a truncated valid image must not silently decode. For `n > len/4` a success is
3750///   *tolerated* (trailing-marker / already-complete-payload leniency).
3751/// - **`Err`** → the erased [`ErrorCategory`] is read back: an incomplete-input
3752///   category passes; any other category fails, naming the offset. If the category
3753///   did not survive erasure at all, the codec is **Pattern A** (native-enum
3754///   `type Error`, not `At<CodecError>`) — reported once, exactly as
3755///   [`check_decode_error_envelope`] reports it.
3756///
3757/// Like the envelope checks, this is **opt-in and NOT part of [`check_all`]**: the
3758/// testkit's own [`reference`](mod@reference) codec is a Pattern-A foil that fails
3759/// it on the erased path, while the internal `minimal` envelope exemplar passes (its
3760/// truncations classify as `MalformedImage`).
3761pub fn check_decode_truncation_series<D>(dec: D, valid: &[u8]) -> Conformance
3762where
3763    D: DecoderConfig + 'static,
3764{
3765    const CHECK: &str = "decode_truncation_series";
3766    let len = valid.len();
3767    if len < 2 {
3768        return Err(fail(
3769            CHECK,
3770            "supply a real encoded image of at least a few bytes",
3771        ));
3772    }
3773
3774    let mut violations: Vec<String> = Vec::new();
3775    let mut reported_pattern_a = false;
3776
3777    for n in truncation_lengths(len) {
3778        let truncated = &valid[..n];
3779        // Drive a FULL decode through the dyn-erased boundary into a throwaway sink.
3780        // `push_decode` reads the pixel body (via the copy-to-sink helper), so this
3781        // exercises *body* truncation, not just the header a `probe` parses.
3782        // `catch_unwind` turns a codec panic into a reported failure naming the
3783        // offset; `AssertUnwindSafe` is sound because the torn sink is discarded.
3784        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3785            let dyn_cfg: &dyn DynDecoderConfig = &dec;
3786            let mut sink = CollectSink::default();
3787            dyn_cfg
3788                .dyn_job()
3789                .push_decode(Cow::Borrowed(truncated), &mut sink, &[])
3790        }));
3791
3792        match outcome {
3793            Err(_panic) => violations.push(format!(
3794                "decode panicked on truncation at {n}/{len} bytes — truncated input must never panic"
3795            )),
3796            Ok(Ok(_output)) => {
3797                if n <= len / 4 {
3798                    violations.push(format!(
3799                        "decode SUCCEEDED on a truncated header ({n} of {len} bytes) — a truncated \
3800                         valid image must not silently decode (pixels are sacred)"
3801                    ));
3802                }
3803                // n > len/4: trailing-marker / already-complete-payload leniency — tolerated.
3804            }
3805            Ok(Err(erased)) => match erased.error_category() {
3806                None => {
3807                    // Pattern A: the category is unrecoverable once erased. Report it
3808                    // once (the same loss `check_decode_error_envelope` catches) —
3809                    // every subsequent Err is None too, so don't spam per-offset.
3810                    if !reported_pattern_a {
3811                        reported_pattern_a = true;
3812                        violations.push(format!(
3813                            "ErrorCategory did not survive dyn-dispatch erasure: the decoder's \
3814                             `type Error` is not `At<CodecError>` (Pattern A — a native error enum \
3815                             erases to a bare `dyn Error`, which cannot be downcast to recover the \
3816                             category). Switch the zencodec trait impls to `type Error = \
3817                             At<CodecError>`. First erased error (truncation at {n}/{len} bytes) \
3818                             was: {erased}"
3819                        ));
3820                    }
3821                }
3822                Some(cat) => {
3823                    if !is_incomplete_input_category(cat) {
3824                        violations.push(format!(
3825                            "truncation at {n}/{len} bytes categorized as {cat:?}; a truncated input \
3826                             is incomplete client data and must map to an incomplete-input category \
3827                             (UnexpectedEof/MalformedImage/UnsupportedImageType/UnsupportedImageFeature), \
3828                             never {cat:?} — that misattributes it (e.g. Internal reads as a codec \
3829                             bug/5xx; OutOfMemory means the codec allocated from an unvalidated length \
3830                             in truncated data)"
3831                        ));
3832                    }
3833                    // incomplete-input category: OK (kept silent on success).
3834                }
3835            },
3836        }
3837    }
3838
3839    if violations.is_empty() {
3840        Ok(())
3841    } else {
3842        Err(fail(CHECK, violations.join("; ")))
3843    }
3844}
3845
3846/// The structural inventory is honest and complete.
3847///
3848/// For a decoder that declares
3849/// [`inventory`](zencodec::decode::DecodeCapabilities::inventory):
3850///
3851/// - `inventory(valid)` succeeds, for the decoder's format, covering
3852///   exactly `valid.len()` bytes, passing
3853///   [`Inventory::validate`](zencodec::inventory::Inventory::validate), and
3854///   attributing at least one part to
3855///   [`ImageData`](zencodec::inventory::Disposition::ImageData);
3856/// - with 37 junk bytes appended, the inventory still validates, still covers
3857///   the whole input, and attributes every appended byte to an *unconsumed*
3858///   part: junk must never be reported as structure, image data or metadata;
3859/// - every truncation of `valid` yields a valid inventory without panicking or
3860///   failing, because truncated and malformed input is exactly what an audit
3861///   needs to see.
3862///
3863/// A decoder that does not declare the capability must return an unsupported-inventory error.
3864///
3865/// `valid` must be a complete file with a logical end (JPEG EOI, PNG IEND, the
3866/// last box). On a stream that is already cut short, appended bytes can
3867/// legitimately continue the cut unit (a JPEG scan without EOI reads them as
3868/// scan data), so the junk assertion doesn't apply: for such corpus files call
3869/// [`Inventory::validate`](zencodec::inventory::Inventory::validate) directly.
3870pub fn check_inventory<D>(dec: D, valid: &[u8]) -> Conformance
3871where
3872    D: DecoderConfig,
3873{
3874    use zencodec::inventory::{Disposition, Inventory};
3875    const CHECK: &str = "inventory";
3876
3877    let run = |data: &[u8]| -> Result<Inventory, String> {
3878        let job = dec.clone().job();
3879        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job.inventory(data))) {
3880            Err(_) => Err(format!("inventory panicked on {} bytes", data.len())),
3881            Ok(Err(e)) => Err(format!("inventory failed on {} bytes: {e}", data.len())),
3882            Ok(Ok(inv)) => Ok(inv),
3883        }
3884    };
3885
3886    if !D::capabilities().inventory() {
3887        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3888            dec.clone().job().inventory(valid)
3889        }))
3890        .map_err(|_| fail(CHECK, "disabled inventory panicked"))?;
3891        let err = result
3892            .err()
3893            .ok_or_else(|| fail(CHECK, "undeclared inventory succeeded"))?;
3894        if err.unsupported_operation() != Some(&UnsupportedOperation::Inventory) {
3895            return Err(fail(
3896                CHECK,
3897                format!("wrong disabled-inventory error: {err}"),
3898            ));
3899        }
3900        return Ok(());
3901    }
3902
3903    let formats = D::formats();
3904    let checked = |data: &[u8], what: &str| -> Result<Inventory, Failure> {
3905        let inv = run(data).map_err(|e| fail(CHECK, format!("{what}: {e}")))?;
3906        if inv.input_len() != data.len() as u64 {
3907            return Err(fail(
3908                CHECK,
3909                format!(
3910                    "{what}: inventory covers {} bytes, the input has {}",
3911                    inv.input_len(),
3912                    data.len()
3913                ),
3914            ));
3915        }
3916        if !formats.contains(&inv.format()) {
3917            return Err(fail(
3918                CHECK,
3919                format!(
3920                    "{what}: inventory format {} is not one this decoder declares",
3921                    inv.format()
3922                ),
3923            ));
3924        }
3925        inv.validate()
3926            .map_err(|e| fail(CHECK, format!("{what}: invalid inventory: {e}\n{inv}")))?;
3927        Ok(inv)
3928    };
3929
3930    let inv = checked(valid, "valid input")?;
3931    if !inv
3932        .parts()
3933        .iter()
3934        .any(|p| p.disposition() == Disposition::ImageData)
3935    {
3936        return Err(fail(
3937            CHECK,
3938            format!("valid input: no part is attributed to image data\n{inv}"),
3939        ));
3940    }
3941
3942    let mut junked = valid.to_vec();
3943    junked.extend((0..37u8).map(|i| i.wrapping_mul(97) ^ 0x5A));
3944    let inv = checked(&junked, "input with 37 appended bytes")?;
3945    let tail = valid.len() as u64..junked.len() as u64;
3946    let mut has_child = vec![false; inv.parts().len()];
3947    for p in inv.parts() {
3948        if let Some(parent) = p.parent() {
3949            has_child[parent.index()] = true;
3950        }
3951    }
3952    for (i, p) in inv.parts().iter().enumerate() {
3953        let overlaps = p.range().start < tail.end && p.range().end > tail.start;
3954        if overlaps && !has_child[i] && p.disposition().is_consumed() {
3955            return Err(fail(
3956                CHECK,
3957                format!(
3958                    "input with 37 appended bytes: part {}..{} covers appended junk but is \
3959                     reported as {}\n{inv}",
3960                    p.range().start,
3961                    p.range().end,
3962                    p.disposition()
3963                ),
3964            ));
3965        }
3966    }
3967
3968    for n in truncation_lengths(valid.len()) {
3969        checked(
3970            &valid[..n],
3971            &format!("truncated to {n} of {} bytes", valid.len()),
3972        )?;
3973    }
3974    Ok(())
3975}
3976
3977/// Check inventory parity through generic and dynamic dispatch under the same
3978/// configuration. Unlike [`check_all`], erased config dispatch requires a
3979/// `'static` config. Native error types need no additional bounds.
3980pub fn check_inventory_dispatch<D: DecoderConfig + 'static>(dec: D, valid: &[u8]) -> Conformance {
3981    check_inventory(dec.clone(), valid)?;
3982    let dynamic: Box<dyn zencodec::decode::DynDecoderConfig> = Box::new(dec.clone());
3983    let check = |data: &[u8]| -> Conformance {
3984        let generic = dec.clone().job().inventory(data);
3985        let erased = dynamic.dyn_job().inventory(data);
3986        match (generic, erased) {
3987            (Ok(a), Ok(b)) if a == b => Ok(()),
3988            (Err(a), Err(b))
3989                if !D::capabilities().inventory()
3990                    && a.unsupported_operation() == Some(&UnsupportedOperation::Inventory)
3991                    && b.unsupported_operation() == Some(&UnsupportedOperation::Inventory) =>
3992            {
3993                Ok(())
3994            }
3995            (a, b) => Err(fail(
3996                "inventory dispatch",
3997                format!("generic/dynamic disagreement: {a:?} vs {b:?}"),
3998            )),
3999        }
4000    };
4001    check(valid)?;
4002    let mut trailing = valid.to_vec();
4003    trailing.extend_from_slice(&[0xA5; 37]);
4004    check(&trailing)?;
4005    for n in truncation_lengths(valid.len()) {
4006        check(&valid[..n])?;
4007    }
4008    Ok(())
4009}
4010
4011/// Run every conformance check with sensible default inputs, returning the first
4012/// failure. The one-call entry point for a codec's test suite; for control over
4013/// image sizes or animation frames, call the individual `check_*` functions.
4014pub fn check_all<E, D>(enc: E, dec: D) -> Conformance
4015where
4016    E: EncoderConfig,
4017    D: DecoderConfig,
4018    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error>,
4019    <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
4020{
4021    let img = TestImage::rgba8_gradient(40, 24);
4022    check_pixel_roundtrip(enc.clone(), dec.clone(), &img)?;
4023    check_cross_path_pixel_equivalence(enc.clone(), dec.clone(), &img)?;
4024    check_orientation_roundtrip(enc.clone(), dec.clone(), &img)?;
4025    check_metadata_no_leak(enc.clone(), dec.clone(), &img)?;
4026    check_capability_honesty(enc.clone(), dec.clone(), &img)?;
4027    check_streaming_output_honesty(enc.clone(), dec.clone(), &streaming_check_image(&enc))?;
4028    check_fidelity_honesty(enc.clone(), dec.clone(), &img)?;
4029    check_color_context_consistency(enc.clone(), dec.clone(), &img)?;
4030    check_color_authority_spec(enc.clone(), dec.clone(), &img)?;
4031    check_native_hdr_roundtrip(enc.clone(), dec.clone())?;
4032    check_gain_map_roundtrip(enc.clone(), dec.clone())?;
4033    {
4034        let bytes = enc_oneshot(&enc, &img, Metadata::none(), MetadataPolicy::PreserveExact)
4035            .map_err(|e| fail("inventory", format!("encode: {e}")))?;
4036        check_inventory(dec.clone(), &bytes)?;
4037        check_incremental_capability_honesty(dec.clone(), &bytes)?;
4038        check_seekable_capability_honesty(dec.clone(), &bytes)?;
4039    }
4040    let frames = [
4041        TestImage::rgba8_gradient_seeded(24, 16, 0),
4042        TestImage::rgba8_gradient_seeded(24, 16, 60),
4043        TestImage::rgba8_gradient_seeded(24, 16, 120),
4044    ];
4045    check_animation_cross_path_equivalence(enc.clone(), dec.clone(), &frames)?;
4046    check_animation_streaming_output_honesty(enc.clone(), dec, &animation_streaming_frames(&enc))?;
4047    Ok(())
4048}
4049
4050#[cfg(test)]
4051#[allow(deprecated)] // Compare published compatibility policy semantics.
4052mod tests {
4053    use super::*;
4054
4055    fn ref_codecs() -> (ReferenceEncoderConfig, ReferenceDecoderConfig) {
4056        (ReferenceEncoderConfig::new(), ReferenceDecoderConfig)
4057    }
4058
4059    #[test]
4060    fn fixture_exif_is_realistic() {
4061        // Guard the hand-laid TIFF offsets: if any are wrong, this fails loudly
4062        // rather than letting the no-leak check run against a malformed blob.
4063        let blob = fixtures::rich_exif_le();
4064        let x = Exif::parse(&blob).expect("fixture EXIF parses");
4065        assert!(x.has_gps(), "fixture must contain GPS");
4066        assert!(x.has_thumbnail(), "fixture must contain a thumbnail");
4067        assert!(x.has_device_ids() && x.has_camera_owner() && x.has_image_unique_id());
4068        assert!(x.has_datetimes() && x.has_time_offsets());
4069        assert_eq!(x.copyright().as_deref(), Some("(C) 2026 Test"));
4070    }
4071
4072    #[test]
4073    fn metadata_check_catches_identifiers_alongside_retained_camera_tags() {
4074        use zencodec::{ExifPolicy, Retention};
4075        let rich = Metadata::none().with_exif(fixtures::rich_exif_le());
4076        let base = ExifPolicy::ATTRIBUTED_ORIENTATION
4077            .with_camera(Retention::Keep)
4078            .with_datetimes(Retention::Keep);
4079        let policy = |exif| MetadataPolicy::Custom(MetadataPolicy::Web.fields().with_exif(exif));
4080        let expected = rich.filtered(&policy(base));
4081        for leaky in [
4082            base.with_device_ids(Retention::Keep),
4083            base.with_camera_owner(Retention::Keep),
4084            base.with_image_unique_id(Retention::Keep),
4085            base.with_time_offsets(Retention::Keep),
4086        ] {
4087            let actual = rich.filtered(&policy(leaky));
4088            assert!(
4089                assert_no_leak(
4090                    "mutation",
4091                    "camera without IDs",
4092                    &actual,
4093                    &expected,
4094                    &Metadata::none()
4095                )
4096                .is_err()
4097            );
4098        }
4099    }
4100
4101    #[test]
4102    fn metadata_check_accepts_only_exact_intrinsic_color() {
4103        let expected = Metadata::none();
4104        let intrinsic = Metadata::none().with_cicp(Cicp::SRGB).with_icc([1, 2, 3]);
4105        assert_no_leak("intrinsic", "discard", &intrinsic, &expected, &intrinsic).unwrap();
4106        for unexpected in [
4107            Metadata::none().with_cicp(Cicp::new(9, 16, 0, true)),
4108            Metadata::none().with_icc([1, 2, 4]),
4109        ] {
4110            assert!(
4111                assert_no_leak("intrinsic", "discard", &unexpected, &expected, &intrinsic).is_err()
4112            );
4113            assert!(
4114                assert_no_leak(
4115                    "intrinsic",
4116                    "discard",
4117                    &unexpected,
4118                    &expected,
4119                    &Metadata::none()
4120                )
4121                .is_err()
4122            );
4123        }
4124        // Intrinsic color never excuses retained descriptive metadata.
4125        for unexpected in [
4126            intrinsic.clone().with_exif(fixtures::rich_exif_le()),
4127            intrinsic.clone().with_xmp(fixtures::sample_xmp()),
4128        ] {
4129            assert!(
4130                assert_no_leak("intrinsic", "discard", &unexpected, &expected, &intrinsic).is_err()
4131            );
4132        }
4133    }
4134
4135    #[test]
4136    fn reference_pixel_roundtrip_rgba8() {
4137        let (e, d) = ref_codecs();
4138        check_pixel_roundtrip(e, d, &TestImage::rgba8_gradient(37, 19)).unwrap();
4139    }
4140
4141    #[test]
4142    fn reference_pixel_roundtrip_rgb8() {
4143        let (e, d) = ref_codecs();
4144        check_pixel_roundtrip(e, d, &TestImage::rgb8_gradient(16, 16)).unwrap();
4145    }
4146
4147    #[test]
4148    fn reference_cross_path_equivalence() {
4149        let (e, d) = ref_codecs();
4150        check_cross_path_pixel_equivalence(e, d, &TestImage::rgba8_gradient(40, 23)).unwrap();
4151    }
4152
4153    #[test]
4154    fn reference_metadata_no_leak() {
4155        let (e, d) = ref_codecs();
4156        check_metadata_no_leak(e, d, &TestImage::rgba8_gradient(8, 8)).unwrap();
4157    }
4158
4159    /// The full reference declares (and honors) every capability, so the
4160    /// true-direction branches must all pass.
4161    #[test]
4162    fn reference_capability_honesty() {
4163        let (e, d) = ref_codecs();
4164        check_capability_honesty(e, d, &TestImage::rgba8_gradient(12, 9)).unwrap();
4165    }
4166
4167    #[test]
4168    fn metadata_capabilities_allow_exclusive_color_carriers() {
4169        let mut violations = Vec::new();
4170        let mut seen = Vec::new();
4171        check_metadata_channel_honesty(
4172            ReferenceEncoderConfig::capabilities(),
4173            <ReferenceDecoderConfig as DecoderConfig>::capabilities(),
4174            |mut metadata| {
4175                // Model a format whose native color header stores ICC or CICP.
4176                if metadata.icc_profile.is_some() {
4177                    metadata.cicp = None;
4178                }
4179                seen.push((
4180                    metadata.icc_profile.is_some(),
4181                    metadata.exif.is_some(),
4182                    metadata.xmp.is_some(),
4183                    metadata.cicp.is_some(),
4184                ));
4185                Ok(metadata)
4186            },
4187            &mut violations,
4188        );
4189        assert!(violations.is_empty(), "{violations:?}");
4190        assert_eq!(
4191            seen,
4192            [
4193                (true, false, false, false),
4194                (false, true, false, false),
4195                (false, false, true, false),
4196                (false, false, false, true),
4197            ]
4198        );
4199    }
4200
4201    #[test]
4202    fn metadata_capabilities_still_reject_dropped_and_hidden_channels() {
4203        let decoder = <ReferenceDecoderConfig as DecoderConfig>::capabilities();
4204        let mut dropped = Vec::new();
4205        check_metadata_channel_honesty(
4206            ReferenceEncoderConfig::capabilities(),
4207            decoder,
4208            |_| Ok(Metadata::none()),
4209            &mut dropped,
4210        );
4211        let mut hidden = Vec::new();
4212        check_metadata_channel_honesty(
4213            MinimalEncoderConfig::capabilities(),
4214            decoder,
4215            Ok,
4216            &mut hidden,
4217        );
4218        for name in ["icc", "exif", "xmp", "cicp"] {
4219            assert!(
4220                dropped
4221                    .iter()
4222                    .any(|v| v.starts_with(&format!("{name}:")) && v.contains("did not survive"))
4223            );
4224            assert!(
4225                hidden
4226                    .iter()
4227                    .any(|v| v.starts_with(&format!("{name}:")) && v.contains("hidden capability"))
4228            );
4229        }
4230    }
4231
4232    /// The reference declares `gain_map` on both ends and honours the whole
4233    /// matrix (1ch/3ch/backward, opt-in surfacing, honest `ReconstructHdr`
4234    /// fallback, the Phase 4 transcode).
4235    #[test]
4236    fn reference_gain_map_roundtrip() {
4237        let (e, d) = ref_codecs();
4238        check_gain_map_roundtrip(e, d).unwrap();
4239    }
4240
4241    /// The minimal codec declares no gain-map support: the trait defaults must
4242    /// reject both entry points with `GainMapEncode`, which is all the check
4243    /// asks of an undeclared encoder.
4244    #[test]
4245    fn minimal_gain_map_roundtrip() {
4246        check_gain_map_roundtrip(MinimalEncoderConfig::new(), MinimalDecoderConfig::new()).unwrap();
4247    }
4248
4249    /// The fixture's ISO 21496-1 values are all dyadic, so the wire format
4250    /// must reproduce them exactly — the check's exact-params assertion rests
4251    /// on this (a non-representable fixture value would fail every codec).
4252    #[test]
4253    fn gain_map_fixture_params_are_wire_exact() {
4254        use zencodec::gainmap::{Iso21496Format, parse_iso21496_fmt, serialize_iso21496_fmt};
4255        for case in GAIN_MAP_CASES {
4256            let p = gain_map_params(case);
4257            for fmt in [Iso21496Format::JxlJhgm, Iso21496Format::AvifTmap] {
4258                let back = parse_iso21496_fmt(&serialize_iso21496_fmt(&p, fmt), fmt).unwrap();
4259                assert_eq!(back, p, "{} via {fmt:?}", case.name);
4260            }
4261        }
4262    }
4263
4264    /// The reference's own-format `with_gain_map_encoded` fast path (not
4265    /// exercised by the generic check — the payload shape is container-specific):
4266    /// a single-frame RGB8 reference image is byte-carried as a 3-channel map,
4267    /// and any other format is the documented mismatch rejection.
4268    #[test]
4269    fn reference_gain_map_encoded_own_format() {
4270        let case = GAIN_MAP_CASES[1]; // 3ch
4271        let fixture = gain_map_fixture(case);
4272        // Encode the gain-map pixels as a standalone reference image...
4273        let gm_img = ReferenceEncoderConfig::new()
4274            .job()
4275            .encoder()
4276            .unwrap()
4277            .encode(fixture.pixels.as_slice())
4278            .unwrap()
4279            .into_vec();
4280        let src = GainMapSource::new(gm_img, ImageFormat::Pnm, fixture.metadata.clone());
4281        let base = TestImage::rgb8_gradient(20, 14);
4282        let bytes = ReferenceEncoderConfig::new()
4283            .job()
4284            .with_gain_map_encoded(src)
4285            .unwrap()
4286            .encoder()
4287            .unwrap()
4288            .encode(base.as_slice())
4289            .unwrap()
4290            .into_vec();
4291        let out = dec_render(&ReferenceDecoderConfig, &bytes, GainMapRender::Components).unwrap();
4292        let gm = out.extras::<DecodedGainMap>().expect("surfaced");
4293        compare_gain_map(
4294            &GainMapObserved::of(gm),
4295            &gain_map_pixels(case),
4296            &fixture.metadata,
4297            true,
4298            "encoded own-format",
4299        )
4300        .unwrap();
4301
4302        // ...and a foreign format is rejected with the codec's own error.
4303        let bad = GainMapSource::new(
4304            vec![0; 8],
4305            ImageFormat::Jpeg,
4306            gain_map_fixture(case).metadata,
4307        );
4308        let err = ReferenceEncoderConfig::new()
4309            .job()
4310            .with_gain_map_encoded(bad)
4311            .err()
4312            .expect("foreign format rejected");
4313        assert!(matches!(err, RefError::Invalid(_)), "{err}");
4314    }
4315
4316    /// A real reference-codec operation cancelled via its `Stop` token surfaces
4317    /// an error whose [`ErrorCategory`] is `Cancelled` — classified through the
4318    /// codec's opt-in [`CategorizedError`] impl, no concrete-enum match needed
4319    /// (issue #99). This is what lets a server map a cancelled request to HTTP
4320    /// 499 instead of treating it as malformed input.
4321    #[test]
4322    fn reference_cancellation_is_classifiable() {
4323        use enough::{Stop, StopReason};
4324        use zencodec::{CategorizedError, CodecErrorExt, ErrorCategory};
4325
4326        // A token already in the stopped state — the codec's first check fires.
4327        struct Cancelled;
4328        impl Stop for Cancelled {
4329            fn check(&self) -> Result<(), StopReason> {
4330                Err(StopReason::Cancelled)
4331            }
4332        }
4333
4334        let img = TestImage::rgba8_gradient(8, 8);
4335        let mut a = ReferenceEncoderConfig::new()
4336            .job()
4337            .animation_frame_encoder()
4338            .expect("animation encoder");
4339        let err = a
4340            .push_frame(
4341                img.as_slice(),
4342                40,
4343                Some(&zencodec::how_far::StopOnly::borrowed(&Cancelled)),
4344            )
4345            .expect_err("a fired stop token must cancel the push");
4346
4347        // Classify it the way a generic consumer would, with no knowledge of RefError:
4348        assert_eq!(
4349            err.category(),
4350            ErrorCategory::Stopped(zencodec::enough::StopReason::Cancelled)
4351        );
4352        // ...and it must NOT be mistaken for a limit or an unsupported operation.
4353        assert!(err.limit_exceeded().is_none());
4354        assert!(err.unsupported_operation().is_none());
4355    }
4356
4357    /// The envelope pattern (`type Error = At<CodecError>`, the `minimal` codec)
4358    /// lets a generic consumer recover the [`ErrorCategory`] *after dyn dispatch
4359    /// erases the concrete error to `BoxedError`* — the case typed-only
4360    /// classification (issue #99) can't reach, because the erased value is a
4361    /// `dyn Error`, not a `dyn CategorizedError`. This is what the envelope buys.
4362    #[test]
4363    fn minimal_envelope_category_survives_dyn_erasure() {
4364        use zencodec::decode::DynDecoderConfig;
4365        use zencodec::{CodecError, CodecErrorExt, ErrorCategory};
4366
4367        // Drive the minimal codec entirely through the dyn surface; its
4368        // `At<CodecError>` is erased to `Box<dyn Error>` by the shim.
4369        let cfg = MinimalDecoderConfig::new();
4370        let dyn_min: &dyn DynDecoderConfig = &cfg;
4371        let erased = dyn_min
4372            .dyn_job()
4373            .probe(b"not a ZCR1 header")
4374            .expect_err("malformed header must fail");
4375        // A consumer holding only `Box<dyn Error>` recovers the category — and
4376        // the originating codec name, so it can tell codecs apart generically.
4377        assert_eq!(
4378            erased.error_category(),
4379            Some(ErrorCategory::Image(zencodec::ImageError::Malformed))
4380        );
4381        assert_eq!(
4382            erased.codec_error().and_then(CodecError::codec),
4383            Some(crate::reference::MINIMAL_CODEC_NAME)
4384        );
4385
4386        // Contrast: the reference codec (`type Error = RefError`) classifies fine
4387        // on the *typed* path, but once dyn dispatch erases it there is no shared
4388        // concrete type to downcast to — the gap the envelope closes.
4389        let ref_cfg = ReferenceDecoderConfig;
4390        let dyn_ref: &dyn DynDecoderConfig = &ref_cfg;
4391        let erased_ref = dyn_ref
4392            .dyn_job()
4393            .probe(b"not a ZCR1 header")
4394            .expect_err("malformed header must fail");
4395        assert_eq!(erased_ref.error_category(), None);
4396        assert!(erased_ref.codec_error().is_none());
4397    }
4398
4399    /// On the typed path, `At<CodecError>` answers the category two ways — the
4400    /// total inherent `category()` on the envelope and the `Option` recovery —
4401    /// and carries a location trace (the `From` bridge starts it).
4402    #[test]
4403    fn minimal_envelope_typed_path_and_trace() {
4404        use std::borrow::Cow;
4405        use zencodec::decode::{DecodeJob, DecoderConfig};
4406        use zencodec::{CodecErrorExt, ErrorCategory};
4407
4408        let err = MinimalDecoderConfig::new()
4409            .job()
4410            .decoder(Cow::Borrowed(b"not a ZCR1 header"), &[])
4411            .expect_err("malformed header must fail");
4412        // Total category + codec name via the concrete envelope:
4413        assert_eq!(
4414            err.error().category(),
4415            ErrorCategory::Image(zencodec::ImageError::Malformed)
4416        );
4417        assert_eq!(
4418            err.error().codec(),
4419            Some(crate::reference::MINIMAL_CODEC_NAME)
4420        );
4421        // Same category via the generic Option recovery:
4422        assert_eq!(
4423            err.error_category(),
4424            Some(ErrorCategory::Image(zencodec::ImageError::Malformed))
4425        );
4426        // The trace was started by the bridge's `.start_at()`.
4427        let dbg = format!("{err:?}");
4428        assert!(dbg.contains("at "), "expected a trace frame: {dbg}");
4429    }
4430
4431    /// The minimal codec declares every optional capability *false* and rejects
4432    /// those paths, so the false-direction branches must all pass.
4433    #[test]
4434    fn minimal_capability_honesty() {
4435        check_capability_honesty(
4436            MinimalEncoderConfig::new(),
4437            MinimalDecoderConfig::new(),
4438            &TestImage::rgba8_gradient(12, 9),
4439        )
4440        .unwrap();
4441    }
4442
4443    /// A lossless-only codec: `Lossless` is exact, every lossy arm promotes to
4444    /// `Lossless` and reports it, and the legacy getter agrees.
4445    #[test]
4446    fn reference_fidelity_honesty() {
4447        let (e, d) = ref_codecs();
4448        check_fidelity_honesty(e, d, &TestImage::rgba8_gradient(12, 9)).unwrap();
4449        let cfg = ReferenceEncoderConfig::new().with_fidelity(Fidelity::ssim2(70.0));
4450        assert_eq!(cfg.resolved_target_fidelity(), Some(Fidelity::Lossless));
4451        assert_eq!(cfg.is_lossless(), Some(true));
4452    }
4453
4454    /// No `lossless`, no `lossy`, no quality dial: every request resolves to
4455    /// `None` and the pixels still round-trip.
4456    #[test]
4457    fn minimal_fidelity_honesty() {
4458        check_fidelity_honesty(
4459            MinimalEncoderConfig::new(),
4460            MinimalDecoderConfig::new(),
4461            &TestImage::rgba8_gradient(12, 9),
4462        )
4463        .unwrap();
4464        assert_eq!(
4465            MinimalEncoderConfig::new()
4466                .with_fidelity(Fidelity::Lossless)
4467                .resolved_target_fidelity(),
4468            None
4469        );
4470    }
4471
4472    #[test]
4473    fn minimal_color_authority_spec() {
4474        check_color_authority_spec(
4475            MinimalEncoderConfig::new(),
4476            MinimalDecoderConfig::new(),
4477            &TestImage::rgba8_gradient(12, 9),
4478        )
4479        .unwrap();
4480    }
4481
4482    /// The reference (PNM-labelled) codec reads ICC and CICP back and leaves the
4483    /// authority at the format's `Icc` default on every metadata mix.
4484    #[test]
4485    fn reference_color_authority_spec() {
4486        let (e, d) = ref_codecs();
4487        check_color_authority_spec(e, d, &TestImage::rgba8_gradient(12, 9)).unwrap();
4488    }
4489
4490    /// The answer key encodes issue #11's Table 4 verbatim.
4491    #[test]
4492    fn expected_color_authority_table() {
4493        use ColorAuthority::{Cicp as C, Icc as I};
4494        use ImageFormat::*;
4495        let e = expected_color_authority;
4496        // CICP outranks ICC.
4497        for f in [Png, Jxl, Heic, Hdr] {
4498            assert_eq!(e(f, true, true), Some(C), "{f:?}");
4499            assert_eq!(e(f, true, false), Some(C), "{f:?}");
4500            assert_eq!(e(f, false, true), Some(I), "{f:?}");
4501            assert_eq!(e(f, false, false), Some(I), "{f:?}");
4502        }
4503        // MIAF: ICC colr outranks nclx.
4504        assert_eq!(e(Avif, true, true), Some(I));
4505        assert_eq!(e(Avif, false, true), Some(I));
4506        assert_eq!(e(Avif, true, false), Some(C));
4507        assert_eq!(e(Avif, false, false), Some(I));
4508        // ICC-only (or sRGB-assumed) formats never name CICP.
4509        for f in [
4510            Jpeg, WebP, Gif, Tiff, Bmp, Ico, Pnm, Farbfeld, Qoi, Tga, Dng, Raw, Pdf,
4511        ] {
4512            for (c, i) in [(true, true), (true, false), (false, true), (false, false)] {
4513                assert_eq!(e(f, c, i), Some(I), "{f:?} cicp={c} icc={i}");
4514            }
4515        }
4516        // No recorded rule.
4517        for f in [Exr, Jp2, Svg, Unknown] {
4518            assert_eq!(e(f, true, true), None, "{f:?}");
4519        }
4520    }
4521
4522    /// The HEIC bug the audit opened with: an `nclx`-only file whose decoder
4523    /// left the authority at the `Icc` default. `to_color_context()` then drops
4524    /// the CICP and the pixels fall back to sRGB — invisible to a pixel round
4525    /// trip, caught here.
4526    #[test]
4527    fn source_color_authority_catches_nclx_only_default_authority() {
4528        let sc = SourceColor::default().with_cicp(Cicp::BT2100_PQ);
4529        let f = check_source_color_authority(ImageFormat::Heic, &sc).unwrap_err();
4530        assert_eq!(f.check, "source_color_authority");
4531        assert!(f.detail.contains("Cicp"), "{f}");
4532        assert!(
4533            check_source_color_authority(
4534                ImageFormat::Heic,
4535                &sc.with_color_authority(ColorAuthority::Cicp)
4536            )
4537            .is_ok()
4538        );
4539    }
4540
4541    /// AVIF: an ICC `colr` box outranks `nclx` (MIAF), so naming CICP with an ICC
4542    /// present is the mismatch there — and a JPEG may never name CICP at all.
4543    #[test]
4544    fn source_color_authority_icc_first_formats() {
4545        let both = SourceColor::default()
4546            .with_cicp(Cicp::SRGB)
4547            .with_icc_profile(fixtures::sample_icc());
4548        assert!(check_source_color_authority(ImageFormat::Avif, &both).is_ok());
4549        let wrong = both.clone().with_color_authority(ColorAuthority::Cicp);
4550        assert!(check_source_color_authority(ImageFormat::Avif, &wrong).is_err());
4551        assert!(check_source_color_authority(ImageFormat::Jpeg, &wrong).is_err());
4552        // A format with no recorded rule never fails.
4553        assert!(check_source_color_authority(ImageFormat::Exr, &wrong).is_ok());
4554    }
4555
4556    /// The minimal codec still round-trips pixels one-shot and cleanly declines
4557    /// every optional path with `UnsupportedOperation`.
4558    #[test]
4559    fn minimal_one_shot_roundtrip_and_pixels() {
4560        let (e, d) = (MinimalEncoderConfig::new(), MinimalDecoderConfig::new());
4561        check_pixel_roundtrip(e, d, &TestImage::rgb8_gradient(10, 7)).unwrap();
4562    }
4563
4564    // ---- error-envelope conformance (the `At<CodecError>` Pattern-B contract) ----
4565
4566    /// The envelope exemplar (`minimal`, `type Error = At<CodecError>`) carries its
4567    /// category AND codec name through dyn-dispatch erasure to `BoxedError`.
4568    #[test]
4569    fn minimal_decode_error_envelope_survives_erasure() {
4570        // 16 bytes of garbage: fails `parse_header` (short / bad magic) →
4571        // RefError::Invalid → At<CodecError>{MalformedImage, "zencodec-testkit/minimal"}.
4572        check_decode_error_envelope(MinimalDecoderConfig::new(), &[0xABu8; 16]).unwrap();
4573    }
4574
4575    /// The negative case proves the check has teeth. `reference` is Pattern A
4576    /// (`type Error = RefError`); its RefError *is* `CategorizedError`, but that
4577    /// category is unrecoverable once erased to `BoxedError`, so the check must
4578    /// FAIL — exactly the loss the envelope prevents. (Same underlying RefError as
4579    /// `minimal` above: the only difference is the envelope `type Error`.)
4580    #[test]
4581    fn reference_pattern_a_fails_the_envelope_check() {
4582        let err = check_decode_error_envelope(ReferenceDecoderConfig, &[0xABu8; 16])
4583            .expect_err("Pattern A must fail the envelope-survival check");
4584        assert_eq!(err.check, "decode_error_envelope");
4585        assert!(
4586            err.detail.contains("Pattern A"),
4587            "the failure should name the Pattern-A cause: {}",
4588            err.detail
4589        );
4590    }
4591
4592    /// The compile-time gate accepts the envelope codec. A Pattern-A codec here
4593    /// would fail to *compile* (`type Error` ≠ `At<CodecError>`); that direction
4594    /// can't live in a normal test, so the runtime check above covers it.
4595    #[test]
4596    fn minimal_satisfies_the_static_envelope_assertion() {
4597        assert_uses_codec_error_envelope::<MinimalEncoderConfig, MinimalDecoderConfig>();
4598    }
4599
4600    // ---- EOF / truncation-series conformance ----
4601
4602    /// The allowed/denied policy of [`is_incomplete_input_category`] over the
4603    /// origin-first [`ErrorCategory`] taxonomy: the entire image-bytes-origin
4604    /// [`Image`](ErrorCategory::Image) arm passes; every other origin — including the
4605    /// misattribution traps [`Resource`](ErrorCategory::Resource) (OOM / limits),
4606    /// [`Internal`](ErrorCategory::Internal), and [`Io`](ErrorCategory::Io) — is denied.
4607    #[test]
4608    fn incomplete_input_category_policy() {
4609        use zencodec::enough::StopReason;
4610        use zencodec::{
4611            CodecIoKind, ErrorCategory as E, ImageError, InternalKind, InvalidKind, LimitKind,
4612            PolicyKind, RequestError, ResourceError, UnsupportedImageKind, UnsupportedOperation,
4613        };
4614
4615        // The whole `Image(_)` arm is allowed (UnexpectedEof ideal; Malformed and both
4616        // Unsupported kinds tolerated because a truncated prefix can look that way).
4617        for c in [
4618            E::Image(ImageError::UnexpectedEof),
4619            E::Image(ImageError::Malformed),
4620            E::Image(ImageError::Unsupported(UnsupportedImageKind::Type)),
4621            E::Image(ImageError::Unsupported(UnsupportedImageKind::Feature)),
4622        ] {
4623            assert!(
4624                is_incomplete_input_category(c),
4625                "{c:?} must be an allowed truncation category"
4626            );
4627        }
4628
4629        // Every non-`Image` origin is denied (representative payload per arm).
4630        for c in [
4631            E::Request(RequestError::Unsupported(UnsupportedOperation::PixelFormat)),
4632            E::Request(RequestError::Unsupported(
4633                UnsupportedOperation::AnimationEncode,
4634            )),
4635            E::Request(RequestError::CmsRequired),
4636            E::Request(RequestError::Invalid(InvalidKind::Parameters)),
4637            E::Request(RequestError::Invalid(InvalidKind::Buffer)),
4638            E::Request(RequestError::Invalid(InvalidKind::State)),
4639            E::Policy(PolicyKind::Decode),
4640            E::Policy(PolicyKind::Encode),
4641            E::Stopped(StopReason::Cancelled),
4642            E::Stopped(StopReason::TimedOut),
4643            E::Resource(ResourceError::Limits(LimitKind::Pixels)),
4644            E::Resource(ResourceError::OutOfMemory),
4645            E::Io(CodecIoKind::opaque()),
4646            E::Internal(InternalKind::Bug),
4647            E::Internal(InternalKind::Dependency),
4648        ] {
4649            assert!(
4650                !is_incomplete_input_category(c),
4651                "{c:?} misattributes a client-side truncation and must be denied"
4652            );
4653        }
4654    }
4655
4656    /// The series generator: small header sizes + fractions all present, every value
4657    /// `< len`, strictly increasing (sorted + deduped), and correct for tiny `len`.
4658    #[test]
4659    fn truncation_series_generator() {
4660        let len = 279usize; // a realistic small reference-image length
4661        let s = truncation_lengths(len);
4662
4663        assert!(
4664            s.windows(2).all(|w| w[0] < w[1]),
4665            "strictly increasing (sorted + deduped): {s:?}"
4666        );
4667        assert!(s.iter().all(|&n| n < len), "every value < len: {s:?}");
4668        for n in [0usize, 1, 2, 3, 4, 8, 16] {
4669            assert!(s.contains(&n), "small header size {n} present: {s:?}");
4670        }
4671        for (num, den) in [(1, 8), (1, 4), (3, 8), (1, 2), (5, 8), (3, 4), (7, 8)] {
4672            assert!(
4673                s.contains(&(len * num / den)),
4674                "fraction {num}/{den} present: {s:?}"
4675            );
4676        }
4677        assert!(s.contains(&(len - 1)), "len-1 present: {s:?}");
4678
4679        // Tiny lengths: everything clamps to `< len`, deduped, no underflow panic.
4680        assert_eq!(truncation_lengths(5), vec![0, 1, 2, 3, 4]);
4681        assert_eq!(truncation_lengths(2), vec![0, 1]);
4682    }
4683
4684    /// End-to-end foil: `reference` is Pattern A (`type Error = RefError`). Its
4685    /// truncations classify fine on the typed path, but the category is
4686    /// unrecoverable once the dyn boundary erases it to `BoxedError`, so every
4687    /// truncated offset yields a `None` category and the check FAILs — naming the
4688    /// Pattern-A cause exactly once (not once per offset), the same limitation
4689    /// [`check_decode_error_envelope`] catches.
4690    #[test]
4691    fn reference_pattern_a_fails_truncation_series() {
4692        let (e, d) = ref_codecs();
4693        let img = TestImage::rgba8_gradient(9, 6);
4694        let valid = enc_oneshot(&e, &img, Metadata::none(), MetadataPolicy::PreserveExact).unwrap();
4695        assert!(valid.len() > 16, "reference image should be non-trivial");
4696
4697        let err = check_decode_truncation_series(d, &valid)
4698            .expect_err("Pattern A must fail the truncation-series check on the erased path");
4699        assert_eq!(err.check, "decode_truncation_series");
4700        assert!(
4701            err.detail.contains("Pattern A"),
4702            "the failure should name the Pattern-A cause: {}",
4703            err.detail
4704        );
4705        assert_eq!(
4706            err.detail.matches("Pattern A").count(),
4707            1,
4708            "the Pattern-A limitation is reported once, not per truncated offset: {}",
4709            err.detail
4710        );
4711    }
4712
4713    /// End-to-end positive: `minimal` is Pattern B (`type Error = At<CodecError>`).
4714    /// Every truncation classifies as `MalformedImage` — an allowed incomplete-input
4715    /// category that survives dyn erasure — with no panic and no silent decode of a
4716    /// truncated header, so the check PASSES.
4717    #[test]
4718    fn minimal_passes_truncation_series() {
4719        let img = TestImage::rgba8_gradient(9, 6);
4720        let valid = enc_oneshot(
4721            &MinimalEncoderConfig::new(),
4722            &img,
4723            Metadata::none(),
4724            MetadataPolicy::PreserveExact,
4725        )
4726        .unwrap();
4727        check_decode_truncation_series(MinimalDecoderConfig::new(), &valid).unwrap();
4728    }
4729
4730    /// A too-short `valid` is rejected up front — the check needs a real encoded
4731    /// image to truncate, not a stray byte.
4732    #[test]
4733    fn truncation_series_rejects_tiny_valid() {
4734        let err = check_decode_truncation_series(MinimalDecoderConfig::new(), &[0x00])
4735            .expect_err("a 1-byte `valid` cannot be a real encoded image");
4736        assert_eq!(err.check, "decode_truncation_series");
4737    }
4738
4739    /// The classifier underpinning the honesty check must flag both kinds of lie
4740    /// (declared-but-broken, and works-but-undeclared) while accepting an honest
4741    /// decline (undeclared + `UnsupportedOperation`).
4742    #[test]
4743    fn detector_catches_a_lie() {
4744        // declared = true, but the operation failed → lie (missing impl).
4745        let mut v = Vec::new();
4746        classify(
4747            "x",
4748            true,
4749            Err::<(), _>(RefError::Invalid("boom".into())),
4750            &mut v,
4751        );
4752        assert_eq!(v.len(), 1, "declared + failed must be flagged");
4753
4754        // declared = false, but the operation succeeded → lie (hidden capability).
4755        let mut v = Vec::new();
4756        classify("y", false, Ok::<(), RefError>(()), &mut v);
4757        assert_eq!(v.len(), 1, "undeclared + worked must be flagged");
4758
4759        // declared = false, and it declined with UnsupportedOperation → honest.
4760        let mut v = Vec::new();
4761        let declined = Err::<(), _>(RefError::Unsupported(
4762            zencodec::UnsupportedOperation::RowLevelEncode,
4763        ));
4764        classify("z", false, declined, &mut v);
4765        assert!(v.is_empty(), "undeclared + UnsupportedOperation is honest");
4766    }
4767
4768    #[test]
4769    fn reference_orientation_roundtrip() {
4770        let (e, d) = ref_codecs();
4771        // Non-square so an axis-swap bug (Rotate90/270/transpose) shows as a
4772        // dimension or pixel mismatch.
4773        check_orientation_roundtrip(e, d, &TestImage::rgba8_gradient(9, 6)).unwrap();
4774    }
4775
4776    /// `render` must match EXIF semantics: identity is a no-op, Rotate90 swaps
4777    /// axes, and self-inverse transforms applied twice return the original.
4778    #[test]
4779    fn render_matches_orientation_semantics() {
4780        let p = TestImage::rgba8_gradient(3, 2).pixels();
4781        assert_eq!(render(&p, Orientation::Identity), p, "identity is a no-op");
4782
4783        let r90 = render(&p, Orientation::Rotate90);
4784        assert_eq!((r90.width, r90.rows), (2, 3), "Rotate90 swaps axes");
4785
4786        // Self-inverse transforms applied twice are the identity.
4787        let r180 = render(&p, Orientation::Rotate180);
4788        assert_eq!(
4789            render(&r180, Orientation::Rotate180),
4790            p,
4791            "Rotate180∘Rotate180 == id"
4792        );
4793        let fh = render(&p, Orientation::FlipH);
4794        assert_eq!(render(&fh, Orientation::FlipH), p, "FlipH∘FlipH == id");
4795        let fv = render(&p, Orientation::FlipV);
4796        assert_eq!(render(&fv, Orientation::FlipV), p, "FlipV∘FlipV == id");
4797    }
4798
4799    #[test]
4800    fn reference_animation_cross_path() {
4801        let (e, d) = ref_codecs();
4802        // Distinct frames, so a frame-ordering or canvas-aliasing bug is visible.
4803        let frames = [
4804            TestImage::rgba8_gradient_seeded(10, 8, 0),
4805            TestImage::rgba8_gradient_seeded(10, 8, 50),
4806            TestImage::rgba8_gradient_seeded(10, 8, 130),
4807        ];
4808        check_animation_cross_path_equivalence(e, d, &frames).unwrap();
4809    }
4810
4811    #[test]
4812    fn minimal_animation_cross_path_skipped() {
4813        // Minimal declares animation=false, so the check is not applicable and passes.
4814        let frames = [TestImage::rgba8_gradient_seeded(8, 8, 0)];
4815        check_animation_cross_path_equivalence(
4816            MinimalEncoderConfig::new(),
4817            MinimalDecoderConfig::new(),
4818            &frames,
4819        )
4820        .unwrap();
4821    }
4822
4823    // ---- native HDR conformance (zencodec#24, Phase 0) ----
4824
4825    /// The reference declares `hdr` + `native_16bit` on both ends and stores
4826    /// CLLI/MDCV + 16-bit samples raw, stamping the descriptor from CICP, so PQ
4827    /// and HLG round-trip in full.
4828    #[test]
4829    fn reference_native_hdr_roundtrip() {
4830        let (e, d) = ref_codecs();
4831        check_native_hdr_roundtrip(e, d).unwrap();
4832    }
4833
4834    /// An SDR-only codec (no `hdr`) is out of scope, not a failure.
4835    #[test]
4836    fn minimal_native_hdr_roundtrip_skipped() {
4837        check_native_hdr_roundtrip(MinimalEncoderConfig::new(), MinimalDecoderConfig::new())
4838            .unwrap();
4839    }
4840
4841    /// The 16-bit HDR decode is labelled exactly `RGB16_BT2100_PQ` — the
4842    /// stamped descriptor equals the constant, so downstream `==` checks on the
4843    /// canonical descriptors hold.
4844    #[test]
4845    fn reference_hdr_decode_is_labelled_pq() {
4846        let (e, d) = ref_codecs();
4847        let img = TestImage::rgb16_gradient(6, 5, PixelDescriptor::RGB16_BT2100_PQ);
4848        let meta = Metadata::none().with_cicp(Cicp::BT2100_PQ);
4849        let bytes = enc_oneshot(&e, &img, meta, MetadataPolicy::PreserveExact).unwrap();
4850        let out = dec_output(&d, &bytes).unwrap();
4851        assert_eq!(out.pixels().descriptor(), PixelDescriptor::RGB16_BT2100_PQ);
4852        assert_eq!(grab(out.pixels()), img.pixels());
4853    }
4854
4855    /// Tolerance is container fixed-point, not slack: a 1/50000 xy step passes,
4856    /// a real drift fails, and luminance is relative.
4857    #[test]
4858    fn mastering_display_tolerance_is_fixed_point_tight() {
4859        let want = MasteringDisplay::HDR10_REFERENCE;
4860        let mut quantized = want;
4861        quantized.primaries_xy[0][0] += 1e-5; // within one 1/50000 step
4862        quantized.max_luminance = 10000.05; // 5e-6 relative
4863        assert!(mastering_display_matches(&quantized, &want));
4864        let mut drifted = want;
4865        drifted.primaries_xy[0][0] += 1e-3;
4866        assert!(!mastering_display_matches(&drifted, &want));
4867        let mut dark = want;
4868        dark.min_luminance = 0.0;
4869        assert!(
4870            !mastering_display_matches(&dark, &want),
4871            "a zeroed min luminance (0.0001 → 0) is a drop, not precision"
4872        );
4873    }
4874
4875    // ---- buffer colour-context conformance (zencodec#25) ----
4876
4877    /// The reference attaches a class-gated context on every path, so both the
4878    /// lenient consistency check and the strict attached check pass.
4879    #[test]
4880    fn reference_color_context_consistency() {
4881        let (e, d) = ref_codecs();
4882        check_color_context_consistency(e, d, &TestImage::rgba8_gradient(11, 9)).unwrap();
4883    }
4884
4885    #[test]
4886    fn reference_color_context_attached() {
4887        let (e, d) = ref_codecs();
4888        check_color_context_attached(e, d, &TestImage::rgb8_gradient(11, 9)).unwrap();
4889    }
4890
4891    /// The minimal codec drops every colour channel, so it attaches nothing and
4892    /// reads nothing back: the lenient check passes (nothing to validate) and the
4893    /// strict check passes (nothing to attach).
4894    #[test]
4895    fn minimal_color_context_checks_pass_without_color() {
4896        let img = TestImage::rgba8_gradient(8, 6);
4897        check_color_context_consistency(
4898            MinimalEncoderConfig::new(),
4899            MinimalDecoderConfig::new(),
4900            &img,
4901        )
4902        .unwrap();
4903        check_color_context_attached(
4904            MinimalEncoderConfig::new(),
4905            MinimalDecoderConfig::new(),
4906            &img,
4907        )
4908        .unwrap();
4909    }
4910
4911    /// The class gate has teeth: an ICC rides only a layout its device class
4912    /// describes, an unreadable class rides nothing, and an empty context is
4913    /// rejected — while class-matching and CICP-only contexts pass.
4914    #[test]
4915    fn buffer_context_class_gate() {
4916        use zenpixels::PixelDescriptor as P;
4917        let rgb_icc = ColorContext::from_icc(fixtures::sample_icc_with_class(b"RGB "));
4918        let gray_icc = ColorContext::from_icc(fixtures::sample_icc_with_class(b"GRAY"));
4919        let junk_icc = ColorContext::from_icc(fixtures::sample_icc_with_class(b"????"));
4920        let cicp_only = ColorContext::from_cicp(Cicp::BT2100_PQ);
4921
4922        assert!(validate_buffer_context(None, P::RGB8_SRGB).is_ok());
4923        assert!(validate_buffer_context(Some(&rgb_icc), P::RGB8_SRGB).is_ok());
4924        assert!(validate_buffer_context(Some(&rgb_icc), P::RGBA8_SRGB).is_ok());
4925        assert!(validate_buffer_context(Some(&gray_icc), P::GRAY8_SRGB).is_ok());
4926        assert!(validate_buffer_context(Some(&cicp_only), P::GRAY8_SRGB).is_ok());
4927
4928        let e = validate_buffer_context(Some(&gray_icc), P::RGB8_SRGB).unwrap_err();
4929        assert!(e.contains("device class Gray"), "{e}");
4930        let e = validate_buffer_context(Some(&rgb_icc), P::GRAY8_SRGB).unwrap_err();
4931        assert!(e.contains("device class Rgb"), "{e}");
4932        let e = validate_buffer_context(Some(&junk_icc), P::RGB8_SRGB).unwrap_err();
4933        assert!(e.contains("unreadable"), "{e}");
4934        let e = validate_buffer_context(Some(&ColorContext::default()), P::RGB8_SRGB).unwrap_err();
4935        assert!(e.contains("neither ICC nor CICP"), "{e}");
4936    }
4937
4938    /// The reference's worked-example gate: a class-matching ICC rides as-is
4939    /// (drop-dupe: the CICP is dropped under ICC authority); a class-mismatched
4940    /// one is replaced by a CICP-only description (the signaled CICP, since the
4941    /// synthetic fixture has no derivable CICP); nothing describable → `None`.
4942    #[test]
4943    fn reference_class_gate_ranks_the_fallbacks() {
4944        use zencodec::decode::SourceColor;
4945        use zenpixels::ColorModel;
4946
4947        let rgb = Arc::<[u8]>::from(fixtures::sample_icc_with_class(b"RGB "));
4948        let gray = Arc::<[u8]>::from(fixtures::sample_icc_with_class(b"GRAY"));
4949
4950        let both = SourceColor::default()
4951            .with_icc_profile(rgb.clone())
4952            .with_cicp(Cicp::SRGB);
4953        let ctx = crate::reference::class_gated_context(&both, ColorModel::Rgb).expect("attached");
4954        assert_eq!(
4955            ctx.icc.as_deref(),
4956            Some(&*rgb),
4957            "class-matching ICC rides as-is"
4958        );
4959        assert_eq!(ctx.cicp, None, "drop-dupe: ICC is the authority");
4960
4961        let mismatched = SourceColor::default()
4962            .with_icc_profile(gray.clone())
4963            .with_cicp(Cicp::SRGB);
4964        let ctx = crate::reference::class_gated_context(&mismatched, ColorModel::Rgb)
4965            .expect("falls back to a CICP-only description");
4966        assert_eq!(
4967            ctx.icc, None,
4968            "a GRAY-class profile never rides an RGB buffer"
4969        );
4970        assert_eq!(ctx.cicp, Some(Cicp::SRGB));
4971
4972        let underivable = SourceColor::default().with_icc_profile(gray);
4973        assert!(
4974            crate::reference::class_gated_context(&underivable, ColorModel::Rgb).is_none(),
4975            "nothing describable → no context (not an empty one)"
4976        );
4977        assert!(
4978            crate::reference::class_gated_context(&SourceColor::default(), ColorModel::Rgb)
4979                .is_none()
4980        );
4981    }
4982
4983    #[test]
4984    fn reference_check_all() {
4985        let (e, d) = ref_codecs();
4986        check_all(e, d).unwrap();
4987    }
4988
4989    #[test]
4990    fn minimal_check_all() {
4991        check_all(MinimalEncoderConfig::new(), MinimalDecoderConfig::new()).unwrap();
4992    }
4993
4994    /// The reference inventory covers every section of a file carrying all the
4995    /// metadata the wire format supports plus three animation frames, and the
4996    /// contract holds for it (junk, truncation).
4997    #[test]
4998    fn reference_inventory_maps_every_section() {
4999        use zencodec::inventory::{Disposition, MetadataKind};
5000        let (e, d) = ref_codecs();
5001        let frames = [
5002            TestImage::rgba8_gradient_seeded(6, 4, 0),
5003            TestImage::rgba8_gradient_seeded(6, 4, 60),
5004            TestImage::rgba8_gradient_seeded(6, 4, 120),
5005        ];
5006        let meta = Metadata::none()
5007            .with_exif(fixtures::rich_exif_le())
5008            .with_xmp(fixtures::sample_xmp())
5009            .with_icc(fixtures::sample_icc())
5010            .with_cicp(Cicp::SRGB);
5011        let bytes = encode_animation(&e, &frames, meta).unwrap();
5012        check_inventory_dispatch(d.clone(), &bytes).unwrap();
5013
5014        let inv = d.clone().job().inventory(&bytes).unwrap();
5015        let count = |disp: Disposition| {
5016            inv.parts()
5017                .iter()
5018                .filter(|p| p.disposition() == disp)
5019                .count()
5020        };
5021        assert_eq!(count(Disposition::Metadata(MetadataKind::Exif)), 1, "{inv}");
5022        assert_eq!(count(Disposition::Metadata(MetadataKind::Xmp)), 1, "{inv}");
5023        assert_eq!(count(Disposition::Metadata(MetadataKind::Icc)), 1, "{inv}");
5024        assert_eq!(count(Disposition::Metadata(MetadataKind::Cicp)), 1, "{inv}");
5025        assert_eq!(
5026            count(Disposition::Metadata(MetadataKind::Animation)),
5027            3,
5028            "{inv}"
5029        );
5030        assert_eq!(count(Disposition::ImageData), 3, "{inv}");
5031        assert_eq!(
5032            inv.unconsumed().count(),
5033            0,
5034            "a clean file has no unconsumed bytes\n{inv}"
5035        );
5036
5037        let mut tail = bytes.clone();
5038        tail.extend_from_slice(b"camera serial 12345");
5039        let inv = d.job().inventory(&tail).unwrap();
5040        let unconsumed: Vec<_> = inv.unconsumed().collect();
5041        assert_eq!(unconsumed.len(), 1, "{inv}");
5042        assert_eq!(unconsumed[0].1.disposition(), Disposition::Trailing);
5043        assert_eq!(
5044            unconsumed[0].1.range(),
5045            bytes.len() as u64..tail.len() as u64
5046        );
5047    }
5048
5049    /// A decoder that does not declare `inventory` must return the explicit unsupported error.
5050    #[test]
5051    fn undeclared_inventory_is_unsupported() {
5052        let bytes = enc_oneshot(
5053            &MinimalEncoderConfig::new(),
5054            &TestImage::rgba8_gradient(5, 3),
5055            Metadata::none(),
5056            MetadataPolicy::PreserveExact,
5057        )
5058        .unwrap();
5059        assert!(!<MinimalDecoderConfig as DecoderConfig>::capabilities().inventory());
5060        check_inventory_dispatch(MinimalDecoderConfig::new(), &bytes).unwrap();
5061    }
5062
5063    /// The reference round-trips metadata faithfully, so under PreserveExact the
5064    /// decoded EXIF must still carry GPS + thumbnail + copyright (positive
5065    /// direction the generic no-leak check intentionally doesn't assert).
5066    #[test]
5067    fn reference_preserve_exact_keeps_everything() {
5068        let (e, d) = ref_codecs();
5069        let img = TestImage::rgba8_gradient(8, 8);
5070        let rich = Metadata::none()
5071            .with_exif(fixtures::rich_exif_le())
5072            .with_xmp(fixtures::sample_xmp())
5073            .with_icc(fixtures::sample_icc());
5074        let bytes = enc_oneshot(&e, &img, rich, MetadataPolicy::PreserveExact).unwrap();
5075        let (_, meta) = dec_oneshot(&d, &bytes).unwrap();
5076        let x = Exif::parse(meta.exif.as_deref().expect("exif kept")).expect("parses");
5077        assert!(x.has_gps() && x.has_thumbnail());
5078        assert_eq!(x.copyright().as_deref(), Some("(C) 2026 Test"));
5079        assert!(meta.xmp.is_some(), "xmp kept");
5080        assert!(meta.icc_profile.is_some(), "icc kept");
5081    }
5082
5083    /// Web strips GPS/thumbnail/XMP but keeps rights — verified on the faithful
5084    /// reference, where decoded == filtered.
5085    #[test]
5086    fn reference_web_strips_privacy_keeps_rights() {
5087        let (e, d) = ref_codecs();
5088        let img = TestImage::rgba8_gradient(8, 8);
5089        let rich = Metadata::none()
5090            .with_exif(fixtures::rich_exif_le())
5091            .with_xmp(fixtures::sample_xmp());
5092        let bytes = enc_oneshot(&e, &img, rich, MetadataPolicy::Web).unwrap();
5093        let (_, meta) = dec_oneshot(&d, &bytes).unwrap();
5094        let x = Exif::parse(meta.exif.as_deref().expect("exif kept")).expect("parses");
5095        assert!(!x.has_gps(), "Web must strip GPS");
5096        assert!(!x.has_thumbnail(), "Web must strip the thumbnail");
5097        assert_eq!(
5098            x.copyright().as_deref(),
5099            Some("(C) 2026 Test"),
5100            "Web keeps rights"
5101        );
5102        assert!(meta.xmp.is_none(), "Web strips XMP");
5103    }
5104
5105    // ---- whereat trace: lines preserved up the stack + crate boundaries ----
5106
5107    /// A whereat trace preserves every `.at()` hop's file:line as an error climbs
5108    /// the stack — and through the `BoxedError` erasure a dyn pipeline performs —
5109    /// so a diagnostic can point at every layer, not just the last. Each frame is
5110    /// attributable to its source file (hence its crate: `Location::file()` embeds
5111    /// the crate directory), so the boundary between codec-internal frames and the
5112    /// caller's is visible in the trace.
5113    #[test]
5114    fn error_trace_preserves_lines_all_the_way_up() {
5115        use std::borrow::Cow;
5116        use zencodec::decode::{DecodeJob, DecoderConfig};
5117
5118        // A real codec error, located inside the codec module (the `?` site in
5119        // minimal.rs) by the bridge's track-caller `start_at` — exactly one frame.
5120        let origin: At<CodecError> = MinimalDecoderConfig::new()
5121            .job()
5122            .decoder(Cow::Borrowed(b"not a ZCR1 header"), &[])
5123            .expect_err("malformed header must fail");
5124        assert_eq!(
5125            origin.frame_count(),
5126            1,
5127            "the codec locates its error exactly once"
5128        );
5129        let f0 = origin
5130            .frames()
5131            .next()
5132            .and_then(|f| f.location())
5133            .expect("origin frame has a location");
5134        let origin_line = f0.line();
5135        assert!(
5136            f0.file().contains("minimal.rs"),
5137            "origin frame is attributed to the codec module, not the caller: {}",
5138            f0.file()
5139        );
5140
5141        // Climb the stack: each `.at()` is a distinct source line — the layers an
5142        // error crosses on the way up (codec boundary → pipeline → app).
5143        let l1 = line!() + 1;
5144        let hop1 = origin.at();
5145        let l2 = line!() + 1;
5146        let climbed = hop1.at();
5147
5148        // Every hop is its own frame, oldest-first, none collapsed or lost.
5149        let locs: Vec<(String, u32)> = climbed
5150            .frames()
5151            .filter_map(|f| f.location().map(|l| (l.file().to_string(), l.line())))
5152            .collect();
5153        assert_eq!(locs.len(), 3, "origin + 2 hops = 3 frames, none lost");
5154        assert_eq!(
5155            locs[0].1, origin_line,
5156            "origin line preserved at the bottom of the trace"
5157        );
5158        assert!(locs[0].0.contains("minimal.rs"));
5159        assert_eq!(
5160            (locs[1].0.contains("lib.rs"), locs[1].1),
5161            (true, l1),
5162            "hop 1 file+line preserved"
5163        );
5164        assert_eq!(
5165            (locs[2].0.contains("lib.rs"), locs[2].1),
5166            (true, l2),
5167            "hop 2 file+line preserved"
5168        );
5169        assert_ne!(
5170            locs[0].0, locs[1].0,
5171            "codec-origin and caller frames are distinguishable by file (the crate-boundary signal)"
5172        );
5173
5174        // The whole trace survives dyn-dispatch erasure to Box<dyn Error> and back:
5175        // no frame, no line is lost when the concrete type is hidden.
5176        let boxed: Box<dyn std::error::Error + Send + Sync> = Box::new(climbed);
5177        let recovered = boxed
5178            .downcast_ref::<At<CodecError>>()
5179            .expect("downcast the erased envelope");
5180        let after: Vec<(String, u32)> = recovered
5181            .frames()
5182            .filter_map(|f| f.location().map(|l| (l.file().to_string(), l.line())))
5183            .collect();
5184        assert_eq!(
5185            after, locs,
5186            "every frame + line survives the BoxedError round-trip"
5187        );
5188    }
5189
5190    /// whereat records crate boundaries when an error crosses them
5191    /// ([`At::at_crate`](whereat::At::at_crate) — the mechanism
5192    /// `whereat::define_at_crate_info!()` wires into zencodec and every codec). The
5193    /// rendered trace shows the crate transition, so a reader can see *which crate*
5194    /// each leg of the propagation happened in.
5195    #[test]
5196    fn error_trace_marks_crate_boundaries() {
5197        use whereat::AtCrateInfo;
5198
5199        // Two crates the error notionally crosses (codec → app).
5200        static CODEC_CRATE: AtCrateInfo = AtCrateInfo::builder().name("demo-codec").build();
5201        static APP_CRATE: AtCrateInfo = AtCrateInfo::builder().name("demo-app").build();
5202
5203        let err = At::wrap(CodecError::new(
5204            Some("demo-codec"),
5205            zencodec::ErrorCategory::Image(zencodec::ImageError::Malformed),
5206        ))
5207        .at_crate(&CODEC_CRATE)
5208        .at()
5209        .at_crate(&APP_CRATE)
5210        .at();
5211
5212        // Both boundaries are recorded as `AtContext::Crate` markers; the
5213        // full-trace display walks them and names each crate at the transition.
5214        let full = err.full_trace().to_string();
5215        assert!(
5216            full.contains("demo-codec") && full.contains("demo-app"),
5217            "both crate boundaries should be recorded + visible in the full trace:\n{full}"
5218        );
5219    }
5220}