Files
effectcraft/crates/ui-egui/tests/ui_keyframes.rs
T
Claude 657f34d40b Layers: with several layers selected, value edits, stopwatches and viewer Scale / Rotate change them all (#355, #491)
With several layers selected, After Effects applies a property edit to the same property of
every selected layer; we changed only the layer whose row was edited (the Properties panel
showed only the first selected layer), so a stopwatch switched on one layer and later moves
of the others had nothing to keyframe (#355), and scaling or rotating a selection changed
one layer (#491).

- Engine: `prop.set`, `prop.toggleAnimation` and `prop.toggleKey` take `selected: true`:
  the change also goes to the same property (by match path, `transform/scale`,
  `effects/<effect>#n/<param>`) of every other selected, unlocked layer whose value has the
  same kind, in one undo step (a scrub keeps folding into it through its `merge` key). With
  `offset: true` (a scrub) each layer changes by the same amount from its own value; a typed
  value sets them all. Position with Separate Dimensions is handled per layer.
- The Timeline, Properties and Effect Controls panels pass `selected` (and `offset` while a
  value is dragged). The Properties panel heads "(N) Selected Objects" (zh-CN / zh-TW / ja /
  uk rows).
- Viewer: a bounding-box handle drag scales every selected layer by the same ratio about its
  own anchor point; the Rotation tool turns them all by the same angle (one undo step).

Tests: engine test (stopwatch, scrub offset, typed value, keyframe button, one undo step,
unselected layers untouched); kittests: the Properties panel with three layers selected
(header, stopwatch on all three undone in one step, a scrub moving all three by the same
amount) and a handle drag scaling two selected layers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 21:23:36 +00:00

401 lines
21 KiB
Rust

//! Headless checks for Timeline keyframes as a person uses them: dragging keys over many frames
//! in one gesture (the drag used to end after the first frame), Shift-snapping, Ctrl+C /
//! Ctrl+V, which the windowing layer delivers as clipboard events rather than key presses.
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Pos2, pos2};
use egui_kittest::Harness;
use serde_json::json;
/// A 4 s, 30 fps comp with a Box layer whose Opacity has keys at 0 s and 1 s, revealed in the
/// Timeline; returns the harness, the layer and the property uid.
fn harness() -> (Harness<'static, EffectcraftApp>, LayerId, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Keys", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap();
let id = s.active_comp().unwrap().layers[0].id;
for (t, v) in [(0.0, 0.0), (1.0, 100.0)] {
s.execute("prop.addKey", json!({"layer": id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
let uid = s.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().uid;
s.execute("layer.select", json!({"layers": [id.0]})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap();
h.run_steps(4);
(h, id, uid)
}
fn key_times(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> Vec<f64> {
let l = h.state().session.active_comp().unwrap().layer(id).unwrap().clone();
l.props.prop("transform/opacity").unwrap().keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect()
}
/// Screen centres of the property's keys, left to right.
fn keys(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Vec<Pos2> {
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));
ks
}
/// Press at `from`, move to `to` in small steps (a real drag), release.
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: egui::Modifiers) {
h.input_mut().events.push(Event::PointerMoved(from));
h.step();
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.step();
for i in 1..=24 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 24.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.step();
h.input_mut().events.push(Event::ModifiersChanged(Default::default()));
h.run_steps(2);
}
#[test]
fn a_key_drag_follows_the_pointer_for_its_whole_length() {
let (mut h, id, uid) = harness();
let ks = keys(&h, uid);
assert_eq!(ks.len(), 2);
let px_per_s = ks[1].x - ks[0].x;
// Drag the 1 s key to where 2.5 s is: many frames, one gesture.
let to = pos2(ks[1].x + px_per_s * 1.5, ks[1].y);
drag(&mut h, ks[1], to, Default::default());
let ts = key_times(&h, id);
assert!((ts[1] - 2.5).abs() <= 2.0 / 30.0, "the key follows the whole drag: {ts:?}");
let steps = h.state().session.history.undo.iter().filter(|(l, _)| l == "Move Keyframes").count();
assert_eq!(steps, 1, "one undo step per drag");
// Shift snaps it to the current time indicator (at 0.5 s), from a few pixels away.
h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(0.5));
h.run_steps(2);
let ks = keys(&h, uid);
let near = pos2(ks[0].x + px_per_s * 0.5 + 4.0, ks[1].y);
drag(&mut h, ks[1], near, egui::Modifiers { shift: true, ..Default::default() });
assert_eq!(key_times(&h, id)[1], 0.5, "snapped to the current time");
}
#[test]
fn ctrl_c_and_ctrl_v_copy_and_paste_keys_at_the_current_time() {
let (mut h, id, uid) = harness();
let ks = keys(&h, uid);
// Click the 1 s key to select it, Ctrl+C (a clipboard event, not a key press).
h.input_mut().events.push(Event::PointerMoved(ks[1]));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: ks[1], button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: ks[1], button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
assert_eq!(h.state().session.state.selected_keys.len(), 1);
h.input_mut().events.push(Event::Copy);
h.step();
let copied: Vec<String> = h
.output()
.platform_output
.commands
.iter()
.filter_map(|c| match c {
egui::OutputCommand::CopyText(t) => Some(t.clone()),
_ => None,
})
.collect();
assert_eq!(copied, ["EffectCraft: 1 keyframe"], "the system clipboard is filled, so Ctrl+V sends a paste event");
assert!(h.state().session.state.clip_is_keys);
// Move to 3 s, Ctrl+V: the key lands there.
h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(3.0));
h.input_mut().events.push(Event::Paste("EffectCraft: 1 keyframe".into()));
h.run_steps(2);
assert_eq!(key_times(&h, id), vec![0.0, 1.0, 3.0]);
}
fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: egui::Modifiers) {
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.step();
h.input_mut().events.push(Event::ModifiersChanged(Default::default()));
h.run_steps(2);
}
#[test]
fn shift_click_toggles_and_ctrl_click_switches_interpolation() {
let (mut h, id, uid) = harness();
let ks = keys(&h, uid);
let shift = egui::Modifiers { shift: true, ..Default::default() };
click_with(&mut h, ks[0], Default::default());
click_with(&mut h, ks[1], shift);
assert_eq!(h.state().session.state.selected_keys.len(), 2, "Shift+click adds");
click_with(&mut h, ks[1], shift);
assert_eq!(h.state().session.state.selected_keys.len(), 1, "and takes out again");
let key =
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys[0].clone();
// Ctrl+click: Linear → Auto Bezier → Linear.
click_with(&mut h, ks[0], egui::Modifiers::COMMAND);
assert!(key(&h).auto_bezier, "Auto Bezier");
click_with(&mut h, ks[0], egui::Modifiers::COMMAND);
assert_eq!(key(&h).out_interp, effectcraft_engine::keyframe::Interp::Linear);
// Ctrl+Alt+click: Hold.
click_with(&mut h, ks[0], egui::Modifiers { alt: true, ..egui::Modifiers::COMMAND });
assert_eq!(key(&h).out_interp, effectcraft_engine::keyframe::Interp::Hold);
}
/// Clicking a key in the Timeline selects its property and layer too, as in After Effects, so
/// the Graph Editor shows the key's curve (#252).
#[test]
fn a_key_clicked_in_the_timeline_shows_in_the_graph_editor() {
let (mut h, id, uid) = harness();
h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap();
h.run_steps(2);
let ks = keys(&h, uid);
click_with(&mut h, ks[1], Default::default());
let st = &h.state().session.state;
assert_eq!(st.selected_keys.len(), 1);
assert_eq!((st.selected_props.clone(), st.selected_layers.clone()), (vec![(id, uid)], vec![id]), "the key's property and layer");
h.state_mut().ui.timeline.graph_editor = true;
h.run_steps(4);
assert!(h.state().auto.find(&format!("timeline.graph.key.{uid}.0.1")).is_some(), "the Graph Editor shows the key");
}
#[test]
fn graph_editor_drags_move_every_selected_key_and_shift_keeps_an_axis() {
let (mut h, id, uid) = harness();
let s = &mut h.state_mut().session;
s.execute("prop.select", json!({"layer": id.0, "prop": uid})).unwrap();
s.execute("keys.selectAll", json!({})).unwrap();
h.state_mut().ui.timeline.graph_editor = true;
h.run_steps(4);
let at = |h: &Harness<'_, EffectcraftApp>, i: usize| {
let e = h.state().auto.find(&format!("timeline.graph.key.{uid}.0.{i}")).unwrap_or_else(|| panic!("no graph key {i}")).clone();
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
};
let (k0, k1) = (at(&h, 0), at(&h, 1));
let px_per_s = k1.x - k0.x;
let values = |h: &Harness<'_, EffectcraftApp>| -> Vec<f64> {
h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys.iter().map(|k| k.value.as_f64()).collect()
};
// Shift: only along time, both keys, by 0.5 s.
drag(&mut h, k1, pos2(k1.x + px_per_s * 0.5, k1.y - 6.0), egui::Modifiers { shift: true, ..Default::default() });
assert_eq!(key_times(&h, id), vec![0.5, 1.5], "both selected keys moved");
assert_eq!(values(&h), vec![0.0, 100.0], "Shift kept the values");
let undo = h.state().session.history.undo.iter().filter(|(l, _)| l == "Transform Keyframes").count();
assert_eq!(undo, 1, "one undo step per drag");
// Without Shift the values move too (both keys, by the same amount).
h.run_steps(2);
let k1 = at(&h, 1);
drag(&mut h, k1, pos2(k1.x, k1.y + 20.0), Default::default());
let v = values(&h);
assert!(v[0] < 0.0 && (v[1] - 100.0 - v[0]).abs() < 1e-6, "{v:?}");
}
#[test]
fn double_click_a_key_to_edit_its_value() {
let (mut h, id, uid) = harness();
let k1 = keys(&h, uid)[1];
h.input_mut().events.push(Event::PointerMoved(k1));
h.step();
// Both clicks inside egui's double-click time.
for _ in 0..2 {
h.input_mut().events.push(Event::PointerButton { pos: k1, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.input_mut().events.push(Event::PointerButton { pos: k1, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
}
h.step();
h.run_steps(2);
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form), "the value dialog opened");
h.run_steps(2);
assert!(h.state().auto.find("form.field.value").is_some(), "one Opacity field");
// OK keeps the key where it is (the dialog edits the value only).
let ok = h.state().auto.find("form.ok").expect("form.ok").clone();
let p = pos2(ok.rect[0] + ok.rect[2] / 2.0, ok.rect[1] + ok.rect[3] / 2.0);
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);
assert!(h.state().dialog.is_none());
let k = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys[1].clone();
assert_eq!((k.time.seconds(), k.value.as_f64()), (1.0, 100.0));
}
#[test]
fn k_follows_the_revealed_properties_and_auto_select_picks_the_speed_graph_for_position() {
let (mut h, id, _) = harness();
// A Rotation key at 2 s on a property that isn't revealed: K skips it.
h.state_mut().session.execute("prop.addKey", json!({"layer": id.0, "path": "transform/rotation", "time": 2.0, "value": 45})).unwrap();
h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0));
h.run_steps(2);
h.input_mut().events.push(Event::Key { key: egui::Key::K, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.run_steps(2);
assert!(h.state().session.time().seconds() > 3.9, "past the hidden key to the work area end: {}", h.state().session.time().seconds());
// Auto-Select Graph Type: Position shows its speed (one curve), Opacity its value.
let s = &mut h.state_mut().session;
for (t, v) in [(0.0, [40.0, 90.0]), (1.0, [280.0, 90.0])] {
s.execute("prop.addKey", json!({"layer": id.0, "path": "transform/position", "time": t, "value": v})).unwrap();
}
let pos = s.active_comp().unwrap().layer(id).unwrap().props.prop("transform/position").unwrap().uid;
s.execute("prop.select", json!({"layer": id.0, "prop": pos})).unwrap();
h.state_mut().ui.timeline.graph_editor = true;
h.run_steps(4);
assert_eq!(h.state().ui.timeline.graph_mode, "auto");
assert!(h.state().auto.find(&format!("timeline.graph.key.{pos}.0.0")).is_some());
assert!(h.state().auto.find(&format!("timeline.graph.key.{pos}.1.0")).is_none(), "speed graph: one curve");
assert!(h.state().auto.find("timeline.graph.autoSelectGraphType").is_some());
h.state_mut().ui.timeline.graph_mode = "value".into();
h.run_steps(3);
assert!(h.state().auto.find(&format!("timeline.graph.key.{pos}.1.0")).is_some(), "value graph: X and Y");
}
/// #284: in the Graph Editor, dragging the handle of one selected key moves the same handle of
/// every selected key with it (After Effects), in one undo step.
#[test]
fn a_graph_handle_drag_moves_the_handles_of_every_selected_key() {
let (mut h, id, uid) = harness();
let s = &mut h.state_mut().session;
s.execute("prop.addKey", json!({"layer": id.0, "path": "transform/opacity", "time": 2.0, "value": 0.0})).unwrap();
s.execute("prop.select", json!({"layer": id.0, "prop": uid})).unwrap();
s.execute("keys.selectAll", json!({})).unwrap();
s.execute("keys.easyEase", json!({})).unwrap();
h.state_mut().ui.timeline.graph_editor = true;
h.run_steps(4);
// (in, out) influence of each key.
let influences = |h: &Harness<'_, EffectcraftApp>| -> Vec<(f64, f64)> {
let l = h.state().session.active_comp().unwrap().layer(id).unwrap().clone();
let keys = l.props.prop("transform/opacity").unwrap().keys.clone();
keys.iter().map(|k| (k.in_ease.first().map_or(0.0, |e| e.influence), k.out_ease.first().map_or(0.0, |e| e.influence))).collect()
};
let before = influences(&h);
let handle = |h: &Harness<'_, EffectcraftApp>, key: usize| {
let e = h.state().auto.find(&format!("timeline.graph.handle.{uid}.0.{key}.out")).unwrap_or_else(|| panic!("no handle {key}")).clone();
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
};
let px_per_s = {
let at = |i: usize| h.state().auto.find(&format!("timeline.graph.key.{uid}.0.{i}")).unwrap().rect[0];
at(1) - at(0)
};
let (k0, k1) = (handle(&h, 0), handle(&h, 1));
let undo = h.state().session.history.undo.len();
// Key 1's out handle 0.2 s further out (the keys are 1 s apart): key 0's goes along.
drag(&mut h, k1, k1 + egui::vec2(px_per_s * 0.2, 0.0), Default::default());
let after = influences(&h);
for i in [0, 1] {
assert!((after[i].1 - before[i].1 - 0.2).abs() < 0.01, "key {i}: {:?} → {:?}", before[i], after[i]);
}
assert_eq!((after[1].0, after[2].0), (before[1].0, before[2].0), "the in handles stay");
assert_eq!(h.state().session.history.undo.len(), undo + 1, "one undo step");
assert!((handle(&h, 0).x - k0.x - px_per_s * 0.2).abs() < 2.0, "key 0's handle moved on screen");
}
/// #457: on a Timeline narrower than the Graph Editor's whole button row, the buttons that don't
/// fit go to a "»" menu at the end of the bar instead of being cut off; each stays reachable and
/// works from there.
#[test]
fn graph_editor_buttons_that_do_not_fit_move_to_the_more_menu() {
let (mut h, _, _) = harness();
h.set_size(egui::vec2(1280.0, 800.0));
h.state_mut().ui.timeline.graph_editor = true;
h.run_steps(4);
let ids = [
"autoSelectGraphType",
"valueGraph",
"speedGraph",
"showSelected",
"autoZoom",
"fit",
"fitSelection",
"snap",
"reference",
"transformBox",
"separateDimensions",
"interpolationHold",
"interpolationLinear",
"interpolationAuto",
"easyEaseEasy",
"easyEaseInEase",
"easyEaseOutEase",
];
let graph = h.state().auto.find("timeline.graph").expect("the Graph Editor is shown").rect;
let right = graph[0] + graph[2];
let shown: Vec<&str> = ids.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.graph.{id}")).is_some()).collect();
for id in &shown {
let r = h.state().auto.find(&format!("timeline.graph.{id}")).unwrap().rect;
assert!(r[0] + r[2] <= right, "{id} is drawn past the bar's right edge");
}
let more = h.state().auto.find("timeline.graph.more").expect("the buttons that don't fit are in a menu").rect;
assert!(more[0] + more[2] <= right);
let p = pos2(more[0] + more[2] / 2.0, more[1] + more[3] / 2.0);
let click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| {
h.input_mut().events.push(Event::PointerMoved(p));
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(3);
};
click(&mut h, p);
let hidden: Vec<&str> = ids.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.graph.more.{id}")).is_some()).collect();
assert_eq!(shown.len() + hidden.len(), ids.len(), "every button is on the bar or in the menu: {shown:?} + {hidden:?}");
assert!(hidden.contains(&"transformBox"), "{hidden:?}");
let before = h.state().ui.timeline.graph_transform_box;
let item = h.state().auto.find("timeline.graph.more.transformBox").unwrap().rect;
click(&mut h, pos2(item[0] + item[2] / 2.0, item[1] + item[3] / 2.0));
assert_eq!(h.state().ui.timeline.graph_transform_box, !before, "the menu item runs the button");
}
/// #355, #491: with three layers selected the Properties panel heads "(3) Selected Objects"; its
/// stopwatch animates Position on all three (one undo step) and scrubbing X moves all three by
/// the same amount.
#[test]
fn properties_panel_edits_every_selected_layer() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Three", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
let mut ids = vec![];
for (i, c) in ["#e04020", "#20e040", "#2040e0"].iter().enumerate() {
let l = s.execute("layer.newSolid", json!({"color": c, "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [60.0 + 80.0 * i as f64, 90.0]})).unwrap();
ids.push(l);
}
s.execute("layer.select", json!({"layers": ids})).unwrap();
let first = s.state.selected_layers[0];
let uid = s.active_comp().unwrap().layer(first).unwrap().props.prop("transform/position").unwrap().uid;
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::Properties);
h.run_steps(4);
assert_eq!(h.state().auto.find("properties.selectedObjects").map(|e| e.label.clone()), Some("(3) Selected Objects".into()));
let pos = |h: &Harness<'_, EffectcraftApp>, l: u64| {
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/position").unwrap().clone()
};
let at = |h: &Harness<'_, EffectcraftApp>, id: &str| {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
};
let sw = at(&h, &format!("properties.prop.{uid}.stopwatch"));
h.input_mut().events.push(Event::PointerMoved(sw));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: sw, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: sw, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
assert!(ids.iter().all(|l| pos(&h, *l).is_animated()), "every selected layer's stopwatch");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert!(ids.iter().all(|l| !pos(&h, *l).is_animated()), "one undo step");
// Scrub X: each layer moves by the same amount.
let before: Vec<f64> = ids.iter().map(|l| pos(&h, *l).value.components()[0]).collect();
let x = at(&h, &format!("properties.prop.{uid}.value.0"));
drag(&mut h, x, x + egui::vec2(30.0, 0.0), Default::default());
let after: Vec<f64> = ids.iter().map(|l| pos(&h, *l).value.components()[0]).collect();
let d = after[0] - before[0];
assert!(d.abs() > 1.0, "the scrub moved X: {before:?} → {after:?}");
for (a, b) in after.iter().zip(&before) {
assert!((a - b - d).abs() < 1e-6, "same change on every layer: {before:?} → {after:?}");
}
}