filmcraft/crates/ui-egui/tests/timecode_ui.rs

211 lines
9.1 KiB
Rust

//! The Timeline's and monitors' current-time timecodes, driven headless through the control
//! channel: dragging scrubs the playhead (1 frame per point, never before the start), a click
//! turns the timecode into a field where Enter goes to the typed time and Escape cancels.
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_time::{FrameRate, parse_timecode};
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use serde_json::{Value, json};
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
}
impl Driver {
fn demo() -> Self {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
session.execute("source.open", json!({"item": 5})).expect("source clip");
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);
let mut d = Driver { harness, tx };
d.frames(4);
d
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(r) = reply.try_recv() {
assert_eq!(r["ok"], true, "{method} {params}: {r}");
self.frames(2);
return r["result"].clone();
}
}
panic!("no reply to {method} {params}");
}
/// Centre of an element, from `ui.elements`.
fn centre(&mut self, id: &str) -> (f64, f64) {
let v = self.ok("ui.elements", json!({"prefix": id}));
let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}")).clone();
let r: Vec<f64> = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)
}
fn label(&mut self, id: &str) -> String {
let v = self.ok("ui.elements", json!({"prefix": id}));
v.as_array().unwrap().iter().find(|e| e["id"] == id).and_then(|e| e["label"].as_str()).unwrap_or_default().to_string()
}
fn drag_by(&mut self, id: &str, dx: f64) {
let (x, y) = self.centre(id);
self.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + dx, "y": y}}));
}
fn type_into(&mut self, id: &str, text: &str, key: &str) {
self.ok("ui.click", json!({"id": id}));
self.ok("ui.type", json!({"text": text}));
self.ok("ui.key", json!({"key": key}));
}
fn frame(&self) -> i64 {
let s = &self.harness.state().session;
s.sequence_rate().frame_at(s.playhead())
}
fn source_rate(&self) -> FrameRate {
filmcraft_engine::clip_ops::source_view(&self.harness.state().session, filmcraft_project::ItemId(5)).unwrap().rate
}
fn source_frame(&self) -> i64 {
self.source_rate().frame_at(self.harness.state().session.state.source_playhead)
}
fn set_frame(&mut self, f: i64) {
self.harness.state_mut().session.execute("playhead.set", json!({"frame": f})).unwrap();
self.frames(2);
}
}
#[test]
fn dragging_the_timeline_timecode_scrubs_and_stops_at_the_start() {
let mut d = Driver::demo();
let before = d.harness.state().session.project.to_json();
d.set_frame(30);
d.drag_by("timeline.timecode", 20.0);
assert_eq!(d.frame(), 50, "1 frame per point");
d.drag_by("timeline.timecode", -12.0);
assert_eq!(d.frame(), 38);
d.drag_by("timeline.timecode", -300.0);
assert_eq!(d.frame(), 0, "never before the start");
assert_eq!(d.harness.state().session.project.to_json(), before, "scrubbing does not edit the project");
}
#[test]
fn typing_in_the_timeline_timecode_goes_to_that_time() {
let mut d = Driver::demo();
d.set_frame(30);
d.type_into("timeline.timecode", "1000", "Enter");
let (rate, df) = {
let s = &d.harness.state().session;
(s.sequence_rate(), s.active_sequence().unwrap().settings.drop_frame)
};
assert_eq!(d.frame(), parse_timecode("1000", rate, df, 0).unwrap(), "the whole value was selected and replaced");
d.type_into("timeline.timecode", "+15", "Enter");
assert_eq!(d.frame(), parse_timecode("1000", rate, df, 0).unwrap() + 15, "relative to the current time");
}
#[test]
fn arrow_keys_move_the_caret_instead_of_committing() {
let mut d = Driver::demo();
let (rate, df) = {
let s = &d.harness.state().session;
(s.sequence_rate(), s.active_sequence().unwrap().settings.drop_frame)
};
// Left on the selected value lands before its last digit: Delete removes that digit
d.set_frame(30);
let tc = d.label("timeline.timecode");
d.ok("ui.click", json!({"id": "timeline.timecode"}));
d.ok("ui.key", json!({"key": "Left"}));
assert_eq!(d.frame(), 30, "Left neither commits nor steps the playhead");
d.ok("ui.key", json!({"key": "Delete"}));
d.ok("ui.key", json!({"key": "Enter"}));
let mut expect = tc.clone();
expect.pop();
assert_eq!(d.frame(), parse_timecode(&expect, rate, df, 0).unwrap(), "{tc} without its last digit");
// Right goes to the end: Backspace removes the last digit, then a new one is typed
d.set_frame(30);
d.ok("ui.click", json!({"id": "timeline.timecode"}));
d.ok("ui.key", json!({"key": "Right"}));
d.ok("ui.key", json!({"key": "Backspace"}));
d.ok("ui.type", json!({"text": "9"}));
d.ok("ui.key", json!({"key": "Enter"}));
let mut expect = tc.clone();
expect.pop();
expect.push('9');
assert_eq!(d.frame(), parse_timecode(&expect, rate, df, 0).unwrap(), "{tc} with its last digit replaced");
}
#[test]
fn escape_cancels_and_a_bad_time_is_reported_without_moving() {
let mut d = Driver::demo();
d.set_frame(30);
d.type_into("timeline.timecode", "1000", "Escape");
assert_eq!(d.frame(), 30, "Escape cancels");
d.type_into("timeline.timecode", "abc", "Enter");
assert_eq!(d.frame(), 30);
assert!(d.harness.state().ui.status.contains("timecode"), "status: {}", d.harness.state().ui.status);
// after a cancel or an error the readout scrubs again
d.drag_by("timeline.timecode", 10.0);
assert_eq!(d.frame(), 40);
}
#[test]
fn the_program_and_source_timecodes_move_their_own_playheads() {
let mut d = Driver::demo();
d.set_frame(30);
d.drag_by("program.timecode", 20.0);
assert_eq!(d.frame(), 50);
let program = d.harness.state().session.playhead();
let source = d.source_frame();
d.drag_by("source.timecode", 20.0);
assert_eq!(d.source_frame(), source + 20);
assert_eq!(d.harness.state().session.playhead(), program, "the Source timecode leaves the sequence alone");
d.type_into("source.timecode", "100", "Enter");
assert_eq!(d.source_frame(), parse_timecode("100", d.source_rate(), false, 0).unwrap(), "1 s at the clip's own rate");
assert_eq!(d.harness.state().session.playhead(), program);
}
#[test]
fn compact_floating_monitor_timecodes_keep_drag_typing_escape_and_caret_routing() {
for scale in [1.0, 2.0] {
let mut d = Driver::demo();
d.harness.ctx.set_pixels_per_point(scale);
d.frames(4);
for (panel, x) in [("Source", 70), ("Program", 360)] {
d.ok("engine.execute", json!({"command": "window.floatPanel", "params": {"panel": panel, "rect": [x, 100, 250, 360]}}));
}
let project = d.harness.state().session.project.clone();
d.set_frame(30);
d.drag_by("program.timecode", 20.0);
assert_eq!(d.frame(), 50);
let program = d.harness.state().session.playhead();
let source = d.source_frame();
d.drag_by("source.timecode", 20.0);
assert_eq!(d.source_frame(), source + 20);
assert_eq!(d.harness.state().session.playhead(), program);
d.type_into("program.timecode", "1000", "Escape");
assert_eq!(d.harness.state().session.playhead(), program);
d.type_into("source.timecode", "100", "Enter");
assert_eq!(d.source_frame(), parse_timecode("100", d.source_rate(), false, 0).unwrap());
assert_eq!(d.harness.state().session.playhead(), program);
d.ok("ui.click", json!({"id": "program.timecode"}));
d.ok("ui.key", json!({"key": "Left"}));
assert_eq!(d.harness.state().session.playhead(), program, "caret arrows must not seek");
d.ok("ui.key", json!({"key": "Escape"}));
d.type_into("program.timecode", "200", "Enter");
let s = &d.harness.state().session;
assert_eq!(d.frame(), parse_timecode("200", s.sequence_rate(), s.active_sequence().unwrap().settings.drop_frame, 0).unwrap());
assert!(std::sync::Arc::ptr_eq(&project, &s.project));
}
}