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