Skip to main content

zencodec_testkit/
contracts.rs

1//! Fixture-driven checks for contracts introduced after the published testkit.
2use super::*;
3use zencodec::animation::FrameDuration;
4use zencodec::decode::DecodePolicy;
5use zencodec::encode::DynEncoderConfig;
6use zencodec::encode::EncodePolicy;
7use zencodec::metadata_rewrite::{Error as RewriteError, Plan, RewriteRequest};
8use zencodec::{LimitKind, ResourceLimits};
9
10/// Verify source timing through borrowed, owned and erased animation decoders.
11/// `expected` names every displayed frame; zero-duration compositing helpers
12/// belong in the codec fixture's expected display sequence, not an inferred count.
13/// The sink API returns only `OutputInfo`, so cannot expose frame timing.
14pub fn check_animation_durations<D: DecoderConfig + 'static>(
15    dec: D,
16    bytes: &[u8],
17    expected: &[FrameDuration],
18) -> Conformance {
19    const CHECK: &str = "animation durations";
20    let err = |e: &dyn std::fmt::Display| fail(CHECK, e.to_string());
21    let mut borrowed = dec
22        .clone()
23        .job()
24        .animation_frame_decoder(Cow::Borrowed(bytes), &[])
25        .map_err(|e| err(&e))?;
26    let mut owned = dec
27        .clone()
28        .job()
29        .animation_frame_decoder(Cow::Borrowed(bytes), &[])
30        .map_err(|e| err(&e))?;
31    let mut dynamic = dec
32        .dyn_job()
33        .into_animation_frame_decoder(Cow::Borrowed(bytes), &[])
34        .map_err(|e| err(&e))?;
35    for (index, duration) in expected.iter().enumerate() {
36        let a = borrowed
37            .render_next_frame(None)
38            .map_err(|e| err(&e))?
39            .ok_or_else(|| fail(CHECK, format!("borrowed frame {index} missing")))?;
40        let b = owned
41            .render_next_frame_owned(None)
42            .map_err(|e| err(&e))?
43            .ok_or_else(|| fail(CHECK, format!("owned frame {index} missing")))?;
44        let c = dynamic
45            .render_next_frame_owned(None)
46            .map_err(|e| err(&e))?
47            .ok_or_else(|| fail(CHECK, format!("erased frame {index} missing")))?;
48        for (path, actual, frame_index) in [
49            ("borrowed", a.duration(), a.frame_index()),
50            ("owned", b.duration(), b.frame_index()),
51            ("erased", c.duration(), c.frame_index()),
52        ] {
53            if actual != *duration || frame_index != index as u32 {
54                return Err(fail(
55                    CHECK,
56                    format!(
57                        "{path} frame {index}: duration {actual:?}, index {frame_index}; expected {duration:?}"
58                    ),
59                ));
60            }
61        }
62        if grab_ref(a.pixels()) != grab(b.pixels()) || grab(b.pixels()) != grab(c.pixels()) {
63            return Err(fail(
64                CHECK,
65                format!("frame {index}: generic/owned/erased pixels disagree"),
66            ));
67        }
68    }
69    if borrowed
70        .render_next_frame(None)
71        .map_err(|e| err(&e))?
72        .is_some()
73        || owned
74            .render_next_frame_owned(None)
75            .map_err(|e| err(&e))?
76            .is_some()
77        || dynamic
78            .render_next_frame_owned(None)
79            .map_err(|e| err(&e))?
80            .is_some()
81    {
82        return Err(fail(
83            CHECK,
84            "unexpected extra frame (possibly accepted a rejected input)",
85        ));
86    }
87    Ok(())
88}
89
90/// Exercise exact or ordinary timing through both generic and erased encoders.
91/// Each pair is `(requested, expected_encoded)`; `None` requires rejection.
92/// Include accepted frames before and after rejected inputs to detect state
93/// mutation. Expected rounded durations must come from the destination clock,
94/// independently of the codec/quantizer under test. At least two must succeed.
95/// For exact mode each successful duration must equal its request.
96pub fn check_animation_timing<E, D>(
97    enc: E,
98    dec: D,
99    pixels: PixelSlice<'_>,
100    exact: bool,
101    cases: &[(FrameDuration, Option<FrameDuration>)],
102) -> Conformance
103where
104    E: EncoderConfig + 'static,
105    D: DecoderConfig + 'static,
106    <E::Job as EncodeJob>::Enc: Encoder + Send,
107    <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
108{
109    const CHECK: &str = "animation timing";
110    let err = |e: &dyn std::fmt::Display| fail(CHECK, e.to_string());
111    let expected: Vec<_> = cases.iter().filter_map(|(_, result)| *result).collect();
112    if expected.len() < 2 {
113        return Err(fail(CHECK, "supply at least two accepted frames"));
114    }
115    let mut generic = enc
116        .clone()
117        .job()
118        .animation_frame_encoder()
119        .map_err(|e| err(&e))?;
120    let mut erased = enc
121        .dyn_job()
122        .into_animation_frame_encoder()
123        .map_err(|e| err(&e))?;
124    for (index, (requested, expected)) in cases.iter().enumerate() {
125        if exact && expected.is_some_and(|v| v != *requested) {
126            return Err(fail(CHECK, "exact test case requests rounding"));
127        }
128        let a = if exact {
129            generic
130                .push_frame_exact(pixels.sub_rows(0, pixels.rows()), *requested, None)
131                .map(|()| *requested)
132        } else {
133            generic.push_frame_timed(pixels.sub_rows(0, pixels.rows()), *requested, None)
134        };
135        let b = if exact {
136            erased
137                .push_frame_exact(pixels.sub_rows(0, pixels.rows()), *requested, None)
138                .map(|()| *requested)
139        } else {
140            erased.push_frame_timed(pixels.sub_rows(0, pixels.rows()), *requested, None)
141        };
142        for (path, result) in [
143            ("generic", a.map_err(|e| e.to_string())),
144            ("erased", b.map_err(|e| e.to_string())),
145        ] {
146            match (expected, result) {
147                (Some(want), Ok(got)) if *want == got => (),
148                (None, Err(_)) => (),
149                (want, got) => {
150                    return Err(fail(
151                        CHECK,
152                        format!("{path} case {index}: expected {want:?}, got {got:?}"),
153                    ));
154                }
155            }
156        }
157    }
158    let a = generic.finish(None).map_err(|e| err(&e))?;
159    let b = erased.finish(None).map_err(|e| err(&e))?;
160    check_animation_durations(dec.clone(), a.data(), &expected)?;
161    check_animation_durations(dec.clone(), b.data(), &expected)?;
162    if decode_anim_owned(&dec, a.data()).map_err(|e| fail(CHECK, e))?
163        != decode_anim_owned(&dec, b.data()).map_err(|e| fail(CHECK, e))?
164    {
165        return Err(fail(CHECK, "generic/erased encoded pixels disagree"));
166    }
167    Ok(())
168}
169
170/// Verify the inventory input-byte boundary and typed errors in both dispatch
171/// paths. This is opt-in for inventory-enabled builds; disabled builds use
172/// `check_inventory_dispatch`. It does not equate a part cap with a memory cap.
173pub fn check_inventory_input_limit<D: DecoderConfig + 'static>(
174    dec: D,
175    valid: &[u8],
176) -> Conformance {
177    const CHECK: &str = "inventory input limit";
178    if valid.is_empty() || !D::capabilities().inventory() {
179        return Err(fail(CHECK, "requires nonempty input and enabled inventory"));
180    }
181    for cap in [valid.len() - 1, valid.len()] {
182        let limits = ResourceLimits::none().with_max_input_bytes(cap as u64);
183        let a = dec.clone().job().with_limits(limits).inventory(valid);
184        let mut job = dec.dyn_job();
185        job.set_limits(limits);
186        let b = job.inventory(valid);
187        if cap == valid.len() {
188            let a = a.map_err(|e| fail(CHECK, format!("exact boundary: {e}")))?;
189            let b = b.map_err(|e| fail(CHECK, format!("erased exact boundary: {e}")))?;
190            a.validate().map_err(|e| fail(CHECK, e.to_string()))?;
191            if a != b {
192                return Err(fail(CHECK, "boundary inventories disagree"));
193            }
194        } else {
195            let wanted = Some(ErrorCategory::Resource(zencodec::ResourceError::Limits(
196                LimitKind::InputSize,
197            )));
198            let a = a
199                .err()
200                .ok_or_else(|| fail(CHECK, "generic accepted oversized input"))?;
201            let b = b
202                .err()
203                .ok_or_else(|| fail(CHECK, "erased accepted oversized input"))?;
204            if a.error_category() != wanted || b.error_category() != wanted {
205                return Err(fail(
206                    CHECK,
207                    format!("expected InputSize, got {a:?} and {b:?}"),
208                ));
209            }
210        }
211    }
212    Ok(())
213}
214
215/// Verify checked encode-policy admission and rejection atomicity. `initial`
216/// must be supported; `request` must match `accepted`. Decode observations must
217/// equal `expected` metadata, making preservation and denial explicit instead
218/// of treating a successful setter as proof of delivery. `metadata` is the
219/// unfiltered input. Color/fidelity observations remain codec-specific.
220#[allow(clippy::too_many_arguments)]
221pub fn check_encode_policy_admission<E, D>(
222    enc: E,
223    dec: D,
224    pixels: PixelSlice<'_>,
225    metadata: Metadata,
226    initial: EncodePolicy,
227    request: EncodePolicy,
228    accepted: bool,
229    expected: &Metadata,
230) -> Conformance
231where
232    E: EncoderConfig + 'static,
233    D: DecoderConfig,
234    <E::Job as EncodeJob>::Enc: Encoder<Error = E::Error> + Send,
235    <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
236{
237    const CHECK: &str = "checked encode policy";
238    let mut job = enc
239        .clone()
240        .job()
241        .with_metadata_policy(metadata.clone(), MetadataPolicy::PreserveExact)
242        .try_with_policy(initial)
243        .map_err(|e| fail(CHECK, format!("initial: {e}")))?;
244    let mut dynamic = enc.dyn_job();
245    dynamic.set_metadata_policy(metadata.clone(), MetadataPolicy::PreserveExact);
246    dynamic
247        .try_set_policy(initial)
248        .map_err(|e| fail(CHECK, format!("erased initial: {e}")))?;
249    let a = job.try_set_policy(request);
250    let b = dynamic.try_set_policy(request);
251    if a.is_ok() != accepted || b.is_ok() != accepted {
252        return Err(fail(CHECK, format!("admission mismatch: {a:?}, {b:?}")));
253    }
254    if !accepted
255        && (a.err() != Some(UnsupportedOperation::EncodePolicy)
256            || b.err()
257                .and_then(|e| e.downcast_ref::<UnsupportedOperation>().copied())
258                != Some(UnsupportedOperation::EncodePolicy))
259    {
260        return Err(fail(CHECK, "rejection lost typed EncodePolicy error"));
261    }
262    let builder = enc
263        .clone()
264        .job()
265        .with_metadata_policy(metadata.clone(), MetadataPolicy::PreserveExact)
266        .try_with_policy(initial)
267        .map_err(|e| fail(CHECK, e.to_string()))?
268        .try_with_policy(request);
269    if builder.is_ok() != accepted {
270        return Err(fail(CHECK, "builder and mutable admission disagree"));
271    }
272    let baseline = match builder {
273        Ok(job) => job,
274        Err(UnsupportedOperation::EncodePolicy) => enc
275            .job()
276            .with_metadata_policy(metadata, MetadataPolicy::PreserveExact)
277            .try_with_policy(initial)
278            .map_err(|e| fail(CHECK, e.to_string()))?,
279        Err(e) => return Err(fail(CHECK, format!("wrong builder rejection: {e}"))),
280    }
281    .encode(pixels.sub_rows(0, pixels.rows()))
282    .map_err(|e| fail(CHECK, e.to_string()))?;
283    let (baseline_pixels, baseline_metadata) =
284        dec_oneshot(&dec, baseline.data()).map_err(|e| fail(CHECK, e))?;
285    let a = job
286        .encode(pixels.sub_rows(0, pixels.rows()))
287        .map_err(|e| fail(CHECK, e.to_string()))?;
288    let b = dynamic
289        .encode(pixels.sub_rows(0, pixels.rows()))
290        .map_err(|e| fail(CHECK, e.to_string()))?;
291    let (pa, ma) = dec_oneshot(&dec, a.data()).map_err(|e| fail(CHECK, e))?;
292    let (pb, mb) = dec_oneshot(&dec, b.data()).map_err(|e| fail(CHECK, e))?;
293    if pa != pb
294        || pa != baseline_pixels
295        || ma != baseline_metadata
296        || ma != *expected
297        || mb != *expected
298    {
299        return Err(fail(
300            CHECK,
301            format!("output mismatch: generic {ma:?}, erased {mb:?}, expected {expected:?}"),
302        ));
303    }
304    Ok(())
305}
306
307/// Verify checked decode-policy admission, typed rejection, and unchanged output
308/// after rejection. Supply explicit expected metadata to test supported metadata
309/// denials. All paths decode the same caller-provided fixture.
310#[allow(clippy::too_many_arguments)]
311pub fn check_decode_policy_admission<D: DecoderConfig + 'static>(
312    dec: D,
313    bytes: &[u8],
314    initial: DecodePolicy,
315    request: DecodePolicy,
316    accepted: bool,
317    expected: &Metadata,
318) -> Conformance {
319    const CHECK: &str = "checked decode policy";
320    let mut job = dec
321        .clone()
322        .job()
323        .try_with_policy(initial)
324        .map_err(|e| fail(CHECK, e.to_string()))?;
325    let mut dynamic = dec.dyn_job();
326    dynamic
327        .try_set_policy(initial)
328        .map_err(|e| fail(CHECK, e.to_string()))?;
329    let a = job.try_set_policy(request);
330    let b = dynamic.try_set_policy(request);
331    if a.is_ok() != accepted || b.is_ok() != accepted {
332        return Err(fail(CHECK, format!("admission mismatch: {a:?}, {b:?}")));
333    }
334    if !accepted
335        && (a.err() != Some(UnsupportedOperation::DecodePolicy)
336            || b.err()
337                .and_then(|e| e.downcast_ref::<UnsupportedOperation>().copied())
338                != Some(UnsupportedOperation::DecodePolicy))
339    {
340        return Err(fail(CHECK, "rejection lost typed DecodePolicy error"));
341    }
342    let builder = dec
343        .clone()
344        .job()
345        .try_with_policy(initial)
346        .map_err(|e| fail(CHECK, e.to_string()))?
347        .try_with_policy(request);
348    if builder.is_ok() != accepted {
349        return Err(fail(CHECK, "builder and mutable admission disagree"));
350    }
351    let baseline = match builder {
352        Ok(job) => job,
353        Err(UnsupportedOperation::DecodePolicy) => dec
354            .clone()
355            .job()
356            .try_with_policy(initial)
357            .map_err(|e| fail(CHECK, e.to_string()))?,
358        Err(e) => return Err(fail(CHECK, format!("wrong builder rejection: {e}"))),
359    }
360    .decoder(Cow::Borrowed(bytes), &[])
361    .map_err(|e| fail(CHECK, e.to_string()))?
362    .decode()
363    .map_err(|e| fail(CHECK, e.to_string()))?;
364    let a = job
365        .decoder(Cow::Borrowed(bytes), &[])
366        .map_err(|e| fail(CHECK, e.to_string()))?
367        .decode()
368        .map_err(|e| fail(CHECK, e.to_string()))?;
369    let b = dynamic
370        .into_decoder(Cow::Borrowed(bytes), &[])
371        .map_err(|e| fail(CHECK, e.to_string()))?
372        .decode()
373        .map_err(|e| fail(CHECK, e.to_string()))?;
374    if grab(a.pixels()) != grab(b.pixels())
375        || a.metadata() != *expected
376        || b.metadata() != *expected
377    {
378        return Err(fail(
379            CHECK,
380            "decoded pixels/metadata disagree with expected policy outcome",
381        ));
382    }
383    if grab(a.pixels()) != grab(baseline.pixels()) || a.metadata() != baseline.metadata() {
384        return Err(fail(
385            CHECK,
386            "builder/mutable output differs or rejection changed output",
387        ));
388    }
389    Ok(())
390}
391
392/// Exercise a codec's metadata-only rewrite entry point with caller-owned
393/// fixtures. The observer must independently verify image payloads, timing,
394/// rendering metadata and requested retention (prefer a third-party reader).
395/// Checks plan materialization, report ranges, deterministic output, typed input,
396/// output and carrier limits, and pre-cancellation. Services are retained.
397pub fn check_metadata_rewrite<'a>(
398    request: RewriteRequest<'a>,
399    rewrite: impl Fn(&RewriteRequest<'a>) -> Result<Plan<'a>, RewriteError>,
400    observe: impl Fn(&[u8]) -> Conformance,
401) -> Conformance {
402    const CHECK: &str = "metadata rewrite";
403    let plan = rewrite(&request).map_err(|e| fail(CHECK, e.to_string()))?;
404    let output = plan.to_vec().map_err(|e| fail(CHECK, e.to_string()))?;
405    if output.is_empty() || request.input().is_empty() {
406        return Err(fail(CHECK, "requires nonempty encoded input and output"));
407    }
408    if plan.output_len() != output.len()
409        || plan.chunks().flatten().copied().collect::<Vec<_>>() != output
410    {
411        return Err(fail(CHECK, "chunk/materialized length disagreement"));
412    }
413    for entry in plan.report() {
414        let r = entry.source_range();
415        if r.start > r.end || r.end > request.input().len() {
416            return Err(fail(CHECK, "report outside original input"));
417        }
418    }
419    observe(&output)?;
420    let again = rewrite(&request).map_err(|e| fail(CHECK, e.to_string()))?;
421    if again.to_vec().map_err(|e| fail(CHECK, e.to_string()))? != output
422        || again.report() != plan.report()
423    {
424        return Err(fail(CHECK, "identical request produced different plan"));
425    }
426    let exact = request
427        .clone()
428        .with_byte_limits(request.input().len(), output.len());
429    if rewrite(&exact)
430        .map_err(|e| fail(CHECK, format!("exact budget: {e}")))?
431        .to_vec()
432        .map_err(|e| fail(CHECK, e.to_string()))?
433        != output
434    {
435        return Err(fail(CHECK, "exact budgets changed output"));
436    }
437    for (limited, expected) in [
438        (
439            request
440                .clone()
441                .with_byte_limits(request.input().len() - 1, usize::MAX),
442            LimitKind::InputSize,
443        ),
444        (
445            request
446                .clone()
447                .with_byte_limits(usize::MAX, output.len() - 1),
448            LimitKind::OutputSize,
449        ),
450        (
451            request.clone().with_carrier_limit(0),
452            LimitKind::MetadataCarriers,
453        ),
454    ] {
455        if !matches!(rewrite(&limited),Err(RewriteError::Limit(kind)) if kind==expected) {
456            return Err(fail(CHECK, format!("missing typed {expected:?} limit")));
457        }
458    }
459    struct Cancelled;
460    impl enough::Stop for Cancelled {
461        fn check(&self) -> Result<(), enough::StopReason> {
462            Err(enough::StopReason::Cancelled)
463        }
464    }
465    static PULSE: std::sync::LazyLock<zencodec::how_far::StopOnly<'static>> =
466        std::sync::LazyLock::new(|| zencodec::how_far::StopOnly::new(Cancelled));
467    let cancelled = request.with_pulse(Some(&*PULSE));
468    if !matches!(
469        rewrite(&cancelled),
470        Err(RewriteError::Cancelled) | Err(RewriteError::Stopped(enough::StopReason::Cancelled))
471    ) {
472        return Err(fail(CHECK, "pre-cancellation was not honored"));
473    }
474    Ok(())
475}
476
477/// Verify the conservative compatibility default for an animation adapter that
478/// has not adopted rational encoding. Both timing methods must reject with
479/// `AnimationTiming`, leave buffered frames intact, and allow legacy encoding
480/// to continue. A false timing capability must not silently quantize requests.
481pub fn check_animation_timing_unsupported<E, D>(
482    enc: E,
483    dec: D,
484    pixels: PixelSlice<'_>,
485) -> Conformance
486where
487    E: EncoderConfig + 'static,
488    D: DecoderConfig + 'static,
489    <E::Job as EncodeJob>::Enc: Encoder + Send,
490    <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
491{
492    const CHECK: &str = "unsupported animation timing";
493    if E::capabilities().animation_timing() {
494        return Err(fail(
495            CHECK,
496            "timing-enabled adapter needs explicit timing cases",
497        ));
498    }
499    let mut generic = enc
500        .clone()
501        .job()
502        .animation_frame_encoder()
503        .map_err(|e| fail(CHECK, e.to_string()))?;
504    let mut dynamic = enc
505        .dyn_job()
506        .into_animation_frame_encoder()
507        .map_err(|e| fail(CHECK, e.to_string()))?;
508    for delay in [40, 60] {
509        for duration in [
510            FrameDuration::from_millis(0),
511            FrameDuration::from_millis(10),
512            FrameDuration::new(1, 30000).unwrap(),
513            FrameDuration::new(u64::MAX, 1).unwrap(),
514        ] {
515            for exact in [true, false] {
516                let a = if exact {
517                    generic.push_frame_exact(pixels.sub_rows(0, pixels.rows()), duration, None)
518                } else {
519                    generic
520                        .push_frame_timed(pixels.sub_rows(0, pixels.rows()), duration, None)
521                        .map(|_| ())
522                };
523                let b = if exact {
524                    dynamic.push_frame_exact(pixels.sub_rows(0, pixels.rows()), duration, None)
525                } else {
526                    dynamic
527                        .push_frame_timed(pixels.sub_rows(0, pixels.rows()), duration, None)
528                        .map(|_| ())
529                };
530                let a = a
531                    .err()
532                    .ok_or_else(|| fail(CHECK, "generic accepted unsupported timing"))?;
533                let b = b
534                    .err()
535                    .ok_or_else(|| fail(CHECK, "erased accepted unsupported timing"))?;
536                if a.unsupported_operation() != Some(&UnsupportedOperation::AnimationTiming)
537                    || b.unsupported_operation() != Some(&UnsupportedOperation::AnimationTiming)
538                {
539                    return Err(fail(CHECK, format!("wrong rejection: {a}, {b}")));
540                }
541            }
542        }
543        generic
544            .push_frame(pixels.sub_rows(0, pixels.rows()), delay, None)
545            .map_err(|e| fail(CHECK, e.to_string()))?;
546        dynamic
547            .push_frame(pixels.sub_rows(0, pixels.rows()), delay, None)
548            .map_err(|e| fail(CHECK, e.to_string()))?;
549    }
550    let a = generic
551        .finish(None)
552        .map_err(|e| fail(CHECK, e.to_string()))?;
553    let b = dynamic
554        .finish(None)
555        .map_err(|e| fail(CHECK, e.to_string()))?;
556    let expected = [
557        FrameDuration::from_millis(40),
558        FrameDuration::from_millis(60),
559    ];
560    check_animation_durations(dec.clone(), a.data(), &expected)?;
561    check_animation_durations(dec, b.data(), &expected)
562}
563
564/// Verify a [`DynReadAt`](zencodec::source::DynReadAt) driver source
565/// against the bytes it claims to serve: length, exact reads at the
566/// edges and middle, one batched out-of-order multi-range read, and an
567/// error (never a short or zero-filled read) past the end.
568pub fn check_read_at_contract(
569    source: &mut dyn zencodec::source::DynReadAt,
570    expected: &[u8],
571) -> Conformance {
572    const CHECK: &str = "read_at_contract";
573    let err = |e: &dyn std::fmt::Display| fail(CHECK, e.to_string());
574
575    let total = source.len().map_err(|e| err(&e))?;
576    if total != expected.len() as u64 {
577        return Err(fail(
578            CHECK,
579            format!("len() = {total}, expected {}", expected.len()),
580        ));
581    }
582    if expected.is_empty() {
583        return Ok(());
584    }
585    let mut all = vec![0u8; expected.len()];
586    source.read_exact_at(0, &mut all).map_err(|e| err(&e))?;
587    if all != expected {
588        return Err(fail(CHECK, "full read bytes differ"));
589    }
590    let mid = expected.len() / 2;
591    let take = (expected.len() - mid).min(16);
592    let mut slice = vec![0u8; take];
593    source
594        .read_exact_at(mid as u64, &mut slice)
595        .map_err(|e| err(&e))?;
596    if slice != expected[mid..mid + take] {
597        return Err(fail(CHECK, "middle read bytes differ"));
598    }
599    let mut head = vec![0u8; take];
600    let mut tail = vec![0u8; take];
601    {
602        let mut reqs: Vec<(u64, &mut [u8])> = vec![
603            ((expected.len() - take) as u64, &mut tail[..]),
604            (0, &mut head[..]),
605        ];
606        source.read_many_at(&mut reqs).map_err(|e| err(&e))?;
607    }
608    if head != expected[..take] || tail != expected[expected.len() - take..] {
609        return Err(fail(CHECK, "batched read bytes differ"));
610    }
611    let mut one = [0u8; 1];
612    if source
613        .read_exact_at(expected.len() as u64, &mut one)
614        .is_ok()
615    {
616        return Err(fail(CHECK, "read past end unexpectedly succeeded"));
617    }
618    Ok(())
619}
620
621fn pixels_of(out: &zencodec::decode::DecodeOutput) -> (u32, u32, PixelDescriptor, Vec<u8>) {
622    let ps = out.pixels();
623    let mut bytes = Vec::new();
624    for y in 0..ps.rows() {
625        bytes.extend_from_slice(ps.row(y));
626    }
627    (ps.width(), ps.rows(), ps.descriptor(), bytes)
628}
629
630/// Verify the `seekable` capability flag in both directions. A decoder
631/// declaring it must decode `data` through `push_decoder_from_source`
632/// over a slice source with the same pixels as the one-shot path; one not
633/// declaring it must reject with [`UnsupportedOperation::SeekableDecode`].
634pub fn check_seekable_capability_honesty<D: DecoderConfig>(dec: D, data: &[u8]) -> Conformance {
635    const CHECK: &str = "seekable_capability_honesty";
636    let declared = D::capabilities().seekable();
637    let mut source = zencodec::source::ReadAtShim(zencodec::source::SliceReadAt::new(data));
638    let mut sink = zencodec_io::CollectSink::new();
639    let result = dec
640        .clone()
641        .job()
642        .push_decoder_from_source(&mut source, &mut sink, &[]);
643    match (declared, result) {
644        (true, Ok(info)) => {
645            let reference = dec
646                .job()
647                .decoder(std::borrow::Cow::Borrowed(data), &[])
648                .and_then(|d| d.decode())
649                .map_err(|e| fail(CHECK, format!("one-shot decode: {e}")))?;
650            let (w, h, desc, bytes) = pixels_of(&reference);
651            if (info.width, info.height) != (w, h) || sink.descriptor() != Some(desc) {
652                return Err(fail(
653                    CHECK,
654                    format!(
655                        "source decode {}x{} {:?} differs from one-shot {w}x{h} {desc:?}",
656                        info.width,
657                        info.height,
658                        sink.descriptor()
659                    ),
660                ));
661            }
662            if !sink.finished() {
663                return Err(fail(CHECK, "source decode did not call sink.finish"));
664            }
665            if sink.bytes() != bytes {
666                return Err(fail(CHECK, "source decode pixels differ from one-shot"));
667            }
668            Ok(())
669        }
670        (true, Err(e)) => Err(fail(
671            CHECK,
672            format!("declared seekable but push_decoder_from_source failed: {e}"),
673        )),
674        (false, Ok(_)) => Err(fail(
675            CHECK,
676            "did not declare seekable but push_decoder_from_source succeeded",
677        )),
678        (false, Err(e)) => match e.downcast_ref::<UnsupportedOperation>().copied() {
679            Some(UnsupportedOperation::SeekableDecode) => Ok(()),
680            _ => Err(fail(
681                CHECK,
682                format!("undeclared seekable rejected with wrong error: {e}"),
683            )),
684        },
685    }
686}
687
688/// Verify the `incremental` capability flag in both directions, then run
689/// [`check_incremental_contract`] on a declared incremental decoder.
690pub fn check_incremental_capability_honesty<D: DecoderConfig>(dec: D, data: &[u8]) -> Conformance {
691    const CHECK: &str = "incremental_capability_honesty";
692    let declared = D::capabilities().incremental();
693    match (declared, dec.clone().job().dyn_incremental_decoder(&[])) {
694        (true, Ok(_)) => check_incremental_contract(dec, data),
695        (true, Err(e)) => Err(fail(
696            CHECK,
697            format!("declared incremental but dyn_incremental_decoder failed: {e}"),
698        )),
699        (false, Ok(_)) => Err(fail(
700            CHECK,
701            "did not declare incremental but dyn_incremental_decoder succeeded",
702        )),
703        (false, Err(e)) => match e.downcast_ref::<UnsupportedOperation>().copied() {
704            Some(UnsupportedOperation::IncrementalDecode) => Ok(()),
705            _ => Err(fail(
706                CHECK,
707                format!("undeclared incremental rejected with wrong error: {e}"),
708            )),
709        },
710    }
711}
712
713/// A sink that records every row write, for ordering checks.
714struct OrderSink {
715    inner: zencodec_io::CollectSink,
716    final_ys: Vec<(u32, u32)>,
717    began: u32,
718}
719
720impl zencodec::decode::DecodeRowSink for OrderSink {
721    fn begin(
722        &mut self,
723        w: u32,
724        h: u32,
725        d: PixelDescriptor,
726    ) -> Result<(), zencodec::decode::SinkError> {
727        self.began += 1;
728        self.inner.begin(w, h, d)
729    }
730    fn provide_next_buffer(
731        &mut self,
732        y: u32,
733        height: u32,
734        width: u32,
735        descriptor: PixelDescriptor,
736    ) -> Result<zenpixels::PixelSliceMut<'_>, zencodec::decode::SinkError> {
737        self.final_ys.push((y, height));
738        self.inner.provide_next_buffer(y, height, width, descriptor)
739    }
740    fn finish(&mut self) -> Result<(), zencodec::decode::SinkError> {
741        self.inner.finish()
742    }
743}
744
745/// A sink that accepts provisional rows and records every batch (tightly
746/// packed copies of the rows it was given), final or provisional.
747#[derive(Default)]
748struct PassSink {
749    batches: Vec<(zencodec::decode::BatchMeta, u32, Vec<u8>)>,
750    current: Option<(zencodec::decode::BatchMeta, zenpixels::PixelBuffer)>,
751    final_through_pass: u32,
752}
753
754impl PassSink {
755    fn ship(&mut self) {
756        if let Some((meta, buf)) = self.current.take() {
757            let s = buf.as_slice();
758            let mut packed = Vec::new();
759            for r in 0..s.rows() {
760                packed.extend_from_slice(s.row(r));
761            }
762            self.batches.push((meta, s.rows(), packed));
763        }
764    }
765}
766
767impl zencodec::decode::DecodeRowSink for PassSink {
768    fn provide_next_buffer(
769        &mut self,
770        y: u32,
771        height: u32,
772        width: u32,
773        descriptor: PixelDescriptor,
774    ) -> Result<zenpixels::PixelSliceMut<'_>, zencodec::decode::SinkError> {
775        self.ship();
776        let (_, buf) = self.current.insert((
777            zencodec::decode::BatchMeta::final_rows(y),
778            zenpixels::PixelBuffer::new(width, height, descriptor),
779        ));
780        Ok(buf.as_slice_mut())
781    }
782    fn accepts_provisional(&self) -> bool {
783        true
784    }
785    fn provide_pass_buffer(
786        &mut self,
787        meta: zencodec::decode::BatchMeta,
788        height: u32,
789        width: u32,
790        descriptor: PixelDescriptor,
791    ) -> Result<zenpixels::PixelSliceMut<'_>, zencodec::decode::SinkError> {
792        if meta.is_final {
793            self.final_through_pass += 1;
794        }
795        self.ship();
796        let (_, buf) = self
797            .current
798            .insert((meta, zenpixels::PixelBuffer::new(width, height, descriptor)));
799        Ok(buf.as_slice_mut())
800    }
801    fn finish(&mut self) -> Result<(), zencodec::decode::SinkError> {
802        self.ship();
803        Ok(())
804    }
805}
806
807/// A stop that trips after `n` checks.
808struct TripAfter {
809    left: std::sync::atomic::AtomicU32,
810}
811
812impl enough::Stop for TripAfter {
813    fn check(&self) -> Result<(), enough::StopReason> {
814        use std::sync::atomic::Ordering;
815        // Single-threaded use: a load and a store are enough.
816        let left = self.left.load(Ordering::SeqCst);
817        if left == 0 {
818            return Err(enough::StopReason::Cancelled);
819        }
820        self.left.store(left - 1, Ordering::SeqCst);
821        Ok(())
822    }
823}
824
825/// Exercise a decoder's [`IncrementalDecode`](zencodec::decode::IncrementalDecode)
826/// contract through the erased decoder callers receive
827/// (`dyn_incremental_decoder`), against the one-shot pixels of `data`:
828///
829/// - driven from a slice with windows of 1, 7 and 64 bytes and the whole
830///   file, the pixels, descriptor and `Done` dimensions match one-shot, the
831///   sink is begun once and finished, and rows reach a final-only sink each
832///   exactly once in increasing order;
833/// - an input at an offset the decoder did not request is ignored without
834///   error (nothing taken, still asking);
835/// - after `Done`, further steps return `Done` again and take nothing;
836/// - a stop that trips mid-decode yields `Cancelled` within one step, then
837///   `Finished`;
838/// - a sink that accepts provisional rows still gets every final row once
839///   through `provide_next_buffer` (never through `provide_pass_buffer`),
840///   reproducing the one-shot pixels;
841/// - truncated input (cut at a quarter and at half) behaves as the one-shot
842///   decoder does on the same bytes: a codec error where it fails (never a
843///   protocol violation), the same pixels where it succeeds;
844/// - no drive exceeds a step budget, so a decoder that spins fails here
845///   instead of hanging.
846pub fn check_incremental_contract<D: DecoderConfig>(dec: D, data: &[u8]) -> Conformance {
847    check_incremental_decoder(
848        || {
849            dec.clone()
850                .job()
851                .dyn_incremental_decoder(&[])
852                .map_err(|e| e.to_string())
853        },
854        |bytes| {
855            dec.clone()
856                .job()
857                .decoder(std::borrow::Cow::Borrowed(bytes), &[])
858                .and_then(|d| d.decode())
859                .map_err(|e| e.to_string())
860        },
861        data,
862    )
863}
864
865/// [`check_incremental_contract`] for any decoder factory, against
866/// `one_shot`, the decoder's one-shot decode of any bytes: for incremental
867/// decoders not reached through a codec's `DecoderConfig`, such as one
868/// built with `zencodec::helpers::WholeFileIncremental`.
869pub fn check_incremental_decoder<F, O>(new_decoder: F, one_shot: O, data: &[u8]) -> Conformance
870where
871    F: Fn() -> Result<Box<zencodec::decode::DynIncrementalDecoder>, String>,
872    O: Fn(&[u8]) -> Result<zencodec::decode::DecodeOutput, String>,
873{
874    use zencodec::decode::{Event, IncrementalDecode, Input, StepError};
875    const CHECK: &str = "incremental_contract";
876    let new_dec = || new_decoder().map_err(|e| fail(CHECK, format!("new decoder: {e}")));
877    let reference = one_shot(data).map_err(|e| fail(CHECK, format!("one-shot decode: {e}")))?;
878    let (w, h, desc, bytes) = pixels_of(&reference);
879    let budget = 4 * data.len() as u64 + 4 * h as u64 + 1000;
880
881    for window in [1usize, 7, 64, data.len().max(1)] {
882        let mut inc = new_dec()?;
883        let mut sink = OrderSink {
884            inner: zencodec_io::CollectSink::new(),
885            final_ys: Vec::new(),
886            began: 0,
887        };
888        let (info, stats) = zencodec_io::drive_from_slice(&mut *inc, data, &mut sink, None, window)
889            .map_err(|e| fail(CHECK, format!("window {window}: {e}")))?;
890        if stats.steps > budget {
891            return Err(fail(
892                CHECK,
893                format!(
894                    "window {window}: {} steps for {} bytes",
895                    stats.steps,
896                    data.len()
897                ),
898            ));
899        }
900        if (info.width, info.height) != (w, h) {
901            return Err(fail(
902                CHECK,
903                format!(
904                    "window {window}: Done reports {}x{}, one-shot is {w}x{h}",
905                    info.width, info.height
906                ),
907            ));
908        }
909        if sink.inner.descriptor() != Some(desc) {
910            return Err(fail(
911                CHECK,
912                format!(
913                    "window {window}: sink began with {:?}, one-shot is {desc:?}",
914                    sink.inner.descriptor()
915                ),
916            ));
917        }
918        if sink.began != 1 || !sink.inner.finished() {
919            return Err(fail(
920                CHECK,
921                format!(
922                    "window {window}: begin called {} times, finish {}",
923                    sink.began,
924                    sink.inner.finished()
925                ),
926            ));
927        }
928        let mut next = 0u32;
929        for &(y, rows) in &sink.final_ys {
930            if y != next {
931                return Err(fail(
932                    CHECK,
933                    format!(
934                        "window {window}: final rows out of order (got y={y}, expected {next})"
935                    ),
936                ));
937            }
938            next = y + rows;
939        }
940        if next != h {
941            return Err(fail(
942                CHECK,
943                format!("window {window}: {next} of {h} rows written"),
944            ));
945        }
946        if sink.inner.bytes() != bytes {
947            return Err(fail(
948                CHECK,
949                format!("window {window}: pixels differ from one-shot"),
950            ));
951        }
952        if inc.info().map(|i| (i.width, i.height)).is_none() {
953            return Err(fail(
954                CHECK,
955                format!("window {window}: info() is None after Done"),
956            ));
957        }
958        // Done repeats and takes nothing.
959        let again = inc
960            .step(
961                Input::new(0, data),
962                &mut sink,
963                Some(&zencodec::how_far::NoPulse),
964            )
965            .map_err(|e| fail(CHECK, format!("step after Done: {e}")))?;
966        if !again.event.is_done() || again.consumed != 0 {
967            return Err(fail(CHECK, format!("step after Done returned {again:?}")));
968        }
969    }
970
971    // Unrequested input is ignored, not an error.
972    {
973        let mut inc = new_dec()?;
974        let mut sink = zencodec_io::CollectSink::new();
975        let p = inc
976            .step(Input::empty(), &mut sink, Some(&zencodec::how_far::NoPulse))
977            .map_err(|e| fail(CHECK, format!("first step: {e}")))?;
978        if p.event == Event::Need {
979            let wanted: Vec<zencodec::decode::InputRequest> = inc.requested().to_vec();
980            let first = wanted.first().and_then(|r| r.offset()).unwrap_or(0);
981            let bogus = first.saturating_add(1).min(data.len() as u64);
982            let tail = &data[bogus as usize..];
983            let p = inc
984                .step(
985                    Input::new(bogus, tail),
986                    &mut sink,
987                    Some(&zencodec::how_far::NoPulse),
988                )
989                .map_err(|e| fail(CHECK, format!("unrequested input at {bogus} failed: {e}")))?;
990            if p.consumed != 0 || p.event != Event::Need {
991                return Err(fail(
992                    CHECK,
993                    format!("unrequested input at {bogus} was used: {p:?}"),
994                ));
995            }
996        }
997    }
998
999    // Cancellation is terminal.
1000    {
1001        let mut inc = new_dec()?;
1002        let mut sink = zencodec_io::CollectSink::new();
1003        let stop = TripAfter {
1004            left: std::sync::atomic::AtomicU32::new(2),
1005        };
1006        let mut input = Input::empty().with_total_len(data.len() as u64);
1007        let mut saw_cancel = false;
1008        for step in 0..budget {
1009            match inc.step(
1010                input,
1011                &mut sink,
1012                Some(&zencodec::how_far::StopOnly::borrowed(&stop)),
1013            ) {
1014                Ok(p) => {
1015                    if p.event.is_done() {
1016                        break;
1017                    }
1018                    // The stop allows two checks; checked at least once per
1019                    // step, the third step cannot succeed.
1020                    if step >= 2 {
1021                        return Err(fail(
1022                            CHECK,
1023                            format!("step {step} succeeded after the stop tripped: {p:?}"),
1024                        ));
1025                    }
1026                    let next = match inc.requested().first() {
1027                        Some(r) if p.event == Event::Need => r.offset().unwrap_or(0),
1028                        _ => input.offset + p.consumed as u64,
1029                    };
1030                    let start = (next as usize).min(data.len());
1031                    input =
1032                        Input::new(start as u64, &data[start..]).with_total_len(data.len() as u64);
1033                }
1034                Err(StepError::Cancelled(_)) => {
1035                    saw_cancel = true;
1036                    break;
1037                }
1038                Err(e) => return Err(fail(CHECK, format!("cancelling: unexpected {e}"))),
1039            }
1040        }
1041        if saw_cancel {
1042            match inc.step(input, &mut sink, Some(&zencodec::how_far::NoPulse)) {
1043                Err(StepError::Finished) => {}
1044                other => {
1045                    return Err(fail(
1046                        CHECK,
1047                        format!("step after cancellation returned {other:?}, not Finished"),
1048                    ));
1049                }
1050            }
1051        }
1052    }
1053
1054    // A sink that accepts provisional rows still receives final rows through
1055    // provide_next_buffer; they cover the image once with the one-shot
1056    // pixels.
1057    {
1058        let mut inc = new_dec()?;
1059        let mut sink = PassSink::default();
1060        zencodec_io::drive_from_slice(&mut *inc, data, &mut sink, None, 64)
1061            .map_err(|e| fail(CHECK, format!("provisional sink: {e}")))?;
1062        if sink.final_through_pass != 0 {
1063            return Err(fail(
1064                CHECK,
1065                format!(
1066                    "provisional sink: {} final batches came through provide_pass_buffer",
1067                    sink.final_through_pass
1068                ),
1069            ));
1070        }
1071        let row = bytes.len() / h.max(1) as usize;
1072        let mut covered = vec![false; h as usize];
1073        let mut out = vec![0u8; bytes.len()];
1074        for (meta, rows, px) in &sink.batches {
1075            if !meta.is_final {
1076                continue;
1077            }
1078            for r in 0..*rows {
1079                let y = (meta.y + r) as usize;
1080                if y >= covered.len() || covered[y] {
1081                    return Err(fail(
1082                        CHECK,
1083                        format!("provisional sink: final row {y} written twice or out of range"),
1084                    ));
1085                }
1086                covered[y] = true;
1087                out[y * row..(y + 1) * row]
1088                    .copy_from_slice(&px[r as usize * row..(r as usize + 1) * row]);
1089            }
1090        }
1091        if covered.iter().any(|c| !c) || out != bytes {
1092            return Err(fail(
1093                CHECK,
1094                "provisional sink: final batches do not reproduce the one-shot pixels",
1095            ));
1096        }
1097    }
1098
1099    // Truncated input behaves as the one-shot decoder does on the same
1100    // bytes: a codec error where one-shot fails (never a protocol violation
1101    // or a hang), the same pixels where one-shot succeeds (the first frame
1102    // of an animation whose later frames are cut, a lenient RLE body).
1103    for cut in [data.len() / 4, data.len() / 2] {
1104        if cut >= data.len() {
1105            continue;
1106        }
1107        let part = &data[..cut];
1108        let mut inc = new_dec()?;
1109        let mut sink = zencodec_io::CollectSink::new();
1110        let got = zencodec_io::drive_from_slice(&mut *inc, part, &mut sink, None, 64);
1111        match (got, one_shot(part)) {
1112            (Err(zencodec_io::DriveError::Step(StepError::Codec(_))), Err(_)) => {}
1113            (Ok(_), Ok(want)) => {
1114                let (_, _, want_desc, want_bytes) = pixels_of(&want);
1115                if sink.descriptor() != Some(want_desc) || sink.bytes() != want_bytes {
1116                    return Err(fail(
1117                        CHECK,
1118                        format!(
1119                            "truncated to {cut}: pixels differ from one-shot on the same bytes"
1120                        ),
1121                    ));
1122                }
1123            }
1124            (Ok(_), Err(e)) => {
1125                return Err(fail(
1126                    CHECK,
1127                    format!(
1128                        "truncated to {cut} of {} bytes decoded, but one-shot fails: {e}",
1129                        data.len()
1130                    ),
1131                ));
1132            }
1133            (Err(e), Ok(_)) => {
1134                return Err(fail(
1135                    CHECK,
1136                    format!("truncated to {cut}: one-shot decodes, incremental fails: {e}"),
1137                ));
1138            }
1139            (Err(e), Err(_)) => {
1140                return Err(fail(
1141                    CHECK,
1142                    format!("truncated to {cut}: expected a codec error, got: {e}"),
1143                ));
1144            }
1145        }
1146    }
1147    Ok(())
1148}