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: 300,
35    max_pixel_difference: 360,
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    build_translated_fixture(
356        180,
357        96,
358        subtree_bounds,
359        Point::new(translation_x, translation_y),
360        vec![text_node_with_style(
361            3,
362            Rect {
363                x: 6.0,
364                y: 6.0,
365                width: 96.0,
366                height: 24.0,
367            },
368            "Shifted",
369            None,
370            text_style,
371        )],
372    )
373}
374
375fn build_fixture(width: u32, height: u32, children: Vec<RenderNode>) -> RenderFixture {
376    let bounds = Rect {
377        x: 0.0,
378        y: 0.0,
379        width: width as f32,
380        height: height as f32,
381    };
382
383    RenderFixture {
384        width,
385        height,
386        graph: RenderGraph::new(graph_layer(
387            bounds,
388            ProjectiveTransform::identity(),
389            with_background(bounds, children),
390        )),
391        normalized_rect: None,
392    }
393}
394
395fn build_translated_fixture(
396    width: u32,
397    height: u32,
398    subtree_bounds: Rect,
399    translation: Point,
400    subtree_children: Vec<RenderNode>,
401) -> RenderFixture {
402    build_translated_fixture_with_context(
403        width,
404        height,
405        subtree_bounds,
406        translation,
407        false,
408        subtree_children,
409    )
410}
411
412fn build_translated_fixture_with_context(
413    width: u32,
414    height: u32,
415    subtree_bounds: Rect,
416    translation: Point,
417    translated_content_context: bool,
418    subtree_children: Vec<RenderNode>,
419) -> RenderFixture {
420    let bounds = Rect {
421        x: 0.0,
422        y: 0.0,
423        width: width as f32,
424        height: height as f32,
425    };
426    let subtree = graph_layer(
427        subtree_bounds,
428        ProjectiveTransform::translation(translation.x, translation.y),
429        subtree_children,
430    );
431    let mut subtree = subtree;
432    subtree.translated_content_context = translated_content_context;
433
434    RenderFixture {
435        width,
436        height,
437        graph: RenderGraph::new(graph_layer(
438            bounds,
439            ProjectiveTransform::identity(),
440            with_background(bounds, vec![RenderNode::Layer(Box::new(subtree))]),
441        )),
442        normalized_rect: Some(Rect {
443            x: translation.x,
444            y: translation.y,
445            width: subtree_bounds.width,
446            height: subtree_bounds.height,
447        }),
448    }
449}
450
451fn graph_layer(
452    local_bounds: Rect,
453    transform_to_parent: ProjectiveTransform,
454    children: Vec<RenderNode>,
455) -> LayerNode {
456    LayerNode {
457        node_id: None,
458        local_bounds,
459        transform_to_parent,
460        motion_context_animated: false,
461        translated_content_context: false,
462        translated_content_offset: Point::default(),
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}