facett-video/src/tests.rs
//! Inject-and-assert tests: a deterministic frame-source backend + an injected
//! clock drive playback; assert frame advance, seek, ROI, and the **copy a frame
//! → paste into a viewer** Exit criterion. Plus the canonical Elm split (FC-2 →
//! FC-3): drive a `Vec<Msg>` through the core harness with no egui / no GPU.
use super::*;
// `Facet` for `state_json`/`caps`; the inherent `state()`/`frame_index()`/… stay.
use facett_core::Facet;
use facett_core::clip::{self, ClipKind, ClipPayload, PasteTarget};
#[test]
fn frame_sequence_backend_reports_count_and_frames() {
let seq = FrameSequence::test_pattern(10, 4, 4);
assert_eq!(seq.frame_count(), 10);
assert!(seq.frame(0).is_some());
assert!(seq.frame(10).is_none());
}
#[test]
fn playback_advances_frames_on_the_injected_clock() {
let seq = FrameSequence::test_pattern(30, 2, 2); // 24 fps
let mut p = VideoPlayer::new("vid", seq);
p.play();
// 0.5 s at 24 fps → 12 frames.
p.advance(0.5);
assert_eq!(p.frame_index(), 12, "12 frames after 0.5s @ 24fps");
// Paused → no advance.
p.pause();
p.advance(1.0);
assert_eq!(p.frame_index(), 12);
}
#[test]
fn playback_loops_at_the_end() {
let seq = FrameSequence::test_pattern(10, 2, 2);
let mut p = VideoPlayer::new("vid", seq);
p.play();
p.advance(1.0); // 24 frames @ 24fps over 10 frames → wraps
assert!(p.frame_index() < 10, "frame index wraps within range");
}
#[test]
fn seek_clamps_to_range() {
let mut p = VideoPlayer::new("vid", FrameSequence::test_pattern(5, 2, 2));
p.seek(999);
assert_eq!(p.frame_index(), 4, "clamped to last frame");
}
#[test]
fn roi_round_trips() {
let mut p = VideoPlayer::new("vid", FrameSequence::test_pattern(5, 2, 2));
let roi = egui::Rect::from_min_max(egui::pos2(0.1, 0.2), egui::pos2(0.6, 0.7));
p.set_roi(Some(roi));
assert_eq!(p.roi(), Some(roi));
let s = p.state_json();
assert!((s["roi"][0].as_f64().unwrap() - 0.1).abs() < 1e-5);
}
#[test]
fn copy_a_frame_pastes_into_an_image_viewer() {
// The M2 image-copy Exit criterion.
clip::clear_typed();
let p = VideoPlayer::new("vid", FrameSequence::test_pattern(5, 8, 6));
let payload = p.copy_payload().expect("a frame to copy");
assert_eq!(payload.kind(), ClipKind::Image);
clip::put_typed_inproc(payload);
struct Viewer(Option<[usize; 2]>);
impl PasteTarget for Viewer {
fn accepts(&self, k: ClipKind) -> bool {
matches!(k, ClipKind::Image | ClipKind::Text)
}
fn paste_payload(&mut self, p: &ClipPayload) {
if let ClipPayload::Image(img) = p {
self.0 = Some(img.size);
}
}
}
let mut v = Viewer(None);
assert!(clip::paste_into(&mut v));
assert_eq!(v.0, Some([8, 6]), "viewer received the copied video frame as a typed image");
clip::clear_typed();
}
#[test]
fn state_json_and_caps() {
let p = VideoPlayer::new("vid", FrameSequence::test_pattern(48, 2, 2));
let caps = p.caps();
assert!(caps.themeable && caps.copyable && caps.selectable && caps.scalable);
let s = p.state_json();
assert_eq!(s["frames"], 48);
assert_eq!(s["playing"], false);
assert!((s["duration"].as_f64().unwrap() - 2.0).abs() < 0.01, "48 frames @ 24fps = 2s");
}
#[test]
fn headless_render_draws_a_frame() {
use facett_core::harness::headless_render;
let mut p = VideoPlayer::new("vid", FrameSequence::test_pattern(10, 16, 12));
let r = headless_render(&mut p);
assert!(r.drew(), "video surface + chrome render headlessly");
assert_eq!(r.state["frames"], 10);
}
// ── The canonical Elm split (FC-2 / FC-3 / FC-8) as headless properties ────────
//
// `view()` (the only egui-touching part) is never called below; the harness feeds
// a `Vec<Msg>` through `update` and snapshots `state()` — no egui, no GPU.
/// FC-2 → FC-3: play / pause / seek / step / speed transitions are all observable
/// in the snapshotted `VideoState` after driving the matching `Msg`s.
#[test]
fn harness_drives_transport_transitions() {
use facett_core::harness;
let mut p = VideoPlayer::new("vid", FrameSequence::test_pattern(20, 2, 2));
// Play then pause flips the observable `playing` flag.
let snap = harness::snapshot(&mut p, [Msg::Play]);
assert!(snap.playing, "Play is observable");
let snap = harness::snapshot(&mut p, [Msg::Pause]);
assert!(!snap.playing, "Pause is observable");
// Toggle flips it back.
let snap = harness::snapshot(&mut p, [Msg::TogglePlay]);
assert!(snap.playing, "TogglePlay flips play<->pause");
// Seek + StepFrame move the playhead, both clamped to the decoded range.
let snap = harness::snapshot(&mut p, [Msg::Seek(5), Msg::StepFrame(2)]);
assert_eq!(snap.frame, 7, "seek 5 then +2 = frame 7");
let snap = harness::snapshot(&mut p, [Msg::StepFrame(-100)]);
assert_eq!(snap.frame, 0, "step clamps at the first frame");
let snap = harness::snapshot(&mut p, [Msg::Seek(999)]);
assert_eq!(snap.frame, 19, "seek clamps at the last frame (20 frames → idx 19)");
// Speed + loop are observable control state.
let snap = harness::snapshot(&mut p, [Msg::SetSpeed(2.0), Msg::SetLoop(false)]);
assert_eq!(snap.speed, 2.0);
assert!(!snap.looping);
// Volume clamps to [0,1].
let snap = harness::snapshot(&mut p, [Msg::SetVolume(5.0)]);
assert_eq!(snap.volume, 1.0, "volume clamps to 1.0");
}
/// FC-7 injected clock: a `Msg::Tick` deterministically advances the playhead —
/// the same transition the pure view emits from `stable_dt`, driven headlessly.
#[test]
fn clock_tick_advances_the_playhead_deterministically() {
use facett_core::harness;
let mut p = VideoPlayer::new("vid", FrameSequence::test_pattern(30, 2, 2)); // 24 fps
// Not playing → a Tick is a no-op (the clock only runs during playback).
let snap = harness::snapshot(&mut p, [Msg::Tick(1.0)]);
assert_eq!(snap.frame, 0, "a Tick while paused does not advance the playhead");
// Playing → 0.5 s @ 24 fps advances exactly 12 frames (deterministic).
let snap = harness::snapshot(&mut p, [Msg::Play, Msg::Tick(0.5)]);
assert_eq!(snap.frame, 12, "Play then Tick(0.5) @ 24fps advances 12 frames");
// Doubling the speed doubles the advance for the same dt.
let mut p2 = VideoPlayer::new("vid", FrameSequence::test_pattern(60, 2, 2));
let snap = harness::snapshot(&mut p2, [Msg::Play, Msg::SetSpeed(2.0), Msg::Tick(0.5)]);
assert_eq!(snap.frame, 24, "2x speed over 0.5s @ 24fps advances 24 frames");
}
/// With loop OFF the playhead stops on the last frame instead of wrapping.
#[test]
fn tick_without_loop_stops_on_the_last_frame() {
use facett_core::harness;
let mut p = VideoPlayer::new("vid", FrameSequence::test_pattern(10, 2, 2));
let snap = harness::snapshot(&mut p, [Msg::SetLoop(false), Msg::Play, Msg::Tick(1.0)]);
assert_eq!(snap.frame, 9, "loop off clamps at the last frame (idx 9)");
assert!(!snap.playing, "playback stops when it reaches the end with loop off");
}
/// FC-8: the player does no async I/O, so the Effect stream is always empty; and
/// FC-3: the observable Model round-trips through serde.
#[test]
fn drive_reports_no_effects_and_state_round_trips() {
use facett_core::harness;
let mut p = VideoPlayer::new("vid", FrameSequence::test_pattern(10, 2, 2));
let effects = harness::drive(&mut p, [Msg::Play, Msg::Seek(3), Msg::SetSpeed(1.5)]);
assert!(effects.is_empty(), "FC-8: the player emits no Effects");
// FC-3: serde(state) -> state round-trips.
let json = serde_json::to_value(p.state()).unwrap();
assert_eq!(json["frame"], 3);
let back: VideoState = serde_json::from_value(json).unwrap();
assert_eq!(&back, p.state(), "the observable Model round-trips through serde");
}
/// FC-5 stable-id selection: the ROI is set via `Msg::SetRoi` and observed in the
/// snapshot as normalised `[l,t,r,b]` (stable across the on-screen letterbox size).
#[test]
fn roi_selection_is_msg_driven_and_snapshots() {
use facett_core::harness;
let mut p = VideoPlayer::new("vid", FrameSequence::test_pattern(5, 2, 2));
let roi = egui::Rect::from_min_max(egui::pos2(0.1, 0.2), egui::pos2(0.6, 0.7));
let snap = harness::snapshot(&mut p, [Msg::SetRoi(Some(roi))]);
assert_eq!(snap.roi, Some([0.1, 0.2, 0.6, 0.7]));
let snap = harness::snapshot(&mut p, [Msg::SetRoi(None)]);
assert_eq!(snap.roi, None, "clearing the ROI is observable");
}
/// The generated `Facet::ui` (view + update loop) renders headlessly and the
/// injected-clock Tick the playing view emits advances the playhead across frames.
#[test]
fn playing_view_advances_across_headless_frames() {
use facett_core::harness::headless_render;
let mut p = VideoPlayer::new("vid", FrameSequence::test_pattern(30, 8, 6));
p.play();
let before = p.frame_index();
// Each headless render runs the view once → emits one Tick → update advances.
let r = headless_render(&mut p);
assert!(r.drew(), "the playing surface tessellates to vertices");
headless_render(&mut p);
assert!(p.frame_index() >= before, "the injected-clock Tick advances the playhead");
assert!(p.is_playing());
}
/// **RED-when-broken: the uniform scale knob reaches the PIXELS.**
///
/// This facet advertised `.scalable()`, stored `scale`, clamped it to `0.25..=4.0` and
/// published it in `state_json` — and then read it from NOWHERE. The deck drew
/// `+`/`−`/`Reset` and a live percentage on the Video tab and not one pixel moved. No
/// test could catch it because none existed, and `drew()` could not have: a stretched
/// image is the same two triangles at any size. So this measures APPLIED geometry —
/// the area of the textured triangles the painter really emitted.
#[test]
fn scale_is_applied_to_the_drawn_surface_not_just_stored() {
use facett_core::harness;
let ink = |s: f32| {
let mut p = VideoPlayer::new("vid", FrameSequence::test_pattern(5, 8, 6));
<_ as facett_core::Facet>::set_scale(&mut p, s);
harness::headless_render(&mut p).textured_ink_area
};
let (small, big) = (ink(1.0), ink(2.0));
assert!(small > 0.0, "the player draws a surface at 1.0x ({small})");
assert!(
big > small * 2.0,
"doubling the scale must roughly QUADRUPLE the drawn surface, got {small:.0} -> \
{big:.0} pt^2 ({:.2}x). A ratio near 1.0 means the knob is stored, clamped and \
serialised but never applied to anything drawn.",
big / small,
);
}