Skip to main content

cranpose_render_common/
render_contract.rs

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