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