1use cranpose_core::NodeId;
2use cranpose_ui::{
3 TextLayoutOptions, TextStyle,
4 text::{AnnotatedString, Shadow, SpanStyle, TextDecoration},
5};
6use cranpose_ui_graphics::{Brush, Color, CornerRadii, DrawPrimitive, Point, Rect, Stroke};
7
8use crate::{
9 graph::{
10 DrawPrimitiveNode, LayerNode, PrimitiveEntry, PrimitiveNode, PrimitivePhase,
11 ProjectiveTransform, RenderGraph, RenderNode, TextPrimitiveNode,
12 },
13 image_compare::{
14 image_difference_stats, normalize_rgba_region, pixel_difference, sample_pixel,
15 },
16};
17
18const BACKGROUND_COLOR: Color = Color(18.0 / 255.0, 18.0 / 255.0, 24.0 / 255.0, 1.0);
19const FOREGROUND_COLOR: Color = Color::WHITE;
20const PIXEL_DIFFERENCE_TOLERANCE: u32 = 24;
21
22const TRANSLATED_SUBTREE_BUDGET: NormalizedDifferenceBudget = NormalizedDifferenceBudget {
23 max_differing_pixels: 270,
24 max_pixel_difference: 360,
25};
26const TRANSLATED_PLAIN_TEXT_BUDGET: NormalizedDifferenceBudget = NormalizedDifferenceBudget {
27 max_differing_pixels: 550,
28 max_pixel_difference: 360,
29};
30const TRANSLATED_TEXT_DECORATIONS_BUDGET: NormalizedDifferenceBudget = NormalizedDifferenceBudget {
31 max_differing_pixels: 320,
32 max_pixel_difference: 400,
33};
34
35#[derive(Clone)]
36pub struct RenderFixture {
37 pub width: u32,
38 pub height: u32,
39 pub graph: RenderGraph,
40 pub normalized_rect: Option<Rect>,
41}
42
43#[derive(Clone, Debug)]
44pub struct RenderedFrame {
45 pub width: u32,
46 pub height: u32,
47 pub pixels: Vec<u8>,
48 pub normalized_rect: Option<Rect>,
49}
50
51#[derive(Clone, Copy)]
52struct NormalizedDifferenceBudget {
53 max_differing_pixels: u32,
54 max_pixel_difference: u32,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum SharedRenderCase {
59 RoundedRect,
60 PrimitiveClip,
61 TranslatedSubtree,
62 TranslatedPlainText,
63 TranslatedTextDecorations,
64 MultilineText,
65 ClippedText,
66 StrokedRoundRect,
67 AnnularSector,
68}
69
70pub const ALL_SHARED_RENDER_CASES: [SharedRenderCase; 9] = [
71 SharedRenderCase::RoundedRect,
72 SharedRenderCase::PrimitiveClip,
73 SharedRenderCase::TranslatedSubtree,
74 SharedRenderCase::TranslatedPlainText,
75 SharedRenderCase::TranslatedTextDecorations,
76 SharedRenderCase::MultilineText,
77 SharedRenderCase::ClippedText,
78 SharedRenderCase::StrokedRoundRect,
79 SharedRenderCase::AnnularSector,
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 SharedRenderCase::StrokedRoundRect => "stroked_round_rect",
93 SharedRenderCase::AnnularSector => "annular_sector",
94 }
95 }
96
97 pub fn fixtures(self) -> Vec<RenderFixture> {
98 match self {
99 SharedRenderCase::RoundedRect => vec![rounded_rect_fixture()],
100 SharedRenderCase::PrimitiveClip => vec![primitive_clip_fixture()],
101 SharedRenderCase::TranslatedSubtree => vec![
102 translated_subtree_fixture(12.3, 14.7),
103 translated_subtree_fixture(32.6, 26.2),
104 ],
105 SharedRenderCase::TranslatedPlainText => vec![
106 translated_plain_text_fixture(14.3, 18.6),
107 translated_plain_text_fixture(36.4, 30.1),
108 ],
109 SharedRenderCase::TranslatedTextDecorations => vec![
110 translated_text_decorations_fixture(14.3, 18.6),
111 translated_text_decorations_fixture(36.4, 30.1),
112 ],
113 SharedRenderCase::MultilineText => vec![multiline_text_fixture()],
114 SharedRenderCase::ClippedText => vec![clipped_text_fixture()],
115 SharedRenderCase::StrokedRoundRect => vec![stroked_round_rect_fixture()],
116 SharedRenderCase::AnnularSector => vec![annular_sector_fixture()],
117 }
118 }
119
120 pub fn assert_frames(self, frames: &[RenderedFrame]) {
121 match self {
122 SharedRenderCase::RoundedRect => {
123 let [frame] = frames else {
124 panic!("rounded_rect expects exactly one rendered frame");
125 };
126 assert_rounded_rect_frame(&frame.pixels, frame.width, frame.height);
127 }
128 SharedRenderCase::PrimitiveClip => {
129 let [frame] = frames else {
130 panic!("primitive_clip expects exactly one rendered frame");
131 };
132 assert_primitive_clip_frame(&frame.pixels, frame.width, frame.height);
133 }
134 SharedRenderCase::TranslatedSubtree => {
135 assert_translated_subtree_frames(frames);
136 }
137 SharedRenderCase::TranslatedPlainText => {
138 assert_translated_plain_text_frames(frames);
139 }
140 SharedRenderCase::TranslatedTextDecorations => {
141 assert_translated_text_decorations_frames(frames);
142 }
143 SharedRenderCase::MultilineText => {
144 let [frame] = frames else {
145 panic!("multiline_text expects exactly one rendered frame");
146 };
147 assert_multiline_text_frame(&frame.pixels, frame.width, frame.height);
148 }
149 SharedRenderCase::StrokedRoundRect => {
150 let [frame] = frames else {
151 panic!("stroked_round_rect expects exactly one rendered frame");
152 };
153 assert_stroked_round_rect_frame(&frame.pixels, frame.width, frame.height);
154 }
155 SharedRenderCase::AnnularSector => {
156 let [frame] = frames else {
157 panic!("annular_sector expects exactly one rendered frame");
158 };
159 assert_annular_sector_frame(&frame.pixels, frame.width, frame.height);
160 }
161 SharedRenderCase::ClippedText => {
162 let [frame] = frames else {
163 panic!("clipped_text expects exactly one rendered frame");
164 };
165 assert_clipped_text_frame(&frame.pixels, frame.width, frame.height);
166 }
167 }
168 }
169}
170
171fn rounded_rect_fixture() -> RenderFixture {
172 build_fixture(
173 72,
174 72,
175 vec![draw_node(
176 DrawPrimitive::RoundRect {
177 rect: Rect {
178 x: 12.0,
179 y: 12.0,
180 width: 48.0,
181 height: 48.0,
182 },
183 brush: Brush::solid(FOREGROUND_COLOR),
184 radii: CornerRadii::uniform(18.0),
185 stroke: None,
186 },
187 None,
188 )],
189 )
190}
191
192fn primitive_clip_fixture() -> RenderFixture {
193 build_fixture(
194 52,
195 44,
196 vec![draw_node(
197 DrawPrimitive::Rect {
198 rect: Rect {
199 x: 8.0,
200 y: 10.0,
201 width: 28.0,
202 height: 18.0,
203 },
204 brush: Brush::solid(FOREGROUND_COLOR),
205 stroke: None,
206 },
207 Some(Rect {
208 x: 14.0,
209 y: 15.0,
210 width: 10.0,
211 height: 6.0,
212 }),
213 )],
214 )
215}
216
217fn translated_subtree_fixture(translation_x: f32, translation_y: f32) -> RenderFixture {
218 let subtree_bounds = Rect {
219 x: 0.0,
220 y: 0.0,
221 width: 48.0,
222 height: 36.0,
223 };
224
225 build_translated_fixture(
226 96,
227 84,
228 subtree_bounds,
229 Point::new(translation_x, translation_y),
230 vec![
231 draw_node(
232 DrawPrimitive::RoundRect {
233 rect: Rect {
234 x: 4.0,
235 y: 4.0,
236 width: 40.0,
237 height: 28.0,
238 },
239 brush: Brush::solid(FOREGROUND_COLOR),
240 radii: CornerRadii::uniform(10.0),
241 stroke: None,
242 },
243 None,
244 ),
245 draw_node(
246 DrawPrimitive::Rect {
247 rect: Rect {
248 x: 10.0,
249 y: 18.0,
250 width: 18.0,
251 height: 10.0,
252 },
253 brush: Brush::solid(Color(0.2, 0.8, 1.0, 1.0)),
254 stroke: None,
255 },
256 Some(Rect {
257 x: 12.0,
258 y: 20.0,
259 width: 10.0,
260 height: 4.0,
261 }),
262 ),
263 ],
264 )
265}
266
267fn translated_plain_text_fixture(translation_x: f32, translation_y: f32) -> RenderFixture {
268 let subtree_bounds = Rect {
269 x: 0.0,
270 y: 0.0,
271 width: 116.0,
272 height: 36.0,
273 };
274
275 let mut fixture = build_translated_fixture_with_context(
276 196,
277 112,
278 subtree_bounds,
279 Point::new(translation_x, translation_y),
280 true,
281 vec![
282 draw_node(
283 DrawPrimitive::RoundRect {
284 rect: Rect {
285 x: 2.0,
286 y: 2.0,
287 width: 112.0,
288 height: 32.0,
289 },
290 brush: Brush::solid(Color(0.24, 0.26, 0.40, 0.92)),
291 radii: CornerRadii::uniform(8.0),
292 stroke: None,
293 },
294 None,
295 ),
296 text_node(
297 33,
298 Rect {
299 x: 10.0,
300 y: 8.0,
301 width: 96.0,
302 height: 18.0,
303 },
304 "Scroll text",
305 None,
306 ),
307 ],
308 );
309 fixture.normalized_rect = Some(Rect {
310 x: translation_x.round(),
311 y: translation_y.round(),
312 width: subtree_bounds.width,
313 height: subtree_bounds.height,
314 });
315 fixture
316}
317
318fn multiline_text_fixture() -> RenderFixture {
319 build_fixture(
320 220,
321 100,
322 vec![text_node(
323 1,
324 Rect {
325 x: 8.0,
326 y: 8.0,
327 width: 180.0,
328 height: 80.0,
329 },
330 "Dynamic\nModifiers",
331 None,
332 )],
333 )
334}
335
336fn clipped_text_fixture() -> RenderFixture {
337 build_fixture(
338 220,
339 100,
340 vec![text_node(
341 2,
342 Rect {
343 x: 8.0,
344 y: 40.0,
345 width: 180.0,
346 height: 24.0,
347 },
348 "Clipped Text",
349 Some(Rect {
350 x: 0.0,
351 y: 0.0,
352 width: 220.0,
353 height: 20.0,
354 }),
355 )],
356 )
357}
358
359fn translated_text_decorations_fixture(translation_x: f32, translation_y: f32) -> RenderFixture {
360 let subtree_bounds = Rect {
361 x: 0.0,
362 y: 0.0,
363 width: 112.0,
364 height: 40.0,
365 };
366 let text_style = TextStyle::from_span_style(SpanStyle {
367 color: Some(FOREGROUND_COLOR),
368 shadow: Some(Shadow {
369 color: Color(0.0, 0.0, 0.0, 0.85),
370 offset: Point::new(3.0, 2.0),
371 blur_radius: 4.0,
372 }),
373 text_decoration: Some(TextDecoration::UNDERLINE),
374 ..Default::default()
375 });
376
377 let mut fixture = build_translated_fixture_with_context(
378 180,
379 96,
380 subtree_bounds,
381 Point::new(translation_x, translation_y),
382 true,
383 vec![text_node_with_style(
384 3,
385 Rect {
386 x: 6.0,
387 y: 6.0,
388 width: 96.0,
389 height: 24.0,
390 },
391 "Shifted",
392 None,
393 text_style,
394 )],
395 );
396 fixture.normalized_rect = Some(Rect {
397 x: translation_x.round(),
398 y: translation_y.round(),
399 width: subtree_bounds.width,
400 height: subtree_bounds.height,
401 });
402 fixture
403}
404
405fn build_fixture(width: u32, height: u32, children: Vec<RenderNode>) -> RenderFixture {
406 let bounds = Rect {
407 x: 0.0,
408 y: 0.0,
409 width: width as f32,
410 height: height as f32,
411 };
412
413 RenderFixture {
414 width,
415 height,
416 graph: RenderGraph::new(graph_layer(
417 bounds,
418 ProjectiveTransform::identity(),
419 with_background(bounds, children),
420 )),
421 normalized_rect: None,
422 }
423}
424
425fn build_translated_fixture(
426 width: u32,
427 height: u32,
428 subtree_bounds: Rect,
429 translation: Point,
430 subtree_children: Vec<RenderNode>,
431) -> RenderFixture {
432 build_translated_fixture_with_context(
433 width,
434 height,
435 subtree_bounds,
436 translation,
437 false,
438 subtree_children,
439 )
440}
441
442fn build_translated_fixture_with_context(
443 width: u32,
444 height: u32,
445 subtree_bounds: Rect,
446 translation: Point,
447 translated_content_context: bool,
448 subtree_children: Vec<RenderNode>,
449) -> RenderFixture {
450 let bounds = Rect {
451 x: 0.0,
452 y: 0.0,
453 width: width as f32,
454 height: height as f32,
455 };
456 let subtree = graph_layer(
457 subtree_bounds,
458 ProjectiveTransform::translation(translation.x, translation.y),
459 subtree_children,
460 );
461 let mut subtree = subtree;
462 subtree.translated_content_context = translated_content_context;
463
464 RenderFixture {
465 width,
466 height,
467 graph: RenderGraph::new(graph_layer(
468 bounds,
469 ProjectiveTransform::identity(),
470 with_background(bounds, vec![RenderNode::Layer(Box::new(subtree))]),
471 )),
472 normalized_rect: Some(Rect {
473 x: translation.x,
474 y: translation.y,
475 width: subtree_bounds.width,
476 height: subtree_bounds.height,
477 }),
478 }
479}
480
481fn graph_layer(
482 local_bounds: Rect,
483 transform_to_parent: ProjectiveTransform,
484 children: Vec<RenderNode>,
485) -> LayerNode {
486 LayerNode {
487 local_bounds,
488 transform_to_parent,
489 children,
490 ..Default::default()
491 }
492}
493
494fn with_background(bounds: Rect, mut children: Vec<RenderNode>) -> Vec<RenderNode> {
495 children.insert(
496 0,
497 draw_node(
498 DrawPrimitive::Rect {
499 rect: bounds,
500 brush: Brush::solid(BACKGROUND_COLOR),
501 stroke: None,
502 },
503 None,
504 ),
505 );
506 children
507}
508
509fn draw_node(primitive: DrawPrimitive, clip: Option<Rect>) -> RenderNode {
510 RenderNode::Primitive(PrimitiveEntry {
511 phase: PrimitivePhase::BeforeChildren,
512 node: PrimitiveNode::Draw(DrawPrimitiveNode { primitive, clip }),
513 })
514}
515
516fn text_node(node_id: NodeId, rect: Rect, text: &str, clip: Option<Rect>) -> RenderNode {
517 text_node_with_style(
518 node_id,
519 rect,
520 text,
521 clip,
522 TextStyle::from_span_style(SpanStyle {
523 color: Some(FOREGROUND_COLOR),
524 ..Default::default()
525 }),
526 )
527}
528
529fn text_node_with_style(
530 node_id: NodeId,
531 rect: Rect,
532 text: &str,
533 clip: Option<Rect>,
534 text_style: TextStyle,
535) -> RenderNode {
536 RenderNode::Primitive(PrimitiveEntry {
537 phase: PrimitivePhase::BeforeChildren,
538 node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
539 node_id,
540 rect,
541 text: std::rc::Rc::new(AnnotatedString::from(text)),
542 text_style,
543 font_size: 14.0,
544 layout_options: TextLayoutOptions::default(),
545 clip,
546 })),
547 })
548}
549
550fn stroked_round_rect_fixture() -> RenderFixture {
551 build_fixture(
552 72,
553 72,
554 vec![draw_node(
555 DrawPrimitive::RoundRect {
556 rect: Rect {
557 x: 16.0,
558 y: 16.0,
559 width: 40.0,
560 height: 40.0,
561 },
562 brush: Brush::solid(FOREGROUND_COLOR),
563 radii: CornerRadii::uniform(10.0),
564 stroke: Some(Stroke::new(6.0)),
565 },
566 None,
567 )],
568 )
569}
570
571fn annular_sector_fixture() -> RenderFixture {
572 build_fixture(
573 72,
574 72,
575 vec![draw_node(
576 DrawPrimitive::Arc {
577 rect: Rect {
578 x: 36.0,
579 y: 36.0,
580 width: 24.0,
581 height: 24.0,
582 },
583 brush: Brush::solid(FOREGROUND_COLOR),
584 center: Point::new(36.0, 36.0),
585 radius: 24.0,
586 start_angle: 0.0,
587 sweep_angle: std::f32::consts::FRAC_PI_2,
588 stroke: None,
589 inner_radius: 12.0,
590 },
591 None,
592 )],
593 )
594}
595
596fn assert_stroked_round_rect_frame(pixels: &[u8], width: u32, height: u32) {
597 assert_eq!((width, height), (72, 72));
598 let background = sample_pixel(pixels, width, 2, 2);
599
600 assert_pixel_matches_background(
601 pixels,
602 width,
603 background,
604 36,
605 16,
606 false,
607 "stroked round rect top edge should contain stroke ink",
608 );
609 assert_pixel_matches_background(
610 pixels,
611 width,
612 background,
613 16,
614 36,
615 false,
616 "stroked round rect left edge should contain stroke ink",
617 );
618 assert_pixel_matches_background(
619 pixels,
620 width,
621 background,
622 36,
623 36,
624 true,
625 "stroked round rect interior must stay empty — a backend that filled \
626 the shape instead of stroking it would fail here",
627 );
628 assert_pixel_matches_background(
629 pixels,
630 width,
631 background,
632 4,
633 36,
634 true,
635 "outside the stroked round rect should stay background-colored",
636 );
637}
638
639fn assert_annular_sector_frame(pixels: &[u8], width: u32, height: u32) {
640 assert_eq!((width, height), (72, 72));
641 let background = sample_pixel(pixels, width, 2, 2);
642
643 assert_pixel_matches_background(
644 pixels,
645 width,
646 background,
647 54,
648 38,
649 false,
650 "annular sector band near 0 degrees should contain fill",
651 );
652 assert_pixel_matches_background(
653 pixels,
654 width,
655 background,
656 38,
657 54,
658 false,
659 "annular sector band near 90 degrees should contain fill",
660 );
661 assert_pixel_matches_background(
662 pixels,
663 width,
664 background,
665 36,
666 36,
667 true,
668 "the annulus hole must stay empty",
669 );
670 assert_pixel_matches_background(
671 pixels,
672 width,
673 background,
674 54,
675 30,
676 true,
677 "past the flat radial edge must stay empty — a rounded cap here would \
678 mean the sector is being drawn as a stroked arc",
679 );
680 assert_pixel_matches_background(
681 pixels,
682 width,
683 background,
684 66,
685 38,
686 true,
687 "beyond the outer radius must stay empty",
688 );
689}
690
691fn assert_rounded_rect_frame(pixels: &[u8], width: u32, height: u32) {
692 assert_eq!((width, height), (72, 72));
693 let background = sample_pixel(pixels, width, 2, 2);
694
695 assert_pixel_matches_background(
696 pixels,
697 width,
698 background,
699 14,
700 14,
701 true,
702 "rounded rect corner should stay background-colored",
703 );
704 assert_pixel_matches_background(
705 pixels,
706 width,
707 background,
708 30,
709 16,
710 false,
711 "rounded rect top edge should contain fill",
712 );
713 assert_pixel_matches_background(
714 pixels,
715 width,
716 background,
717 36,
718 36,
719 false,
720 "rounded rect center should contain fill",
721 );
722}
723
724fn assert_primitive_clip_frame(pixels: &[u8], width: u32, height: u32) {
725 assert_eq!((width, height), (52, 44));
726 let background = sample_pixel(pixels, width, 2, 2);
727
728 assert_pixel_matches_background(
729 pixels,
730 width,
731 background,
732 18,
733 18,
734 false,
735 "pixel inside primitive clip should contain fill",
736 );
737 assert_pixel_matches_background(
738 pixels,
739 width,
740 background,
741 10,
742 12,
743 true,
744 "pixel inside source rect but outside clip should stay background-colored",
745 );
746 assert_pixel_matches_background(
747 pixels,
748 width,
749 background,
750 30,
751 20,
752 true,
753 "pixel on the far side of the source rect but outside clip should stay background-colored",
754 );
755}
756
757fn assert_multiline_text_frame(pixels: &[u8], width: u32, height: u32) {
758 assert_eq!((width, height), (220, 100));
759 let background = sample_pixel(pixels, width, 2, 2);
760 let (ink_top, ink_bottom) = ink_y_range(pixels, width, height, background)
761 .expect("expected rendered text ink in multiline contract frame");
762 let ink_height = ink_bottom - ink_top;
763 assert!(
764 ink_height >= 18,
765 "expected two text lines of ink, observed span {ink_height}px (y={ink_top}..{ink_bottom})"
766 );
767 let mid_y = ink_top + ink_height / 2;
768 let first_line_ink =
769 count_non_background_pixels_in_band(pixels, width, ink_top, mid_y, background);
770 let second_line_ink =
771 count_non_background_pixels_in_band(pixels, width, mid_y, ink_bottom, background);
772 assert!(
773 first_line_ink > 20,
774 "expected first line ink in multiline contract frame, got {first_line_ink}"
775 );
776 assert!(
777 second_line_ink > 20,
778 "expected second line ink in multiline contract frame, got {second_line_ink}"
779 );
780}
781
782fn assert_translated_subtree_frames(frames: &[RenderedFrame]) {
783 let [base, moved] = frames else {
784 panic!("translated_subtree expects exactly two rendered frames");
785 };
786 assert_eq!((base.width, base.height), (96, 84));
787 assert_eq!((moved.width, moved.height), (96, 84));
788 assert_ne!(
789 base.pixels, moved.pixels,
790 "translated subtree contract should move within the full frame"
791 );
792 assert_normalized_region_matches(
793 base,
794 moved,
795 TRANSLATED_SUBTREE_BUDGET,
796 "translated subtree output should remain invariant under rigid parent translation",
797 );
798}
799
800fn assert_translated_plain_text_frames(frames: &[RenderedFrame]) {
801 let [base, moved] = frames else {
802 panic!("translated_plain_text expects exactly two rendered frames");
803 };
804 assert_eq!((base.width, base.height), (196, 112));
805 assert_eq!((moved.width, moved.height), (196, 112));
806 assert_ne!(
807 base.pixels, moved.pixels,
808 "translated plain text contract should move within the full frame"
809 );
810 assert_normalized_region_matches(
811 base,
812 moved,
813 TRANSLATED_PLAIN_TEXT_BUDGET,
814 "translated plain text should remain visually stable after normalization",
815 );
816}
817
818fn assert_translated_text_decorations_frames(frames: &[RenderedFrame]) {
819 let [base, moved] = frames else {
820 panic!("translated_text_decorations expects exactly two rendered frames");
821 };
822 assert_eq!((base.width, base.height), (180, 96));
823 assert_eq!((moved.width, moved.height), (180, 96));
824 assert_ne!(
825 base.pixels, moved.pixels,
826 "translated text contract should move within the full frame"
827 );
828 assert_normalized_region_matches(
829 base,
830 moved,
831 TRANSLATED_TEXT_DECORATIONS_BUDGET,
832 "normalized text/shadow/decoration output should remain invariant under rigid parent translation",
833 );
834
835 let background = sample_pixel(&base.pixels, base.width, 2, 2);
836 let base_crop = normalize_frame_region(base);
837 let (crop_width, crop_height) = normalized_output_dimensions(base);
838 let ink_pixels = count_non_background_pixels(&base_crop, crop_width, crop_height, background);
839 assert!(
840 ink_pixels > 120,
841 "translated text contract should contain visible ink, observed {ink_pixels} differing pixels"
842 );
843}
844
845fn assert_clipped_text_frame(pixels: &[u8], width: u32, height: u32) {
846 assert_eq!((width, height), (220, 100));
847 let background = sample_pixel(pixels, width, 2, 2);
848 let total_ink = count_non_background_pixels(pixels, width, height, background);
849 assert_eq!(
850 total_ink, 0,
851 "fully clipped text should not draw ink, but observed {total_ink} differing pixels"
852 );
853}
854
855fn normalize_frame_region(frame: &RenderedFrame) -> Vec<u8> {
856 let rect = normalized_rect(frame);
857 let (width, height) = normalized_output_dimensions(frame);
858 normalize_rgba_region(
859 &frame.pixels,
860 frame.width,
861 frame.height,
862 rect,
863 width,
864 height,
865 )
866}
867
868fn assert_normalized_region_matches(
869 base: &RenderedFrame,
870 moved: &RenderedFrame,
871 budget: NormalizedDifferenceBudget,
872 message: &str,
873) {
874 assert_eq!(
875 normalized_output_dimensions(base),
876 normalized_output_dimensions(moved),
877 "normalized comparison requires matching output sizes",
878 );
879 let (width, height) = normalized_output_dimensions(base);
880 let base_normalized = normalize_frame_region(base);
881 let moved_normalized = normalize_frame_region(moved);
882 let stats = image_difference_stats(
883 &base_normalized,
884 &moved_normalized,
885 width,
886 height,
887 PIXEL_DIFFERENCE_TOLERANCE,
888 );
889 if stats.differing_pixels > budget.max_differing_pixels
890 || stats.max_difference > budget.max_pixel_difference
891 {
892 let diff = stats
893 .first_difference
894 .as_ref()
895 .expect("failing normalized comparison should report first difference");
896 panic!(
897 "{message}; differing_pixels={} max_diff={} first differing normalized pixel at ({}, {}) base={:?} moved={:?} diff={}",
898 stats.differing_pixels,
899 stats.max_difference,
900 diff.x,
901 diff.y,
902 diff.lhs,
903 diff.rhs,
904 diff.difference
905 );
906 }
907}
908
909fn normalized_rect(frame: &RenderedFrame) -> Rect {
910 frame
911 .normalized_rect
912 .expect("normalized render frame missing normalized_rect")
913}
914
915fn normalized_output_dimensions(frame: &RenderedFrame) -> (u32, u32) {
916 let rect = normalized_rect(frame);
917 (
918 normalized_dimension(rect.width, "width"),
919 normalized_dimension(rect.height, "height"),
920 )
921}
922
923fn normalized_dimension(value: f32, axis: &str) -> u32 {
924 let rounded = value.round();
925 assert!(
926 (value - rounded).abs() <= 0.01,
927 "normalized {axis} must stay pixel-sized for stable comparison, got {value}",
928 );
929 assert!(
930 rounded > 0.0,
931 "normalized {axis} must be positive, got {value}"
932 );
933 rounded as u32
934}
935
936fn is_background_like(pixel: [u8; 4], background: [u8; 4]) -> bool {
937 pixel_difference(pixel, background) <= PIXEL_DIFFERENCE_TOLERANCE
938}
939
940fn assert_pixel_matches_background(
941 pixels: &[u8],
942 width: u32,
943 background: [u8; 4],
944 x: u32,
945 y: u32,
946 expect_background: bool,
947 message: &str,
948) {
949 let pixel = sample_pixel(pixels, width, x, y);
950 let background_like = is_background_like(pixel, background);
951 assert_eq!(
952 background_like, expect_background,
953 "{message}; pixel at ({x},{y}) was {pixel:?} against background {background:?}"
954 );
955}
956
957fn count_non_background_pixels(pixels: &[u8], width: u32, height: u32, background: [u8; 4]) -> u32 {
958 count_non_background_pixels_in_band(pixels, width, 0, height, background)
959}
960
961fn count_non_background_pixels_in_band(
962 pixels: &[u8],
963 width: u32,
964 y_start: u32,
965 y_end: u32,
966 background: [u8; 4],
967) -> u32 {
968 let mut count = 0;
969 for y in y_start..y_end {
970 for x in 0..width {
971 if !is_background_like(sample_pixel(pixels, width, x, y), background) {
972 count += 1;
973 }
974 }
975 }
976 count
977}
978
979fn ink_y_range(pixels: &[u8], width: u32, height: u32, background: [u8; 4]) -> Option<(u32, u32)> {
980 let mut top = None;
981 let mut bottom = 0u32;
982 for y in 0..height {
983 for x in 0..width {
984 if !is_background_like(sample_pixel(pixels, width, x, y), background) {
985 top.get_or_insert(y);
986 bottom = y + 1;
987 break;
988 }
989 }
990 }
991 top.map(|top_y| (top_y, bottom))
992}
993
994#[cfg(test)]
995mod tests {
996 use std::collections::HashSet;
997
998 use super::*;
999
1000 #[test]
1001 fn shared_render_cases_have_unique_names() {
1002 let names: HashSet<_> = ALL_SHARED_RENDER_CASES
1003 .into_iter()
1004 .map(SharedRenderCase::name)
1005 .collect();
1006 assert_eq!(names.len(), ALL_SHARED_RENDER_CASES.len());
1007 }
1008
1009 #[test]
1010 fn shared_render_cases_build_non_empty_graphs() {
1011 for case in ALL_SHARED_RENDER_CASES {
1012 for fixture in case.fixtures() {
1013 assert!(fixture.width > 0);
1014 assert!(fixture.height > 0);
1015 assert!(
1016 !fixture.graph.root.children.is_empty(),
1017 "shared render case {} should emit at least one render node",
1018 case.name()
1019 );
1020 }
1021 }
1022 }
1023}