Skip to main content

cranpose_render_common/
render_contract.rs

1use cranpose_ui_graphics::{
2    Brush, Color, CornerRadii, DrawPrimitive, DrawScope, DrawScopeDefault, DrawStyle, Path, Point,
3    Rect, Size, Stroke, Trapezoid,
4};
5
6use crate::{
7    graph::{
8        DrawPrimitiveNode, LayerNode, PrimitiveEntry, PrimitiveNode, PrimitivePhase,
9        ProjectiveTransform, RenderGraph, RenderNode,
10    },
11    image_compare::{
12        image_difference_stats, normalize_rgba_region, pixel_difference, sample_pixel,
13    },
14};
15
16mod draw_groups;
17mod draw_text_brush;
18mod text_fixtures;
19
20use text_fixtures::{
21    assert_clipped_text_frame, assert_multiline_text_frame, assert_translated_plain_text_frames,
22    assert_translated_text_decorations_frames, clipped_text_fixture, multiline_text_fixture,
23    translated_plain_text_fixture, translated_text_decorations_fixture,
24};
25
26const BACKGROUND_COLOR: Color = Color(18.0 / 255.0, 18.0 / 255.0, 24.0 / 255.0, 1.0);
27const FOREGROUND_COLOR: Color = Color::WHITE;
28const PIXEL_DIFFERENCE_TOLERANCE: u32 = 24;
29
30const TRANSLATED_SUBTREE_BUDGET: NormalizedDifferenceBudget = NormalizedDifferenceBudget {
31    max_differing_pixels: 270,
32    max_pixel_difference: 360,
33};
34
35/// A scene that every backend renders for a shared case, with the frame size it renders at.
36#[derive(Clone)]
37pub struct RenderFixture {
38    /// Frame width in pixels.
39    pub width: u32,
40    /// Frame height in pixels.
41    pub height: u32,
42    /// The scene to render.
43    pub graph: RenderGraph,
44    /// Region of the frame, in pixels, to resample to a fixed size before frames are compared, or
45    /// `None` when frames are compared at their rendered size.
46    pub normalized_rect: Option<Rect>,
47}
48
49/// A frame that a backend rendered for a `RenderFixture`.
50#[derive(Clone, Debug)]
51pub struct RenderedFrame {
52    /// Frame width in pixels.
53    pub width: u32,
54    /// Frame height in pixels.
55    pub height: u32,
56    /// RGBA8 pixels, row by row, four bytes per pixel.
57    pub pixels: Vec<u8>,
58    /// The fixture's `normalized_rect`, carried over to the frame.
59    pub normalized_rect: Option<Rect>,
60}
61
62#[derive(Clone, Copy)]
63struct NormalizedDifferenceBudget {
64    max_differing_pixels: u32,
65    max_pixel_difference: u32,
66}
67
68/// A rendering case that every backend must draw alike.
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub enum SharedRenderCase {
71    /// A filled rounded rectangle.
72    RoundedRect,
73    /// A rectangle cut by a primitive clip.
74    PrimitiveClip,
75    /// A subtree drawn at fractional translations.
76    TranslatedSubtree,
77    /// Plain text drawn at fractional translations.
78    TranslatedPlainText,
79    /// Text with decorations drawn at fractional translations.
80    TranslatedTextDecorations,
81    /// Text wrapped over several lines.
82    MultilineText,
83    /// Text cut by a clip.
84    ClippedText,
85    /// A stroked rounded rectangle.
86    StrokedRoundRect,
87    /// A ring segment drawn as an arc band.
88    AnnularSector,
89    /// A filled path.
90    FilledPath,
91    /// Draws a `DrawScope` transform or clip places: scaled, turned, cut to
92    /// a rect and to a path, moved, inset, text scaled, and a grouped
93    /// overlay over the overlay before it.
94    DrawScopeGroups,
95    /// Draws in groups of a node whose graphics layer modulates alpha and
96    /// filters colour, painted as the node's plain draws are.
97    PaintedDrawGroups,
98    /// `DrawScope` text filled with a gradient brush across its extent.
99    GradientDrawText,
100}
101
102/// Every `SharedRenderCase`, for running the whole contract.
103pub const ALL_SHARED_RENDER_CASES: [SharedRenderCase; 13] = [
104    SharedRenderCase::RoundedRect,
105    SharedRenderCase::PrimitiveClip,
106    SharedRenderCase::TranslatedSubtree,
107    SharedRenderCase::TranslatedPlainText,
108    SharedRenderCase::TranslatedTextDecorations,
109    SharedRenderCase::MultilineText,
110    SharedRenderCase::ClippedText,
111    SharedRenderCase::StrokedRoundRect,
112    SharedRenderCase::AnnularSector,
113    SharedRenderCase::FilledPath,
114    SharedRenderCase::DrawScopeGroups,
115    SharedRenderCase::PaintedDrawGroups,
116    SharedRenderCase::GradientDrawText,
117];
118
119impl SharedRenderCase {
120    /// Short snake_case name of the case, for test names and reports.
121    pub fn name(self) -> &'static str {
122        match self {
123            Self::RoundedRect => "rounded_rect",
124            Self::PrimitiveClip => "primitive_clip",
125            Self::TranslatedSubtree => "translated_subtree",
126            Self::TranslatedPlainText => "translated_plain_text",
127            Self::TranslatedTextDecorations => "translated_text_decorations",
128            Self::MultilineText => "multiline_text",
129            Self::ClippedText => "clipped_text",
130            Self::StrokedRoundRect => "stroked_round_rect",
131            Self::AnnularSector => "annular_sector",
132            Self::FilledPath => "filled_path",
133            Self::DrawScopeGroups => "draw_scope_groups",
134            Self::PaintedDrawGroups => "painted_draw_groups",
135            Self::GradientDrawText => "gradient_draw_text",
136        }
137    }
138
139    /// The fixtures to render for this case. A translated case returns two fixtures that differ in
140    /// their fractional offsets.
141    pub fn fixtures(self) -> Vec<RenderFixture> {
142        // Draw scope text records as an app lays it out, in the default
143        // font.
144        cranpose_ui::AppContext::new().enter(|| {
145            cranpose_ui::text::set_text_fonts(
146                crate::text_fonts::TextFonts::from_fonts_or_default(&[]).resolver(),
147            );
148            self.fixtures_in_app()
149        })
150    }
151
152    fn fixtures_in_app(self) -> Vec<RenderFixture> {
153        match self {
154            Self::RoundedRect => vec![rounded_rect_fixture()],
155            Self::PrimitiveClip => vec![primitive_clip_fixture()],
156            Self::TranslatedSubtree => vec![
157                translated_subtree_fixture(12.3, 14.7),
158                translated_subtree_fixture(32.6, 26.2),
159            ],
160            Self::TranslatedPlainText => vec![
161                translated_plain_text_fixture(14.3, 18.6),
162                translated_plain_text_fixture(36.4, 30.1),
163            ],
164            Self::TranslatedTextDecorations => vec![
165                translated_text_decorations_fixture(14.3, 18.6),
166                translated_text_decorations_fixture(36.4, 30.1),
167            ],
168            Self::MultilineText => vec![multiline_text_fixture()],
169            Self::ClippedText => vec![clipped_text_fixture()],
170            Self::StrokedRoundRect => vec![stroked_round_rect_fixture()],
171            Self::AnnularSector => vec![annular_sector_fixture()],
172            Self::FilledPath => vec![filled_path_fixture()],
173            Self::DrawScopeGroups => vec![draw_groups::draw_groups_fixture()],
174            Self::PaintedDrawGroups => vec![draw_groups::painted_groups_fixture()],
175            Self::GradientDrawText => vec![draw_text_brush::gradient_text_fixture()],
176        }
177    }
178
179    /// Checks the frames that were rendered from `fixtures`, in the same order. Panics when the
180    /// frame count is wrong or a frame does not match what the case expects.
181    pub fn assert_frames(self, frames: &[RenderedFrame]) {
182        match self {
183            Self::RoundedRect => {
184                let [frame] = frames else {
185                    panic!("rounded_rect expects exactly one rendered frame");
186                };
187                assert_rounded_rect_frame(&frame.pixels, frame.width, frame.height);
188            }
189            Self::PrimitiveClip => {
190                let [frame] = frames else {
191                    panic!("primitive_clip expects exactly one rendered frame");
192                };
193                assert_primitive_clip_frame(&frame.pixels, frame.width, frame.height);
194            }
195            Self::TranslatedSubtree => {
196                assert_translated_subtree_frames(frames);
197            }
198            Self::TranslatedPlainText => {
199                assert_translated_plain_text_frames(frames);
200            }
201            Self::TranslatedTextDecorations => {
202                assert_translated_text_decorations_frames(frames);
203            }
204            Self::MultilineText => {
205                let [frame] = frames else {
206                    panic!("multiline_text expects exactly one rendered frame");
207                };
208                assert_multiline_text_frame(&frame.pixels, frame.width, frame.height);
209            }
210            Self::StrokedRoundRect => {
211                let [frame] = frames else {
212                    panic!("stroked_round_rect expects exactly one rendered frame");
213                };
214                assert_stroked_round_rect_frame(&frame.pixels, frame.width, frame.height);
215            }
216            Self::AnnularSector => {
217                let [frame] = frames else {
218                    panic!("annular_sector expects exactly one rendered frame");
219                };
220                assert_annular_sector_frame(&frame.pixels, frame.width, frame.height);
221            }
222            Self::ClippedText => {
223                let [frame] = frames else {
224                    panic!("clipped_text expects exactly one rendered frame");
225                };
226                assert_clipped_text_frame(&frame.pixels, frame.width, frame.height);
227            }
228            Self::FilledPath => {
229                let [frame] = frames else {
230                    panic!("filled_path expects exactly one rendered frame");
231                };
232                assert_filled_path_frame(&frame.pixels, frame.width, frame.height);
233            }
234            Self::DrawScopeGroups => {
235                let [frame] = frames else {
236                    panic!("draw_scope_groups expects exactly one rendered frame");
237                };
238                draw_groups::assert_draw_groups_frame(&frame.pixels, frame.width, frame.height);
239            }
240            Self::PaintedDrawGroups => {
241                let [frame] = frames else {
242                    panic!("painted_draw_groups expects exactly one rendered frame");
243                };
244                draw_groups::assert_painted_groups_frame(&frame.pixels, frame.width, frame.height);
245            }
246            Self::GradientDrawText => {
247                let [frame] = frames else {
248                    panic!("gradient_draw_text expects exactly one rendered frame");
249                };
250                draw_text_brush::assert_gradient_text_frame(
251                    &frame.pixels,
252                    frame.width,
253                    frame.height,
254                );
255            }
256        }
257    }
258}
259
260fn rounded_rect_fixture() -> RenderFixture {
261    build_fixture(
262        72,
263        72,
264        vec![draw_node(
265            DrawPrimitive::RoundRect {
266                rect: Rect {
267                    x: 12.0,
268                    y: 12.0,
269                    width: 48.0,
270                    height: 48.0,
271                },
272                brush: Brush::solid(FOREGROUND_COLOR),
273                radii: CornerRadii::uniform(18.0),
274                stroke: None,
275            },
276            None,
277        )],
278    )
279}
280
281fn primitive_clip_fixture() -> RenderFixture {
282    build_fixture(
283        52,
284        44,
285        vec![draw_node(
286            DrawPrimitive::Rect {
287                rect: Rect {
288                    x: 8.0,
289                    y: 10.0,
290                    width: 28.0,
291                    height: 18.0,
292                },
293                brush: Brush::solid(FOREGROUND_COLOR),
294                stroke: None,
295            },
296            Some(Rect {
297                x: 14.0,
298                y: 15.0,
299                width: 10.0,
300                height: 6.0,
301            }),
302        )],
303    )
304}
305
306fn translated_subtree_fixture(translation_x: f32, translation_y: f32) -> RenderFixture {
307    let subtree_bounds = Rect {
308        x: 0.0,
309        y: 0.0,
310        width: 48.0,
311        height: 36.0,
312    };
313
314    build_translated_fixture(
315        96,
316        84,
317        subtree_bounds,
318        Point::new(translation_x, translation_y),
319        vec![
320            draw_node(
321                DrawPrimitive::RoundRect {
322                    rect: Rect {
323                        x: 4.0,
324                        y: 4.0,
325                        width: 40.0,
326                        height: 28.0,
327                    },
328                    brush: Brush::solid(FOREGROUND_COLOR),
329                    radii: CornerRadii::uniform(10.0),
330                    stroke: None,
331                },
332                None,
333            ),
334            draw_node(
335                DrawPrimitive::Rect {
336                    rect: Rect {
337                        x: 10.0,
338                        y: 18.0,
339                        width: 18.0,
340                        height: 10.0,
341                    },
342                    brush: Brush::solid(Color(0.2, 0.8, 1.0, 1.0)),
343                    stroke: None,
344                },
345                Some(Rect {
346                    x: 12.0,
347                    y: 20.0,
348                    width: 10.0,
349                    height: 4.0,
350                }),
351            ),
352        ],
353    )
354}
355
356fn build_fixture(width: u32, height: u32, children: Vec<RenderNode>) -> RenderFixture {
357    let bounds = Rect {
358        x: 0.0,
359        y: 0.0,
360        width: width as f32,
361        height: height as f32,
362    };
363
364    RenderFixture {
365        width,
366        height,
367        graph: RenderGraph::new(graph_layer(
368            bounds,
369            ProjectiveTransform::identity(),
370            with_background(bounds, children),
371        )),
372        normalized_rect: None,
373    }
374}
375
376fn build_translated_fixture(
377    width: u32,
378    height: u32,
379    subtree_bounds: Rect,
380    translation: Point,
381    subtree_children: Vec<RenderNode>,
382) -> RenderFixture {
383    build_translated_fixture_with_context(
384        width,
385        height,
386        subtree_bounds,
387        translation,
388        false,
389        subtree_children,
390    )
391}
392
393fn build_translated_fixture_with_context(
394    width: u32,
395    height: u32,
396    subtree_bounds: Rect,
397    translation: Point,
398    translated_content_context: bool,
399    subtree_children: Vec<RenderNode>,
400) -> RenderFixture {
401    let bounds = Rect {
402        x: 0.0,
403        y: 0.0,
404        width: width as f32,
405        height: height as f32,
406    };
407    let subtree = graph_layer(
408        subtree_bounds,
409        ProjectiveTransform::translation(translation.x, translation.y),
410        subtree_children,
411    );
412    let mut subtree = subtree;
413    subtree.translated_content_context = translated_content_context;
414
415    RenderFixture {
416        width,
417        height,
418        graph: RenderGraph::new(graph_layer(
419            bounds,
420            ProjectiveTransform::identity(),
421            with_background(bounds, vec![RenderNode::Layer(Box::new(subtree))]),
422        )),
423        normalized_rect: Some(Rect {
424            x: translation.x,
425            y: translation.y,
426            width: subtree_bounds.width,
427            height: subtree_bounds.height,
428        }),
429    }
430}
431
432fn graph_layer(
433    local_bounds: Rect,
434    transform_to_parent: ProjectiveTransform,
435    children: Vec<RenderNode>,
436) -> LayerNode {
437    LayerNode {
438        local_bounds,
439        transform_to_parent,
440        children,
441        ..Default::default()
442    }
443}
444
445fn with_background(bounds: Rect, mut children: Vec<RenderNode>) -> Vec<RenderNode> {
446    children.insert(
447        0,
448        draw_node(
449            DrawPrimitive::Rect {
450                rect: bounds,
451                brush: Brush::solid(BACKGROUND_COLOR),
452                stroke: None,
453            },
454            None,
455        ),
456    );
457    children
458}
459
460fn draw_node(primitive: DrawPrimitive, clip: Option<Rect>) -> RenderNode {
461    RenderNode::Primitive(PrimitiveEntry {
462        phase: PrimitivePhase::BeforeChildren,
463        node: PrimitiveNode::Draw(Box::new(DrawPrimitiveNode { primitive, clip })),
464    })
465}
466
467fn stroked_round_rect_fixture() -> RenderFixture {
468    build_fixture(
469        72,
470        72,
471        vec![draw_node(
472            DrawPrimitive::RoundRect {
473                rect: Rect {
474                    x: 16.0,
475                    y: 16.0,
476                    width: 40.0,
477                    height: 40.0,
478                },
479                brush: Brush::solid(FOREGROUND_COLOR),
480                radii: CornerRadii::uniform(10.0),
481                stroke: Some(Stroke::new(6.0)),
482            },
483            None,
484        )],
485    )
486}
487
488fn annular_sector_fixture() -> RenderFixture {
489    build_fixture(
490        72,
491        72,
492        vec![draw_node(
493            DrawPrimitive::Arc {
494                rect: Rect {
495                    x: 36.0,
496                    y: 36.0,
497                    width: 24.0,
498                    height: 24.0,
499                },
500                brush: Brush::solid(FOREGROUND_COLOR),
501                center: Point::new(36.0, 36.0),
502                radius: 24.0,
503                start_angle: 0.0,
504                sweep_angle: std::f32::consts::FRAC_PI_2,
505                stroke: None,
506                inner_radius: 12.0,
507            },
508            None,
509        )],
510    )
511}
512
513/// A sparkline a draw scope fills: slices whose sides meet, with steep
514/// edges that stand above the columns beside them.
515fn filled_path_primitives() -> Vec<DrawPrimitive> {
516    let mut path = Path::new();
517    path.move_to(Point::new(6.0, 66.0));
518    for (index, y) in [40.0, 8.0, 52.0, 20.5, 61.0, 14.25].into_iter().enumerate() {
519        path.line_to(Point::new(6.0 + index as f32 * 12.0, y));
520    }
521    path.line_to(Point::new(66.0, 66.0));
522    path.close();
523    let mut recording = cranpose_ui_graphics::CommandRecording::default();
524    let mut scope = DrawScopeDefault::new(Size::new(72.0, 72.0), &mut recording);
525    scope.draw_path(&path, Brush::solid(FOREGROUND_COLOR), DrawStyle::Fill);
526    scope.into_primitives()
527}
528
529fn filled_path_fixture() -> RenderFixture {
530    build_fixture(
531        72,
532        72,
533        filled_path_primitives()
534            .into_iter()
535            .map(|primitive| draw_node(primitive, None))
536            .collect(),
537    )
538}
539
540/// Each pixel takes the share of the foreground the fill's slices give it.
541fn assert_filled_path_frame(pixels: &[u8], width: u32, height: u32) {
542    assert_eq!((width, height), (72, 72));
543    let slices: Vec<Trapezoid> = filled_path_primitives()
544        .iter()
545        .filter_map(|primitive| match primitive {
546            DrawPrimitive::Trapezoid { trapezoid, .. } => Some(*trapezoid),
547            _ => None,
548        })
549        .collect();
550    assert_eq!(slices.len(), 5, "the fill slices between its points");
551    let background = f32::from(sample_pixel(pixels, width, 2, 2)[0]);
552    let foreground = FOREGROUND_COLOR.r() * 255.0;
553    for y in 0..height {
554        for x in 0..width {
555            let centre = Point::new(x as f32 + 0.5, y as f32 + 0.5);
556            let expected = slices.iter().fold(0.0f32, |value, slice| {
557                value + slice.coverage(centre) * (1.0 - value)
558            });
559            let actual = (f32::from(sample_pixel(pixels, width, x, y)[0]) - background)
560                / (foreground - background);
561            assert!(
562                (actual - expected).abs() <= 0.05,
563                "filled path pixel ({x}, {y}) carries {actual:.3} of the foreground, \
564                 its slices give it {expected:.3}"
565            );
566        }
567    }
568}
569
570fn assert_stroked_round_rect_frame(pixels: &[u8], width: u32, height: u32) {
571    assert_eq!((width, height), (72, 72));
572    let background = sample_pixel(pixels, width, 2, 2);
573
574    assert_pixel_matches_background(
575        pixels,
576        width,
577        background,
578        36,
579        16,
580        false,
581        "stroked round rect top edge should contain stroke ink",
582    );
583    assert_pixel_matches_background(
584        pixels,
585        width,
586        background,
587        16,
588        36,
589        false,
590        "stroked round rect left edge should contain stroke ink",
591    );
592    assert_pixel_matches_background(
593        pixels,
594        width,
595        background,
596        36,
597        36,
598        true,
599        "stroked round rect interior must stay empty — a backend that filled \
600         the shape instead of stroking it would fail here",
601    );
602    assert_pixel_matches_background(
603        pixels,
604        width,
605        background,
606        4,
607        36,
608        true,
609        "outside the stroked round rect should stay background-colored",
610    );
611}
612
613fn assert_annular_sector_frame(pixels: &[u8], width: u32, height: u32) {
614    assert_eq!((width, height), (72, 72));
615    let background = sample_pixel(pixels, width, 2, 2);
616
617    assert_pixel_matches_background(
618        pixels,
619        width,
620        background,
621        54,
622        38,
623        false,
624        "annular sector band near 0 degrees should contain fill",
625    );
626    assert_pixel_matches_background(
627        pixels,
628        width,
629        background,
630        38,
631        54,
632        false,
633        "annular sector band near 90 degrees should contain fill",
634    );
635    assert_pixel_matches_background(
636        pixels,
637        width,
638        background,
639        36,
640        36,
641        true,
642        "the annulus hole must stay empty",
643    );
644    assert_pixel_matches_background(
645        pixels,
646        width,
647        background,
648        54,
649        30,
650        true,
651        "past the flat radial edge must stay empty — a rounded cap here would \
652         mean the sector is being drawn as a stroked arc",
653    );
654    assert_pixel_matches_background(
655        pixels,
656        width,
657        background,
658        66,
659        38,
660        true,
661        "beyond the outer radius must stay empty",
662    );
663}
664
665fn assert_rounded_rect_frame(pixels: &[u8], width: u32, height: u32) {
666    assert_eq!((width, height), (72, 72));
667    let background = sample_pixel(pixels, width, 2, 2);
668
669    assert_pixel_matches_background(
670        pixels,
671        width,
672        background,
673        14,
674        14,
675        true,
676        "rounded rect corner should stay background-colored",
677    );
678    assert_pixel_matches_background(
679        pixels,
680        width,
681        background,
682        30,
683        16,
684        false,
685        "rounded rect top edge should contain fill",
686    );
687    assert_pixel_matches_background(
688        pixels,
689        width,
690        background,
691        36,
692        36,
693        false,
694        "rounded rect center should contain fill",
695    );
696}
697
698fn assert_primitive_clip_frame(pixels: &[u8], width: u32, height: u32) {
699    assert_eq!((width, height), (52, 44));
700    let background = sample_pixel(pixels, width, 2, 2);
701
702    assert_pixel_matches_background(
703        pixels,
704        width,
705        background,
706        18,
707        18,
708        false,
709        "pixel inside primitive clip should contain fill",
710    );
711    assert_pixel_matches_background(
712        pixels,
713        width,
714        background,
715        10,
716        12,
717        true,
718        "pixel inside source rect but outside clip should stay background-colored",
719    );
720    assert_pixel_matches_background(
721        pixels,
722        width,
723        background,
724        30,
725        20,
726        true,
727        "pixel on the far side of the source rect but outside clip should stay background-colored",
728    );
729}
730
731fn assert_translated_subtree_frames(frames: &[RenderedFrame]) {
732    let [base, moved] = frames else {
733        panic!("translated_subtree expects exactly two rendered frames");
734    };
735    assert_eq!((base.width, base.height), (96, 84));
736    assert_eq!((moved.width, moved.height), (96, 84));
737    assert_ne!(
738        base.pixels, moved.pixels,
739        "translated subtree contract should move within the full frame"
740    );
741    assert_normalized_region_matches(
742        base,
743        moved,
744        TRANSLATED_SUBTREE_BUDGET,
745        "translated subtree output should remain invariant under rigid parent translation",
746    );
747}
748
749fn normalize_frame_region(frame: &RenderedFrame) -> Vec<u8> {
750    let rect = normalized_rect(frame);
751    let (width, height) = normalized_output_dimensions(frame);
752    normalize_rgba_region(
753        &frame.pixels,
754        frame.width,
755        frame.height,
756        rect,
757        width,
758        height,
759    )
760}
761
762fn assert_normalized_region_matches(
763    base: &RenderedFrame,
764    moved: &RenderedFrame,
765    budget: NormalizedDifferenceBudget,
766    message: &str,
767) {
768    assert_eq!(
769        normalized_output_dimensions(base),
770        normalized_output_dimensions(moved),
771        "normalized comparison requires matching output sizes",
772    );
773    let (width, height) = normalized_output_dimensions(base);
774    let base_normalized = normalize_frame_region(base);
775    let moved_normalized = normalize_frame_region(moved);
776    let stats = image_difference_stats(
777        &base_normalized,
778        &moved_normalized,
779        width,
780        height,
781        PIXEL_DIFFERENCE_TOLERANCE,
782    );
783    if stats.differing_pixels > budget.max_differing_pixels
784        || stats.max_difference > budget.max_pixel_difference
785    {
786        let diff = stats
787            .first_difference
788            .as_ref()
789            .expect("failing normalized comparison should report first difference");
790        panic!(
791            "{message}; differing_pixels={} max_diff={} first differing normalized pixel at ({}, {}) base={:?} moved={:?} diff={}",
792            stats.differing_pixels,
793            stats.max_difference,
794            diff.x,
795            diff.y,
796            diff.lhs,
797            diff.rhs,
798            diff.difference
799        );
800    }
801}
802
803fn normalized_rect(frame: &RenderedFrame) -> Rect {
804    frame
805        .normalized_rect
806        .expect("normalized render frame missing normalized_rect")
807}
808
809fn normalized_output_dimensions(frame: &RenderedFrame) -> (u32, u32) {
810    let rect = normalized_rect(frame);
811    (
812        normalized_dimension(rect.width, "width"),
813        normalized_dimension(rect.height, "height"),
814    )
815}
816
817fn normalized_dimension(value: f32, axis: &str) -> u32 {
818    let rounded = value.round();
819    assert!(
820        (value - rounded).abs() <= 0.01,
821        "normalized {axis} must stay pixel-sized for stable comparison, got {value}",
822    );
823    assert!(
824        rounded > 0.0,
825        "normalized {axis} must be positive, got {value}"
826    );
827    rounded as u32
828}
829
830fn is_background_like(pixel: [u8; 4], background: [u8; 4]) -> bool {
831    pixel_difference(pixel, background) <= PIXEL_DIFFERENCE_TOLERANCE
832}
833
834fn assert_pixel_matches_background(
835    pixels: &[u8],
836    width: u32,
837    background: [u8; 4],
838    x: u32,
839    y: u32,
840    expect_background: bool,
841    message: &str,
842) {
843    let pixel = sample_pixel(pixels, width, x, y);
844    let background_like = is_background_like(pixel, background);
845    assert_eq!(
846        background_like, expect_background,
847        "{message}; pixel at ({x},{y}) was {pixel:?} against background {background:?}"
848    );
849}
850
851fn count_non_background_pixels(pixels: &[u8], width: u32, height: u32, background: [u8; 4]) -> u32 {
852    count_non_background_pixels_in_band(pixels, width, 0, height, background)
853}
854
855fn count_non_background_pixels_in_band(
856    pixels: &[u8],
857    width: u32,
858    y_start: u32,
859    y_end: u32,
860    background: [u8; 4],
861) -> u32 {
862    let mut count = 0;
863    for y in y_start..y_end {
864        for x in 0..width {
865            if !is_background_like(sample_pixel(pixels, width, x, y), background) {
866                count += 1;
867            }
868        }
869    }
870    count
871}
872
873fn ink_y_range(pixels: &[u8], width: u32, height: u32, background: [u8; 4]) -> Option<(u32, u32)> {
874    let mut top = None;
875    let mut bottom = 0u32;
876    for y in 0..height {
877        for x in 0..width {
878            if !is_background_like(sample_pixel(pixels, width, x, y), background) {
879                top.get_or_insert(y);
880                bottom = y + 1;
881                break;
882            }
883        }
884    }
885    top.map(|top_y| (top_y, bottom))
886}
887
888#[cfg(test)]
889#[path = "tests/render_contract_tests.rs"]
890mod tests;