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