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