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

481 lines
24 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.

//! On-screen transform handles on the Program monitor (#639), driven headless through the control
//! channel: selecting Motion (or the Transform effect) in Effect Controls shows a box with handles
//! and the anchor point; dragging moves, scales, rotates and moves the anchor point, one undo
//! step per drag, keying the playhead when the stopwatch is on. Clicking an effect header selects
//! it and only its arrow twirls it; double-clicking a clip's picture selects its Motion.
//!
//! With `FILMCRAFT_UI_SHOTS=<dir>` the tests also render the UI with wgpu and write PNGs there.
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_project::{ClipId, ParamValue};
use filmcraft_time::Tick;
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>,
shots: Option<std::path::PathBuf>,
}
impl Driver {
fn demo() -> Self {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
let shots = std::env::var_os("FILMCRAFT_UI_SHOTS").map(std::path::PathBuf::from);
// frames 1/60 s apart, so that two clicks make a double-click
let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000);
if shots.is_some() {
b = b.wgpu().with_pixels_per_point(1.0);
}
let harness = b.build_eframe(move |_cc| app);
let mut d = Driver { harness, tx, shots };
d.frames(4);
d.ok("ui.set", json!({"workspace": "Effects"}));
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 call(&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(v) = reply.try_recv() {
return v;
}
}
panic!("no reply to {method} {params}");
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let r = self.call(method, params.clone());
assert_eq!(r["ok"], true, "{method} {params}: {r}");
r["result"].clone()
}
fn exec(&mut self, command: &str, params: Value) -> Value {
self.ok("engine.execute", json!({"command": command, "params": params}))
}
fn find(&mut self, id: &str) -> Option<[f64; 4]> {
let v = self.ok("ui.elements", json!({"prefix": id}));
let e = v.as_array().unwrap().iter().find(|e| e["id"] == id)?.clone();
let r: Vec<f64> = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
Some([r[0], r[1], r[2], r[3]])
}
fn rect(&mut self, id: &str) -> [f64; 4] {
self.find(id).unwrap_or_else(|| panic!("no element {id}"))
}
fn center(&mut self, id: &str) -> (f64, f64) {
let r = self.rect(id);
(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)
}
fn click(&mut self, id: &str) {
self.ok("ui.click", json!({"id": id}));
self.frames(2);
}
fn drag_from(&mut self, (x, y): (f64, f64), dx: f64, dy: f64) {
self.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + dx, "y": y + dy}, "steps": 6}));
self.frames(2);
}
fn drag(&mut self, id: &str, dx: f64, dy: f64) {
let at = self.center(id);
self.drag_from(at, dx, dy);
}
/// A drag of 48 × 24 points with `button` (the control channel's `ui.drag` uses the left one).
fn drag_with(&mut self, (x, y): (f64, f64), button: egui::PointerButton) {
let from = egui::pos2(x as f32, y as f32);
let press = |pos, pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() };
let mut events = vec![egui::Event::PointerMoved(from), press(from, true)];
events.extend((1..=6).map(|k| egui::Event::PointerMoved(from + egui::vec2(8.0 * k as f32, 4.0 * k as f32))));
events.push(press(from + egui::vec2(48.0, 24.0), false));
for e in events {
self.harness.input_mut().events.push(e);
self.frames(1);
}
self.frames(3);
}
fn undo_len(&mut self) -> usize {
self.exec("history.list", json!({}))["undo"].as_array().unwrap().len()
}
fn selected_effect(&mut self) -> Value {
self.exec("state.inspect", json!({}))["selected_effect"].clone()
}
/// The first V1 clip, selected, with the playhead in its middle.
fn first_clip(&mut self) -> (u64, i64) {
let seq = self.exec("sequence.inspect", json!({}));
let clip = &seq["video"][0]["items"][0];
let (id, start, dur) = (clip["clip"].as_u64().unwrap(), clip["start"].as_i64().unwrap(), clip["duration"].as_i64().unwrap());
self.exec("playhead.set", json!({"time": start + dur / 2}));
self.exec("timeline.select", json!({"clips": [id]}));
self.frames(3);
(id, start + dur / 2)
}
/// A parameter of a clip's effect: its value at the playhead and its keyframe times.
fn param(&self, clip: u64, effect: &str, param: &str) -> (ParamValue, Vec<Tick>) {
let app = self.harness.state();
let it = app.session.active_sequence().unwrap().find_item(ClipId(clip)).unwrap().1;
let mt = it.source_time_at(app.session.playhead());
let p = it.effect(effect).unwrap().param(param).unwrap();
(p.value_at(mt), p.keyframes.iter().map(|k| k.time).collect())
}
fn num(&self, clip: u64, effect: &str, param: &str) -> f64 {
self.param(clip, effect, param).0.as_f64().unwrap()
}
fn point(&self, clip: u64, effect: &str, param: &str) -> (f64, f64) {
match self.param(clip, effect, param).0 {
ParamValue::Vec2(v) => (v.x, v.y),
other => panic!("{param} is not a point: {other:?}"),
}
}
fn shot(&mut self, name: &str) {
let Some(dir) = self.shots.clone() else { return };
for _ in 0..40 {
self.frames(1);
std::thread::sleep(std::time::Duration::from_millis(25));
}
let img = self.harness.render().expect("wgpu render");
std::fs::create_dir_all(&dir).unwrap();
img.save(dir.join(format!("{name}.png"))).unwrap();
}
}
fn near(a: (f64, f64), b: (f64, f64)) -> bool {
(a.0 - b.0).abs() < 1.5 && (a.1 - b.1).abs() < 1.5
}
const HANDLES: [&str; 11] = [
"program.transform.box",
"program.transform.anchor",
"program.transform.handle.0",
"program.transform.handle.1",
"program.transform.handle.2",
"program.transform.handle.3",
"program.transform.handle.4",
"program.transform.handle.5",
"program.transform.handle.6",
"program.transform.handle.7",
"program.transform.rotate.1",
];
#[test]
fn motion_handles_move_scale_rotate_and_move_the_anchor() {
let mut d = Driver::demo();
let (clip, _) = d.first_clip();
// half size, so the whole box and the corners' rotate zones are on the monitor
d.exec("effects.setParam", json!({"clip": clip, "effect": "motion", "param": "scale", "value": 50.0}));
d.frames(2);
assert!(d.find("program.transform.box").is_none(), "no handles before Motion is selected");
d.click("effectControls.effect.motion");
assert_eq!(d.selected_effect(), json!({"clip": clip, "effect": "motion", "instance": 0}));
for id in HANDLES {
d.rect(id);
}
d.shot("transform-motion-selected");
// a drag inside the box (away from the anchor point) moves the picture: one undo step
let undo = d.undo_len();
let p0 = d.point(clip, "motion", "position");
let b = d.rect("program.transform.box");
let pic = d.rect("program.picture");
d.drag_from((b[0] + b[2] * 0.3, b[1] + b[3] * 0.3), 40.0, 20.0);
let p1 = d.point(clip, "motion", "position");
let k = 1920.0 / pic[2];
assert!((p1.0 - p0.0 - 40.0 * k).abs() < 2.0 * k && (p1.1 - p0.1 - 20.0 * k).abs() < 2.0 * k, "{p0:?} → {p1:?} (×{k})");
assert_eq!(d.num(clip, "motion", "scale"), 50.0);
assert_eq!(d.undo_len(), undo + 1, "one undo step per drag");
assert!(d.param(clip, "motion", "position").1.is_empty(), "the stopwatch is off: no keyframe");
// a corner scales both axes about the anchor point (Uniform Scale is on); the drawn square is
// 5 points wide but a press a few points beside it, outside the box, still takes it
let undo = d.undo_len();
let (hx, hy) = d.center("program.transform.handle.2");
d.drag_from((hx + 4.0, hy + 4.0), 30.0, 30.0);
let s = d.num(clip, "motion", "scale");
assert!(s > 55.0, "scale {s}");
assert_eq!(d.point(clip, "motion", "position"), p1, "scaling keeps the position");
assert_eq!(d.undo_len(), undo + 1);
// just outside a corner rotates: down from beside the top-right corner turns clockwise
d.drag("program.transform.rotate.1", 0.0, 60.0);
let r = d.num(clip, "motion", "rotation");
assert!(r > 5.0, "rotation {r}");
d.shot("transform-motion-rotated");
// the anchor point moves over the picture, which stays where it is
let corners: Vec<(f64, f64)> = (0..4).map(|n| d.center(&format!("program.transform.handle.{n}"))).collect();
let (a0, p2) = (d.point(clip, "motion", "anchor"), d.point(clip, "motion", "position"));
let at = d.center("program.transform.anchor");
let undo = d.undo_len();
d.drag("program.transform.anchor", 30.0, 20.0);
assert!(near(d.center("program.transform.anchor"), (at.0 + 30.0, at.1 + 20.0)), "the anchor point follows the pointer");
for (n, c) in corners.iter().enumerate() {
assert!(near(d.center(&format!("program.transform.handle.{n}")), *c), "corner {n} stays put");
}
assert_ne!(d.point(clip, "motion", "anchor"), a0);
let p3 = d.point(clip, "motion", "position");
assert!((p3.0 - p2.0 - 30.0 * k).abs() < 2.0 * k && (p3.1 - p2.1 - 20.0 * k).abs() < 2.0 * k, "position {p2:?} → {p3:?}");
assert_eq!(d.undo_len(), undo + 1, "anchor and position change in one undo step");
d.exec("edit.undo", json!({}));
assert_eq!((d.point(clip, "motion", "anchor"), d.point(clip, "motion", "position")), (a0, p2));
// Uniform Scale off: a side handle changes only the width, the top only the height
d.exec("effects.setParam", json!({"clip": clip, "effect": "motion", "param": "rotation", "value": 0.0}));
d.exec("effects.setParam", json!({"clip": clip, "effect": "motion", "param": "uniform_scale", "value": false}));
d.exec("effects.setParam", json!({"clip": clip, "effect": "motion", "param": "scale_width", "value": 50.0}));
d.exec("effects.setParam", json!({"clip": clip, "effect": "motion", "param": "scale", "value": 50.0}));
d.frames(2);
d.drag("program.transform.handle.5", 30.0, 10.0);
assert!(d.num(clip, "motion", "scale_width") > 55.0);
assert_eq!(d.num(clip, "motion", "scale"), 50.0);
let w = d.num(clip, "motion", "scale_width");
d.drag("program.transform.handle.4", 10.0, -30.0);
assert!(d.num(clip, "motion", "scale") > 55.0);
assert_eq!(d.num(clip, "motion", "scale_width"), w);
// a corner changes both, each by its own side
d.drag("program.transform.handle.2", 30.0, 0.0);
assert!(d.num(clip, "motion", "scale_width") > w + 3.0);
d.shot("transform-motion-non-uniform");
// a click beside the box, beyond its rotate zone, lets go of it
let b = d.rect("program.transform.box");
d.ok("ui.click", json!({"x": b[0] - 40.0, "y": b[1] + b[3] / 2.0}));
d.frames(2);
assert_eq!(d.selected_effect(), Value::Null);
assert!(d.find("program.transform.box").is_none());
}
#[test]
fn a_drag_keys_the_playhead_and_headers_select_while_arrows_twirl() {
let mut d = Driver::demo();
let (clip, mid) = d.first_clip();
d.exec("effects.setParam", json!({"clip": clip, "effect": "motion", "param": "scale", "value": 50.0}));
// stopwatch on at the middle of the clip, then a second further on
d.exec("effects.toggleAnimation", json!({"clip": clip, "effect": "motion", "param": "position"}));
let key0 = d.param(clip, "motion", "position").1;
assert_eq!(key0.len(), 1);
d.exec("playhead.set", json!({"time": mid + filmcraft_time::TICKS_PER_SECOND}));
d.frames(2);
// clicking a property (its name) selects Motion too
let (sx, sy) = d.center("effectControls.motion.scale.stopwatch");
d.ok("ui.click", json!({"x": sx + 30.0, "y": sy}));
d.frames(2);
assert_eq!(d.selected_effect(), json!({"clip": clip, "effect": "motion", "instance": 0}));
let p0 = d.point(clip, "motion", "position");
let undo = d.undo_len();
let b = d.rect("program.transform.box");
d.drag_from((b[0] + b[2] * 0.3, b[1] + b[3] * 0.3), -30.0, 15.0);
let (moved, keys) = d.param(clip, "motion", "position");
assert_eq!(keys.len(), 2, "a keyframe at the playhead: {keys:?}");
assert!(keys.contains(&key0[0]));
assert_ne!(moved, ParamValue::Vec2(filmcraft_geom::Vec2::new(p0.0, p0.1)));
assert_eq!(d.undo_len(), undo + 1);
// the first keyframe keeps its value
d.exec("playhead.set", json!({"time": mid}));
d.frames(2);
assert_eq!(d.point(clip, "motion", "position"), p0);
// the header selects without twirling; the arrow twirls without selecting
d.exec("effects.select", json!({"none": true}));
d.click("effectControls.effect.motion");
assert_eq!(d.selected_effect(), json!({"clip": clip, "effect": "motion", "instance": 0}));
assert!(d.find("effectControls.motion.position.stopwatch").is_some(), "Motion is still open");
d.click("effectControls.effect.motion.twirl");
assert!(d.find("effectControls.motion.position.stopwatch").is_none(), "the arrow folds Motion");
assert_eq!(d.selected_effect(), json!({"clip": clip, "effect": "motion", "instance": 0}));
d.click("effectControls.effect.motion.twirl");
assert!(d.find("effectControls.motion.position.stopwatch").is_some(), "and opens it again");
// selecting another effect hides the handles: Opacity has none
d.click("effectControls.effect.opacity");
assert_eq!(d.selected_effect(), json!({"clip": clip, "effect": "opacity", "instance": 0}));
assert!(d.find("program.transform.box").is_none());
}
#[test]
fn transform_effect_handles_and_double_click_selection() {
let mut d = Driver::demo();
let (clip, _) = d.first_clip();
d.exec("effects.apply", json!({"clips": [clip], "effect": "transform"}));
d.exec("effects.setParam", json!({"clip": clip, "effect": "transform", "param": "scale_height", "value": 50.0}));
d.frames(2);
// fold the fixed effects so the Transform header is on screen
for fx in ["motion", "opacity", "time_remap"] {
d.click(&format!("effectControls.effect.{fx}.twirl"));
}
d.click("effectControls.effect.transform");
assert_eq!(d.selected_effect(), json!({"clip": clip, "effect": "transform", "instance": 0}));
for id in HANDLES {
d.rect(id);
}
d.shot("transform-effect-selected");
let motion = d.point(clip, "motion", "position");
let p0 = d.point(clip, "transform", "position");
let b = d.rect("program.transform.box");
d.drag_from((b[0] + b[2] * 0.3, b[1] + b[3] * 0.3), 40.0, 0.0);
let p1 = d.point(clip, "transform", "position");
assert!(p1.0 > p0.0 + 20.0 && (p1.1 - p0.1).abs() < 1e-6, "{p0:?} → {p1:?}");
assert_eq!(d.point(clip, "motion", "position"), motion, "Motion is left alone");
d.drag("program.transform.handle.2", 30.0, 30.0);
assert!(d.num(clip, "transform", "scale_height") > 55.0);
d.drag("program.transform.rotate.1", 0.0, 60.0);
assert!(d.num(clip, "transform", "rotation") > 5.0);
d.exec("effects.setParam", json!({"clip": clip, "effect": "transform", "param": "rotation", "value": 0.0}));
d.exec("effects.setParam", json!({"clip": clip, "effect": "transform", "param": "uniform_scale", "value": false}));
d.exec("effects.setParam", json!({"clip": clip, "effect": "transform", "param": "scale_width", "value": 50.0}));
d.exec("effects.setParam", json!({"clip": clip, "effect": "transform", "param": "scale_height", "value": 50.0}));
d.frames(2);
d.drag("program.transform.handle.7", -30.0, 0.0);
assert!(d.num(clip, "transform", "scale_width") > 55.0);
assert_eq!(d.num(clip, "transform", "scale_height"), 50.0);
let corners: Vec<(f64, f64)> = (0..4).map(|n| d.center(&format!("program.transform.handle.{n}"))).collect();
d.drag("program.transform.anchor", -25.0, 15.0);
for (n, c) in corners.iter().enumerate() {
assert!(near(d.center(&format!("program.transform.handle.{n}")), *c), "corner {n} stays put");
}
// double-clicking a picture selects its clip's Motion: the overlay on V2, then V1 under it
let seq = d.exec("sequence.inspect", json!({}));
let overlay = seq["video"][1]["items"][0]["clip"].as_u64().unwrap();
let start = seq["video"][1]["items"][0]["start"].as_i64().unwrap();
d.exec("playhead.set", json!({"time": start + filmcraft_time::TICKS_PER_SECOND}));
d.frames(3);
let pic = d.rect("program.picture");
d.ok("ui.click", json!({"x": pic[0] + pic[2] * 0.8, "y": pic[1] + pic[3] * 0.76, "count": 2}));
d.frames(3);
assert_eq!(d.selected_effect(), json!({"clip": overlay, "effect": "motion", "instance": 0}));
let sel = d.exec("state.inspect", json!({}))["selection"].clone();
assert!(sel.as_array().unwrap().contains(&json!(overlay)), "{sel}");
d.rect("program.transform.anchor");
d.shot("transform-double-click-overlay");
// half a second later, or egui counts the next clicks as a triple click
d.frames(30);
d.ok("ui.click", json!({"x": pic[0] + pic[2] * 0.2, "y": pic[1] + pic[3] * 0.2, "count": 2}));
d.frames(3);
let under = d.exec("sequence.inspect", json!({}))["video"][0]["items"]
.as_array()
.unwrap()
.iter()
.find(|it| {
let (s, l) = (it["start"].as_i64().unwrap(), it["duration"].as_i64().unwrap());
(s..s + l).contains(&(start + filmcraft_time::TICKS_PER_SECOND))
})
.unwrap()["clip"]
.as_u64()
.unwrap();
assert_eq!(d.selected_effect(), json!({"clip": under, "effect": "motion", "instance": 0}));
}
/// Only the left button takes hold of the box: a middle or right drag leaves the picture alone.
#[test]
fn only_the_left_button_drags_the_handles() {
let mut d = Driver::demo();
let (clip, _) = d.first_clip();
d.exec("effects.setParam", json!({"clip": clip, "effect": "motion", "param": "scale", "value": 50.0}));
d.click("effectControls.effect.motion");
let (p0, undo) = (d.point(clip, "motion", "position"), d.undo_len());
let b = d.rect("program.transform.box");
let from = (b[0] + b[2] * 0.3, b[1] + b[3] * 0.3);
for button in [egui::PointerButton::Middle, egui::PointerButton::Secondary] {
d.drag_with(from, button);
assert_eq!(d.point(clip, "motion", "position"), p0, "{button:?} drag moved the picture");
assert_eq!(d.undo_len(), undo, "{button:?} drag made an undo step");
}
d.drag_from(from, 40.0, 20.0);
assert_ne!(d.point(clip, "motion", "position"), p0, "the left button still moves it");
}
/// The other things dragged on the Program monitor take the left button only too: a guide, a
/// mask's body and vertices, and a graphic layer stay put under a middle or right drag.
#[test]
fn guides_masks_and_graphics_take_the_left_button_only() {
let mut d = Driver::demo();
let (clip, _) = d.first_clip();
let others = [egui::PointerButton::Middle, egui::PointerButton::Secondary];
let project = |d: &Driver| d.harness.state().session.project.to_json();
// a guide
d.ok("ui.menu.invoke", json!({"id": "view.addGuide", "params": {"orientation": "vertical", "position": 700}}));
d.frames(2);
let g = d.rect("program.guide.0");
for b in others {
d.drag_with((g[0] + g[2] / 2.0, g[1] + g[3] * 0.2), b);
assert_eq!(d.rect("program.guide.0"), g, "a {b:?} drag moved the guide");
}
// a mask
d.exec("effects.apply", json!({"clips": [clip], "effect": "gaussian_blur"}));
let fx = {
let s = &d.harness.state().session;
s.active_sequence().unwrap().find_item(ClipId(clip)).unwrap().1.effects.iter().position(|e| e.effect == "gaussian_blur").unwrap()
};
d.exec("masks.add", json!({"clip": clip, "effect": fx, "shape": "ellipse"}));
d.exec("masks.select", json!({"clip": clip, "effect": fx, "mask": 0}));
d.frames(3);
let before = project(&d);
for id in ["program.mask.body", "program.mask.vertex.0"] {
for b in others {
let at = d.center(id);
d.drag_with(at, b);
assert_eq!(project(&d), before, "a {b:?} drag on {id} changed the mask");
}
}
// a graphic layer
d.exec("masks.select", json!({"none": true}));
let gclip = d.exec("graphics.newText", json!({"text": "Title", "position": [600, 500], "size": 120}))["clip"].as_u64().unwrap();
d.frames(3);
let before = project(&d);
let at = d.center(&format!("program.layer.{gclip}.0"));
for b in others {
d.drag_with(at, b);
assert_eq!(project(&d), before, "a {b:?} drag moved the layer");
}
d.drag_from(at, 40.0, 20.0);
assert_ne!(project(&d), before, "the left button still moves the layer");
}
/// A picture moved past the edge of the frame (and the monitor) keeps its handles in reach: the
/// ones that would be off the monitor stop at its edge, and dragging them still scales (#1393).
#[test]
fn handles_stay_in_reach_when_the_picture_is_past_the_edge() {
let mut d = Driver::demo();
let (clip, _) = d.first_clip();
// centred on the frame's bottom-right corner: the right and bottom handles are beyond it
d.exec("effects.setParam", json!({"clip": clip, "effect": "motion", "param": "position", "value": [1920.0, 1080.0]}));
d.click("effectControls.effect.motion");
let pic = d.rect("program.picture");
let (cx, cy) = d.center("program.transform.handle.0");
assert!(near((cx, cy), (pic[0] + pic[2] / 2.0, pic[1] + pic[3] / 2.0)), "the top-left corner is at the frame's centre");
let (hx, hy) = d.center("program.transform.handle.2");
assert!(hx < 1600.0 && hy < 980.0, "the bottom-right handle is on screen: ({hx}, {hy})");
// (its true place is about 280 points right of the picture, past the monitor)
assert!(hx <= pic[0] + pic[2] + 40.0 && hy <= pic[1] + pic[3] + 40.0, "and on the monitor: ({hx}, {hy}), picture {pic:?}");
let p0 = d.point(clip, "motion", "position");
d.drag("program.transform.handle.2", -30.0, -30.0);
let s = d.num(clip, "motion", "scale");
assert!(s < 95.0, "the bottom-right handle scales: {s}");
assert_eq!(d.point(clip, "motion", "position"), p0, "the picture is not moved");
d.shot("transform-past-the-edge");
}
/// Anywhere just outside the box rotates, beside the middle of an edge as beside a corner.
#[test]
fn rotating_from_beside_an_edge() {
let mut d = Driver::demo();
let (clip, _) = d.first_clip();
d.exec("effects.setParam", json!({"clip": clip, "effect": "motion", "param": "scale", "value": 50.0}));
d.click("effectControls.effect.motion");
let left = d.rect("program.transform.handle.7");
let (x, y) = (left[0] + left[2] / 2.0 - 14.0, left[1] + left[3] / 2.0);
d.drag_from((x, y), 0.0, -60.0);
let r = d.num(clip, "motion", "rotation");
assert!(r > 5.0, "up from beside the left edge turns clockwise: {r}");
}