Skip to main content

cranpose_render_common/
render_contract.rs

1use cranpose_core::NodeId;
2use cranpose_ui::{
3    TextLayoutOptions, TextStyle,
4    text::{Shadow, SpanStyle, TextDecoration},
5};
6use cranpose_ui_graphics::{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(Box::new(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: cranpose_ui::text::shared_plain_annotated_string(text),
542            render_text: cranpose_ui::text::shared_plain_render_string(text),
543            text_style: std::sync::Arc::new(text_style),
544            font_size: 14.0,
545            layout_options: TextLayoutOptions::default(),
546            clip,
547        })),
548    })
549}
550
551fn stroked_round_rect_fixture() -> RenderFixture {
552    build_fixture(
553        72,
554        72,
555        vec![draw_node(
556            DrawPrimitive::RoundRect {
557                rect: Rect {
558                    x: 16.0,
559                    y: 16.0,
560                    width: 40.0,
561                    height: 40.0,
562                },
563                brush: Brush::solid(FOREGROUND_COLOR),
564                radii: CornerRadii::uniform(10.0),
565                stroke: Some(Stroke::new(6.0)),
566            },
567            None,
568        )],
569    )
570}
571
572fn annular_sector_fixture() -> RenderFixture {
573    build_fixture(
574        72,
575        72,
576        vec![draw_node(
577            DrawPrimitive::Arc {
578                rect: Rect {
579                    x: 36.0,
580                    y: 36.0,
581                    width: 24.0,
582                    height: 24.0,
583                },
584                brush: Brush::solid(FOREGROUND_COLOR),
585                center: Point::new(36.0, 36.0),
586                radius: 24.0,
587                start_angle: 0.0,
588                sweep_angle: std::f32::consts::FRAC_PI_2,
589                stroke: None,
590                inner_radius: 12.0,
591            },
592            None,
593        )],
594    )
595}
596
597fn assert_stroked_round_rect_frame(pixels: &[u8], width: u32, height: u32) {
598    assert_eq!((width, height), (72, 72));
599    let background = sample_pixel(pixels, width, 2, 2);
600
601    assert_pixel_matches_background(
602        pixels,
603        width,
604        background,
605        36,
606        16,
607        false,
608        "stroked round rect top edge should contain stroke ink",
609    );
610    assert_pixel_matches_background(
611        pixels,
612        width,
613        background,
614        16,
615        36,
616        false,
617        "stroked round rect left edge should contain stroke ink",
618    );
619    assert_pixel_matches_background(
620        pixels,
621        width,
622        background,
623        36,
624        36,
625        true,
626        "stroked round rect interior must stay empty — a backend that filled \
627         the shape instead of stroking it would fail here",
628    );
629    assert_pixel_matches_background(
630        pixels,
631        width,
632        background,
633        4,
634        36,
635        true,
636        "outside the stroked round rect should stay background-colored",
637    );
638}
639
640fn assert_annular_sector_frame(pixels: &[u8], width: u32, height: u32) {
641    assert_eq!((width, height), (72, 72));
642    let background = sample_pixel(pixels, width, 2, 2);
643
644    assert_pixel_matches_background(
645        pixels,
646        width,
647        background,
648        54,
649        38,
650        false,
651        "annular sector band near 0 degrees should contain fill",
652    );
653    assert_pixel_matches_background(
654        pixels,
655        width,
656        background,
657        38,
658        54,
659        false,
660        "annular sector band near 90 degrees should contain fill",
661    );
662    assert_pixel_matches_background(
663        pixels,
664        width,
665        background,
666        36,
667        36,
668        true,
669        "the annulus hole must stay empty",
670    );
671    assert_pixel_matches_background(
672        pixels,
673        width,
674        background,
675        54,
676        30,
677        true,
678        "past the flat radial edge must stay empty — a rounded cap here would \
679         mean the sector is being drawn as a stroked arc",
680    );
681    assert_pixel_matches_background(
682        pixels,
683        width,
684        background,
685        66,
686        38,
687        true,
688        "beyond the outer radius must stay empty",
689    );
690}
691
692fn assert_rounded_rect_frame(pixels: &[u8], width: u32, height: u32) {
693    assert_eq!((width, height), (72, 72));
694    let background = sample_pixel(pixels, width, 2, 2);
695
696    assert_pixel_matches_background(
697        pixels,
698        width,
699        background,
700        14,
701        14,
702        true,
703        "rounded rect corner should stay background-colored",
704    );
705    assert_pixel_matches_background(
706        pixels,
707        width,
708        background,
709        30,
710        16,
711        false,
712        "rounded rect top edge should contain fill",
713    );
714    assert_pixel_matches_background(
715        pixels,
716        width,
717        background,
718        36,
719        36,
720        false,
721        "rounded rect center should contain fill",
722    );
723}
724
725fn assert_primitive_clip_frame(pixels: &[u8], width: u32, height: u32) {
726    assert_eq!((width, height), (52, 44));
727    let background = sample_pixel(pixels, width, 2, 2);
728
729    assert_pixel_matches_background(
730        pixels,
731        width,
732        background,
733        18,
734        18,
735        false,
736        "pixel inside primitive clip should contain fill",
737    );
738    assert_pixel_matches_background(
739        pixels,
740        width,
741        background,
742        10,
743        12,
744        true,
745        "pixel inside source rect but outside clip should stay background-colored",
746    );
747    assert_pixel_matches_background(
748        pixels,
749        width,
750        background,
751        30,
752        20,
753        true,
754        "pixel on the far side of the source rect but outside clip should stay background-colored",
755    );
756}
757
758fn assert_multiline_text_frame(pixels: &[u8], width: u32, height: u32) {
759    assert_eq!((width, height), (220, 100));
760    let background = sample_pixel(pixels, width, 2, 2);
761    let (ink_top, ink_bottom) = ink_y_range(pixels, width, height, background)
762        .expect("expected rendered text ink in multiline contract frame");
763    let ink_height = ink_bottom - ink_top;
764    assert!(
765        ink_height >= 18,
766        "expected two text lines of ink, observed span {ink_height}px (y={ink_top}..{ink_bottom})"
767    );
768    let mid_y = ink_top + ink_height / 2;
769    let first_line_ink =
770        count_non_background_pixels_in_band(pixels, width, ink_top, mid_y, background);
771    let second_line_ink =
772        count_non_background_pixels_in_band(pixels, width, mid_y, ink_bottom, background);
773    assert!(
774        first_line_ink > 20,
775        "expected first line ink in multiline contract frame, got {first_line_ink}"
776    );
777    assert!(
778        second_line_ink > 20,
779        "expected second line ink in multiline contract frame, got {second_line_ink}"
780    );
781}
782
783fn assert_translated_subtree_frames(frames: &[RenderedFrame]) {
784    let [base, moved] = frames else {
785        panic!("translated_subtree expects exactly two rendered frames");
786    };
787    assert_eq!((base.width, base.height), (96, 84));
788    assert_eq!((moved.width, moved.height), (96, 84));
789    assert_ne!(
790        base.pixels, moved.pixels,
791        "translated subtree contract should move within the full frame"
792    );
793    assert_normalized_region_matches(
794        base,
795        moved,
796        TRANSLATED_SUBTREE_BUDGET,
797        "translated subtree output should remain invariant under rigid parent translation",
798    );
799}
800
801fn assert_translated_plain_text_frames(frames: &[RenderedFrame]) {
802    let [base, moved] = frames else {
803        panic!("translated_plain_text expects exactly two rendered frames");
804    };
805    assert_eq!((base.width, base.height), (196, 112));
806    assert_eq!((moved.width, moved.height), (196, 112));
807    assert_ne!(
808        base.pixels, moved.pixels,
809        "translated plain text contract should move within the full frame"
810    );
811    assert_normalized_region_matches(
812        base,
813        moved,
814        TRANSLATED_PLAIN_TEXT_BUDGET,
815        "translated plain text should remain visually stable after normalization",
816    );
817}
818
819fn assert_translated_text_decorations_frames(frames: &[RenderedFrame]) {
820    let [base, moved] = frames else {
821        panic!("translated_text_decorations expects exactly two rendered frames");
822    };
823    assert_eq!((base.width, base.height), (180, 96));
824    assert_eq!((moved.width, moved.height), (180, 96));
825    assert_ne!(
826        base.pixels, moved.pixels,
827        "translated text contract should move within the full frame"
828    );
829    assert_normalized_region_matches(
830        base,
831        moved,
832        TRANSLATED_TEXT_DECORATIONS_BUDGET,
833        "normalized text/shadow/decoration output should remain invariant under rigid parent translation",
834    );
835
836    let background = sample_pixel(&base.pixels, base.width, 2, 2);
837    let base_crop = normalize_frame_region(base);
838    let (crop_width, crop_height) = normalized_output_dimensions(base);
839    let ink_pixels = count_non_background_pixels(&base_crop, crop_width, crop_height, background);
840    assert!(
841        ink_pixels > 120,
842        "translated text contract should contain visible ink, observed {ink_pixels} differing pixels"
843    );
844}
845
846fn assert_clipped_text_frame(pixels: &[u8], width: u32, height: u32) {
847    assert_eq!((width, height), (220, 100));
848    let background = sample_pixel(pixels, width, 2, 2);
849    let total_ink = count_non_background_pixels(pixels, width, height, background);
850    assert_eq!(
851        total_ink, 0,
852        "fully clipped text should not draw ink, but observed {total_ink} differing pixels"
853    );
854}
855
856fn normalize_frame_region(frame: &RenderedFrame) -> Vec<u8> {
857    let rect = normalized_rect(frame);
858    let (width, height) = normalized_output_dimensions(frame);
859    normalize_rgba_region(
860        &frame.pixels,
861        frame.width,
862        frame.height,
863        rect,
864        width,
865        height,
866    )
867}
868
869fn assert_normalized_region_matches(
870    base: &RenderedFrame,
871    moved: &RenderedFrame,
872    budget: NormalizedDifferenceBudget,
873    message: &str,
874) {
875    assert_eq!(
876        normalized_output_dimensions(base),
877        normalized_output_dimensions(moved),
878        "normalized comparison requires matching output sizes",
879    );
880    let (width, height) = normalized_output_dimensions(base);
881    let base_normalized = normalize_frame_region(base);
882    let moved_normalized = normalize_frame_region(moved);
883    let stats = image_difference_stats(
884        &base_normalized,
885        &moved_normalized,
886        width,
887        height,
888        PIXEL_DIFFERENCE_TOLERANCE,
889    );
890    if stats.differing_pixels > budget.max_differing_pixels
891        || stats.max_difference > budget.max_pixel_difference
892    {
893        let diff = stats
894            .first_difference
895            .as_ref()
896            .expect("failing normalized comparison should report first difference");
897        panic!(
898            "{message}; differing_pixels={} max_diff={} first differing normalized pixel at ({}, {}) base={:?} moved={:?} diff={}",
899            stats.differing_pixels,
900            stats.max_difference,
901            diff.x,
902            diff.y,
903            diff.lhs,
904            diff.rhs,
905            diff.difference
906        );
907    }
908}
909
910fn normalized_rect(frame: &RenderedFrame) -> Rect {
911    frame
912        .normalized_rect
913        .expect("normalized render frame missing normalized_rect")
914}
915
916fn normalized_output_dimensions(frame: &RenderedFrame) -> (u32, u32) {
917    let rect = normalized_rect(frame);
918    (
919        normalized_dimension(rect.width, "width"),
920        normalized_dimension(rect.height, "height"),
921    )
922}
923
924fn normalized_dimension(value: f32, axis: &str) -> u32 {
925    let rounded = value.round();
926    assert!(
927        (value - rounded).abs() <= 0.01,
928        "normalized {axis} must stay pixel-sized for stable comparison, got {value}",
929    );
930    assert!(
931        rounded > 0.0,
932        "normalized {axis} must be positive, got {value}"
933    );
934    rounded as u32
935}
936
937fn is_background_like(pixel: [u8; 4], background: [u8; 4]) -> bool {
938    pixel_difference(pixel, background) <= PIXEL_DIFFERENCE_TOLERANCE
939}
940
941fn assert_pixel_matches_background(
942    pixels: &[u8],
943    width: u32,
944    background: [u8; 4],
945    x: u32,
946    y: u32,
947    expect_background: bool,
948    message: &str,
949) {
950    let pixel = sample_pixel(pixels, width, x, y);
951    let background_like = is_background_like(pixel, background);
952    assert_eq!(
953        background_like, expect_background,
954        "{message}; pixel at ({x},{y}) was {pixel:?} against background {background:?}"
955    );
956}
957
958fn count_non_background_pixels(pixels: &[u8], width: u32, height: u32, background: [u8; 4]) -> u32 {
959    count_non_background_pixels_in_band(pixels, width, 0, height, background)
960}
961
962fn count_non_background_pixels_in_band(
963    pixels: &[u8],
964    width: u32,
965    y_start: u32,
966    y_end: u32,
967    background: [u8; 4],
968) -> u32 {
969    let mut count = 0;
970    for y in y_start..y_end {
971        for x in 0..width {
972            if !is_background_like(sample_pixel(pixels, width, x, y), background) {
973                count += 1;
974            }
975        }
976    }
977    count
978}
979
980fn ink_y_range(pixels: &[u8], width: u32, height: u32, background: [u8; 4]) -> Option<(u32, u32)> {
981    let mut top = None;
982    let mut bottom = 0u32;
983    for y in 0..height {
984        for x in 0..width {
985            if !is_background_like(sample_pixel(pixels, width, x, y), background) {
986                top.get_or_insert(y);
987                bottom = y + 1;
988                break;
989            }
990        }
991    }
992    top.map(|top_y| (top_y, bottom))
993}
994
995#[cfg(test)]
996#[path = "tests/render_contract_tests.rs"]
997mod tests;