//! The conformance suite every [`MediaPlayer`] must pass. //! //! One set of behaviours, run against every engine: `FakePlayer` and `MpvPlayer` //! in `cargo test`, `ExoPlayerPlayer` instrumented on a device, `WebviewPlayer` //! in vitest. A new engine is finished when it passes this. //! //! Written *before* the second engine on purpose. A suite written afterwards //! encodes whatever the first engine happened to do, which is how three separate //! playback implementations drifted apart in the first place. //! //! Each case names the defect it exists to prevent. Two of them — //! [`opens_at_a_start_position`] and [`seek_while_opening_is_honoured`] — fail //! against the pre-migration mpv path, which is what makes them a reproduction //! of DR-241 rather than a restatement of it. //! //! Engines differ in *when* an open completes, so the suite drives that through //! a [`Harness`] rather than sleeping: the fake completes on demand, mpv waits //! for its `FileLoaded` event, ExoPlayer for `STATE_READY`. //! //! Available to `cargo test` and, behind the `conformance` feature, to the //! `player-conformance` binary — so an engine that cannot run in-process //! (ExoPlayer on a device) is driven by exactly the same cases rather than by a //! second, drifting checklist. //! //! TRACES: UR-081 | DR-243 | UT-220 use std::time::Duration; use super::media_player::{MediaPlayer, OpenRequest, Phase}; /// How the suite drives one engine. pub trait Harness { type Player: MediaPlayer; fn player(&mut self) -> &mut Self::Player; /// A request this engine can actually open, at `start`. fn request(&self, start: Duration) -> OpenRequest; /// Block until an in-flight `open` has finished (or failed). /// /// The fake completes on demand; a real engine waits for its own readiness /// event. Never a sleep — a timing-dependent suite is worse than none. fn settle(&mut self); /// Whether the engine is producing audio. Engines that cannot answer may /// return `None`, which skips the silence assertions rather than passing /// them vacuously. fn audible(&mut self) -> Option; /// How far a landed position may differ from the one asked for. Keyframe /// granularity makes exactness the wrong bar for a real decoder. fn seek_tolerance(&self) -> Duration { Duration::from_secs(5) } /// Wait for a completed seek to be visible in `snapshot()`. /// /// Engines differ in when that happens: one may record the target the /// moment it accepts the seek, another may not report it until the decoder /// has actually moved. Asserting immediately therefore passes on the first /// and races on the second — which is precisely how this suite produced a /// failure that came and went with machine load rather than with the code. /// /// Default is a no-op, for engines whose snapshot is synchronous. fn await_seek(&mut self, _target: Duration) {} } fn assert_near(actual: Duration, expected: Duration, tolerance: Duration, what: &str) { let delta = actual.abs_diff(expected); assert!( delta <= tolerance, "{what}: expected ~{expected:?}, got {actual:?} (tolerance {tolerance:?})" ); } /// Opening at zero reaches a usable state and starts near the beginning. pub fn opens_from_the_beginning(h: &mut H) { let req = h.request(Duration::ZERO); h.player().open(req).expect("open failed"); h.settle(); let s = h.player().snapshot(); assert!( matches!(s.phase, Phase::Playing | Phase::Ready), "after open the engine should hold media, phase was {:?}", s.phase ); assert_near( s.position, Duration::ZERO, h.seek_tolerance(), "start of item", ); } /// **DR-241.** Opening at a position starts *there*, not at zero. /// /// The whole reason `OpenRequest` carries `start`. Under the previous contract a /// caller had to `load()` then `seek()`, and because `loadfile` is asynchronous /// the seek was issued against a player with nothing loaded, failed, and was /// discarded — so resume and transcoded skip both played from the beginning. pub fn opens_at_a_start_position(h: &mut H) { let start = Duration::from_secs(600); let req = h.request(start); h.player().open(req).expect("open failed"); h.settle(); let s = h.player().snapshot(); assert_ne!( s.position, Duration::ZERO, "opened at {start:?} but playback began at zero - the start position was dropped" ); assert_near(s.position, start, h.seek_tolerance(), "start position"); } /// **DR-241.** A seek issued while opening is honoured, not lost. /// /// The engine owns this window; no caller can avoid it, because a caller cannot /// see when the pipeline becomes ready. pub fn seek_while_opening_is_honoured(h: &mut H) { let target = Duration::from_secs(300); let req = h.request(Duration::ZERO); h.player().open(req).expect("open failed"); // Deliberately before settle(): this is the race, expressed on purpose. h.player().seek(target).expect("seek during open failed"); h.settle(); let s = h.player().snapshot(); assert_near( s.position, target, h.seek_tolerance(), "seek issued while opening", ); } /// A later intent wins: the seek replaces the start position it overtook. pub fn seek_while_opening_overrides_start(h: &mut H) { let start = Duration::from_secs(600); let target = Duration::from_secs(120); let req = h.request(start); h.player().open(req).expect("open failed"); h.player().seek(target).expect("seek during open failed"); h.settle(); assert_near( h.player().snapshot().position, target, h.seek_tolerance(), "seek should override the start position it overtook", ); } /// Seeking a settled item lands where asked. pub fn seeks_after_open(h: &mut H) { let req = h.request(Duration::ZERO); h.player().open(req).expect("open failed"); h.settle(); let target = Duration::from_secs(420); h.player().seek(target).expect("seek failed"); h.await_seek(target); assert_near( h.player().snapshot().position, target, h.seek_tolerance(), "seek after open", ); } /// **DR-239.** Pause and play are reflected in the engine's own state. /// /// An engine that changes nothing observable is indistinguishable from one that /// ignored the call — which is exactly how a handler for mpv's `pause` property /// sat unreachable while the UI waited for an event that never came. pub fn pause_and_play_are_observable(h: &mut H) { let req = h.request(Duration::ZERO); h.player().open(req).expect("open failed"); h.settle(); h.player().pause().expect("pause failed"); assert_eq!( h.player().snapshot().phase, Phase::Paused, "pause must be visible in the snapshot" ); if let Some(audible) = h.audible() { assert!(!audible, "a paused engine must be silent"); } h.player().play().expect("play failed"); assert_eq!( h.player().snapshot().phase, Phase::Playing, "play must be visible in the snapshot" ); } /// `close()` reaches Idle, is silent, and can be called twice. pub fn close_is_silent_and_idempotent(h: &mut H) { let req = h.request(Duration::ZERO); h.player().open(req).expect("open failed"); h.settle(); h.player().close().expect("close failed"); assert_eq!(h.player().snapshot().phase, Phase::Idle); if let Some(audible) = h.audible() { assert!(!audible, "a closed engine must be silent"); } h.player().close().expect("close must be idempotent"); assert_eq!(h.player().snapshot().phase, Phase::Idle); } /// Closing during an open must not let playback start afterwards. /// /// The shape of the "audio keeps playing after leaving the player" report: an /// open still in flight completed after the stop, and nothing was left to tell /// it not to. pub fn close_during_open_never_plays(h: &mut H) { let req = h.request(Duration::ZERO); h.player().open(req).expect("open failed"); h.player().close().expect("close during open failed"); h.settle(); let s = h.player().snapshot(); assert!( !s.phase.is_active(), "an open cancelled by close must not start playing, phase was {:?}", s.phase ); if let Some(audible) = h.audible() { assert!(!audible, "an engine closed during open must be silent"); } } /// Volume, mute and rate round-trip through the snapshot. pub fn transport_settings_round_trip(h: &mut H) { let req = h.request(Duration::ZERO); h.player().open(req).expect("open failed"); h.settle(); h.player().set_volume(0.25).expect("set_volume failed"); h.player().set_muted(true).expect("set_muted failed"); h.player().set_rate(1.5).expect("set_rate failed"); let s = h.player().snapshot(); assert!((s.volume - 0.25).abs() < 0.01, "volume did not round-trip"); assert!(s.muted, "mute did not round-trip"); assert!((s.rate - 1.5).abs() < 0.01, "rate did not round-trip"); } /// Run every case against one engine. /// /// Each case gets a fresh harness, because a suite whose cases depend on each /// other's leftovers is one that hides state bugs instead of finding them. #[macro_export] macro_rules! media_player_conformance { ($name:ident, $make:expr) => { mod $name { use super::*; use $crate::player::conformance as c; macro_rules! case { ($case:ident) => { #[test] fn $case() { let mut h = $make; c::$case(&mut h); } }; } case!(opens_from_the_beginning); case!(opens_at_a_start_position); case!(seek_while_opening_is_honoured); case!(seek_while_opening_overrides_start); case!(seeks_after_open); case!(pause_and_play_are_observable); case!(close_is_silent_and_idempotent); case!(close_during_open_never_plays); case!(transport_settings_round_trip); } }; }