Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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,
    );
}