Skip to main content

cranpose_render_common/
render_contract.rs

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