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