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