Skip to main content

cranpose_render_common/
render_contract.rs

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