Skip to main content

cranpose_render_common/
render_contract.rs

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