Files
effectcraft/crates/ui-egui/tests/ui_viewer.rs
T

746 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Headless checks for the Composition viewer's interaction parity: bottom bar order, region of
//! interest drawing, rulers and guides, snapping, the shape Pen and motion-path key drags
//! (egui_kittest, UI logic only).
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::state::Tool;
use egui::{Event, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
fn app() -> EffectcraftApp {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 640, "height": 360})).unwrap();
s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap();
s.execute("edit.deselectAll", json!({})).unwrap();
EffectcraftApp::new(s)
}
fn harness() -> Harness<'static, EffectcraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app());
h.run_steps(3);
h
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
/// Comp pixel → screen point.
fn screen(h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> Pos2 {
let c = rect(h, "viewer.comp");
let z = c.width() / 640.0;
c.min + vec2(p[0] * z, p[1] * z)
}
#[test]
fn bottom_bar_in_after_effects_order() {
let h = harness();
let order = [
"viewer.magnification",
"viewer.resolution",
"viewer.transparency",
"viewer.masks",
"viewer.roi",
"viewer.grid",
"viewer.channel",
"viewer.resetExposure",
"viewer.exposure",
"viewer.snapshot",
"viewer.showSnapshot",
"viewer.fastPreviews",
"viewer.timecode",
];
let xs: Vec<f32> = order.iter().map(|id| rect(&h, id).min.x).collect();
assert!(xs.windows(2).all(|w| w[0] < w[1]), "{xs:?}");
let mag = &h.state().auto.find("viewer.magnification").unwrap().label;
assert_eq!(mag, "Magnification");
}
#[test]
fn region_of_interest_is_drawn_in_the_viewer() {
let mut h = harness();
let at = rect(&h, "viewer.roi").center();
click(&mut h, at);
assert!(h.state().ui.viewer.roi_draw);
let (a, b) = (screen(&h, [100.0, 50.0]), screen(&h, [300.0, 250.0]));
drag(&mut h, a, b);
let r = h.state().session.state.region_of_interest.expect("roi");
assert!((r[0] - 100.0).abs() <= 2.0 && (r[1] - 50.0).abs() <= 2.0 && (r[2] - 200.0).abs() <= 3.0 && (r[3] - 200.0).abs() <= 3.0, "{r:?}");
assert!(!h.state().ui.viewer.roi_draw);
h.run_steps(3);
assert!(h.state().auto.find("viewer.regionOfInterest").is_some());
// The button clears it again.
let at = rect(&h, "viewer.roi").center();
click(&mut h, at);
assert!(h.state().session.state.region_of_interest.is_none());
}
#[test]
fn rulers_make_guides_and_guides_move() {
let mut h = harness();
h.state_mut().ui.viewer.rulers = true;
h.run_steps(3);
let top = rect(&h, "viewer.ruler.top");
let to = screen(&h, [0.0, 120.0]);
drag(&mut h, pos2(to.x + 200.0, top.center().y), pos2(to.x + 200.0, to.y));
let g = h.state().session.active_comp().unwrap().guides.clone();
assert_eq!(g.len(), 1);
assert!(!g[0].vertical && (g[0].position - 120.0).abs() <= 2.0, "{g:?}");
// Drag the guide down with the Selection tool.
let from = pos2(screen(&h, [500.0, 0.0]).x, screen(&h, [0.0, g[0].position as f32]).y);
let to2 = pos2(from.x, screen(&h, [0.0, 200.0]).y);
drag(&mut h, from, to2);
let g = h.state().session.active_comp().unwrap().guides.clone();
assert!((g[0].position - 200.0).abs() <= 2.0, "{g:?}");
}
#[test]
fn layer_drag_snaps_to_comp_centre_and_ctrl_disables() {
let mut h = harness();
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
h.run_steps(2);
// Grab the box at its centre and drop it 3 px from the comp centre: it snaps there.
let from = screen(&h, [100.0, 100.0]);
let to = screen(&h, [323.0, 182.0]);
drag(&mut h, from, to);
let pos = |h: &Harness<'_, EffectcraftApp>| {
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
l.props.prop("transform/position").unwrap().value.as_vec3()
};
let p = pos(&h);
assert!((p[0] - 320.0).abs() < 0.01 && (p[1] - 180.0).abs() < 0.01, "{p:?}");
// Snapping off: the same drag lands where the pointer is.
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
h.run_steps(2);
drag(&mut h, from, to);
let p = pos(&h);
assert!((p[0] - 320.0).abs() > 1.0, "{p:?}");
}
#[test]
fn shape_pen_draws_a_closed_shape_layer() {
let mut h = harness();
h.state_mut().ui.tool = Tool::Pen;
h.run_steps(2);
let n0 = h.state().session.active_comp().unwrap().layers.len();
for p in [[200.0, 100.0], [400.0, 120.0], [300.0, 260.0], [200.0, 100.0]] {
let at = screen(&h, p);
click(&mut h, at);
}
let comp = h.state().session.active_comp().unwrap().clone();
assert_eq!(comp.layers.len(), n0 + 1);
let l = &comp.layers[0];
assert!(matches!(l.source, effectcraft_engine::project::LayerSource::Shape));
let g = l.props.sub("contents").unwrap().groups().next().unwrap();
assert_eq!(g.name, "Shape 1");
let path = g.sub("contents").unwrap().groups().next().unwrap();
let sp = path.get("path").unwrap().value.as_path().unwrap().clone();
assert_eq!(sp.vertices.len(), 3);
assert!(sp.closed);
assert!((sp.vertices[0][0] - (200.0 - 320.0)).abs() < 1.5, "{:?}", sp.vertices);
// G cycles the Pen slot: Add Vertex, Delete Vertex, Convert Vertex, Mask Feather, Pen.
let ctx = h.ctx.clone();
let mut seen = vec![];
for _ in 0..5 {
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "tool.pen", json!({})).unwrap();
seen.push(h.state().ui.tool);
}
assert_eq!(seen, vec![Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather, Tool::Pen]);
// Add Vertex on the first segment.
h.state_mut().ui.tool = Tool::PenAdd;
h.run_steps(2);
let at = screen(&h, [300.0, 110.0]);
click(&mut h, at);
let comp = h.state().session.active_comp().unwrap().clone();
let sp = comp.layers[0]
.props
.sub("contents")
.unwrap()
.groups()
.next()
.unwrap()
.sub("contents")
.unwrap()
.groups()
.next()
.unwrap()
.get("path")
.unwrap()
.value
.as_path()
.unwrap()
.clone();
assert_eq!(sp.vertices.len(), 4);
}
#[test]
fn motion_path_key_drag_edits_that_key() {
let mut h = harness();
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 0.0, "value": [100, 100, 0]})).unwrap();
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 2.0, "value": [500, 100, 0]})).unwrap();
s.execute("view.snapping", json!({"value": false})).unwrap();
s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0));
h.run_steps(3);
assert!(h.state().auto.find(&format!("viewer.motionPath.{}.1", box_id.0)).is_some());
let from = screen(&h, [500.0, 100.0]);
let to2 = screen(&h, [500.0, 300.0]);
drag(&mut h, from, to2);
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
let k = &l.props.prop("transform/position").unwrap().keys;
let v1 = k[1].value.as_vec3();
assert!((v1[1] - 300.0).abs() < 2.0 && (v1[0] - 500.0).abs() < 2.0, "{v1:?}");
assert_eq!(k[0].value.as_vec3(), [100.0, 100.0, 0.0]);
}
#[test]
fn graph_editor_transform_box_scales_selected_keys_in_time() {
let mut h = harness();
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
let uid = s.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().uid;
s.execute("prop.select", json!({"layer": box_id.0, "prop": uid})).unwrap();
let keys: Vec<_> = [0.0, 1.0, 2.0].iter().map(|t| json!({"layer": box_id.0, "prop": uid, "time": t})).collect();
s.execute("keys.select", json!({"keys": keys})).unwrap();
h.state_mut().ui.timeline.graph_editor = true;
h.run_steps(4);
for id in ["timeline.graph.snap", "timeline.graph.reference", "timeline.graph.transformBox", "timeline.graph.transformBox.5"] {
assert!(h.state().auto.find(id).is_some(), "missing {id}");
}
// Drag the right edge handle to the right: the keys spread out in time, the first stays.
let r = rect(&h, "timeline.graph.transformBox.5");
let k0 = rect(&h, &format!("timeline.graph.key.{uid}.0.0")).center();
let k2 = rect(&h, &format!("timeline.graph.key.{uid}.0.2")).center();
let to = r.center() + vec2((k2.x - k0.x) * 0.5, 0.0);
drag(&mut h, r.center(), to);
let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().clone();
let ts: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
assert_eq!(ts[0], 0.0);
assert!(ts[2] > 2.5 && ts[2] < 3.5, "{ts:?}");
assert_eq!(p.keys.iter().map(|k| k.value.as_f64()).collect::<Vec<_>>(), vec![0.0, 50.0, 100.0]);
let undo = h.state().session.history.undo.iter().filter(|(l, _)| l == "Transform Keyframes").count();
assert_eq!(undo, 1, "one undo step per drag");
}
#[test]
fn timeline_alt_drag_scales_a_key_group_in_time() {
let mut h = harness();
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
let uid = s.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().uid;
s.execute("keys.selectAll", json!({"layers": [box_id.0]})).unwrap();
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap();
h.run_steps(4);
let mut ks: Vec<Pos2> =
h.state().auto.query(&format!("timeline.key.{uid}.")).iter().map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)).collect();
ks.sort_by(|a, b| a.x.total_cmp(&b.x));
assert_eq!(ks.len(), 3, "{ks:?}");
let (k0, k2) = (ks[0], ks[2]);
// Alt-drag the last key to where 3 s would be: the group scales by 1.5 about the first key.
let to = pos2(k0.x + (k2.x - k0.x) * 1.5, k2.y);
let alt = egui::Modifiers { alt: true, ..Default::default() };
h.input_mut().events.push(Event::PointerMoved(k2));
h.step();
h.input_mut().events.push(Event::ModifiersChanged(alt));
h.input_mut().events.push(Event::PointerButton { pos: k2, button: egui::PointerButton::Primary, pressed: true, modifiers: alt });
h.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(k2 + (to - k2) * (i as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: alt });
h.run_steps(2);
let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().clone();
let ts: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
assert_eq!(ts.len(), 3);
assert_eq!(ts[0], 0.0);
assert!((ts[2] - 3.0).abs() < 0.1 && (ts[1] - 1.5).abs() < 0.1, "{ts:?}");
}
#[test]
fn mask_feather_tool_adds_and_drags_feather_points() {
let mut h = harness();
// A mask on the Box (80×80 at the comp centre, comp 280…360 × 140…220): layer 10…70.
let (box_id, mask) = {
let s = &mut h.state_mut().session;
let b = s.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id;
let m = s.execute("mask.new", json!({"layer": b.0, "vertices": [[10, 10], [70, 10], [70, 70], [10, 70]], "closed": true})).unwrap()["mask"]
.as_u64()
.unwrap();
s.execute("layer.select", json!({"layers": [b.0]})).unwrap();
(b, m)
};
h.state_mut().ui.tool = Tool::MaskFeather;
h.run_steps(3);
// Press on the top edge (comp 320,150) and drag 12 px up: an outer feather point of ≈12 px.
let from = screen(&h, [320.0, 150.0]);
let z = rect(&h, "viewer.comp").width() / 640.0;
drag(&mut h, from, from - vec2(0.0, 12.0 * z));
let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap();
let r = pts["points"][0]["radius"].as_f64().unwrap();
assert!((r - 12.0).abs() < 1.5, "{pts}");
assert_eq!(pts["points"][0]["segment"], json!(0));
// The handle is registered; dragging it inwards makes it an inner feather point.
h.run_steps(2);
let handle = rect(&h, &format!("viewer.mask.{mask}.feather.0")).center();
let to = screen(&h, [320.0, 160.0]);
drag(&mut h, handle, to);
let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap();
let r = pts["points"][0]["radius"].as_f64().unwrap();
assert!((r + 10.0).abs() < 1.5, "{pts}");
assert_eq!(pts["points"].as_array().unwrap().len(), 1);
// One drag = one undo step each.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap();
assert!(pts["points"][0]["radius"].as_f64().unwrap() > 0.0);
}
#[test]
fn region_of_interest_resizes_by_its_handles() {
let mut h = harness();
h.state_mut().session.execute("view.setRegionOfInterest", json!({"rect": [100, 50, 200, 200]})).unwrap();
h.run_steps(3);
// Bottom-right corner (handle 2) to (400, 300): the top-left stays.
let c = rect(&h, "viewer.regionOfInterest.handle.2").center();
let to = screen(&h, [400.0, 300.0]);
drag(&mut h, c, to);
let r = h.state().session.state.region_of_interest.expect("roi");
assert!((r[0] - 100.0).abs() <= 1.0 && (r[1] - 50.0).abs() <= 1.0 && (r[2] - 300.0).abs() <= 2.0 && (r[3] - 250.0).abs() <= 2.0, "{r:?}");
// The left edge (handle 7) only moves x.
h.run_steps(2);
let c = rect(&h, "viewer.regionOfInterest.handle.7").center();
let to = pos2(screen(&h, [150.0, 0.0]).x, c.y + 40.0);
drag(&mut h, c, to);
let r = h.state().session.state.region_of_interest.expect("roi");
assert!((r[0] - 150.0).abs() <= 1.0 && (r[1] - 50.0).abs() <= 1.0 && (r[2] - 250.0).abs() <= 2.0 && (r[3] - 250.0).abs() <= 2.0, "{r:?}");
}
#[test]
fn pan_behind_snaps_the_anchor_point() {
let mut h = harness();
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
h.state_mut().ui.tool = Tool::PanBehind;
h.run_steps(2);
// Drag the anchor (at the box centre) to 3 px from the comp centre: it snaps there, and the
// position follows so the box doesn't move.
let from = screen(&h, [100.0, 100.0]);
let to = screen(&h, [323.0, 182.0]);
// (The gesture applies the pointer of the previous frame: hold the end point a frame.)
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for i in 1..=10 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i.min(8) as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
let p = l.props.prop("transform/position").unwrap().value.as_vec3();
let a = l.props.prop("transform/anchor").unwrap().value.as_vec3();
assert!((p[0] - 320.0).abs() < 0.01 && (p[1] - 180.0).abs() < 0.01, "anchor point snapped to the comp centre: {p:?}");
assert!((a[0] - 260.0).abs() < 0.01 && (a[1] - 120.0).abs() < 0.01, "{a:?}");
}
#[test]
fn reference_axes_toggle_from_the_grid_menu() {
let mut h = harness();
// A 3D layer makes the axes relevant.
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("layer.setSwitch", json!({"layers": [box_id.0], "switch": "threeD", "value": true})).unwrap();
h.run_steps(3);
assert!(h.state().auto.find("viewer.referenceAxes").is_some(), "on by default (Settings ▸ 3D)");
let g = rect(&h, "viewer.grid").center();
click(&mut h, g);
h.run_steps(2);
let item = rect(&h, "viewer.gridItem.6").center();
click(&mut h, item);
h.run_steps(3);
assert!(!h.state().session.prefs.three_d.show_reference_axes);
assert!(h.state().auto.find("viewer.referenceAxes").is_none());
}
/// A Box layer pinned with two Position pins and a Bend pin in the middle (Bend tool active).
fn puppet_harness() -> (Harness<'static, EffectcraftApp>, u64, u64) {
let mut h = harness();
let s = &mut h.state_mut().session;
// No full-frame Plate behind the Box: the comp around the Box is empty, like the canvas
// around a character.
s.execute("edit.clear", json!({"layers": ["Plate"]})).unwrap();
s.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
let id = s.state.selected_layers[0].0;
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 40]})).unwrap();
s.execute("puppet.addPin", json!({"layer": id, "position": [70, 40]})).unwrap();
let bend = s.execute("puppet.addPin", json!({"layer": id, "kind": "bend", "position": [40, 40]})).unwrap()["pin"].as_u64().unwrap();
h.state_mut().ui.tool = Tool::PuppetBend;
h.run_steps(3);
(h, id, bend)
}
fn pin_value(h: &Harness<'_, EffectcraftApp>, layer: u64, pin: u64, prop: &str) -> f64 {
let l = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().clone();
l.props.find_group(pin).unwrap().get(prop).unwrap().value.as_f64()
}
#[test]
fn puppet_bend_pin_rotates_by_its_ring_and_scales_by_its_square() {
let (mut h, id, bend) = puppet_harness();
let center = rect(&h, &format!("viewer.puppetPin.{bend}")).center();
let ring = rect(&h, &format!("viewer.puppetPin.{bend}.rotate")).center();
let square = rect(&h, &format!("viewer.puppetPin.{bend}.scale")).center();
// At 0° the square is on the right of the ring, the rotate target opposite it.
assert!(square.x > center.x + 7.0 && (square.y - center.y).abs() < 0.5, "{square:?} {center:?}");
// Drag the ring a quarter turn clockwise (left → top): +90°, one undo step.
let undo_before = h.state().session.history.undo.len();
drag(&mut h, ring, center + vec2(0.0, -(center.x - ring.x)));
let r = pin_value(&h, id, bend, "rotation");
assert!((r - 90.0).abs() < 1.0, "rotation {r}");
assert_eq!(h.state().session.history.undo.len(), undo_before + 1);
// The square follows the rotation (now below the pin; the bend carries the pin itself a
// little); dragging it twice as far from the pin scales 200 %.
let center = rect(&h, &format!("viewer.puppetPin.{bend}")).center();
let square = rect(&h, &format!("viewer.puppetPin.{bend}.scale")).center();
assert!(square.y > center.y + 7.0, "{square:?}");
drag(&mut h, square, center + (square - center) * 2.0);
let sc = pin_value(&h, id, bend, "scale");
assert!((sc - 200.0).abs() < 2.0, "scale {sc}");
}
#[test]
fn puppet_pin_click_selects_and_delete_removes_only_the_pins() {
let (mut h, id, bend) = puppet_harness();
let pin = |h: &Harness<'_, EffectcraftApp>| h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect::<Vec<_>>();
// Clicking a pin selects it (instead of adding a pin on top of it).
let at = rect(&h, &format!("viewer.puppetPin.{bend}")).center();
click(&mut h, at);
assert_eq!(pin(&h), vec![bend]);
let info = h.state_mut().session.execute("puppet.info", json!({"layer": id})).unwrap();
assert_eq!(info["meshes"][0]["pins"].as_array().unwrap().len(), 3, "no pin added");
// Delete removes the pin; the layer stays.
h.input_mut().events.push(Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.run_steps(2);
let info = h.state_mut().session.execute("puppet.info", json!({"layer": id})).unwrap();
assert_eq!(info["meshes"][0]["pins"].as_array().unwrap().len(), 2);
assert!(h.state().session.active_comp().unwrap().layer(LayerId(id)).is_some());
}
#[test]
fn puppet_marquee_selects_pins_and_alt_drag_works_over_the_art() {
let (mut h, _id, bend) = puppet_harness();
let p1 = rect(&h, &format!("viewer.puppetPin.{bend}")).center();
let pins: Vec<u64> = h.state().auto.previous.iter().filter_map(|e| e.id.strip_prefix("viewer.puppetPin.").and_then(|r| r.parse().ok())).collect();
assert_eq!(pins.len(), 3, "{pins:?}");
// A box around every pin, started on empty canvas (outside all art).
let comp = rect(&h, "viewer.comp");
drag(&mut h, comp.min + vec2(4.0, 4.0), comp.max - vec2(4.0, 4.0));
let mut sel: Vec<u64> = h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect();
sel.sort();
let mut want = pins.clone();
want.sort();
assert_eq!(sel, want);
// Alt-drag a small box over the art (around the Bend pin only).
h.input_mut().events.push(Event::PointerMoved(p1 - vec2(6.0, 6.0)));
h.input_mut().events.push(Event::PointerButton {
pos: p1 - vec2(6.0, 6.0),
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::ALT,
});
h.step();
for i in 1..=6 {
h.input_mut().events.push(Event::PointerMoved(p1 - vec2(6.0, 6.0) + vec2(12.0, 12.0) * (i as f32 / 6.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton {
pos: p1 + vec2(6.0, 6.0),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::ALT,
});
h.run_steps(2);
let sel: Vec<u64> = h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect();
assert_eq!(sel, vec![bend]);
}
/// Drag with `button` from `from` to `to` in steps, then release.
fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: Pos2, to: Pos2) {
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button, pressed: true, modifiers: Default::default() });
h.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button, pressed: false, modifiers: Default::default() });
h.run_steps(3);
}
#[test]
fn middle_and_hand_drags_pan_the_viewer_and_the_pan_stays_after_release() {
let mut h = harness();
let c = rect(&h, "viewer.comp").center();
let before = h.state().ui.viewer.pan;
// Middle-button drag with the Selection tool: pans, and stays panned after the release.
drag_with(&mut h, egui::PointerButton::Middle, c, c + vec2(120.0, 60.0));
let after = h.state().ui.viewer.pan;
assert!((after[0] - before[0] - 120.0).abs() < 1.0 && (after[1] - before[1] - 60.0).abs() < 1.0, "{before:?} → {after:?}");
assert_eq!(h.state().ui.tool, Tool::Selection, "the tool is unchanged");
// The Hand tool's primary drag keeps its pan too.
h.state_mut().ui.tool = Tool::Hand;
h.run_steps(2);
let c = rect(&h, "viewer.comp").center();
drag_with(&mut h, egui::PointerButton::Primary, c, c - vec2(50.0, 30.0));
let end = h.state().ui.viewer.pan;
assert!((end[0] - after[0] + 50.0).abs() < 1.0 && (end[1] - after[1] + 30.0).abs() < 1.0, "{after:?} → {end:?}");
}
#[test]
fn timeline_rows_drag_to_reorder_layers() {
let mut h = harness();
let names = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
assert_eq!(names(&h), ["Box", "Plate"]);
let row = |h: &Harness<'_, EffectcraftApp>, name: &str| {
let l = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id.0;
rect(h, &format!("timeline.layer.{l}.row"))
};
// Drag "Box" (top) below "Plate": it becomes the bottom layer, in one undo step.
let (from, plate) = (row(&h, "Box").center(), row(&h, "Plate"));
let steps = h.state().session.history.undo.len();
drag(&mut h, from, plate.center() + vec2(0.0, plate.height() * 0.4));
assert_eq!(names(&h), ["Plate", "Box"]);
assert_eq!(h.state().session.history.undo.len(), steps + 1);
// Dropping a layer where it already is changes nothing.
let b = row(&h, "Box").center();
drag(&mut h, b, b + vec2(0.0, 3.0));
assert_eq!(names(&h), ["Plate", "Box"]);
assert_eq!(h.state().session.history.undo.len(), steps + 1);
// And back to the top.
let (from, top) = (row(&h, "Box").center(), row(&h, "Plate"));
drag(&mut h, from, top.center() - vec2(0.0, top.height() * 0.4));
assert_eq!(names(&h), ["Box", "Plate"]);
}
fn layer_id(h: &Harness<'_, EffectcraftApp>, name: &str) -> u64 {
h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id.0
}
/// Uid of a layer's property by match path (`transform/position`).
fn prop_uid(h: &Harness<'_, EffectcraftApp>, layer: &str, path: &str) -> u64 {
let id = layer_id(h, layer);
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop(path).unwrap().uid
}
/// Is the property's row on screen in the Timeline?
fn shown(h: &Harness<'_, EffectcraftApp>, uid: u64) -> bool {
h.state().auto.find(&format!("timeline.prop.{uid}.stopwatch")).is_some()
}
fn key(h: &mut Harness<'_, EffectcraftApp>, key: egui::Key, modifiers: egui::Modifiers) {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
h.run_steps(2);
}
#[test]
fn timeline_twirl_arrows_open_layers_and_groups() {
let mut h = harness();
let id = layer_id(&h, "Box");
let transform = {
let comp = h.state().session.active_comp().unwrap().clone();
comp.layer(LayerId(id)).unwrap().props.sub("transform").unwrap().uid
};
let at = rect(&h, &format!("timeline.layer.{id}.twirl")).center();
click(&mut h, at);
assert!(h.state().ui.timeline.open_layers.contains(&id), "the layer twirls open");
let at = rect(&h, &format!("timeline.group.{transform}.twirl")).center();
click(&mut h, at);
for p in ["anchor", "position", "scale", "rotation", "opacity"] {
assert!(shown(&h, prop_uid(&h, "Box", &format!("transform/{p}"))), "{p} row");
}
// And closed again.
let at = rect(&h, &format!("timeline.layer.{id}.twirl")).center();
click(&mut h, at);
h.run_steps(2);
assert!(!h.state().ui.timeline.open_layers.contains(&id));
assert!(!shown(&h, prop_uid(&h, "Box", "transform/position")));
}
#[test]
fn timeline_rename_commits_on_enter_and_on_click_away() {
// The rename field (double-click a name, or Enter on a selected layer) used to grab the
// keyboard every frame: clicking away or Enter could never leave it. (The harness can't
// double-click — its frames are longer than a double-click — so this opens it with Enter.)
let mut h = harness();
h.state_mut().session.execute("layer.newNull", json!({"name": "Ctrl"})).unwrap();
let id = layer_id(&h, "Ctrl");
let name = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().name.clone();
let rename = |h: &mut Harness<'_, EffectcraftApp>| {
h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap();
h.run_steps(2);
key(h, egui::Key::Enter, Default::default());
h.run_steps(1);
assert!(h.ctx.memory(|m| m.focused().is_some()), "the name field has focus");
};
// The whole name is selected: typing replaces it; Enter commits and frees the keyboard.
rename(&mut h);
h.input_mut().events.push(Event::Text("Rig".into()));
h.step();
key(&mut h, egui::Key::Enter, Default::default());
assert_eq!(name(&h), "Rig");
assert!(h.ctx.memory(|m| m.focused().is_none()), "the field let go of the keyboard");
key(&mut h, egui::Key::P, Default::default());
assert_eq!(h.state().ui.timeline.reveal, vec!["position"], "P works after renaming");
// Clicking away commits too.
rename(&mut h);
h.input_mut().events.push(Event::Text("Hand".into()));
h.step();
let away = rect(&h, "viewer.comp").center();
click(&mut h, away);
assert_eq!(name(&h), "Hand");
assert!(h.ctx.memory(|m| m.focused().is_none()));
// Escape cancels.
rename(&mut h);
h.input_mut().events.push(Event::Text("Nope".into()));
h.step();
key(&mut h, egui::Key::Escape, Default::default());
assert_eq!(name(&h), "Hand");
}
/// Press `k` twice within one frame (a quick double press: the harness' frames are long).
fn key_twice(h: &mut Harness<'_, EffectcraftApp>, k: egui::Key) {
for _ in 0..2 {
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() });
}
h.run_steps(2);
}
#[test]
fn property_shortcuts_reveal_and_key_transform_properties() {
let mut h = harness();
h.state_mut().session.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
h.run_steps(2);
let t = |h: &Harness<'_, EffectcraftApp>, p: &str| shown(h, prop_uid(h, "Box", &format!("transform/{p}")));
let only = |h: &Harness<'_, EffectcraftApp>, want: &[&str]| {
for p in ["anchor", "position", "scale", "rotation", "opacity"] {
assert_eq!(t(h, p), want.contains(&p), "{p} shown? want {want:?}");
}
};
let none = egui::Modifiers::default();
// P, then T: one property at a time; Shift+S adds Scale; P again hides.
key(&mut h, egui::Key::P, none);
only(&h, &["position"]);
key(&mut h, egui::Key::T, none);
only(&h, &["opacity"]);
key(&mut h, egui::Key::S, egui::Modifiers::SHIFT);
only(&h, &["scale", "opacity"]);
key(&mut h, egui::Key::A, none);
only(&h, &["anchor"]);
key(&mut h, egui::Key::R, none);
only(&h, &["rotation"]);
// Alt+Shift+P: a Position keyframe at the current time; again removes it.
let pos_keys = |h: &Harness<'_, EffectcraftApp>| {
let id = layer_id(h, "Box");
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/position").unwrap().keys.len()
};
let alt_shift = egui::Modifiers { alt: true, shift: true, ..Default::default() };
key(&mut h, egui::Key::P, alt_shift);
assert_eq!(pos_keys(&h), 1);
key(&mut h, egui::Key::P, alt_shift);
assert_eq!(pos_keys(&h), 0);
// U: only keyframed properties (Opacity animated); U again later hides; UU modified ones.
h.state_mut().session.execute("prop.addKey", json!({"layer": "Box", "path": "transform/opacity", "time": 0, "value": 50})).unwrap();
h.state_mut().session.execute("prop.addKey", json!({"layer": "Box", "path": "transform/opacity", "time": 1, "value": 100})).unwrap();
h.run_steps(2);
key(&mut h, egui::Key::U, none);
only(&h, &["opacity"]);
h.run_steps(40);
key(&mut h, egui::Key::U, none);
only(&h, &[]);
h.state_mut().session.execute("prop.set", json!({"layer": "Box", "path": "transform/rotation", "value": 30})).unwrap();
h.run_steps(40);
key_twice(&mut h, egui::Key::U);
assert!(t(&h, "opacity") && t(&h, "rotation"), "UU: animated and changed properties");
assert!(!t(&h, "scale"), "an untouched property stays hidden");
// Ctrl+`: twirl the selected layer open, then closed.
h.run_steps(40);
key(&mut h, egui::Key::Backtick, egui::Modifiers::COMMAND);
let id = layer_id(&h, "Box");
assert!(h.state().ui.timeline.open_layers.contains(&id) && h.state().ui.timeline.reveal.is_empty());
key(&mut h, egui::Key::Backtick, egui::Modifiers::COMMAND);
assert!(!h.state().ui.timeline.open_layers.contains(&id));
}
#[test]
fn double_press_shortcuts_reveal_their_second_set() {
let mut h = harness();
h.state_mut().session.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
h.state_mut().session.execute("prop.setExpression", json!({"layer": "Box", "path": "transform/scale", "expression": "value"})).unwrap();
h.run_steps(2);
let reveal = |h: &Harness<'_, EffectcraftApp>| h.state().ui.timeline.reveal.clone();
// EE: properties with expressions (Scale), replacing E's effects.
key_twice(&mut h, egui::Key::E);
assert_eq!(reveal(&h), vec!["expressions"]);
assert!(shown(&h, prop_uid(&h, "Box", "transform/scale")));
h.run_steps(40);
// TT: Mask Opacity; MM: every mask property; a single M: Mask Path.
key_twice(&mut h, egui::Key::T);
assert_eq!(reveal(&h), vec!["maskOpacity"]);
h.run_steps(40);
key(&mut h, egui::Key::M, Default::default());
assert_eq!(reveal(&h), vec!["maskPath"]);
h.run_steps(40);
key_twice(&mut h, egui::Key::M);
assert_eq!(reveal(&h), vec!["masks"]);
h.run_steps(40);
// RR Time Remap, AA Material Options, PP paint/puppet, SS selected properties, FF missing effects.
for (k, want) in
[(egui::Key::R, "timeRemap"), (egui::Key::A, "material"), (egui::Key::P, "paint"), (egui::Key::S, "selected"), (egui::Key::F, "missingEffects")]
{
key_twice(&mut h, k);
assert_eq!(reveal(&h), vec![want], "{k:?}{k:?}");
h.run_steps(40);
}
}