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 {
31 max_differing_pixels: 550,
32 max_pixel_difference: 360,
33};
34const TRANSLATED_TEXT_DECORATIONS_BUDGET: NormalizedDifferenceBudget = NormalizedDifferenceBudget {
35 max_differing_pixels: 300,
36 max_pixel_difference: 360,
37};
38
39#[derive(Clone)]
40pub struct RenderFixture {
41 pub width: u32,
42 pub height: u32,
43 pub graph: RenderGraph,
44 pub normalized_rect: Option<Rect>,
45}
46
47#[derive(Clone, Debug)]
48pub struct RenderedFrame {
49 pub width: u32,
50 pub height: u32,
51 pub pixels: Vec<u8>,
52 pub normalized_rect: Option<Rect>,
53}
54
55#[derive(Clone, Copy)]
56struct NormalizedDifferenceBudget {
57 max_differing_pixels: u32,
58 max_pixel_difference: u32,
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum SharedRenderCase {
63 RoundedRect,
64 PrimitiveClip,
65 TranslatedSubtree,
66 TranslatedPlainText,
67 TranslatedTextDecorations,
68 MultilineText,
69 ClippedText,
70}
71
72pub const ALL_SHARED_RENDER_CASES: [SharedRenderCase; 7] = [
73 SharedRenderCase::RoundedRect,
74 SharedRenderCase::PrimitiveClip,
75 SharedRenderCase::TranslatedSubtree,
76 SharedRenderCase::TranslatedPlainText,
77 SharedRenderCase::TranslatedTextDecorations,
78 SharedRenderCase::MultilineText,
79 SharedRenderCase::ClippedText,
80];
81
82impl SharedRenderCase {
83 pub fn name(self) -> &'static str {
84 match self {
85 SharedRenderCase::RoundedRect => "rounded_rect",
86 SharedRenderCase::PrimitiveClip => "primitive_clip",
87 SharedRenderCase::TranslatedSubtree => "translated_subtree",
88 SharedRenderCase::TranslatedPlainText => "translated_plain_text",
89 SharedRenderCase::TranslatedTextDecorations => "translated_text_decorations",
90 SharedRenderCase::MultilineText => "multiline_text",
91 SharedRenderCase::ClippedText => "clipped_text",
92 }
93 }
94
95 pub fn fixtures(self) -> Vec<RenderFixture> {
96 match self {
97 SharedRenderCase::RoundedRect => vec![rounded_rect_fixture()],
98 SharedRenderCase::PrimitiveClip => vec![primitive_clip_fixture()],
99 SharedRenderCase::TranslatedSubtree => vec![
100 translated_subtree_fixture(12.3, 14.7),
101 translated_subtree_fixture(32.6, 26.2),
102 ],
103 SharedRenderCase::TranslatedPlainText => vec![
104 translated_plain_text_fixture(14.3, 18.6),
105 translated_plain_text_fixture(36.4, 30.1),
106 ],
107 SharedRenderCase::TranslatedTextDecorations => vec![
108 translated_text_decorations_fixture(14.3, 18.6),
109 translated_text_decorations_fixture(36.4, 30.1),
110 ],
111 SharedRenderCase::MultilineText => vec![multiline_text_fixture()],
112 SharedRenderCase::ClippedText => vec![clipped_text_fixture()],
113 }
114 }
115
116 pub fn assert_frames(self, frames: &[RenderedFrame]) {
117 match self {
118 SharedRenderCase::RoundedRect => {
119 let [frame] = frames else {
120 panic!("rounded_rect expects exactly one rendered frame");
121 };
122 assert_rounded_rect_frame(&frame.pixels, frame.width, frame.height);
123 }
124 SharedRenderCase::PrimitiveClip => {
125 let [frame] = frames else {
126 panic!("primitive_clip expects exactly one rendered frame");
127 };
128 assert_primitive_clip_frame(&frame.pixels, frame.width, frame.height);
129 }
130 SharedRenderCase::TranslatedSubtree => {
131 assert_translated_subtree_frames(frames);
132 }
133 SharedRenderCase::TranslatedPlainText => {
134 assert_translated_plain_text_frames(frames);
135 }
136 SharedRenderCase::TranslatedTextDecorations => {
137 assert_translated_text_decorations_frames(frames);
138 }
139 SharedRenderCase::MultilineText => {
140 let [frame] = frames else {
141 panic!("multiline_text expects exactly one rendered frame");
142 };
143 assert_multiline_text_frame(&frame.pixels, frame.width, frame.height);
144 }
145 SharedRenderCase::ClippedText => {
146 let [frame] = frames else {
147 panic!("clipped_text expects exactly one rendered frame");
148 };
149 assert_clipped_text_frame(&frame.pixels, frame.width, frame.height);
150 }
151 }
152 }
153}
154
155fn rounded_rect_fixture() -> RenderFixture {
156 build_fixture(
157 72,
158 72,
159 vec![draw_node(
160 DrawPrimitive::RoundRect {
161 rect: Rect {
162 x: 12.0,
163 y: 12.0,
164 width: 48.0,
165 height: 48.0,
166 },
167 brush: Brush::solid(FOREGROUND_COLOR),
168 radii: CornerRadii::uniform(18.0),
169 },
170 None,
171 )],
172 )
173}
174
175fn primitive_clip_fixture() -> RenderFixture {
176 build_fixture(
177 52,
178 44,
179 vec![draw_node(
180 DrawPrimitive::Rect {
181 rect: Rect {
182 x: 8.0,
183 y: 10.0,
184 width: 28.0,
185 height: 18.0,
186 },
187 brush: Brush::solid(FOREGROUND_COLOR),
188 },
189 Some(Rect {
190 x: 14.0,
191 y: 15.0,
192 width: 10.0,
193 height: 6.0,
194 }),
195 )],
196 )
197}
198
199fn translated_subtree_fixture(translation_x: f32, translation_y: f32) -> RenderFixture {
200 let subtree_bounds = Rect {
201 x: 0.0,
202 y: 0.0,
203 width: 48.0,
204 height: 36.0,
205 };
206
207 build_translated_fixture(
208 96,
209 84,
210 subtree_bounds,
211 Point::new(translation_x, translation_y),
212 vec![
213 draw_node(
214 DrawPrimitive::RoundRect {
215 rect: Rect {
216 x: 4.0,
217 y: 4.0,
218 width: 40.0,
219 height: 28.0,
220 },
221 brush: Brush::solid(FOREGROUND_COLOR),
222 radii: CornerRadii::uniform(10.0),
223 },
224 None,
225 ),
226 draw_node(
227 DrawPrimitive::Rect {
228 rect: Rect {
229 x: 10.0,
230 y: 18.0,
231 width: 18.0,
232 height: 10.0,
233 },
234 brush: Brush::solid(Color(0.2, 0.8, 1.0, 1.0)),
235 },
236 Some(Rect {
237 x: 12.0,
238 y: 20.0,
239 width: 10.0,
240 height: 4.0,
241 }),
242 ),
243 ],
244 )
245}
246
247fn translated_plain_text_fixture(translation_x: f32, translation_y: f32) -> RenderFixture {
248 let subtree_bounds = Rect {
249 x: 0.0,
250 y: 0.0,
251 width: 116.0,
252 height: 36.0,
253 };
254
255 let mut fixture = build_translated_fixture_with_context(
256 196,
257 112,
258 subtree_bounds,
259 Point::new(translation_x, translation_y),
260 true,
261 vec![
262 draw_node(
263 DrawPrimitive::RoundRect {
264 rect: Rect {
265 x: 2.0,
266 y: 2.0,
267 width: 112.0,
268 height: 32.0,
269 },
270 brush: Brush::solid(Color(0.24, 0.26, 0.40, 0.92)),
271 radii: CornerRadii::uniform(8.0),
272 },
273 None,
274 ),
275 text_node(
276 33,
277 Rect {
278 x: 10.0,
279 y: 8.0,
280 width: 96.0,
281 height: 18.0,
282 },
283 "Scroll text",
284 None,
285 ),
286 ],
287 );
288 fixture.normalized_rect = Some(Rect {
289 x: translation_x.round(),
290 y: translation_y.round(),
291 width: subtree_bounds.width,
292 height: subtree_bounds.height,
293 });
294 fixture
295}
296
297fn multiline_text_fixture() -> RenderFixture {
298 build_fixture(
299 220,
300 100,
301 vec![text_node(
302 1,
303 Rect {
304 x: 8.0,
305 y: 8.0,
306 width: 180.0,
307 height: 80.0,
308 },
309 "Dynamic\nModifiers",
310 None,
311 )],
312 )
313}
314
315fn clipped_text_fixture() -> RenderFixture {
316 build_fixture(
317 220,
318 100,
319 vec![text_node(
320 2,
321 Rect {
322 x: 8.0,
323 y: 40.0,
324 width: 180.0,
325 height: 24.0,
326 },
327 "Clipped Text",
328 Some(Rect {
329 x: 0.0,
330 y: 0.0,
331 width: 220.0,
332 height: 20.0,
333 }),
334 )],
335 )
336}
337
338fn translated_text_decorations_fixture(translation_x: f32, translation_y: f32) -> RenderFixture {
339 let subtree_bounds = Rect {
340 x: 0.0,
341 y: 0.0,
342 width: 112.0,
343 height: 40.0,
344 };
345 let text_style = TextStyle::from_span_style(SpanStyle {
346 color: Some(FOREGROUND_COLOR),
347 shadow: Some(Shadow {
348 color: Color(0.0, 0.0, 0.0, 0.85),
349 offset: Point::new(3.0, 2.0),
350 blur_radius: 4.0,
351 }),
352 text_decoration: Some(TextDecoration::UNDERLINE),
353 ..Default::default()
354 });
355
356 build_translated_fixture(
357 180,
358 96,
359 subtree_bounds,
360 Point::new(translation_x, translation_y),
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}
375
376fn build_fixture(width: u32, height: u32, children: Vec<RenderNode>) -> RenderFixture {
377 let bounds = Rect {
378 x: 0.0,
379 y: 0.0,
380 width: width as f32,
381 height: height as f32,
382 };
383
384 RenderFixture {
385 width,
386 height,
387 graph: RenderGraph::new(graph_layer(
388 bounds,
389 ProjectiveTransform::identity(),
390 with_background(bounds, children),
391 )),
392 normalized_rect: None,
393 }
394}
395
396fn build_translated_fixture(
397 width: u32,
398 height: u32,
399 subtree_bounds: Rect,
400 translation: Point,
401 subtree_children: Vec<RenderNode>,
402) -> RenderFixture {
403 build_translated_fixture_with_context(
404 width,
405 height,
406 subtree_bounds,
407 translation,
408 false,
409 subtree_children,
410 )
411}
412
413fn build_translated_fixture_with_context(
414 width: u32,
415 height: u32,
416 subtree_bounds: Rect,
417 translation: Point,
418 translated_content_context: bool,
419 subtree_children: Vec<RenderNode>,
420) -> RenderFixture {
421 let bounds = Rect {
422 x: 0.0,
423 y: 0.0,
424 width: width as f32,
425 height: height as f32,
426 };
427 let subtree = graph_layer(
428 subtree_bounds,
429 ProjectiveTransform::translation(translation.x, translation.y),
430 subtree_children,
431 );
432 let mut subtree = subtree;
433 subtree.translated_content_context = translated_content_context;
434
435 RenderFixture {
436 width,
437 height,
438 graph: RenderGraph::new(graph_layer(
439 bounds,
440 ProjectiveTransform::identity(),
441 with_background(bounds, vec![RenderNode::Layer(Box::new(subtree))]),
442 )),
443 normalized_rect: Some(Rect {
444 x: translation.x,
445 y: translation.y,
446 width: subtree_bounds.width,
447 height: subtree_bounds.height,
448 }),
449 }
450}
451
452fn graph_layer(
453 local_bounds: Rect,
454 transform_to_parent: ProjectiveTransform,
455 children: Vec<RenderNode>,
456) -> LayerNode {
457 LayerNode {
458 node_id: None,
459 local_bounds,
460 transform_to_parent,
461 motion_context_animated: false,
462 translated_content_context: false,
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 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}