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
19const TRANSLATED_SUBTREE_BUDGET: NormalizedDifferenceBudget = NormalizedDifferenceBudget {
23 max_differing_pixels: 245,
24 max_pixel_difference: 360,
25};
26const 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 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}