1use presentar_core::{
10 widget::{AccessibleRole, LayoutResult},
11 Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event, Key,
12 MouseButton, Point, Rect, Size, TypeId, Widget,
13};
14use serde::{Deserialize, Serialize};
15use std::any::Any;
16use std::time::Duration;
17
18const GLYPH_SCALE: f32 = 0.25;
20const GLYPH_ASPECT: f32 = 0.866;
22const DEFAULT_WIDTH: f32 = 320.0;
24const DEFAULT_HEIGHT: f32 = 180.0;
26const COORD_LIMIT: f32 = 1.0e15;
29
30fn sane(value: f32) -> f32 {
32 if value.is_finite() {
33 value.clamp(-COORD_LIMIT, COORD_LIMIT)
34 } else {
35 0.0
36 }
37}
38
39fn sane_bounds(bounds: Rect) -> Rect {
43 Rect::new(
44 sane(bounds.x),
45 sane(bounds.y),
46 sane(bounds.width).max(0.0),
47 sane(bounds.height).max(0.0),
48 )
49}
50
51fn valid_bounds(bounds: Rect) -> bool {
53 [bounds.x, bounds.y, bounds.width, bounds.height]
54 .iter()
55 .all(|v| v.is_finite())
56 && bounds.width >= 0.0
57 && bounds.height >= 0.0
58}
59
60fn sane_extent(value: f32) -> Option<f32> {
63 value.is_finite().then(|| value.clamp(0.0, COORD_LIMIT))
64}
65
66fn fit(value: f32, min: f32, max: f32) -> f32 {
70 sane(value.max(min).min(max)).max(0.0)
71}
72
73fn within(bounds: Rect, point: Point) -> Point {
75 Point::new(
76 point.x.max(bounds.x).min(bounds.x + bounds.width),
77 point.y.max(bounds.y).min(bounds.y + bounds.height),
78 )
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
83pub enum VideoState {
84 #[default]
86 Paused,
87 Playing,
89 Ended,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct VideoToggled {
96 pub playing: bool,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct Video {
103 src: String,
105 poster: Option<String>,
107 alt: String,
109 width: Option<f32>,
111 height: Option<f32>,
113 autoplay: bool,
115 controls: bool,
117 state: VideoState,
119 accessible_name_value: Option<String>,
121 test_id_value: Option<String>,
123 #[serde(skip)]
125 pressed: bool,
126 #[serde(skip)]
128 bounds: Rect,
129}
130
131impl Default for Video {
132 fn default() -> Self {
133 Self {
134 src: String::new(),
135 poster: None,
136 alt: String::new(),
137 width: None,
138 height: None,
139 autoplay: false,
140 controls: true,
141 state: VideoState::Paused,
142 accessible_name_value: None,
143 test_id_value: None,
144 pressed: false,
145 bounds: Rect::default(),
146 }
147 }
148}
149
150impl Video {
151 #[must_use]
153 pub fn new(src: impl Into<String>) -> Self {
154 Self {
155 src: src.into(),
156 ..Self::default()
157 }
158 }
159
160 #[must_use]
162 pub fn poster(mut self, url: impl Into<String>) -> Self {
163 self.poster = Some(url.into());
164 self
165 }
166
167 #[must_use]
169 pub fn alt(mut self, alt: impl Into<String>) -> Self {
170 self.alt = alt.into();
171 self
172 }
173
174 #[must_use]
180 pub fn width(mut self, width: f32) -> Self {
181 self.width = sane_extent(width);
182 self
183 }
184
185 #[must_use]
187 pub fn height(mut self, height: f32) -> Self {
188 self.height = sane_extent(height);
189 self
190 }
191
192 #[must_use]
194 pub fn size(self, width: f32, height: f32) -> Self {
195 self.width(width).height(height)
196 }
197
198 #[must_use]
200 pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
201 self.accessible_name_value = Some(name.into());
202 self
203 }
204
205 #[must_use]
207 pub fn test_id(mut self, id: impl Into<String>) -> Self {
208 self.test_id_value = Some(id.into());
209 self
210 }
211
212 #[must_use]
214 pub const fn autoplay(mut self, autoplay: bool) -> Self {
215 self.autoplay = autoplay;
216 self.state = if autoplay {
217 VideoState::Playing
218 } else {
219 VideoState::Paused
220 };
221 self
222 }
223
224 #[must_use]
226 pub const fn controls(mut self, controls: bool) -> Self {
227 self.controls = controls;
228 self
229 }
230
231 #[must_use]
233 pub fn get_src(&self) -> &str {
234 &self.src
235 }
236
237 #[must_use]
239 pub fn get_poster(&self) -> Option<&str> {
240 self.poster.as_deref()
241 }
242
243 #[must_use]
245 pub fn get_alt(&self) -> &str {
246 &self.alt
247 }
248
249 #[must_use]
251 pub fn get_width(&self) -> Option<f32> {
252 self.width.and_then(sane_extent)
253 }
254
255 #[must_use]
257 pub fn get_height(&self) -> Option<f32> {
258 self.height.and_then(sane_extent)
259 }
260
261 #[must_use]
263 pub const fn is_autoplay(&self) -> bool {
264 self.autoplay
265 }
266
267 #[must_use]
269 pub const fn has_controls(&self) -> bool {
270 self.controls
271 }
272
273 #[must_use]
275 pub const fn state(&self) -> VideoState {
276 self.state
277 }
278
279 #[must_use]
281 pub const fn is_playing(&self) -> bool {
282 matches!(self.state, VideoState::Playing)
283 }
284
285 pub fn play(&mut self) {
287 self.state = VideoState::Playing;
288 }
289
290 pub fn pause(&mut self) {
292 if self.is_playing() {
293 self.state = VideoState::Paused;
294 }
295 }
296
297 pub fn toggle(&mut self) {
299 if self.is_playing() {
300 self.pause();
301 } else {
302 self.play();
303 }
304 }
305
306 pub fn ended(&mut self) {
308 self.state = VideoState::Ended;
309 }
310
311 #[must_use]
317 pub fn play_glyph(&self) -> [Point; 3] {
318 let bounds = sane_bounds(self.bounds);
319 let centre = bounds.center();
320 let height = bounds.width.min(bounds.height).max(0.0) * GLYPH_SCALE;
321 let width = height * GLYPH_ASPECT;
322 [
323 Point::new(centre.x - width / 2.0, centre.y - height / 2.0),
324 Point::new(centre.x - width / 2.0, centre.y + height / 2.0),
325 Point::new(centre.x + width / 2.0, centre.y),
326 ]
327 .map(|vertex| within(bounds, vertex))
328 }
329
330 fn frame_color() -> Color {
332 Color::rgb(0.0, 0.0, 0.0)
333 }
334
335 fn placeholder_color() -> Color {
337 Color::rgb(0.15, 0.15, 0.18)
338 }
339
340 fn poster_color() -> Color {
342 Color::rgb(0.2, 0.2, 0.28)
343 }
344
345 fn toggled_message(&mut self) -> Option<Box<dyn Any + Send>> {
347 self.toggle();
348 Some(Box::new(VideoToggled {
349 playing: self.is_playing(),
350 }))
351 }
352
353 fn effective_name(&self) -> Option<&str> {
355 self.accessible_name_value
356 .as_deref()
357 .filter(|name| !name.trim().is_empty())
358 .or((!self.alt.trim().is_empty()).then_some(self.alt.as_str()))
359 }
360}
361
362fn escape_attr(value: &str) -> String {
364 value
365 .replace('&', "&")
366 .replace('"', """)
367 .replace('<', "<")
368 .replace('>', ">")
369}
370
371fn record(
373 passed: &mut Vec<BrickAssertion>,
374 failed: &mut Vec<(BrickAssertion, String)>,
375 ok: bool,
376 assertion: BrickAssertion,
377 reason: &str,
378) {
379 if ok {
380 passed.push(assertion);
381 } else {
382 failed.push((assertion, reason.to_string()));
383 }
384}
385
386impl Widget for Video {
387 fn type_id(&self) -> TypeId {
388 TypeId::of::<Self>()
389 }
390
391 fn measure(&self, constraints: Constraints) -> Size {
392 Size::new(
395 fit(
396 self.get_width().unwrap_or(DEFAULT_WIDTH),
397 constraints.min_width,
398 constraints.max_width,
399 ),
400 fit(
401 self.get_height().unwrap_or(DEFAULT_HEIGHT),
402 constraints.min_height,
403 constraints.max_height,
404 ),
405 )
406 }
407
408 fn layout(&mut self, bounds: Rect) -> LayoutResult {
416 self.bounds = bounds;
417 LayoutResult {
418 size: sane_bounds(bounds).size(),
419 }
420 }
421
422 fn paint(&self, canvas: &mut dyn Canvas) {
423 let bounds = sane_bounds(self.bounds);
424 if self.is_playing() {
425 canvas.fill_rect(bounds, Self::frame_color());
426 return;
427 }
428 let backdrop = if self.poster.is_some() {
429 Self::poster_color()
430 } else {
431 Self::placeholder_color()
432 };
433 canvas.fill_rect(bounds, backdrop);
434 canvas.fill_polygon(&self.play_glyph(), Color::WHITE);
435 }
436
437 fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
438 let bounds = sane_bounds(self.bounds);
439 match event {
440 Event::MouseDown {
441 position,
442 button: MouseButton::Left,
443 } => {
444 self.pressed = bounds.contains_point(position);
445 None
446 }
447 Event::MouseUp {
448 position,
449 button: MouseButton::Left,
450 } => {
451 let was_pressed = std::mem::take(&mut self.pressed);
452 if was_pressed && bounds.contains_point(position) {
453 self.toggled_message()
454 } else {
455 None
456 }
457 }
458 Event::MouseLeave => {
459 self.pressed = false;
460 None
461 }
462 Event::KeyUp {
463 key: Key::Enter | Key::Space,
464 ..
465 } => self.toggled_message(),
466 _ => None,
467 }
468 }
469
470 fn children(&self) -> &[Box<dyn Widget>] {
471 &[]
472 }
473
474 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
475 &mut []
476 }
477
478 fn is_interactive(&self) -> bool {
479 true
480 }
481
482 fn is_focusable(&self) -> bool {
483 true
484 }
485
486 fn accessible_name(&self) -> Option<&str> {
487 self.effective_name()
488 }
489
490 fn accessible_role(&self) -> AccessibleRole {
491 AccessibleRole::Generic
492 }
493
494 fn bounds(&self) -> Rect {
495 self.bounds
496 }
497
498 fn test_id(&self) -> Option<&str> {
499 self.test_id_value.as_deref()
500 }
501}
502
503impl Brick for Video {
505 fn brick_name(&self) -> &'static str {
506 "Video"
507 }
508
509 fn assertions(&self) -> &[BrickAssertion] {
510 &[BrickAssertion::MaxLatencyMs(16), BrickAssertion::Focusable]
511 }
512
513 fn budget(&self) -> BrickBudget {
514 BrickBudget::uniform(16)
515 }
516
517 fn verify(&self) -> BrickVerification {
518 let mut passed = self.assertions().to_vec();
519 let mut failed = Vec::new();
520
521 record(
522 &mut passed,
523 &mut failed,
524 !self.src.is_empty(),
525 BrickAssertion::ElementPresent("video[src]".into()),
526 "Video has no src",
527 );
528 record(
529 &mut passed,
530 &mut failed,
531 self.effective_name().is_some(),
532 BrickAssertion::ElementPresent("accessible-name".into()),
533 "Video has no accessible name",
534 );
535 record(
536 &mut passed,
537 &mut failed,
538 valid_bounds(self.bounds),
539 BrickAssertion::ElementPresent("valid-bounds".into()),
540 "Video bounds are not finite and >= 0",
541 );
542
543 BrickVerification {
544 passed,
545 failed,
546 verification_time: Duration::from_micros(10),
547 }
548 }
549
550 fn to_html(&self) -> String {
551 let poster = self.poster.as_deref().map_or_else(String::new, |url| {
552 format!(r#" poster="{}""#, escape_attr(url))
553 });
554 let controls = if self.controls { " controls" } else { "" };
555 let autoplay = if self.autoplay { " autoplay" } else { "" };
556 format!(
557 r#"<video class="brick-video" src="{}"{}{}{} aria-label="{}"></video>"#,
558 escape_attr(&self.src),
559 poster,
560 controls,
561 autoplay,
562 escape_attr(self.effective_name().unwrap_or(""))
563 )
564 }
565
566 fn to_css(&self) -> String {
567 ".brick-video { display: block; background: #000; }".to_string()
568 }
569
570 fn test_id(&self) -> Option<&str> {
571 self.test_id_value.as_deref()
572 }
573}
574
575#[cfg(test)]
576#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
577mod tests {
578 use super::*;
579 use crate::modal::Modal;
580 use presentar_core::draw::DrawCommand;
581 use presentar_core::RecordingCanvas;
582 use proptest::prelude::*;
583
584 const BOUNDS: Rect = Rect {
585 x: 10.0,
586 y: 20.0,
587 width: 320.0,
588 height: 180.0,
589 };
590
591 fn named(src: &str) -> Video {
592 Video::new(src).accessible_name("Lecture video")
593 }
594
595 fn laid_out(mut video: Video) -> Video {
596 video.layout(BOUNDS);
597 video
598 }
599
600 fn click(video: &mut Video, at: Point) -> Option<Box<dyn Any + Send>> {
601 video.event(&Event::MouseDown {
602 position: at,
603 button: MouseButton::Left,
604 });
605 video.event(&Event::MouseUp {
606 position: at,
607 button: MouseButton::Left,
608 })
609 }
610
611 fn toggled(msg: &Option<Box<dyn Any + Send>>) -> Option<VideoToggled> {
612 msg.as_ref()
613 .and_then(|m| m.downcast_ref::<VideoToggled>())
614 .cloned()
615 }
616
617 fn paint_commands(video: &Video) -> Vec<DrawCommand> {
618 let mut canvas = RecordingCanvas::new();
619 video.paint(&mut canvas);
620 canvas.commands().to_vec()
621 }
622
623 fn contains(outer: &Rect, p: &Point, eps: f32) -> bool {
624 p.x >= outer.x - eps
625 && p.x <= outer.x + outer.width + eps
626 && p.y >= outer.y - eps
627 && p.y <= outer.y + outer.height + eps
628 }
629
630 fn rect_inside(outer: &Rect, inner: &Rect, eps: f32) -> bool {
631 contains(outer, &Point::new(inner.x, inner.y), eps)
632 && contains(
633 outer,
634 &Point::new(inner.x + inner.width, inner.y + inner.height),
635 eps,
636 )
637 }
638
639 fn glyph_centre(points: &[Point]) -> Point {
640 let min_x = points.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
641 let max_x = points.iter().map(|p| p.x).fold(f32::NEG_INFINITY, f32::max);
642 let min_y = points.iter().map(|p| p.y).fold(f32::INFINITY, f32::min);
643 let max_y = points.iter().map(|p| p.y).fold(f32::NEG_INFINITY, f32::max);
644 Point::new((min_x + max_x) / 2.0, (min_y + max_y) / 2.0)
645 }
646
647 #[test]
650 fn video_new_has_documented_defaults() {
651 let v = Video::new("movie.mp4");
652 assert_eq!(v.get_src(), "movie.mp4");
653 assert!(v.get_poster().is_none());
654 assert!(v.get_alt().is_empty());
655 assert!(v.get_width().is_none());
656 assert!(v.get_height().is_none());
657 assert!(!v.is_autoplay());
658 assert!(v.has_controls());
659 assert_eq!(v.state(), VideoState::Paused);
660 assert!(!v.is_playing());
661 }
662
663 #[test]
664 fn video_default_is_empty_and_paused() {
665 let v = Video::default();
666 assert!(v.get_src().is_empty());
667 assert_eq!(v.state(), VideoState::default());
668 assert_eq!(VideoState::default(), VideoState::Paused);
669 }
670
671 #[test]
672 fn video_builder_sets_every_field() {
673 let v = Video::new("a.mp4")
674 .poster("a.png")
675 .alt("Intro")
676 .size(640.0, 360.0)
677 .accessible_name("Intro video")
678 .test_id("hero-video")
679 .autoplay(true)
680 .controls(false);
681 assert_eq!(v.get_poster(), Some("a.png"));
682 assert_eq!(v.get_alt(), "Intro");
683 assert_eq!(v.get_width(), Some(640.0));
684 assert_eq!(v.get_height(), Some(360.0));
685 assert_eq!(Widget::accessible_name(&v), Some("Intro video"));
686 assert_eq!(Widget::test_id(&v), Some("hero-video"));
687 assert_eq!(Brick::test_id(&v), Some("hero-video"));
688 assert!(v.is_autoplay());
689 assert!(!v.has_controls());
690 }
691
692 #[test]
693 fn video_negative_size_clamps_to_zero() {
694 let v = Video::default().width(-5.0).height(-7.0);
695 assert_eq!(v.get_width(), Some(0.0));
696 assert_eq!(v.get_height(), Some(0.0));
697 }
698
699 #[test]
700 fn video_autoplay_decides_initial_state() {
701 assert_eq!(Video::new("a").autoplay(true).state(), VideoState::Playing);
702 assert!(Video::new("a").autoplay(true).is_playing());
703 assert_eq!(
704 Video::new("a").autoplay(true).autoplay(false).state(),
705 VideoState::Paused
706 );
707 }
708
709 #[test]
712 fn video_play_pause_toggle_ended_transitions() {
713 let mut v = Video::new("a");
714 v.play();
715 assert_eq!(v.state(), VideoState::Playing);
716 v.pause();
717 assert_eq!(v.state(), VideoState::Paused);
718 v.toggle();
719 assert!(v.is_playing());
720 v.toggle();
721 assert!(!v.is_playing());
722 v.play();
723 v.ended();
724 assert_eq!(v.state(), VideoState::Ended);
725 assert!(!v.is_playing());
726 }
727
728 #[test]
729 fn video_pause_keeps_ended_and_toggle_replays() {
730 let mut v = Video::new("a");
731 v.ended();
732 v.pause();
733 assert_eq!(v.state(), VideoState::Ended);
734 v.toggle();
735 assert_eq!(v.state(), VideoState::Playing);
736 }
737
738 #[test]
741 fn video_click_inside_toggles_and_emits() {
742 let mut v = laid_out(named("a"));
743 let inside = BOUNDS.center();
744
745 let first = click(&mut v, inside);
746 assert_eq!(toggled(&first), Some(VideoToggled { playing: true }));
747 assert!(v.is_playing());
748
749 let second = click(&mut v, inside);
750 assert_eq!(toggled(&second), Some(VideoToggled { playing: false }));
751 assert!(!v.is_playing());
752 }
753
754 #[test]
755 fn video_click_outside_does_nothing() {
756 let mut v = laid_out(named("a"));
757 let outside = Point::new(BOUNDS.x - 1.0, BOUNDS.y - 1.0);
758 assert!(click(&mut v, outside).is_none());
759 assert_eq!(v.state(), VideoState::Paused);
760 }
761
762 #[test]
763 fn video_press_and_release_must_both_be_inside() {
764 let mut v = laid_out(named("a"));
765 let inside = BOUNDS.center();
766 let outside = Point::new(BOUNDS.x + BOUNDS.width + 5.0, BOUNDS.y);
767
768 v.event(&Event::MouseDown {
770 position: inside,
771 button: MouseButton::Left,
772 });
773 let released = v.event(&Event::MouseUp {
774 position: outside,
775 button: MouseButton::Left,
776 });
777 assert!(released.is_none());
778
779 v.event(&Event::MouseDown {
781 position: outside,
782 button: MouseButton::Left,
783 });
784 let released = v.event(&Event::MouseUp {
785 position: inside,
786 button: MouseButton::Left,
787 });
788 assert!(released.is_none());
789 assert!(!v.is_playing());
790 }
791
792 #[test]
793 fn video_non_left_button_and_mouse_leave_do_not_toggle() {
794 let mut v = laid_out(named("a"));
795 let inside = BOUNDS.center();
796
797 v.event(&Event::MouseDown {
798 position: inside,
799 button: MouseButton::Right,
800 });
801 let up = v.event(&Event::MouseUp {
802 position: inside,
803 button: MouseButton::Right,
804 });
805 assert!(up.is_none());
806
807 v.event(&Event::MouseDown {
808 position: inside,
809 button: MouseButton::Left,
810 });
811 assert!(v.event(&Event::MouseLeave).is_none());
812 let up = v.event(&Event::MouseUp {
813 position: inside,
814 button: MouseButton::Left,
815 });
816 assert!(up.is_none());
817 assert!(!v.is_playing());
818 }
819
820 #[test]
821 fn video_space_and_enter_toggle_other_events_ignored() {
822 let mut v = laid_out(named("a"));
823
824 let space = v.event(&Event::key_up(Key::Space));
825 assert_eq!(toggled(&space), Some(VideoToggled { playing: true }));
826 let enter = v.event(&Event::key_up(Key::Enter));
827 assert_eq!(toggled(&enter), Some(VideoToggled { playing: false }));
828
829 assert!(v.event(&Event::key_down(Key::Space)).is_none());
830 assert!(v.event(&Event::key_up(Key::A)).is_none());
831 assert!(!v.is_playing());
832 }
833
834 #[test]
837 fn video_paint_paused_draws_placeholder_and_glyph() {
838 let v = laid_out(named("a"));
839 let cmds = paint_commands(&v);
840 assert_eq!(cmds.len(), 2);
841 assert!(matches!(
842 &cmds[0],
843 DrawCommand::Rect { bounds, style, .. }
844 if *bounds == BOUNDS && style.fill == Some(Video::placeholder_color())
845 ));
846 assert!(matches!(
847 &cmds[1],
848 DrawCommand::Path { points, closed, .. } if *closed && points.len() == 3
849 ));
850 }
851
852 #[test]
853 fn video_paint_with_poster_uses_poster_colour() {
854 let v = laid_out(named("a").poster("p.png"));
855 assert!(matches!(
856 &paint_commands(&v)[0],
857 DrawCommand::Rect { style, .. } if style.fill == Some(Video::poster_color())
858 ));
859 }
860
861 #[test]
862 fn video_paint_playing_draws_frame_and_no_glyph() {
863 let mut v = laid_out(named("a"));
864 v.play();
865 let cmds = paint_commands(&v);
866 assert_eq!(cmds.len(), 1);
867 assert!(matches!(
868 &cmds[0],
869 DrawCommand::Rect { bounds, style, .. }
870 if *bounds == BOUNDS && style.fill == Some(Video::frame_color())
871 ));
872 }
873
874 #[test]
875 fn video_paint_ended_shows_glyph_again() {
876 let mut v = laid_out(named("a"));
877 v.play();
878 v.ended();
879 let cmds = paint_commands(&v);
880 assert_eq!(cmds.len(), 2);
881 assert!(matches!(cmds[1], DrawCommand::Path { .. }));
882 }
883
884 #[test]
885 fn video_glyph_is_centred_and_inside_bounds() {
886 let v = laid_out(named("a"));
887 let glyph = v.play_glyph();
888 let centre = glyph_centre(&glyph);
889 assert!((centre.x - BOUNDS.center().x).abs() < 0.5);
890 assert!((centre.y - BOUNDS.center().y).abs() < 0.5);
891 for p in &glyph {
892 assert!(contains(&BOUNDS, p, 0.0));
893 }
894 }
895
896 #[test]
897 fn video_glyph_in_degenerate_bounds_collapses_to_centre() {
898 let mut v = named("a");
899 v.layout(Rect::new(5.0, 5.0, 0.0, 0.0));
900 let glyph = v.play_glyph();
901 for p in &glyph {
902 assert!((p.x - 5.0).abs() < f32::EPSILON);
903 assert!((p.y - 5.0).abs() < f32::EPSILON);
904 }
905 let mut v = named("a");
906 v.layout(Rect::new(0.0, 0.0, -10.0, 40.0));
907 let glyph = v.play_glyph();
908 assert!((glyph[0].x - glyph[2].x).abs() < f32::EPSILON);
909 }
910
911 #[test]
914 fn video_measure_uses_default_and_explicit_size() {
915 let loose = Constraints::loose(Size::new(1000.0, 1000.0));
916 assert_eq!(Video::new("a").measure(loose), Size::new(320.0, 180.0));
917 assert_eq!(
918 Video::new("a").size(200.0, 100.0).measure(loose),
919 Size::new(200.0, 100.0)
920 );
921 let tight = Constraints::loose(Size::new(150.0, 90.0));
922 assert_eq!(Video::new("a").measure(tight), Size::new(150.0, 90.0));
923 }
924
925 #[test]
926 fn video_layout_stores_bounds_and_returns_size() {
927 let mut v = named("a");
928 let result = v.layout(BOUNDS);
929 assert_eq!(result.size, BOUNDS.size());
930 assert_eq!(v.bounds, BOUNDS);
931 }
932
933 #[test]
934 fn video_widget_metadata() {
935 let mut v = named("a");
936 assert_eq!(Widget::type_id(&v), TypeId::of::<Video>());
937 assert!(v.children().is_empty());
938 assert!(v.children_mut().is_empty());
939 assert!(v.is_interactive());
940 assert!(v.is_focusable());
941 assert_eq!(v.accessible_role(), AccessibleRole::Generic);
942 assert_eq!(Widget::test_id(&v), None);
943 }
944
945 #[test]
946 fn video_accessible_name_falls_back_to_alt() {
947 let v = Video::new("a").alt("Alt text");
948 assert_eq!(Widget::accessible_name(&v), Some("Alt text"));
949 let v = Video::new("a").alt("Alt text").accessible_name("Named");
950 assert_eq!(Widget::accessible_name(&v), Some("Named"));
951 let v = Video::new("a").alt("Alt text").accessible_name("");
952 assert_eq!(Widget::accessible_name(&v), Some("Alt text"));
953 assert_eq!(Widget::accessible_name(&Video::new("a")), None);
954 }
955
956 #[test]
959 fn video_brick_metadata() {
960 let v = named("a");
961 assert_eq!(v.brick_name(), "Video");
962 assert!(v.assertions().contains(&BrickAssertion::MaxLatencyMs(16)));
963 assert_eq!(v.budget(), BrickBudget::uniform(16));
964 }
965
966 #[test]
967 fn video_verify_fails_on_empty_src() {
968 let result = named("").verify();
969 assert!(!result.is_valid());
970 assert_eq!(result.failed.len(), 1);
971 assert!(result.failed[0].1.contains("src"));
972 }
973
974 #[test]
975 fn video_verify_fails_without_accessible_name() {
976 let result = Video::new("a.mp4").verify();
977 assert!(!result.is_valid());
978 assert_eq!(result.failed.len(), 1);
979 assert!(result.failed[0].1.contains("accessible name"));
980 assert!(!Video::new("a.mp4").accessible_name("").verify().is_valid());
982 assert!(!Video::new("a.mp4")
984 .accessible_name(" ")
985 .verify()
986 .is_valid());
987 assert!(!Video::new("a.mp4").alt(" ").verify().is_valid());
988 assert_eq!(
989 result.failed[0].0,
990 BrickAssertion::ElementPresent("accessible-name".into())
991 );
992 let mut v = Video::new("a.mp4");
994 v.layout(Rect::new(3.0, 4.0, 50.0, 60.0));
995 assert_eq!(Widget::bounds(&v), Rect::new(3.0, 4.0, 50.0, 60.0));
996 }
997
998 #[test]
999 fn video_verify_reports_both_failures() {
1000 let result = Video::default().verify();
1001 assert_eq!(result.failed.len(), 2);
1002 }
1003
1004 #[test]
1005 fn video_verify_passes_in_every_state() {
1006 let mut v = named("a.mp4");
1007 assert!(v.verify().is_valid());
1008 v.play();
1009 assert!(v.verify().is_valid());
1010 v.ended();
1011 assert!(v.verify().is_valid());
1012 v.pause();
1013 assert!(v.verify().is_valid());
1014 assert!(Video::new("a.mp4").alt("Intro").verify().is_valid());
1016 }
1017
1018 #[test]
1019 fn video_to_html_reflects_configuration() {
1020 let v = Video::new("a.mp4")
1021 .poster("a.png")
1022 .autoplay(true)
1023 .accessible_name("Intro");
1024 let html = v.to_html();
1025 assert!(html.contains(r#"src="a.mp4""#));
1026 assert!(html.contains(r#"poster="a.png""#));
1027 assert!(html.contains(" controls"));
1028 assert!(html.contains(" autoplay"));
1029 assert!(html.contains(r#"aria-label="Intro""#));
1030
1031 let bare = Video::new("b.mp4").controls(false).to_html();
1032 assert!(!bare.contains("poster"));
1033 assert!(!bare.contains("controls"));
1034 assert!(!bare.contains("autoplay"));
1035 assert!(bare.contains(r#"aria-label="""#));
1036 }
1037
1038 #[test]
1039 fn video_to_html_escapes_attributes() {
1040 let html = Video::new(r#"a"b&c<d>.mp4"#).to_html();
1041 assert!(html.contains("a"b&c<d>.mp4"));
1042 let with_poster = named("a").poster(r#"p"><x>.png"#).to_html();
1043 assert!(with_poster.contains(r#"poster="p"><x>.png""#));
1044 let with_name = Video::new("a").accessible_name(r#"n"<b>"#).to_html();
1045 assert!(with_name.contains("n"<b>"));
1046 }
1047
1048 #[test]
1049 fn video_to_css_declares_the_class() {
1050 assert!(named("a").to_css().contains(".brick-video"));
1051 }
1052
1053 #[test]
1056 fn video_serde_roundtrip_keeps_state_and_drops_runtime_fields() {
1057 let mut v = named("a.mp4").poster("p.png").size(10.0, 20.0);
1058 v.layout(BOUNDS);
1059 v.play();
1060 let json = serde_json::to_string(&v).unwrap();
1061 let back: Video = serde_json::from_str(&json).unwrap();
1062 assert_eq!(back.get_src(), "a.mp4");
1063 assert_eq!(back.get_poster(), Some("p.png"));
1064 assert_eq!(back.state(), VideoState::Playing);
1065 assert_eq!(back.bounds, Rect::default());
1066
1067 let state_json = serde_json::to_string(&VideoState::Ended).unwrap();
1068 let state: VideoState = serde_json::from_str(&state_json).unwrap();
1069 assert_eq!(state, VideoState::Ended);
1070 }
1071
1072 #[test]
1073 fn video_debug_and_clone_are_usable() {
1074 let v = named("a.mp4");
1075 let copy = v.clone();
1076 assert!(format!("{copy:?}").contains("a.mp4"));
1077 let msg = VideoToggled { playing: true };
1078 assert_eq!(msg.clone(), msg);
1079 assert!(format!("{msg:?}").contains("playing"));
1080 }
1081
1082 #[test]
1085 fn video_inside_modal_lays_out_and_paints_inside_bounds() {
1086 let mut modal = Modal::new().content(named("a.mp4"));
1089 modal.show();
1090 let viewport = Rect::new(0.0, 0.0, 800.0, 600.0);
1091 modal.layout(viewport);
1092
1093 let mut canvas = RecordingCanvas::new();
1094 modal.paint(&mut canvas);
1095 let cmds = canvas.commands();
1096
1097 let content = modal.content_bounds();
1098 let glyph_idx = cmds
1099 .iter()
1100 .position(|c| matches!(c, DrawCommand::Path { .. }))
1101 .expect("the paused Video must paint a glyph path");
1102 let points = cmds
1103 .iter()
1104 .find_map(|c| match c {
1105 DrawCommand::Path { points, .. } => Some(points),
1106 _ => None,
1107 })
1108 .expect("the glyph path");
1109 let frame = cmds[..=glyph_idx]
1111 .iter()
1112 .filter_map(|c| match c {
1113 DrawCommand::Rect { bounds, .. } => Some(bounds),
1114 _ => None,
1115 })
1116 .next_back()
1117 .expect("the video backdrop rect");
1118
1119 assert!(rect_inside(&content, frame, 0.001));
1120 for p in points {
1121 assert!(contains(frame, p, 0.001));
1122 }
1123 let centre = glyph_centre(points);
1124 assert!((centre.x - frame.center().x).abs() < 0.5);
1125 assert!((centre.y - frame.center().y).abs() < 0.5);
1126 }
1127
1128 const LIMIT: f32 = 1.0e15;
1132
1133 fn oracle_coord(value: f32) -> f32 {
1134 if value.is_finite() {
1135 value.clamp(-LIMIT, LIMIT)
1136 } else {
1137 0.0
1138 }
1139 }
1140
1141 fn oracle_bounds(b: Rect) -> Rect {
1143 Rect::new(
1144 oracle_coord(b.x),
1145 oracle_coord(b.y),
1146 oracle_coord(b.width).max(0.0),
1147 oracle_coord(b.height).max(0.0),
1148 )
1149 }
1150
1151 fn point_finite_inside(p: &Point, outer: &Rect) -> bool {
1152 p.x.is_finite() && p.y.is_finite() && contains(outer, p, 0.0)
1153 }
1154
1155 fn rect_finite_inside(r: &Rect, outer: &Rect) -> bool {
1156 [r.x, r.y, r.width, r.height].iter().all(|v| v.is_finite()) && rect_inside(outer, r, 0.0)
1157 }
1158
1159 fn commands_finite_inside(cmds: &[DrawCommand], outer: &Rect) -> bool {
1161 cmds.iter().all(|cmd| match cmd {
1162 DrawCommand::Rect { bounds, .. } => rect_finite_inside(bounds, outer),
1163 DrawCommand::Path { points, .. } => {
1164 !points.is_empty() && points.iter().all(|p| point_finite_inside(p, outer))
1165 }
1166 _ => false,
1167 })
1168 }
1169
1170 #[test]
1171 fn commands_helper_rejects_anything_but_rects_and_paths() {
1172 let circle = DrawCommand::filled_circle(Point::new(1.0, 1.0), 1.0, Color::WHITE);
1173 assert!(!commands_finite_inside(&[circle], &BOUNDS));
1174 let empty_path = DrawCommand::Path {
1175 points: Vec::new(),
1176 closed: true,
1177 style: presentar_core::draw::StrokeStyle::default(),
1178 };
1179 assert!(!commands_finite_inside(&[empty_path], &BOUNDS));
1180 }
1181
1182 fn layout_paint_is_finite_inside(bounds: Rect) -> bool {
1184 let expected = oracle_bounds(bounds);
1185 let mut v = named("a");
1186 let result = v.layout(bounds);
1187 let mut ok = result.size.width.is_finite()
1188 && result.size.height.is_finite()
1189 && v.play_glyph()
1190 .iter()
1191 .all(|p| point_finite_inside(p, &expected));
1192 ok &= commands_finite_inside(&paint_commands(&v), &expected);
1193 v.play();
1194 ok &= commands_finite_inside(&paint_commands(&v), &expected);
1195 ok
1196 }
1197
1198 #[test]
1199 fn video_reviewer_repro_f32_max_bounds_paint_finite_and_inside() {
1200 let bounds = Rect::new(f32::MAX, f32::MAX, f32::MAX, f32::MAX);
1201 let mut v = named("a");
1202 v.layout(bounds);
1203 let expected = oracle_bounds(bounds);
1204 assert_eq!(Widget::bounds(&v), bounds);
1206 assert!(v
1207 .play_glyph()
1208 .iter()
1209 .all(|p| point_finite_inside(p, &expected)));
1210 let cmds = paint_commands(&v);
1211 assert_eq!(cmds.len(), 2);
1212 assert!(commands_finite_inside(&cmds, &expected), "{cmds:?}");
1213 }
1214
1215 #[test]
1216 fn video_reviewer_repro_negative_extents_collapse_inside_the_origin() {
1217 let mut v = named("a");
1218 let result = v.layout(Rect::new(10.0, 20.0, -50.0, -40.0));
1219 let zero = Rect::new(10.0, 20.0, 0.0, 0.0);
1220 assert_eq!(result.size, Size::new(0.0, 0.0));
1221 assert_eq!(Widget::bounds(&v), Rect::new(10.0, 20.0, -50.0, -40.0));
1222 for p in v.play_glyph() {
1223 assert_eq!(p, Point::new(10.0, 20.0));
1224 }
1225 let cmds = paint_commands(&v);
1226 assert!(matches!(
1227 &cmds[0],
1228 DrawCommand::Rect { bounds, .. } if *bounds == zero
1229 ));
1230 assert!(commands_finite_inside(&cmds, &zero));
1231 }
1232
1233 #[test]
1234 fn video_layout_extreme_bounds_table_paints_finite_and_inside() {
1235 let extremes = [
1236 f32::MAX,
1237 f32::MIN,
1238 f32::INFINITY,
1239 f32::NEG_INFINITY,
1240 f32::NAN,
1241 f32::MIN_POSITIVE,
1242 -0.0,
1243 LIMIT,
1244 -LIMIT,
1245 3.0e15,
1246 -50.0,
1247 ];
1248 for &a in &extremes {
1249 for &b in &extremes {
1250 for bounds in [
1251 Rect::new(a, a, b, b),
1252 Rect::new(a, b, b, a),
1253 Rect::new(10.0, 20.0, a, b),
1254 Rect::new(a, b, 100.0, 50.0),
1255 ] {
1256 assert!(layout_paint_is_finite_inside(bounds), "{bounds:?}");
1257 }
1258 }
1259 }
1260 }
1261
1262 #[test]
1263 fn video_layout_keeps_ordinary_bounds_untouched() {
1264 assert!(layout_paint_is_finite_inside(BOUNDS));
1265 let mut v = named("a");
1266 assert_eq!(v.layout(BOUNDS).size, BOUNDS.size());
1267 assert_eq!(Widget::bounds(&v), BOUNDS);
1268 }
1269
1270 #[test]
1271 fn video_non_finite_size_counts_as_absent() {
1272 for bad in [f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {
1273 let v = Video::new("a").size(bad, bad);
1274 assert_eq!(v.get_width(), None, "{bad}");
1275 assert_eq!(v.get_height(), None, "{bad}");
1276 assert_eq!(
1277 v.measure(Constraints::unbounded()),
1278 Size::new(320.0, 180.0),
1279 "{bad}"
1280 );
1281 }
1282 }
1283
1284 #[test]
1285 fn video_huge_size_is_clamped_to_the_coordinate_limit() {
1286 let v = Video::new("a").size(f32::MAX, 1.0e30);
1287 assert_eq!(v.get_width(), Some(LIMIT));
1288 assert_eq!(v.get_height(), Some(LIMIT));
1289 assert_eq!(v.measure(Constraints::unbounded()), Size::new(LIMIT, LIMIT));
1290 }
1291
1292 #[test]
1293 fn video_measure_is_finite_for_degenerate_constraints() {
1294 let v = Video::new("a").size(f32::MAX, 10.0);
1295 for c in [
1296 Constraints::unbounded(),
1297 Constraints::new(f32::INFINITY, f32::INFINITY, 0.0, 5.0),
1298 Constraints::new(0.0, f32::NAN, f32::NAN, f32::INFINITY),
1299 Constraints::new(9.0, 3.0, 9.0, 3.0),
1300 ] {
1301 let s = v.measure(c);
1302 assert!(
1303 s.width.is_finite() && s.height.is_finite(),
1304 "{c:?} -> {s:?}"
1305 );
1306 }
1307 }
1308
1309 #[test]
1310 fn video_verify_flags_invalid_bounds() {
1311 for bad in [
1312 Rect::new(f32::NAN, 0.0, 10.0, 10.0),
1313 Rect::new(0.0, f32::INFINITY, 10.0, 10.0),
1314 Rect::new(0.0, 0.0, -1.0, 10.0),
1315 Rect::new(0.0, 0.0, 10.0, -1.0),
1316 Rect::new(0.0, 0.0, f32::NAN, 10.0),
1317 Rect::new(0.0, 0.0, 10.0, f32::NEG_INFINITY),
1318 ] {
1319 let mut v = named("a.mp4");
1320 v.bounds = bad;
1321 let result = v.verify();
1322 assert!(!result.is_valid(), "{bad:?}");
1323 assert_eq!(result.failed.len(), 1, "{bad:?}");
1324 assert_eq!(
1325 result.failed[0].0,
1326 BrickAssertion::ElementPresent("valid-bounds".into())
1327 );
1328 assert!(result.failed[0].1.contains("bounds"));
1329 }
1330 }
1331
1332 #[test]
1333 fn video_verify_accepts_good_bounds_and_rejects_wild_ones() {
1334 let mut v = named("a.mp4");
1335 v.layout(BOUNDS);
1336 let result = v.verify();
1337 assert!(result.is_valid());
1338 assert!(result
1339 .passed
1340 .contains(&BrickAssertion::ElementPresent("valid-bounds".into())));
1341 v.layout(Rect::new(f32::NAN, f32::MAX, -1.0, f32::INFINITY));
1344 let result = v.verify();
1345 assert!(!result.is_valid());
1346 assert!(result
1347 .failed
1348 .iter()
1349 .any(|(a, _)| *a == BrickAssertion::ElementPresent("valid-bounds".into())));
1350 }
1351
1352 proptest! {
1353 #[test]
1354 fn video_layout_paint_finite_and_inside_any_f32_bounds(
1355 x in proptest::num::f32::ANY,
1356 y in proptest::num::f32::ANY,
1357 w in proptest::num::f32::ANY,
1358 h in proptest::num::f32::ANY,
1359 ) {
1360 prop_assert!(layout_paint_is_finite_inside(Rect::new(x, y, w, h)));
1361 }
1362
1363 #[test]
1364 fn video_measure_finite_for_any_size_and_constraints(
1365 w in proptest::num::f32::ANY,
1366 h in proptest::num::f32::ANY,
1367 a in proptest::num::f32::ANY,
1368 b in proptest::num::f32::ANY,
1369 c in proptest::num::f32::ANY,
1370 d in proptest::num::f32::ANY,
1371 ) {
1372 let s = Video::new("a").size(w, h).measure(Constraints::new(a, b, c, d));
1373 prop_assert!(s.width.is_finite() && s.height.is_finite());
1374 }
1375 }
1376
1377 proptest! {
1380 #[test]
1381 fn video_paint_stays_inside_any_bounds(
1382 x in -1000.0f32..1000.0,
1383 y in -1000.0f32..1000.0,
1384 w in 0.0f32..2000.0,
1385 h in 0.0f32..2000.0,
1386 ) {
1387 let bounds = Rect::new(x, y, w, h);
1388 let mut v = named("a");
1389 v.layout(bounds);
1390
1391 let cmds = paint_commands(&v);
1393 prop_assert_eq!(cmds.len(), 2);
1394 for cmd in &cmds {
1395 match cmd {
1396 DrawCommand::Rect { bounds: r, .. } => {
1397 prop_assert!(rect_inside(&bounds, r, 1e-3));
1398 }
1399 DrawCommand::Path { points, .. } => {
1400 for p in points {
1401 prop_assert!(contains(&bounds, p, 1e-3));
1402 }
1403 let centre = glyph_centre(points);
1404 prop_assert!((centre.x - bounds.center().x).abs() < 0.5);
1405 prop_assert!((centre.y - bounds.center().y).abs() < 0.5);
1406 }
1407 other => prop_assert!(false, "unexpected command {:?}", other),
1408 }
1409 }
1410
1411 v.play();
1413 let cmds = paint_commands(&v);
1414 prop_assert_eq!(cmds.len(), 1);
1415 match &cmds[0] {
1416 DrawCommand::Rect { bounds: r, .. } => {
1417 prop_assert!(rect_inside(&bounds, r, 1e-3));
1418 }
1419 other => prop_assert!(false, "unexpected command {:?}", other),
1420 }
1421 }
1422 }
1423
1424 fn wild() -> impl Strategy<Value = f32> {
1440 prop_oneof![
1441 proptest::num::f32::ANY,
1442 Just(f32::NAN),
1443 Just(f32::INFINITY),
1444 Just(f32::NEG_INFINITY),
1445 Just(f32::MAX),
1446 Just(f32::MIN),
1447 Just(f32::MIN_POSITIVE),
1448 Just(1.0e-40_f32),
1449 Just(-0.0_f32),
1450 Just(-10.0_f32),
1451 -1000.0_f32..1000.0,
1452 ]
1453 }
1454
1455 fn finite_non_negative(v: f32) -> bool {
1456 v.is_finite() && v >= 0.0
1457 }
1458
1459 fn size_sane(s: Size) -> bool {
1460 finite_non_negative(s.width) && finite_non_negative(s.height)
1461 }
1462
1463 fn rect_sane(r: &Rect) -> bool {
1464 r.x.is_finite()
1465 && r.y.is_finite()
1466 && finite_non_negative(r.width)
1467 && finite_non_negative(r.height)
1468 }
1469
1470 fn command_widths_sane(cmd: &DrawCommand) -> bool {
1472 match cmd {
1473 DrawCommand::Rect {
1474 bounds,
1475 radius,
1476 style,
1477 } => {
1478 rect_sane(bounds)
1479 && [
1480 radius.top_left,
1481 radius.top_right,
1482 radius.bottom_right,
1483 radius.bottom_left,
1484 ]
1485 .into_iter()
1486 .all(finite_non_negative)
1487 && style
1488 .stroke
1489 .as_ref()
1490 .is_none_or(|s| finite_non_negative(s.width))
1491 && style.shadow.as_ref().is_none_or(|s| {
1492 s.offset_x.is_finite()
1493 && s.offset_y.is_finite()
1494 && finite_non_negative(s.blur)
1495 })
1496 }
1497 DrawCommand::Path { points, style, .. } => {
1498 finite_non_negative(style.width)
1499 && style.dash.iter().copied().all(finite_non_negative)
1500 && points.iter().all(|p| p.x.is_finite() && p.y.is_finite())
1501 }
1502 _ => false,
1503 }
1504 }
1505
1506 fn raw_bounds_valid(b: Rect) -> bool {
1508 [b.x, b.y, b.width, b.height].iter().all(|v| v.is_finite())
1509 && b.width >= 0.0
1510 && b.height >= 0.0
1511 }
1512
1513 fn bits(r: Rect) -> [u32; 4] {
1514 [
1515 r.x.to_bits(),
1516 r.y.to_bits(),
1517 r.width.to_bits(),
1518 r.height.to_bits(),
1519 ]
1520 }
1521
1522 #[test]
1523 fn reviewer_repro_video_measure_negative_upper_limits_is_not_negative() {
1524 let size = Video::new("demo.mp4").measure(Constraints::new(0.0, -10.0, 0.0, -10.0));
1525 assert!(size_sane(size), "{size:?}");
1526 assert_eq!(size, Size::new(0.0, 0.0));
1527 }
1528
1529 #[test]
1530 fn reviewer_repro_video_verify_fails_after_layout_with_bad_bounds() {
1531 let mut v = named("a.mp4");
1532 v.layout(Rect::new(f32::NAN, 0.0, -5.0, -5.0));
1533 let result = v.verify();
1534 assert!(!result.is_valid());
1535 assert_eq!(result.failed.len(), 1);
1536 assert_eq!(
1537 result.failed[0].0,
1538 BrickAssertion::ElementPresent("valid-bounds".into())
1539 );
1540 let cmds = paint_commands(&v);
1542 assert!(cmds.iter().all(command_widths_sane), "{cmds:?}");
1543 }
1544
1545 #[test]
1546 fn class_video_helpers_reject_what_they_should() {
1547 let bad_radius = DrawCommand::rounded_rect(BOUNDS, -1.0, Color::WHITE);
1548 assert!(!command_widths_sane(&bad_radius));
1549 let nan_radius = DrawCommand::rounded_rect(BOUNDS, f32::NAN, Color::WHITE);
1550 assert!(!command_widths_sane(&nan_radius));
1551 let negative = DrawCommand::filled_rect(Rect::new(0.0, 0.0, -1.0, 1.0), Color::WHITE);
1552 assert!(!command_widths_sane(&negative));
1553 let wide = DrawCommand::Path {
1554 points: vec![Point::new(0.0, 0.0)],
1555 closed: false,
1556 style: presentar_core::draw::StrokeStyle {
1557 width: f32::INFINITY,
1558 ..presentar_core::draw::StrokeStyle::default()
1559 },
1560 };
1561 assert!(!command_widths_sane(&wide));
1562 assert!(!raw_bounds_valid(Rect::new(0.0, 0.0, -1.0, 1.0)));
1563 assert!(raw_bounds_valid(BOUNDS));
1564 }
1565
1566 proptest! {
1567 #[test]
1568 fn class_video_builders_and_entry_points_total(
1569 src in ".{0,8}",
1570 alt in ".{0,8}",
1571 autoplay in any::<bool>(),
1572 controls in any::<bool>(),
1573 ) {
1574 let mut v = Video::new(src.clone())
1575 .poster("p.png")
1576 .alt(alt)
1577 .accessible_name("name")
1578 .test_id("id")
1579 .autoplay(autoplay)
1580 .controls(controls);
1581 prop_assert_eq!(v.get_src(), src.as_str());
1582 prop_assert_eq!(v.is_autoplay(), autoplay);
1583 prop_assert_eq!(v.has_controls(), controls);
1584 prop_assert_eq!(v.is_playing(), autoplay);
1585 prop_assert!(v.get_poster().is_some());
1586 let _ = v.get_alt();
1587 v.play();
1588 prop_assert!(v.is_playing());
1589 v.pause();
1590 v.toggle();
1591 v.ended();
1592 prop_assert_eq!(v.state(), VideoState::Ended);
1593 prop_assert!(!v.to_html().is_empty());
1594 prop_assert!(!v.to_css().is_empty());
1595 }
1596
1597 #[test]
1598 fn class_video_size_builders_clamp_any_f32(w in wild(), h in wild()) {
1599 for v in [
1600 Video::new("a").size(w, h),
1601 Video::new("a").width(w).height(h),
1602 ] {
1603 for got in [v.get_width(), v.get_height()].into_iter().flatten() {
1604 prop_assert!(finite_non_negative(got) && got <= LIMIT, "{got}");
1605 }
1606 prop_assert_eq!(v.get_width().is_some(), w.is_finite());
1607 prop_assert_eq!(v.get_height().is_some(), h.is_finite());
1608 }
1609 }
1610
1611 #[test]
1612 fn class_video_measure_is_finite_and_not_negative(
1613 w in wild(),
1614 h in wild(),
1615 a in wild(),
1616 b in wild(),
1617 c in wild(),
1618 d in wild(),
1619 ) {
1620 let constraints = Constraints::new(a, b, c, d);
1621 for v in [
1622 Video::new("a"),
1623 Video::new("a").size(w, h),
1624 Video::new("a").width(w),
1625 Video::new("a").height(h),
1626 ] {
1627 let s = v.measure(constraints);
1628 prop_assert!(size_sane(s), "{constraints:?} -> {s:?}");
1629 }
1630 }
1631
1632 #[test]
1633 fn class_video_layout_size_is_sane_and_verify_judges_raw_bounds(
1634 x in wild(),
1635 y in wild(),
1636 w in wild(),
1637 h in wild(),
1638 ) {
1639 let bounds = Rect::new(x, y, w, h);
1640 let mut v = named("a.mp4");
1641 let result = v.layout(bounds);
1642 prop_assert!(size_sane(result.size), "{:?}", result.size);
1643 prop_assert_eq!(result.size, oracle_bounds(bounds).size());
1644 prop_assert_eq!(bits(Widget::bounds(&v)), bits(bounds));
1646 let verdict = v.verify();
1647 prop_assert_eq!(verdict.is_valid(), raw_bounds_valid(bounds));
1648 if !raw_bounds_valid(bounds) {
1649 prop_assert_eq!(
1650 &verdict.failed[0].0,
1651 &BrickAssertion::ElementPresent("valid-bounds".into())
1652 );
1653 }
1654 }
1655
1656 #[test]
1657 fn class_video_paint_and_glyph_are_sane_for_any_bounds(
1658 x in wild(),
1659 y in wild(),
1660 w in wild(),
1661 h in wild(),
1662 playing in any::<bool>(),
1663 poster in any::<bool>(),
1664 ) {
1665 let bounds = Rect::new(x, y, w, h);
1666 let expected = oracle_bounds(bounds);
1667 let mut v = if poster { named("a").poster("p.png") } else { named("a") };
1668 v.layout(bounds);
1669 if playing {
1670 v.play();
1671 }
1672 for p in v.play_glyph() {
1673 prop_assert!(point_finite_inside(&p, &expected), "{p:?} outside {expected:?}");
1674 }
1675 let cmds = paint_commands(&v);
1676 prop_assert_eq!(cmds.len(), if playing { 1 } else { 2 });
1677 prop_assert!(cmds.iter().all(command_widths_sane), "{cmds:?}");
1678 prop_assert!(commands_finite_inside(&cmds, &expected), "{cmds:?}");
1679 }
1680
1681 #[test]
1682 fn class_video_event_is_total_and_hits_only_inside(
1683 x in wild(),
1684 y in wild(),
1685 w in wild(),
1686 h in wild(),
1687 px in wild(),
1688 py in wild(),
1689 ) {
1690 let bounds = Rect::new(x, y, w, h);
1691 let expected = oracle_bounds(bounds);
1692 let mut v = named("a");
1693 v.layout(bounds);
1694 let at = Point::new(px, py);
1695 let hit = click(&mut v, at).is_some();
1696 prop_assert_eq!(hit, contains(&expected, &at, 0.0));
1697 prop_assert!(v.event(&Event::MouseLeave).is_none());
1699 prop_assert!(v.event(&Event::key_up(Key::Space)).is_some());
1700 }
1701 }
1702
1703 #[test]
1704 fn class_video_event_hits_the_sanitised_rectangle_not_the_raw_one() {
1705 let mut v = named("a");
1707 v.layout(Rect::new(10.0, 20.0, f32::NAN, f32::NAN));
1708 assert!(click(&mut v, Point::new(10.0, 20.0)).is_some());
1709 assert!(click(&mut v, Point::new(11.0, 20.0)).is_none());
1710 }
1711
1712 #[test]
1713 fn class_video_deserialized_sizes_measure_sane() {
1714 for (w, h) in [
1715 (-5.0, 1.0e30),
1716 (1.0e30, -5.0),
1717 (3.4e38, 3.4e38),
1718 (-1.0, -1.0),
1719 ] {
1720 let mut json = serde_json::to_value(Video::new("a")).unwrap();
1721 json["width"] = serde_json::json!(w);
1722 json["height"] = serde_json::json!(h);
1723 let v: Video = serde_json::from_value(json).unwrap();
1724 for c in [
1725 Constraints::unbounded(),
1726 Constraints::new(0.0, -10.0, 0.0, -10.0),
1727 Constraints::new(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
1728 ] {
1729 let s = v.measure(c);
1730 assert!(size_sane(s), "{w} {h} {c:?} -> {s:?}");
1731 }
1732 }
1733 }
1734}