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