Skip to main content

presentar_widgets/
video.rs

1//! Video widget: playback state, layout and draw commands.
2//!
3//! This is the pure (non-browser) half of the widget. It owns the playback
4//! state machine and turns it into draw commands; the real `<video>` element
5//! on `wasm32` is bound to this state by a later ticket.
6//!
7//! Contract: `contracts/present-video-widget-v1.yaml`.
8
9use 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
18/// Play glyph height as a fraction of the shorter side of the bounds.
19const GLYPH_SCALE: f32 = 0.25;
20/// Play glyph width over height (an equilateral triangle pointing right).
21const GLYPH_ASPECT: f32 = 0.866;
22/// Default intrinsic width when none is set (16:9).
23const DEFAULT_WIDTH: f32 = 320.0;
24/// Default intrinsic height when none is set (16:9).
25const DEFAULT_HEIGHT: f32 = 180.0;
26/// Largest magnitude accepted for any coordinate or extent. Larger finite
27/// values are clamped so that `x + width` can never overflow to infinity.
28const COORD_LIMIT: f32 = 1.0e15;
29
30/// A finite coordinate: NaN and infinities become 0, huge values are clamped.
31fn sane(value: f32) -> f32 {
32    if value.is_finite() {
33        value.clamp(-COORD_LIMIT, COORD_LIMIT)
34    } else {
35        0.0
36    }
37}
38
39/// `bounds` made safe to compute with: every component finite and within
40/// `COORD_LIMIT`, and the size not negative. Valid bounds in range pass through
41/// unchanged.
42fn 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
51/// Whether `bounds` is finite with a size that is not negative.
52fn 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
60/// A usable intrinsic extent: finite values are clamped into
61/// `[0, COORD_LIMIT]`; NaN and infinities count as "not set".
62fn sane_extent(value: f32) -> Option<f32> {
63    value.is_finite().then(|| value.clamp(0.0, COORD_LIMIT))
64}
65
66/// Fit `value` between `min` and `max` and keep it finite. Unlike
67/// `f32::clamp` this never panics, whatever the constraints (NaN, infinite or
68/// `min > max`); a lower bound that exceeds the upper one loses.
69fn fit(value: f32, min: f32, max: f32) -> f32 {
70    sane(value.max(min).min(max)).max(0.0)
71}
72
73/// `point` pulled onto `bounds` (NaN-safe; never panics).
74fn 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/// Playback state of a [`Video`].
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
83pub enum VideoState {
84    /// Not playing; the poster and the play glyph are shown
85    #[default]
86    Paused,
87    /// Playing; the current frame is shown
88    Playing,
89    /// Playback reached the end; the poster and the play glyph are shown
90    Ended,
91}
92
93/// Message emitted when the user toggles playback.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct VideoToggled {
96    /// Whether the video is playing after the toggle
97    pub playing: bool,
98}
99
100/// Video widget (state, layout and draw commands).
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct Video {
103    /// Video source URI
104    src: String,
105    /// Poster image URI shown while not playing
106    poster: Option<String>,
107    /// Alternative text for accessibility
108    alt: String,
109    /// Intrinsic width
110    width: Option<f32>,
111    /// Intrinsic height
112    height: Option<f32>,
113    /// Start playing as soon as the widget is created
114    autoplay: bool,
115    /// Show the native controls
116    controls: bool,
117    /// Playback state
118    state: VideoState,
119    /// Accessible name override
120    accessible_name_value: Option<String>,
121    /// Test ID
122    test_id_value: Option<String>,
123    /// Mouse is pressed inside the widget
124    #[serde(skip)]
125    pressed: bool,
126    /// Cached bounds
127    #[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    /// Create a new video with a source.
152    #[must_use]
153    pub fn new(src: impl Into<String>) -> Self {
154        Self {
155            src: src.into(),
156            ..Self::default()
157        }
158    }
159
160    /// Set the poster image.
161    #[must_use]
162    pub fn poster(mut self, url: impl Into<String>) -> Self {
163        self.poster = Some(url.into());
164        self
165    }
166
167    /// Set the alt text.
168    #[must_use]
169    pub fn alt(mut self, alt: impl Into<String>) -> Self {
170        self.alt = alt.into();
171        self
172    }
173
174    /// Set the intrinsic width.
175    ///
176    /// Negative values become 0 and values above 1e15 are clamped to it; NaN
177    /// and infinities count as "not set", so `measure` falls back to the
178    /// default width instead of reporting a non-finite size.
179    #[must_use]
180    pub fn width(mut self, width: f32) -> Self {
181        self.width = sane_extent(width);
182        self
183    }
184
185    /// Set the intrinsic height (same rules as [`Video::width`]).
186    #[must_use]
187    pub fn height(mut self, height: f32) -> Self {
188        self.height = sane_extent(height);
189        self
190    }
191
192    /// Set both width and height.
193    #[must_use]
194    pub fn size(self, width: f32, height: f32) -> Self {
195        self.width(width).height(height)
196    }
197
198    /// Set the accessible name.
199    #[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    /// Set the test ID.
206    #[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    /// Set autoplay; the video starts `Playing` when true, `Paused` when false.
213    #[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    /// Set whether the controls are shown.
225    #[must_use]
226    pub const fn controls(mut self, controls: bool) -> Self {
227        self.controls = controls;
228        self
229    }
230
231    /// Get the source.
232    #[must_use]
233    pub fn get_src(&self) -> &str {
234        &self.src
235    }
236
237    /// Get the poster URI.
238    #[must_use]
239    pub fn get_poster(&self) -> Option<&str> {
240        self.poster.as_deref()
241    }
242
243    /// Get the alt text.
244    #[must_use]
245    pub fn get_alt(&self) -> &str {
246        &self.alt
247    }
248
249    /// Get the intrinsic width.
250    #[must_use]
251    pub fn get_width(&self) -> Option<f32> {
252        self.width.and_then(sane_extent)
253    }
254
255    /// Get the intrinsic height.
256    #[must_use]
257    pub fn get_height(&self) -> Option<f32> {
258        self.height.and_then(sane_extent)
259    }
260
261    /// Whether autoplay is set.
262    #[must_use]
263    pub const fn is_autoplay(&self) -> bool {
264        self.autoplay
265    }
266
267    /// Whether controls are shown.
268    #[must_use]
269    pub const fn has_controls(&self) -> bool {
270        self.controls
271    }
272
273    /// Get the playback state.
274    #[must_use]
275    pub const fn state(&self) -> VideoState {
276        self.state
277    }
278
279    /// Whether the video is playing.
280    #[must_use]
281    pub const fn is_playing(&self) -> bool {
282        matches!(self.state, VideoState::Playing)
283    }
284
285    /// Start (or restart) playback.
286    pub fn play(&mut self) {
287        self.state = VideoState::Playing;
288    }
289
290    /// Pause playback. A video that has already ended stays `Ended`.
291    pub fn pause(&mut self) {
292        if self.is_playing() {
293            self.state = VideoState::Paused;
294        }
295    }
296
297    /// Toggle between playing and not playing.
298    pub fn toggle(&mut self) {
299        if self.is_playing() {
300            self.pause();
301        } else {
302            self.play();
303        }
304    }
305
306    /// Mark playback as having reached the end.
307    pub fn ended(&mut self) {
308        self.state = VideoState::Ended;
309    }
310
311    /// The three vertices of the play glyph, centred in the laid-out bounds.
312    ///
313    /// The bounding box of the triangle has the same centre as the bounds.
314    /// Every vertex is finite and inside the sanitised laid-out bounds, whatever
315    /// rectangle was passed to `layout`.
316    #[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    /// Colour of the frame while playing.
331    fn frame_color() -> Color {
332        Color::rgb(0.0, 0.0, 0.0)
333    }
334
335    /// Colour of the placeholder shown when no poster is set.
336    fn placeholder_color() -> Color {
337        Color::rgb(0.15, 0.15, 0.18)
338    }
339
340    /// Colour standing in for the poster image.
341    fn poster_color() -> Color {
342        Color::rgb(0.2, 0.2, 0.28)
343    }
344
345    /// Toggle playback and build the message describing the new state.
346    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    /// Non-empty accessible name, if any.
354    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
362/// Escape a string for use inside a double-quoted HTML attribute.
363fn escape_attr(value: &str) -> String {
364    value
365        .replace('&', "&amp;")
366        .replace('"', "&quot;")
367        .replace('<', "&lt;")
368        .replace('>', "&gt;")
369}
370
371/// Record one verification outcome.
372fn 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        // Not `Constraints::constrain`: its `f32::clamp` panics on NaN or
393        // inverted constraints and passes an infinite minimum through.
394        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    /// Lay the video out in `bounds`.
409    ///
410    /// The bounds are stored as given, so `verify` can judge them (like `Grid`
411    /// and `Waveform`). They are sanitised where they are used (NaN and
412    /// infinities become 0, components are clamped to +-1e15, a negative size
413    /// becomes 0), so every rect and glyph vertex `paint` emits is finite and
414    /// the returned size is finite and not negative.
415    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
503// PROBAR-SPEC-009: Brick Architecture - Tests define interface
504impl 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    // ===== Construction =====
648
649    #[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    // ===== State machine =====
710
711    #[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    // ===== Events =====
739
740    #[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        // Press inside, release outside.
769        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        // Press outside, release inside.
780        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    // ===== Paint =====
835
836    #[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    // ===== Widget =====
912
913    #[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    // ===== Brick =====
957
958    #[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        // An empty name is no name.
981        assert!(!Video::new("a.mp4").accessible_name("").verify().is_valid());
982        // A blank name or blank alt text is no name either.
983        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        // The widget reports the bounds it was laid out with.
993        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        // Alt text alone satisfies the name requirement.
1015        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&quot;b&amp;c&lt;d&gt;.mp4"));
1042        let with_poster = named("a").poster(r#"p"><x>.png"#).to_html();
1043        assert!(with_poster.contains(r#"poster="p&quot;&gt;&lt;x&gt;.png""#));
1044        let with_name = Video::new("a").accessible_name(r#"n"<b>"#).to_html();
1045        assert!(with_name.contains("n&quot;&lt;b&gt;"));
1046    }
1047
1048    #[test]
1049    fn video_to_css_declares_the_class() {
1050        assert!(named("a").to_css().contains(".brick-video"));
1051    }
1052
1053    // ===== Serde / derives =====
1054
1055    #[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    // ===== Composition =====
1083
1084    #[test]
1085    fn video_inside_modal_lays_out_and_paints_inside_bounds() {
1086        // Modal::content takes any `impl Widget`, so the Video is placed in
1087        // the modal's content slot directly.
1088        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        // The last rect painted before the glyph is the video backdrop.
1110        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    // ===== Overflow-proof size and bounds (APR-6897 review, defect 3) =====
1129
1130    /// Documented coordinate limit: independent of the implementation's constant.
1131    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    /// The rectangle every emitted draw must stay in.
1142    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    /// Every rect and every glyph vertex in `cmds` is finite and inside `outer`.
1160    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    /// Lay `bounds` out, then check the glyph and the paint in both states.
1183    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        // `bounds()` reports the raw laid-out rectangle so `verify` can judge it.
1205        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        // layout keeps the raw bounds, so verify catches wild input; paint stays
1342        // finite because it sanitises where it uses them.
1343        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    // ===== Properties =====
1378
1379    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            // Not playing: backdrop + centred glyph, all inside bounds.
1392            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            // Playing: exactly the frame rect, no glyph.
1412            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    // ===== Class property: every public entry point, any f32 (PR #6897, round 3) =====
1425    //
1426    // Entry point                       covered by
1427    // new / poster / alt / accessible_name / test_id / autoplay / controls
1428    //                                   class_video_builders_and_entry_points_total
1429    // width / height / size             class_video_size_builders_clamp_any_f32
1430    // get_* / is_* / state / play / pause / toggle / ended
1431    //                                   class_video_builders_and_entry_points_total
1432    // measure                           class_video_measure_is_finite_and_not_negative
1433    // layout / bounds / verify          class_video_layout_size_is_sane_and_verify_judges_raw_bounds
1434    // play_glyph / paint                class_video_paint_and_glyph_are_sane_for_any_bounds
1435    // event                             class_video_event_is_total_and_hits_only_inside
1436    // Deserialize (out-of-range size)   class_video_deserialized_sizes_measure_sane
1437
1438    /// Any f32, with the awkward ones over-represented.
1439    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    /// Every coordinate finite; every width, radius and blur finite and not negative.
1471    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    /// What `Brick::verify` must judge a laid-out rectangle by: the raw values.
1507    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        // The paint is still safe: the bad rectangle collapses to a point.
1541        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            // bounds() reports what was laid out, so verify() can see it.
1645            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            // Keys and leave never panic and never depend on the bounds.
1698            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        // Raw bounds are NaN-wide: the sanitised rectangle is the point (10, 20).
1706        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}