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