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 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 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}