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