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