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        motion_context_animated: false,
469        translated_content_context: false,
470        translated_content_offset: Point::default(),
471        graphics_layer: GraphicsLayer::default(),
472        clip_to_bounds: false,
473        shadow_clip: None,
474        hit_test: None,
475        has_hit_targets: false,
476        isolation: IsolationReasons::default(),
477        cache_policy: CachePolicy::None,
478        cache_hashes: LayerRasterCacheHashes::default(),
479        cache_hashes_valid: false,
480        children,
481    }
482}
483
484fn with_background(bounds: Rect, mut children: Vec<RenderNode>) -> Vec<RenderNode> {
485    children.insert(
486        0,
487        draw_node(
488            DrawPrimitive::Rect {
489                rect: bounds,
490                brush: Brush::solid(BACKGROUND_COLOR),
491            },
492            None,
493        ),
494    );
495    children
496}
497
498fn draw_node(primitive: DrawPrimitive, clip: Option<Rect>) -> RenderNode {
499    RenderNode::Primitive(PrimitiveEntry {
500        phase: PrimitivePhase::BeforeChildren,
501        node: PrimitiveNode::Draw(DrawPrimitiveNode { primitive, clip }),
502    })
503}
504
505fn text_node(node_id: NodeId, rect: Rect, text: &str, clip: Option<Rect>) -> RenderNode {
506    text_node_with_style(
507        node_id,
508        rect,
509        text,
510        clip,
511        TextStyle::from_span_style(SpanStyle {
512            color: Some(FOREGROUND_COLOR),
513            ..Default::default()
514        }),
515    )
516}
517
518fn text_node_with_style(
519    node_id: NodeId,
520    rect: Rect,
521    text: &str,
522    clip: Option<Rect>,
523    text_style: TextStyle,
524) -> RenderNode {
525    RenderNode::Primitive(PrimitiveEntry {
526        phase: PrimitivePhase::BeforeChildren,
527        node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
528            node_id,
529            rect,
530            text: AnnotatedString::from(text),
531            text_style,
532            font_size: 14.0,
533            layout_options: TextLayoutOptions::default(),
534            clip,
535        })),
536    })
537}
538
539fn assert_rounded_rect_frame(pixels: &[u8], width: u32, height: u32) {
540    assert_eq!((width, height), (72, 72));
541    let background = sample_pixel(pixels, width, 2, 2);
542
543    assert_pixel_matches_background(
544        pixels,
545        width,
546        background,
547        14,
548        14,
549        true,
550        "rounded rect corner should stay background-colored",
551    );
552    assert_pixel_matches_background(
553        pixels,
554        width,
555        background,
556        30,
557        16,
558        false,
559        "rounded rect top edge should contain fill",
560    );
561    assert_pixel_matches_background(
562        pixels,
563        width,
564        background,
565        36,
566        36,
567        false,
568        "rounded rect center should contain fill",
569    );
570}
571
572fn assert_primitive_clip_frame(pixels: &[u8], width: u32, height: u32) {
573    assert_eq!((width, height), (52, 44));
574    let background = sample_pixel(pixels, width, 2, 2);
575
576    assert_pixel_matches_background(
577        pixels,
578        width,
579        background,
580        18,
581        18,
582        false,
583        "pixel inside primitive clip should contain fill",
584    );
585    assert_pixel_matches_background(
586        pixels,
587        width,
588        background,
589        10,
590        12,
591        true,
592        "pixel inside source rect but outside clip should stay background-colored",
593    );
594    assert_pixel_matches_background(
595        pixels,
596        width,
597        background,
598        30,
599        20,
600        true,
601        "pixel on the far side of the source rect but outside clip should stay background-colored",
602    );
603}
604
605fn assert_multiline_text_frame(pixels: &[u8], width: u32, height: u32) {
606    assert_eq!((width, height), (220, 100));
607    let background = sample_pixel(pixels, width, 2, 2);
608    let (ink_top, ink_bottom) = ink_y_range(pixels, width, height, background)
609        .expect("expected rendered text ink in multiline contract frame");
610    let ink_height = ink_bottom - ink_top;
611    assert!(
612        ink_height >= 18,
613        "expected two text lines of ink, observed span {ink_height}px (y={ink_top}..{ink_bottom})"
614    );
615    let mid_y = ink_top + ink_height / 2;
616    let first_line_ink =
617        count_non_background_pixels_in_band(pixels, width, ink_top, mid_y, background);
618    let second_line_ink =
619        count_non_background_pixels_in_band(pixels, width, mid_y, ink_bottom, background);
620    assert!(
621        first_line_ink > 20,
622        "expected first line ink in multiline contract frame, got {first_line_ink}"
623    );
624    assert!(
625        second_line_ink > 20,
626        "expected second line ink in multiline contract frame, got {second_line_ink}"
627    );
628}
629
630fn assert_translated_subtree_frames(frames: &[RenderedFrame]) {
631    let [base, moved] = frames else {
632        panic!("translated_subtree expects exactly two rendered frames");
633    };
634    assert_eq!((base.width, base.height), (96, 84));
635    assert_eq!((moved.width, moved.height), (96, 84));
636    assert_ne!(
637        base.pixels, moved.pixels,
638        "translated subtree contract should move within the full frame"
639    );
640    assert_normalized_region_matches(
641        base,
642        moved,
643        TRANSLATED_SUBTREE_BUDGET,
644        "translated subtree output should remain invariant under rigid parent translation",
645    );
646}
647
648fn assert_translated_plain_text_frames(frames: &[RenderedFrame]) {
649    let [base, moved] = frames else {
650        panic!("translated_plain_text expects exactly two rendered frames");
651    };
652    assert_eq!((base.width, base.height), (196, 112));
653    assert_eq!((moved.width, moved.height), (196, 112));
654    assert_ne!(
655        base.pixels, moved.pixels,
656        "translated plain text contract should move within the full frame"
657    );
658    assert_normalized_region_matches(
659        base,
660        moved,
661        TRANSLATED_PLAIN_TEXT_BUDGET,
662        "translated plain text should remain visually stable after normalization",
663    );
664}
665
666fn assert_translated_text_decorations_frames(frames: &[RenderedFrame]) {
667    let [base, moved] = frames else {
668        panic!("translated_text_decorations expects exactly two rendered frames");
669    };
670    assert_eq!((base.width, base.height), (180, 96));
671    assert_eq!((moved.width, moved.height), (180, 96));
672    assert_ne!(
673        base.pixels, moved.pixels,
674        "translated text contract should move within the full frame"
675    );
676    assert_normalized_region_matches(
677        base,
678        moved,
679        TRANSLATED_TEXT_DECORATIONS_BUDGET,
680        "normalized text/shadow/decoration output should remain invariant under rigid parent translation",
681    );
682
683    let background = sample_pixel(&base.pixels, base.width, 2, 2);
684    let base_crop = normalize_frame_region(base);
685    let (crop_width, crop_height) = normalized_output_dimensions(base);
686    let ink_pixels = count_non_background_pixels(&base_crop, crop_width, crop_height, background);
687    assert!(
688        ink_pixels > 120,
689        "translated text contract should contain visible ink, observed {ink_pixels} differing pixels"
690    );
691}
692
693fn assert_clipped_text_frame(pixels: &[u8], width: u32, height: u32) {
694    assert_eq!((width, height), (220, 100));
695    let background = sample_pixel(pixels, width, 2, 2);
696    let total_ink = count_non_background_pixels(pixels, width, height, background);
697    assert_eq!(
698        total_ink, 0,
699        "fully clipped text should not draw ink, but observed {total_ink} differing pixels"
700    );
701}
702
703fn normalize_frame_region(frame: &RenderedFrame) -> Vec<u8> {
704    let rect = normalized_rect(frame);
705    let (width, height) = normalized_output_dimensions(frame);
706    normalize_rgba_region(
707        &frame.pixels,
708        frame.width,
709        frame.height,
710        rect,
711        width,
712        height,
713    )
714}
715
716fn assert_normalized_region_matches(
717    base: &RenderedFrame,
718    moved: &RenderedFrame,
719    budget: NormalizedDifferenceBudget,
720    message: &str,
721) {
722    assert_eq!(
723        normalized_output_dimensions(base),
724        normalized_output_dimensions(moved),
725        "normalized comparison requires matching output sizes",
726    );
727    let (width, height) = normalized_output_dimensions(base);
728    let base_normalized = normalize_frame_region(base);
729    let moved_normalized = normalize_frame_region(moved);
730    let stats = image_difference_stats(
731        &base_normalized,
732        &moved_normalized,
733        width,
734        height,
735        PIXEL_DIFFERENCE_TOLERANCE,
736    );
737    // Fractional parent motion changes root-space sampling phase. The shared contract tolerates the
738    // bounded edge drift that both backends currently produce after normalization, while still
739    // rejecting regressions that move or distort the local picture materially.
740    if stats.differing_pixels > budget.max_differing_pixels
741        || stats.max_difference > budget.max_pixel_difference
742    {
743        let diff = stats
744            .first_difference
745            .as_ref()
746            .expect("failing normalized comparison should report first difference");
747        panic!(
748            "{message}; differing_pixels={} max_diff={} first differing normalized pixel at ({}, {}) base={:?} moved={:?} diff={}",
749            stats.differing_pixels,
750            stats.max_difference,
751            diff.x,
752            diff.y,
753            diff.lhs,
754            diff.rhs,
755            diff.difference
756        );
757    }
758}
759
760fn normalized_rect(frame: &RenderedFrame) -> Rect {
761    frame
762        .normalized_rect
763        .expect("normalized render frame missing normalized_rect")
764}
765
766fn normalized_output_dimensions(frame: &RenderedFrame) -> (u32, u32) {
767    let rect = normalized_rect(frame);
768    (
769        normalized_dimension(rect.width, "width"),
770        normalized_dimension(rect.height, "height"),
771    )
772}
773
774fn normalized_dimension(value: f32, axis: &str) -> u32 {
775    let rounded = value.round();
776    assert!(
777        (value - rounded).abs() <= 0.01,
778        "normalized {axis} must stay pixel-sized for stable comparison, got {value}",
779    );
780    assert!(
781        rounded > 0.0,
782        "normalized {axis} must be positive, got {value}"
783    );
784    rounded as u32
785}
786
787fn is_background_like(pixel: [u8; 4], background: [u8; 4]) -> bool {
788    pixel_difference(pixel, background) <= PIXEL_DIFFERENCE_TOLERANCE
789}
790
791fn assert_pixel_matches_background(
792    pixels: &[u8],
793    width: u32,
794    background: [u8; 4],
795    x: u32,
796    y: u32,
797    expect_background: bool,
798    message: &str,
799) {
800    let pixel = sample_pixel(pixels, width, x, y);
801    let background_like = is_background_like(pixel, background);
802    assert_eq!(
803        background_like, expect_background,
804        "{message}; pixel at ({x},{y}) was {pixel:?} against background {background:?}"
805    );
806}
807
808fn count_non_background_pixels(pixels: &[u8], width: u32, height: u32, background: [u8; 4]) -> u32 {
809    count_non_background_pixels_in_band(pixels, width, 0, height, background)
810}
811
812fn count_non_background_pixels_in_band(
813    pixels: &[u8],
814    width: u32,
815    y_start: u32,
816    y_end: u32,
817    background: [u8; 4],
818) -> u32 {
819    let mut count = 0;
820    for y in y_start..y_end {
821        for x in 0..width {
822            if !is_background_like(sample_pixel(pixels, width, x, y), background) {
823                count += 1;
824            }
825        }
826    }
827    count
828}
829
830fn ink_y_range(pixels: &[u8], width: u32, height: u32, background: [u8; 4]) -> Option<(u32, u32)> {
831    let mut top = None;
832    let mut bottom = 0u32;
833    for y in 0..height {
834        for x in 0..width {
835            if !is_background_like(sample_pixel(pixels, width, x, y), background) {
836                top.get_or_insert(y);
837                bottom = y + 1;
838                break;
839            }
840        }
841    }
842    top.map(|top_y| (top_y, bottom))
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848    use std::collections::HashSet;
849
850    #[test]
851    fn shared_render_cases_have_unique_names() {
852        let names: HashSet<_> = ALL_SHARED_RENDER_CASES
853            .into_iter()
854            .map(SharedRenderCase::name)
855            .collect();
856        assert_eq!(names.len(), ALL_SHARED_RENDER_CASES.len());
857    }
858
859    #[test]
860    fn shared_render_cases_build_non_empty_graphs() {
861        for case in ALL_SHARED_RENDER_CASES {
862            for fixture in case.fixtures() {
863                assert!(fixture.width > 0);
864                assert!(fixture.height > 0);
865                assert!(
866                    !fixture.graph.root.children.is_empty(),
867                    "shared render case {} should emit at least one render node",
868                    case.name()
869                );
870            }
871        }
872    }
873}