mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
refactor(script): move loose test files to src/tests/
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
//! Layers, property groups and properties are callable like After Effects (#758):
|
||||
//! `layer("ADBE Effect Parade")`, `fxGroup(1)` and `fx("Slider")` are `.property(...)`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::keyframe::Value as KV;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Outcome, run_code};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), ..Default::default() };
|
||||
crate::install(&mut s);
|
||||
s
|
||||
}
|
||||
|
||||
fn ok(s: &mut Session, code: &str) -> Outcome {
|
||||
let o = run_code(s, code, "test.jsx");
|
||||
assert!(o.error.is_none(), "script failed: {:?}\noutput: {:?}", o.error, o.output);
|
||||
o
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_groups_and_properties_are_callable() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("M", 200, 200, 1, 2, 30);
|
||||
var l = c.layers.addSolid([1, 1, 1], "S", 200, 200, 1);
|
||||
l("ADBE Effect Parade").addProperty("ADBE Slider Control");
|
||||
var fxGroup = l("Effects");
|
||||
var fx = fxGroup(1);
|
||||
fx("Slider").setValue(42);
|
||||
l("ADBE Mask Parade").addProperty("ADBE Mask Atom");
|
||||
var pos = l("ADBE Transform Group")("ADBE Position");
|
||||
[
|
||||
l("ADBE Effect Parade").matchName, fxGroup.numProperties, fx.matchName, fx.name,
|
||||
l("Effects")("Slider Control")("Slider").value, fxGroup(1)(1).value,
|
||||
l("Masks")(1).matchName, l("Masks")(1)("ADBE Mask Shape").name,
|
||||
pos.matchName, pos.name, pos.propertyIndex, pos.parentProperty("Opacity").value,
|
||||
pos.parentProperty.parentProperty(1).matchName === l.property(1).matchName,
|
||||
l("Nope") === null, fxGroup(9) === null, pos("anything") === null,
|
||||
l instanceof AVLayer, fxGroup instanceof PropertyGroup, pos instanceof Property, l.name,
|
||||
l.transform.position.matchName, l.Effects(1).matchName
|
||||
]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
o.result,
|
||||
json!([
|
||||
"ADBE Effect Parade",
|
||||
1,
|
||||
"ADBE Slider Control",
|
||||
"Slider Control",
|
||||
42,
|
||||
42,
|
||||
"ADBE Mask Atom",
|
||||
"Mask Path",
|
||||
"ADBE Position",
|
||||
"Position",
|
||||
2,
|
||||
100,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"S",
|
||||
"ADBE Position",
|
||||
"ADBE Slider Control"
|
||||
])
|
||||
);
|
||||
let c = s.project.items.values().find_map(|i| i.as_comp()).expect("comp");
|
||||
let l = c.layers.first().expect("layer");
|
||||
assert_eq!(l.props.prop("effects/#1/slider").map(|p| p.value.clone()), Some(KV::Scalar(42.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calling_without_a_name_is_a_script_error() {
|
||||
let mut s = session();
|
||||
for code in [
|
||||
"var c = app.project.items.addComp('M', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l();",
|
||||
"var c = app.project.items.addComp('M', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l('Transform')(null);",
|
||||
"var c = app.project.items.addComp('M', 100, 100, 1, 1, 25); var l = c.layers.addNull(); new l('Transform');",
|
||||
] {
|
||||
let o = run_code(&mut s, code, "test.jsx");
|
||||
assert!(o.error.is_some(), "expected an error for {code}");
|
||||
}
|
||||
// A called layer still reports itself as a layer, and its JSON result is unchanged.
|
||||
let o = ok(&mut s, "var c = app.project.items.addComp('N', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l.name = 'Ctl'; l");
|
||||
assert_eq!(o.result, json!({ "type": "[object AVLayer]", "name": "Ctl" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_preferences_save_to_disk_is_callable() {
|
||||
let mut s = session();
|
||||
let o = ok(&mut s, "app.preferences.saveToDisk(); app.preferences.reload(); [typeof app.preferences.saveToDisk, app.preferences.getPrefAsLong('a', 'b')]");
|
||||
assert_eq!(o.result, json!(["function", 0]));
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
//! The scripting API the bundled Ease Presets ScriptUI panel uses (After Effects parity:
|
||||
//! `app.settings` that last between runs, per-key methods that keep the key selection, eases and
|
||||
//! spatial tangents reported as they play, `selectedProperties` with keyed properties,
|
||||
//! `isInterpolationTypeValid`), and the panel itself (`extensions/scriptui-panels/Ease
|
||||
//! Presets.jsx`) easing selected keyframes, keeping user presets and taking over the presets the
|
||||
//! core panel of v0.6.0 saved.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::commands::scripts::SCRIPT_SETTINGS_FILE;
|
||||
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
|
||||
use effectcraft_engine::project::{LayerId, Property};
|
||||
use effectcraft_keyframe::{Ease, Interp, ease_progress};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::run_code;
|
||||
|
||||
const PANEL: &str = "Ease Presets.jsx";
|
||||
|
||||
fn session(store: &Arc<MemoryConfig>) -> Session {
|
||||
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() };
|
||||
crate::install(&mut s);
|
||||
s.config = Some(store.clone());
|
||||
s.load_settings();
|
||||
s
|
||||
}
|
||||
|
||||
/// A comp with one solid; returns its id.
|
||||
fn setup(s: &mut Session) -> u64 {
|
||||
s.execute("comp.new", json!({"name": "Ease", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Box", "color": "#ff0000", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn animate(s: &mut Session, l: u64, path: &str, keys: &[(f64, Value)]) {
|
||||
for (t, v) in keys {
|
||||
s.execute("prop.addKey", json!({"layer": l, "path": path, "time": t, "value": v})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn prop(s: &Session, l: u64, path: &str) -> Property {
|
||||
s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop(path).unwrap().clone()
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64, tol: f64) -> bool {
|
||||
(a - b).abs() < tol
|
||||
}
|
||||
|
||||
fn ok(s: &mut Session, code: &str) -> Value {
|
||||
let out = run_code(s, code, "test.jsx");
|
||||
assert!(out.error.is_none(), "{:?}", out.error);
|
||||
out.result
|
||||
}
|
||||
|
||||
fn at(s: f64) -> Tick {
|
||||
Tick::from_seconds_f64(s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_settings_last_between_runs() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
ok(&mut s, r#"app.settings.saveSetting("My Tool", "size", 12); app.settings.saveSetting("My Tool", "name", "Box");"#);
|
||||
// A new run (the next launch) reads them back.
|
||||
let mut s = session(&store);
|
||||
let r = ok(
|
||||
&mut s,
|
||||
r#"[app.settings.haveSetting("My Tool", "size"), app.settings.getSetting("My Tool", "size"), app.settings.getSetting("My Tool", "name"), app.settings.haveSetting("My Tool", "nope")]"#,
|
||||
);
|
||||
assert_eq!(r, json!([true, "12", "Box", false]));
|
||||
assert_eq!(s.execute("script.settings.get", json!({"section": "My Tool", "key": "name"})).unwrap(), json!({"have": true, "value": "Box"}));
|
||||
// A corrupt settings file loads as none, never an error.
|
||||
store.write(SCRIPT_SETTINGS_FILE, "{oops").unwrap();
|
||||
let mut s = session(&store);
|
||||
assert_eq!(ok(&mut s, r#"app.settings.haveSetting("My Tool", "size")"#), json!(false));
|
||||
assert!(s.execute("script.settings.save", json!({"section": "x"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_key_methods_keep_the_key_selection_and_report_eases_as_they_play() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (2.0, json!(0))]);
|
||||
let key = |t: f64| json!({"layer": l, "path": "transform/opacity", "time": t});
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
s.execute("keys.select", json!({"keys": [key(0.0), key(2.0)]})).unwrap();
|
||||
let before = s.state.selected_keys.clone();
|
||||
let r = ok(
|
||||
&mut s,
|
||||
r#"var comp = app.project.activeItem, p = comp.layer(1).transform.opacity;
|
||||
// A linear side reports the speed it plays at (33.3 % influence).
|
||||
var lin = p.keyInTemporalEase(3)[0];
|
||||
p.setTemporalEaseAtKey(2, [new KeyframeEase(0, 50)], [new KeyframeEase(0, 75)]);
|
||||
p.setInterpolationTypeAtKey(3, KeyframeInterpolationType.HOLD, KeyframeInterpolationType.HOLD);
|
||||
// Keyed properties are selected properties, as in After Effects.
|
||||
var sel = comp.selectedProperties;
|
||||
[lin.speed, lin.influence, p.selectedKeys, sel.length, sel.length ? sel[0].name : ""]"#,
|
||||
);
|
||||
assert!(close(r[0].as_f64().unwrap(), -100.0, 1e-9) && close(r[1].as_f64().unwrap(), 100.0 / 3.0, 1e-9), "{r}");
|
||||
assert_eq!(r[2], json!([1, 3]), "the selection is the user's");
|
||||
assert_eq!((r[3].clone(), r[4].clone()), (json!(1), json!("Opacity")));
|
||||
assert_eq!(s.state.selected_keys, before);
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert_eq!((op.keys[1].in_ease[0], op.keys[1].out_ease[0]), (Ease { speed: 0.0, influence: 0.5 }, Ease { speed: 0.0, influence: 0.75 }));
|
||||
assert_eq!(op.keys[2].in_interp, Interp::Hold);
|
||||
// removeKey drops the removed key from the selection, keeps the rest.
|
||||
ok(&mut s, "app.project.activeItem.layer(1).transform.opacity.removeKey(3)");
|
||||
assert_eq!(s.state.selected_keys, before[..1]);
|
||||
// Values that don't interpolate only hold.
|
||||
let r = ok(
|
||||
&mut s,
|
||||
r#"var comp = app.project.activeItem, o = comp.layer(1).transform.opacity, t = comp.layers.addText("Hi").text.sourceText;
|
||||
[t.isInterpolationTypeValid(KeyframeInterpolationType.BEZIER), t.isInterpolationTypeValid(KeyframeInterpolationType.HOLD), o.isInterpolationTypeValid(KeyframeInterpolationType.BEZIER)]"#,
|
||||
);
|
||||
assert_eq!(r, json!([false, true, true]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_tangents_are_reported_as_the_path_plays() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (2.0, json!([100, 100, 0]))]);
|
||||
let pos = prop(&s, l, "transform/position");
|
||||
let (inn, out) = effectcraft_keyframe::spatial_tangents(&pos.keys, 1);
|
||||
let r = ok(&mut s, "var p = app.project.activeItem.layer(1).transform.position; [p.keyInSpatialTangent(2), p.keyOutSpatialTangent(2)]");
|
||||
for (got, want) in [(&r[0], inn), (&r[1], out)] {
|
||||
for d in 0..2 {
|
||||
assert!(close(got[d].as_f64().unwrap(), want[d], 1e-9), "{r} vs {inn:?} {out:?}");
|
||||
}
|
||||
}
|
||||
assert!(out[0].abs() + out[1].abs() > 1.0, "the middle key's path curves: {out:?}");
|
||||
assert_ne!(pos.keys[1].spatial_out, out, "auto-Bezier: the stored tangent isn't the one that plays");
|
||||
}
|
||||
|
||||
/// The panel's window and a control's description by name.
|
||||
fn open_panel(s: &mut Session) -> u64 {
|
||||
let r = s.execute("window.scriptPanel", json!({"name": PANEL})).unwrap();
|
||||
r["window"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn control(s: &mut Session, win: u64, name: &str) -> Value {
|
||||
fn find(w: &Value, name: &str) -> Option<Value> {
|
||||
if w["name"] == name {
|
||||
return Some(w.clone());
|
||||
}
|
||||
w["children"].as_array()?.iter().find_map(|c| find(c, name))
|
||||
}
|
||||
let w = s.execute("scriptui.get", json!({"window": win})).unwrap();
|
||||
find(&w["root"], name).unwrap_or_else(|| panic!("no control {name}"))
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, win: u64, widget: &str, value: Value) {
|
||||
s.execute("scriptui.set", json!({"window": win, "widget": widget, "value": value})).unwrap();
|
||||
}
|
||||
|
||||
fn click(s: &mut Session, win: u64, widget: &str) {
|
||||
s.execute("scriptui.click", json!({"window": win, "widget": widget})).unwrap();
|
||||
}
|
||||
|
||||
fn status(s: &mut Session, win: u64) -> String {
|
||||
control(s, win, "status")["text"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
|
||||
animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (2.0, json!(90))]);
|
||||
s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": l, "path": "transform/rotation", "add": true})).unwrap();
|
||||
let selected = s.state.selected_keys.clone();
|
||||
let win = open_panel(&mut s);
|
||||
// It is a bundled extension listed with the ScriptUI panels.
|
||||
let list = s.execute("file.scripts.list", json!({})).unwrap();
|
||||
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == PANEL && e["panel"] == true && e["source"] == "extension"));
|
||||
let steps = s.history.undo.len();
|
||||
set(&mut s, win, "presets", json!("Ease In-Out"));
|
||||
// In-Out (the default) on every key: each pair gets the whole curve, as before.
|
||||
assert_eq!(status(&mut s, win), "Eased 5 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}");
|
||||
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
|
||||
assert_eq!(s.history.undo.last().map(|(label, _)| label.as_str()), Some("Apply Ease Preset"));
|
||||
assert_eq!(s.state.selected_keys, selected, "the keys stay selected for the next preset");
|
||||
let eased = Ease { speed: 0.0, influence: 0.5 };
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert_eq!((op.keys[0].out_interp, op.keys[0].out_ease[0]), (Interp::Bezier, eased));
|
||||
assert_eq!((op.keys[1].in_ease[0], op.keys[1].out_ease[0]), (eased, eased));
|
||||
assert_eq!((op.keys[2].in_interp, op.keys[2].in_ease[0]), (Interp::Bezier, eased));
|
||||
// The first key's in side and the last key's out side are not part of a pair.
|
||||
assert_eq!((op.keys[0].in_interp, op.keys[2].out_interp), (Interp::Linear, Interp::Linear));
|
||||
// A symmetric curve passes the middle value at mid-time, slow near the keys.
|
||||
assert!(close(op.value_at(at(2.0)).as_f64(), 60.0, 1e-6));
|
||||
let quarter = op.value_at(at(1.5)).as_f64();
|
||||
assert!(close(quarter, 100.0 - 80.0 * ease_progress(0.0, 0.5, 0.0, 0.5, 0.25), 1e-6), "{quarter}");
|
||||
assert_eq!(prop(&s, l, "transform/rotation").keys[1].in_interp, Interp::Bezier);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert!(op.keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear));
|
||||
// Nothing selected: nothing changes, and the panel says why.
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
let steps = s.history.undo.len();
|
||||
set(&mut s, win, "presets", json!("Linear"));
|
||||
assert_eq!(status(&mut s, win), "Select keyframes with another keyframe next to it");
|
||||
assert_eq!(s.history.undo.len(), steps);
|
||||
}
|
||||
|
||||
/// Select only the keys at `keys` (property path, time in seconds).
|
||||
fn select_keys(s: &mut Session, l: u64, keys: &[(&str, f64)]) {
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
let keys: Vec<Value> = keys.iter().map(|(path, t)| json!({"layer": l, "path": path, "time": t})).collect();
|
||||
s.execute("keys.select", json!({"keys": keys})).unwrap();
|
||||
}
|
||||
|
||||
/// Type a curve (Out %, Out speed, In %, In speed), choose the sides to ease and click Apply.
|
||||
fn apply_typed(s: &mut Session, win: u64, mode: &str, c: [f64; 4]) {
|
||||
for (name, v) in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().zip(c) {
|
||||
set(s, win, name, json!(v.to_string()));
|
||||
}
|
||||
set(s, win, "mode", json!(mode));
|
||||
click(s, win, "apply");
|
||||
}
|
||||
|
||||
fn eased(e: Ease, speed: f64, influence: f64) -> bool {
|
||||
close(e.speed, speed, 1e-9) && close(e.influence, influence, 1e-9)
|
||||
}
|
||||
|
||||
/// #1329: one selected keyframe eases its incoming side (In), its outgoing side (Out) or both,
|
||||
/// each scaled to the segment on that side (+100/s in, −40/s out here), without changing its
|
||||
/// neighbours, key times, values or the selection; one undo step restores it.
|
||||
#[test]
|
||||
fn one_selected_keyframe_eases_its_in_out_or_both_sides() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
|
||||
let win = open_panel(&mut s);
|
||||
// In-Out is the default.
|
||||
assert_eq!(control(&mut s, win, "mode")["selection"], json!([2]));
|
||||
let before = prop(&s, l, "transform/opacity");
|
||||
for mode in ["In", "Out", "In-Out"] {
|
||||
select_keys(&mut s, l, &[("transform/opacity", 1.0)]);
|
||||
let selected = s.state.selected_keys.clone();
|
||||
let steps = s.history.undo.len();
|
||||
apply_typed(&mut s, win, mode, [30.0, 0.5, 60.0, 2.0]);
|
||||
assert_eq!(status(&mut s, win), format!("Eased 1 keyframe(s) ({mode}) with the curve"));
|
||||
assert_eq!(s.history.undo.len(), steps + 1, "{mode}: one undo step");
|
||||
assert_eq!(s.state.selected_keys, selected, "{mode}: the selection stays");
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert_eq!((&op.keys[0], &op.keys[2]), (&before.keys[0], &before.keys[2]), "{mode}: the neighbours don't change");
|
||||
assert_eq!((op.keys[1].time, op.keys[1].value.as_f64()), (before.keys[1].time, 100.0), "{mode}");
|
||||
let k = &op.keys[1];
|
||||
let (ins, outs) = (mode != "Out", mode != "In");
|
||||
assert_eq!(k.in_interp, if ins { Interp::Bezier } else { Interp::Linear }, "{mode}");
|
||||
assert_eq!(k.out_interp, if outs { Interp::Bezier } else { Interp::Linear }, "{mode}");
|
||||
if ins {
|
||||
assert!(eased(k.in_ease[0], 2.0 * 100.0, 0.6), "{mode}: {:?}", k.in_ease);
|
||||
} else {
|
||||
assert!(close(op.value_at(at(0.5)).as_f64(), 50.0, 1e-6), "{mode}: the in side still plays linearly");
|
||||
}
|
||||
if outs {
|
||||
assert!(eased(k.out_ease[0], 0.5 * -40.0, 0.3), "{mode}: {:?}", k.out_ease);
|
||||
} else {
|
||||
assert!(close(op.value_at(at(2.0)).as_f64(), 60.0, 1e-6), "{mode}: the out side still plays linearly");
|
||||
}
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys, "{mode}: undone");
|
||||
}
|
||||
}
|
||||
|
||||
/// A side without a neighbouring key isn't eased; when no selected key has one, nothing changes
|
||||
/// (no undo step) and the panel says why. Keys that aren't neighbours, and keys of several
|
||||
/// properties, each ease on their own.
|
||||
#[test]
|
||||
fn first_last_and_scattered_keyframes_ease_the_sides_that_have_a_segment() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
|
||||
animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (2.0, json!(90))]);
|
||||
let win = open_panel(&mut s);
|
||||
let before = prop(&s, l, "transform/opacity");
|
||||
let c = [30.0, 0.5, 60.0, 2.0];
|
||||
for (mode, t, why) in [("In", 0.0, "before it"), ("Out", 3.0, "after it")] {
|
||||
select_keys(&mut s, l, &[("transform/opacity", t)]);
|
||||
let steps = s.history.undo.len();
|
||||
apply_typed(&mut s, win, mode, c);
|
||||
assert_eq!(status(&mut s, win), format!("Select keyframes with another keyframe {why}"));
|
||||
assert_eq!(s.history.undo.len(), steps, "{mode}: no undo step");
|
||||
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
|
||||
}
|
||||
// In-Out on the first key eases only its out side.
|
||||
select_keys(&mut s, l, &[("transform/opacity", 0.0)]);
|
||||
apply_typed(&mut s, win, "In-Out", c);
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert_eq!((op.keys[0].in_interp, op.keys[0].out_interp), (Interp::Linear, Interp::Bezier));
|
||||
assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease);
|
||||
assert_eq!(op.keys[1..], before.keys[1..]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// The first and last Opacity keys (not neighbours) and the last Rotation key.
|
||||
select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 3.0), ("transform/rotation", 2.0)]);
|
||||
let steps = s.history.undo.len();
|
||||
apply_typed(&mut s, win, "In-Out", c);
|
||||
assert_eq!(status(&mut s, win), "Eased 3 keyframe(s) (In-Out) with the curve");
|
||||
assert_eq!(s.history.undo.len(), steps + 1);
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease);
|
||||
assert!(eased(op.keys[2].in_ease[0], 2.0 * -40.0, 0.6), "{:?}", op.keys[2].in_ease);
|
||||
assert_eq!(op.keys[1], before.keys[1], "the unselected middle key");
|
||||
let rot = prop(&s, l, "transform/rotation");
|
||||
assert!(eased(rot.keys[1].in_ease[0], 2.0 * 45.0, 0.6), "{:?}", rot.keys[1].in_ease);
|
||||
assert_eq!(rot.keys[0].out_interp, Interp::Linear, "the unselected neighbour");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
|
||||
assert!(prop(&s, l, "transform/rotation").keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear));
|
||||
}
|
||||
|
||||
/// A change from the pair-only panel: In-Out on a selected pair also eases the pair's outer sides
|
||||
/// that face an unselected key (the selected keys' own sides), and Out on the pair eases only
|
||||
/// out sides.
|
||||
#[test]
|
||||
fn a_selected_pair_eases_the_sides_its_mode_names() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
|
||||
let win = open_panel(&mut s);
|
||||
let before = prop(&s, l, "transform/opacity");
|
||||
let c = [30.0, 0.5, 60.0, 2.0];
|
||||
select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 1.0)]);
|
||||
apply_typed(&mut s, win, "In-Out", c);
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].in_ease[0], 200.0, 0.6));
|
||||
assert!(eased(op.keys[1].out_ease[0], -20.0, 0.3), "the side facing the unselected key: {:?}", op.keys[1].out_ease);
|
||||
assert_eq!(op.keys[2], before.keys[2]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
apply_typed(&mut s, win, "Out", c);
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].out_ease[0], -20.0, 0.3));
|
||||
assert_eq!((op.keys[0].in_interp, op.keys[1].in_interp), (Interp::Linear, Interp::Linear));
|
||||
}
|
||||
|
||||
/// Easing one side of a key leaves the other playing as it did: a Hold stays a Hold, and an
|
||||
/// auto-Bezier side keeps its shape. A preset click uses the chosen sides too.
|
||||
#[test]
|
||||
fn the_side_not_eased_keeps_playing_hold_and_auto_bezier() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
|
||||
animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]);
|
||||
ok(
|
||||
&mut s,
|
||||
r#"var t = app.project.activeItem.layer(1).transform, K = KeyframeInterpolationType;
|
||||
t.opacity.setInterpolationTypeAtKey(2, K.LINEAR, K.HOLD);
|
||||
t.rotation.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER);
|
||||
t.rotation.setTemporalAutoBezierAtKey(2, true);"#,
|
||||
);
|
||||
let rot = prop(&s, l, "transform/rotation");
|
||||
let after: Vec<f64> = [1.5, 2.0, 2.5].iter().map(|t| rot.value_at(at(*t)).as_f64()).collect();
|
||||
let win = open_panel(&mut s);
|
||||
set(&mut s, win, "mode", json!("In"));
|
||||
select_keys(&mut s, l, &[("transform/opacity", 1.0), ("transform/rotation", 1.0)]);
|
||||
set(&mut s, win, "presets", json!("Ease In-Out"));
|
||||
assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In) with \u{201c}Ease In-Out\u{201d}");
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert_eq!((op.keys[1].in_interp, op.keys[1].out_interp), (Interp::Bezier, Interp::Hold));
|
||||
assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5), "{:?}", op.keys[1].in_ease);
|
||||
assert_eq!(op.value_at(at(2.0)).as_f64(), 100.0, "still holds");
|
||||
let eased_rot = prop(&s, l, "transform/rotation");
|
||||
assert!(eased(eased_rot.keys[1].in_ease[0], 0.0, 0.5), "{:?}", eased_rot.keys[1].in_ease);
|
||||
for (t, want) in [1.5, 2.0, 2.5].iter().zip(after) {
|
||||
assert!(close(eased_rot.value_at(at(*t)).as_f64(), want, 1e-6), "the auto-Bezier out side at {t}");
|
||||
}
|
||||
assert!(!close(eased_rot.value_at(at(0.25)).as_f64(), rot.value_at(at(0.25)).as_f64(), 1e-3), "the in side changed");
|
||||
}
|
||||
|
||||
/// Roving keys pass speed straight through (the engine keeps their sides linear), so an ease set
|
||||
/// on one wouldn't play: they are skipped and the panel says so, rather than reporting them eased.
|
||||
#[test]
|
||||
fn roving_keyframes_are_skipped_and_reported() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (2.0, json!([200, 0, 0]))]);
|
||||
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
|
||||
ok(&mut s, "app.project.activeItem.layer(1).transform.position.setRovingAtKey(2, true)");
|
||||
let pos = prop(&s, l, "transform/position");
|
||||
assert!(pos.keys[1].roving);
|
||||
let roving_at = pos.keys[1].time.seconds();
|
||||
let win = open_panel(&mut s);
|
||||
// Only the roving key: nothing changes, no undo step.
|
||||
select_keys(&mut s, l, &[("transform/position", roving_at)]);
|
||||
let steps = s.history.undo.len();
|
||||
set(&mut s, win, "presets", json!("Ease In-Out"));
|
||||
assert_eq!(status(&mut s, win), "Nothing eased: skipped 1 roving keyframe(s); turn off Roving to ease them");
|
||||
assert_eq!(s.history.undo.len(), steps);
|
||||
assert_eq!(prop(&s, l, "transform/position").keys, pos.keys);
|
||||
// With an ordinary key: that one is eased, the roving one is counted as skipped.
|
||||
select_keys(&mut s, l, &[("transform/position", roving_at), ("transform/opacity", 1.0)]);
|
||||
set(&mut s, win, "presets", json!("Linear"));
|
||||
set(&mut s, win, "presets", json!("Ease In-Out"));
|
||||
assert_eq!(
|
||||
status(&mut s, win),
|
||||
"Eased 1 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}; skipped 1 roving keyframe(s); turn off Roving to ease them"
|
||||
);
|
||||
let k = &prop(&s, l, "transform/position").keys[1];
|
||||
assert!(k.roving && k.in_interp == Interp::Linear && k.out_interp == Interp::Linear, "{k:?}");
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5) && eased(op.keys[1].out_ease[0], 0.0, 0.5));
|
||||
}
|
||||
|
||||
/// Out alone leaves an auto-Bezier in side playing as it did, and undo and redo restore each state.
|
||||
#[test]
|
||||
fn out_alone_keeps_the_in_side_playing_through_undo_and_redo() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]);
|
||||
ok(
|
||||
&mut s,
|
||||
r#"var r = app.project.activeItem.layer(1).transform.rotation, K = KeyframeInterpolationType;
|
||||
r.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER);
|
||||
r.setTemporalAutoBezierAtKey(2, true);"#,
|
||||
);
|
||||
let before = prop(&s, l, "transform/rotation");
|
||||
let win = open_panel(&mut s);
|
||||
select_keys(&mut s, l, &[("transform/rotation", 1.0)]);
|
||||
apply_typed(&mut s, win, "Out", [60.0, 0.0, 33.0, 1.0]);
|
||||
let after = prop(&s, l, "transform/rotation");
|
||||
assert!(eased(after.keys[1].out_ease[0], 0.0, 0.6), "{:?}", after.keys[1].out_ease);
|
||||
for t in [0.25, 0.5, 0.75] {
|
||||
assert!(close(after.value_at(at(t)).as_f64(), before.value_at(at(t)).as_f64(), 1e-6), "the in side at {t}");
|
||||
}
|
||||
assert!(!close(after.value_at(at(2.0)).as_f64(), before.value_at(at(2.0)).as_f64(), 1e-3), "the out side changed");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/rotation").keys, before.keys);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/rotation").keys, after.keys);
|
||||
}
|
||||
|
||||
/// On a curved path, a single key's in and out speeds follow the length of the path on each side
|
||||
/// (unequal lengths and durations), and the side not eased plays as it did.
|
||||
#[test]
|
||||
fn a_single_key_on_a_curved_path_eases_along_each_side() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([100, 300, 0]))]);
|
||||
let before = prop(&s, l, "transform/position");
|
||||
let (len_in, len_out) = (effectcraft_keyframe::spatial_segment_length(&before.keys, 0), effectcraft_keyframe::spatial_segment_length(&before.keys, 1));
|
||||
let win = open_panel(&mut s);
|
||||
select_keys(&mut s, l, &[("transform/position", 1.0)]);
|
||||
apply_typed(&mut s, win, "In", [40.0, 1.5, 50.0, 2.0]);
|
||||
let pos = prop(&s, l, "transform/position");
|
||||
let k = &pos.keys[1];
|
||||
// In speed: twice the incoming segment's average (its path length over 1 s).
|
||||
assert!(k.in_ease.iter().all(|e| close(e.speed, 2.0 * len_in, 0.05 * len_in) && close(e.influence, 0.5, 1e-9)), "{:?} vs {len_in}", k.in_ease);
|
||||
// The out side (2 s along a longer path) plays as before.
|
||||
for t in [1.5, 2.0, 2.5] {
|
||||
let (a, b) = (pos.value_at(at(t)).as_vec2(), before.value_at(at(t)).as_vec2());
|
||||
assert!(close(a[0], b[0], 1e-6) && close(a[1], b[1], 1e-6), "out side at {t}: {a:?} vs {b:?}");
|
||||
}
|
||||
// Playback on the in side: arriving at twice the average speed, the last tenth of a second
|
||||
// covers more of the path than a constant speed would (len_in / 10).
|
||||
let (p9, p10) = (pos.value_at(at(0.9)).as_vec2(), pos.value_at(at(1.0)).as_vec2());
|
||||
let step = ((p10[0] - p9[0]).powi(2) + (p10[1] - p9[1]).powi(2)).sqrt();
|
||||
assert!(step > len_in / 10.0 * 1.2, "{step} vs {len_in}");
|
||||
assert!(len_out > len_in, "{len_out} {len_in}");
|
||||
}
|
||||
|
||||
/// On one key, each side scales to its own segment: per dimension, and along the motion path.
|
||||
#[test]
|
||||
fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
// Scale: +100/−50 per second into the key, 0/+50 per second out of it.
|
||||
animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50])), (3.0, json!([200, 150]))]);
|
||||
// Position on a straight path: 100 px/s into the key, 50 px/s out of it.
|
||||
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([200, 0, 0]))]);
|
||||
let win = open_panel(&mut s);
|
||||
select_keys(&mut s, l, &[("transform/scale", 1.0), ("transform/position", 1.0)]);
|
||||
apply_typed(&mut s, win, "In-Out", [40.0, 1.5, 50.0, 2.0]);
|
||||
assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In-Out) with the curve");
|
||||
let k = prop(&s, l, "transform/scale").keys[1].clone();
|
||||
assert!(eased(k.in_ease[0], 200.0, 0.5) && eased(k.in_ease[1], -100.0, 0.5), "{:?}", k.in_ease);
|
||||
assert!(eased(k.out_ease[0], 0.0, 0.4) && eased(k.out_ease[1], 75.0, 0.4), "{:?}", k.out_ease);
|
||||
let k = prop(&s, l, "transform/position").keys[1].clone();
|
||||
assert!(k.in_ease.iter().all(|e| close(e.speed, 200.0, 0.5) && close(e.influence, 0.5, 1e-9)), "{:?}", k.in_ease);
|
||||
assert!(k.out_ease.iter().all(|e| close(e.speed, 75.0, 0.5) && close(e.influence, 0.4, 1e-9)), "{:?}", k.out_ease);
|
||||
let pos = prop(&s, l, "transform/position");
|
||||
assert_eq!(pos.value_at(at(1.0)).as_vec2()[0], 100.0, "the key's value stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_typed_curve_scales_to_each_dimension_and_follows_motion_paths() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50]))]);
|
||||
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (2.0, json!([300, 400, 0]))]);
|
||||
s.execute("prop.select", json!({"layer": l, "path": "transform/scale"})).unwrap();
|
||||
let win = open_panel(&mut s);
|
||||
let typed = |s: &mut Session, c: [f64; 4]| {
|
||||
for (name, v) in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().zip(c) {
|
||||
set(s, win, name, json!(v.to_string()));
|
||||
}
|
||||
click(s, win, "apply");
|
||||
};
|
||||
typed(&mut s, [60.0, 0.0, 20.0, 2.0]);
|
||||
let sc = prop(&s, l, "transform/scale");
|
||||
// X rises 100/s and Y falls 50/s: the in speed (2× average) is per dimension.
|
||||
assert_eq!(sc.keys[1].in_ease[..2], [Ease { speed: 200.0, influence: 0.2 }, Ease { speed: -100.0, influence: 0.2 }]);
|
||||
let f = ease_progress(0.0, 0.6, 2.0, 0.2, 0.5);
|
||||
let v = sc.value_at(at(0.5)).as_vec2();
|
||||
assert!(close(v[0], 100.0 + 100.0 * f, 1e-6) && close(v[1], 100.0 - 50.0 * f, 1e-6), "{v:?}");
|
||||
// On Position the speed is along the 500 px path.
|
||||
s.execute("prop.select", json!({"layer": l, "path": "transform/position"})).unwrap();
|
||||
typed(&mut s, [25.0, 0.4, 75.0, 0.0]);
|
||||
let pos = prop(&s, l, "transform/position");
|
||||
assert!(pos.keys[0].out_ease.iter().all(|e| close(e.speed, 0.4 * 250.0, 1e-6) && close(e.influence, 0.25, 1e-9)), "{:?}", pos.keys[0].out_ease);
|
||||
let f = ease_progress(0.4, 0.25, 0.0, 0.75, 0.5);
|
||||
let p = pos.value_at(at(1.0)).as_vec2();
|
||||
assert!(close(p[0], 300.0 * f, 0.05) && close(p[1], 400.0 * f, 0.05), "{p:?} {f}");
|
||||
// From Keys reads the applied curve back into the fields.
|
||||
for name in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"] {
|
||||
set(&mut s, win, name, json!("1"));
|
||||
}
|
||||
click(&mut s, win, "fromKeys");
|
||||
let fields: Vec<String> =
|
||||
["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().map(|n| control(&mut s, win, n)["text"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(fields, ["25", "0.4", "75", "0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_presets_save_rename_delete_and_persist() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100))]);
|
||||
s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap();
|
||||
let win = open_panel(&mut s);
|
||||
set(&mut s, win, "presets", json!("Decelerate"));
|
||||
// Save keeps the curve in the fields under the typed name.
|
||||
set(&mut s, win, "name", json!(" Settle "));
|
||||
click(&mut s, win, "save");
|
||||
assert_eq!(status(&mut s, win), "Saved the ease preset \u{201c}Settle\u{201d}");
|
||||
// Built-in names are refused.
|
||||
set(&mut s, win, "name", json!("linear"));
|
||||
click(&mut s, win, "save");
|
||||
assert!(status(&mut s, win).contains("is a built-in preset"));
|
||||
let items = |s: &mut Session| control(s, win, "presets")["items"].clone();
|
||||
assert_eq!(items(&mut s).as_array().unwrap().last().unwrap(), "Settle");
|
||||
// Rename and Delete act on the selected user preset; built-in presets can't change.
|
||||
set(&mut s, win, "presets", json!("Linear"));
|
||||
set(&mut s, win, "name", json!("Straight"));
|
||||
click(&mut s, win, "rename");
|
||||
assert_eq!(status(&mut s, win), "Select one of your own presets first");
|
||||
set(&mut s, win, "presets", json!("Settle"));
|
||||
set(&mut s, win, "name", json!("Soft Landing"));
|
||||
click(&mut s, win, "rename");
|
||||
let names = items(&mut s);
|
||||
assert!(names.as_array().unwrap().contains(&json!("Soft Landing")) && !names.as_array().unwrap().contains(&json!("Settle")), "{names}");
|
||||
// Kept with app.settings: a new run of the panel (the next launch) lists them.
|
||||
let stored: Value = serde_json::from_str(&store.read(SCRIPT_SETTINGS_FILE).unwrap()).unwrap();
|
||||
let presets: Value = serde_json::from_str(stored["Ease Presets"]["userPresets"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(presets["presets"][0]["name"], "Soft Landing");
|
||||
assert!(close(presets["presets"][0]["curve"]["outSpeed"].as_f64().unwrap(), 1.5, 1e-9), "{presets}");
|
||||
let mut s2 = session(&store);
|
||||
setup(&mut s2);
|
||||
let win2 = open_panel(&mut s2);
|
||||
assert_eq!(control(&mut s2, win2, "presets")["items"].as_array().unwrap().len(), 13);
|
||||
set(&mut s2, win2, "presets", json!("Soft Landing"));
|
||||
click(&mut s2, win2, "delete");
|
||||
assert_eq!(control(&mut s2, win2, "presets")["items"].as_array().unwrap().len(), 12, "the built-in presets");
|
||||
}
|
||||
|
||||
/// User presets the core Ease Presets panel of v0.6.0 saved (`ease_presets.json`) move to the
|
||||
/// panel's settings when settings load, once; unusable entries are skipped by the panel.
|
||||
#[test]
|
||||
fn presets_saved_by_the_old_core_panel_carry_over() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let c = json!({"outInfluence": 30, "outSpeed": 0, "inInfluence": 30, "inSpeed": 0});
|
||||
let old = json!({"version": 1, "presets": [
|
||||
{"name": "Snap", "curve": c},
|
||||
{"name": "Linear", "curve": c},
|
||||
{"name": "", "curve": c},
|
||||
{"name": "No curve"},
|
||||
{"name": "snap", "curve": c},
|
||||
{"name": "Bad", "curve": {"outInfluence": "x", "outSpeed": 0, "inInfluence": 30, "inSpeed": 0}}
|
||||
]});
|
||||
store.write("ease_presets.json", &old.to_string()).unwrap();
|
||||
let mut s = session(&store);
|
||||
setup(&mut s);
|
||||
let win = open_panel(&mut s);
|
||||
let items = control(&mut s, win, "presets")["items"].clone();
|
||||
assert_eq!(items.as_array().unwrap().len(), 13, "{items}");
|
||||
assert_eq!(items[12], "Snap");
|
||||
// Deleting it there is final: the old file doesn't come back.
|
||||
set(&mut s, win, "presets", json!("Snap"));
|
||||
click(&mut s, win, "delete");
|
||||
let mut s = session(&store);
|
||||
setup(&mut s);
|
||||
let win = open_panel(&mut s);
|
||||
assert_eq!(control(&mut s, win, "presets")["items"].as_array().unwrap().len(), 12);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! #760: `$.evalFile` and the `BridgeTalk` host identification.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Outcome, run_code};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::default();
|
||||
crate::install(&mut s);
|
||||
s
|
||||
}
|
||||
|
||||
fn tmp(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ec-evalfile-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn write(path: &Path, text: &str) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
|
||||
/// Run the script file `main` (its code read from disk, as File ▸ Scripts ▸ Run Script File does).
|
||||
fn run_file(s: &mut Session, main: &Path) -> Outcome {
|
||||
let code = std::fs::read_to_string(main).unwrap();
|
||||
run_code(s, &code, &main.to_string_lossy())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_file_evaluates_relative_to_the_calling_script() {
|
||||
let dir = tmp("ok");
|
||||
// lib/a.jsx loads b.jsx next to itself, not next to main.jsx.
|
||||
write(&dir.join("lib/a.jsx"), "var fromA = $.evalFile('b.jsx') + 1;\nfunction twice(x) { return 2 * x; }\nfromA");
|
||||
write(&dir.join("lib/b.jsx"), "var fromB = 20;\nfromB");
|
||||
write(
|
||||
&dir.join("main.jsx"),
|
||||
"var r = $.evalFile(new File($.fileName).path + '/lib/a.jsx');\nvar s = $.evalFile('lib/b.jsx');\n[r, s, twice(fromB), typeof fromA]",
|
||||
);
|
||||
let mut s = session();
|
||||
let o = run_file(&mut s, &dir.join("main.jsx"));
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert_eq!(o.result, json!([21, 20, 40, "number"]));
|
||||
// Errors in the evaluated file reach the caller, which can catch them.
|
||||
write(&dir.join("bad.jsx"), "throw new Error('bad file');");
|
||||
write(&dir.join("main.jsx"), "var m; try { $.evalFile('bad.jsx'); } catch (e) { m = e.message; } m");
|
||||
assert_eq!(run_file(&mut s, &dir.join("main.jsx")).result, json!("bad file"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_file_is_bounded_and_gated() {
|
||||
let dir = tmp("hostile");
|
||||
// A file that evaluates itself forever stops at the depth limit.
|
||||
write(&dir.join("loop.jsx"), "$.evalFile('loop.jsx');");
|
||||
let mut s = session();
|
||||
let e = run_file(&mut s, &dir.join("loop.jsx")).error.expect("an error");
|
||||
assert!(e.message.contains("nested too deeply"), "{e:?}");
|
||||
// Deeply bracketed files are refused before they reach the parser.
|
||||
write(&dir.join("deep.jsx"), &"[".repeat(100_000));
|
||||
write(&dir.join("main.jsx"), "$.evalFile('deep.jsx')");
|
||||
let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error");
|
||||
assert!(e.message.contains("nested too deeply"), "{e:?}");
|
||||
// Missing files are a clear error.
|
||||
write(&dir.join("main.jsx"), "$.evalFile('missing.jsx')");
|
||||
let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error");
|
||||
assert!(e.message.contains("missing.jsx"), "{e:?}");
|
||||
// Files outside the script's (and the project's) folder need the file preference.
|
||||
write(&dir.join("outside.jsx"), "7");
|
||||
write(&dir.join("app/main.jsx"), "$.evalFile('../outside.jsx')");
|
||||
let e = run_file(&mut s, &dir.join("app/main.jsx")).error.expect("blocked");
|
||||
assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}");
|
||||
s.prefs.scripting.allow_scripts_write_files = true;
|
||||
let o = run_file(&mut s, &dir.join("app/main.jsx"));
|
||||
assert_eq!(o.result, json!(7), "{:?}", o.error);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridgetalk_identifies_effectcraft() {
|
||||
let mut s = session();
|
||||
let o = run_code(
|
||||
&mut s,
|
||||
"[typeof BridgeTalk, BridgeTalk.appName, BridgeTalk.appVersion, BridgeTalk.appSpecifier, BridgeTalk.isRunning('aftereffects'), \
|
||||
BridgeTalk.getSpecifier('effectcraft'), BridgeTalk.getStatus('photoshop'), new BridgeTalk().sender]",
|
||||
"bt.jsx",
|
||||
);
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
let v = env!("CARGO_PKG_VERSION");
|
||||
let spec = format!("effectcraft-{v}");
|
||||
assert_eq!(o.result, json!(["function", "effectcraft", v, spec, false, spec, "ISNOTINSTALLED", spec]));
|
||||
// Messages to other applications are unsupported, with a clear error.
|
||||
let e = run_code(&mut s, "var bt = new BridgeTalk(); bt.target = 'photoshop'; bt.body = '1'; bt.send();", "bt.jsx").error.expect("unsupported");
|
||||
assert!(e.message.contains("not supported"), "{e:?}");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! #759: ExtendScript's `#include` / `//@include` / `#includepath` directives.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Outcome, run_code};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::default();
|
||||
crate::install(&mut s);
|
||||
s
|
||||
}
|
||||
|
||||
fn tmp(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ec-include-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn write(path: &Path, text: &str) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
|
||||
/// Run the script file `main` (its code read from disk, as File ▸ Scripts ▸ Run Script File does).
|
||||
fn run_file(s: &mut Session, main: &Path) -> Outcome {
|
||||
let code = std::fs::read_to_string(main).unwrap();
|
||||
run_code(s, &code, &main.to_string_lossy())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_are_inlined_relative_to_the_script() {
|
||||
let dir = tmp("ok");
|
||||
write(&dir.join("lib/math.jsx"), "#include \"util.jsxinc\"\nfunction twice(x) { return 2 * x; }");
|
||||
write(&dir.join("lib/util.jsxinc"), "// includes its includer back: inlined once only\n#include \"math.jsx\"\nvar unit = 'px';");
|
||||
write(&dir.join("vendor/extra.jsx"), "var extra = 40;");
|
||||
write(
|
||||
&dir.join("main.jsx"),
|
||||
"#target aftereffects\n#targetengine \"main\"\n#strict on\n//@include 'lib/math.jsx'\n#includepath \"vendor;lib\"\n#include \"extra.jsx\";\n#include \"util.jsxinc\"\n[twice(1) + extra, unit, $.fileName.split('\\\\').pop().split('/').pop()]",
|
||||
);
|
||||
let mut s = session();
|
||||
let o = run_file(&mut s, &dir.join("main.jsx"));
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert_eq!(o.result, json!([42, "px", "main.jsx"]));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_point_at_the_file_and_line_they_came_from() {
|
||||
let dir = tmp("err");
|
||||
write(&dir.join("lib.jsx"), "var a = 1;\nvar b = 2;\nnotDefined();");
|
||||
write(&dir.join("main.jsx"), "// main\n#include \"lib.jsx\"\nvar c = 3;");
|
||||
let mut s = session();
|
||||
let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error");
|
||||
assert!(e.file.ends_with("lib.jsx") && e.line == Some(3), "{e:?}");
|
||||
// After the include, lines in the script itself keep their numbers.
|
||||
write(&dir.join("lib.jsx"), "var a = 1;\nvar b = 2;");
|
||||
write(&dir.join("main.jsx"), "// main\n#include \"lib.jsx\"\nvar c = 3;\nalsoNotDefined();");
|
||||
let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error");
|
||||
assert!(e.file.ends_with("main.jsx") && e.line == Some(4), "{e:?}");
|
||||
// A missing file is a clear error on the directive's line.
|
||||
write(&dir.join("main.jsx"), "var x = 1;\n#include \"missing.jsx\"");
|
||||
let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error");
|
||||
assert!(e.message.contains("missing.jsx") && e.line == Some(2), "{e:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_includes_are_bounded_and_gated() {
|
||||
let dir = tmp("hostile");
|
||||
// A chain deeper than the limit is an error, not a stack overflow.
|
||||
for i in 0..100 {
|
||||
write(&dir.join(format!("chain/{i}.jsx")), &format!("#include \"{}.jsx\"", i + 1));
|
||||
}
|
||||
write(&dir.join("chain/100.jsx"), "1");
|
||||
let mut s = session();
|
||||
let e = run_file(&mut s, &dir.join("chain/0.jsx")).error.expect("an error");
|
||||
assert!(e.message.contains("nested too deeply"), "{e:?}");
|
||||
// Files outside the script's (and the project's) folder need the file preference.
|
||||
write(&dir.join("outside.jsx"), "var secret = 7;");
|
||||
write(&dir.join("app/main.jsx"), "#include \"../outside.jsx\"\nsecret");
|
||||
let e = run_file(&mut s, &dir.join("app/main.jsx")).error.expect("blocked");
|
||||
assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}");
|
||||
// Code run without a file can't include relative to anything but the project's folder.
|
||||
let e = run_code(&mut s, "#include \"outside.jsx\"", "console").error.expect("no folder");
|
||||
assert!(e.message.contains("outside.jsx"), "{e:?}");
|
||||
s.prefs.scripting.allow_scripts_write_files = true;
|
||||
let o = run_file(&mut s, &dir.join("app/main.jsx"));
|
||||
assert_eq!(o.result, json!(7), "{:?}", o.error);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! ScriptUI Panels, as in After Effects: scripts in the user's `Scripts/ScriptUI Panels` folder
|
||||
//! (in the settings folder) are listed at the bottom of the Window menu, open as dockable panels
|
||||
//! with `this` = the panel, and the panels open when the app quits open again at the next launch.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::commands::scripts::{OPEN_PANELS_FILE, PANELS_DIR};
|
||||
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
|
||||
use effectcraft_engine::scriptui::WindowKind;
|
||||
use serde_json::json;
|
||||
|
||||
const PANEL: &str = "var p = this;\np.add('statictext', undefined, 'Hello from a panel');\np.layout.layout(true);\n";
|
||||
|
||||
fn session(store: &Arc<MemoryConfig>) -> Session {
|
||||
let mut s = Session::default();
|
||||
crate::install(&mut s);
|
||||
s.config = Some(store.clone());
|
||||
s
|
||||
}
|
||||
|
||||
fn panel_window(s: &Session, name: &str) -> Option<u32> {
|
||||
s.script_ui.windows.iter().find(|w| w.kind == WindowKind::Panel && w.script == name).map(|w| w.id)
|
||||
}
|
||||
|
||||
fn remembered(store: &MemoryConfig) -> serde_json::Value {
|
||||
serde_json::from_str(&store.read(OPEN_PANELS_FILE).unwrap_or_default()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panel_dropped_in_the_folder_is_listed_and_opens_as_a_panel() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
store.write(&format!("{PANELS_DIR}/Hello Panel.jsx"), PANEL).unwrap();
|
||||
// Not a script: not listed.
|
||||
store.write(&format!("{PANELS_DIR}/notes.txt"), "x").unwrap();
|
||||
let mut s = session(&store);
|
||||
let cx = effectcraft_engine::menus::DynCtx::default();
|
||||
let (entries, _) = effectcraft_engine::menus::dynamic(&s, "scriptPanels", &cx);
|
||||
let names: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect();
|
||||
assert!(names.contains(&"Hello Panel.jsx") && !names.contains(&"notes.txt"), "{names:?}");
|
||||
let e = entries.iter().find(|e| e.label == "Hello Panel.jsx").unwrap();
|
||||
assert_eq!((e.command.as_str(), &e.params), ("window.scriptPanel", &json!({"name": "Hello Panel.jsx"})));
|
||||
let r = s.execute("window.scriptPanel", json!({"name": "Hello Panel.jsx"})).unwrap();
|
||||
let id = r["window"].as_u64().unwrap() as u32;
|
||||
let w = s.script_ui.window(id).unwrap();
|
||||
assert_eq!((w.kind, w.title.as_str()), (WindowKind::Panel, "Hello Panel"));
|
||||
assert_eq!(w.root.children[0].text, "Hello from a panel");
|
||||
// Frontends dock it (`window.scriptPanel {window}` for the UI).
|
||||
let events = s.drain_events();
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, effectcraft_engine::Event::Frontend { command, params } if command == "window.scriptPanel" && params["window"] == id))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_panels_open_again_at_the_next_launch() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
store.write(&format!("{PANELS_DIR}/Hello Panel.jsx"), PANEL).unwrap();
|
||||
let mut s = session(&store);
|
||||
s.execute("window.scriptPanel", json!({"name": "Hello Panel.jsx"})).unwrap();
|
||||
s.execute("window.scriptPanel", json!({"name": "Layer Tools.jsx"})).unwrap();
|
||||
assert_eq!(remembered(&store), json!({"open": ["Hello Panel.jsx", "Layer Tools.jsx"]}));
|
||||
// Quit with both open; the next launch opens them again.
|
||||
drop(s);
|
||||
let mut s = session(&store);
|
||||
let r = s.execute("window.restoreScriptPanels", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"opened": ["Hello Panel.jsx", "Layer Tools.jsx"], "failed": []}));
|
||||
assert!(panel_window(&s, "Hello Panel.jsx").is_some() && panel_window(&s, "Layer Tools.jsx").is_some());
|
||||
// Closing a panel (its tab) forgets it.
|
||||
let id = panel_window(&s, "Layer Tools.jsx").unwrap();
|
||||
s.execute("scriptui.close", json!({"window": id})).unwrap();
|
||||
assert_eq!(remembered(&store), json!({"open": ["Hello Panel.jsx"]}));
|
||||
// A panel whose script was removed, or that fails, is reported and forgotten; the others open.
|
||||
store.write(OPEN_PANELS_FILE, &json!({"open": ["Gone.jsx", "Broken.jsx", "Hello Panel.jsx"]}).to_string()).unwrap();
|
||||
store.write(&format!("{PANELS_DIR}/Broken.jsx"), "throw new Error('nope');").unwrap();
|
||||
let mut s = session(&store);
|
||||
let r = s.execute("window.restoreScriptPanels", json!({})).unwrap();
|
||||
assert_eq!(r["opened"], json!(["Hello Panel.jsx"]));
|
||||
let failed: Vec<&str> = r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(failed, ["Gone.jsx", "Broken.jsx"]);
|
||||
assert_eq!(remembered(&store), json!({"open": ["Hello Panel.jsx"]}));
|
||||
// A corrupt list opens nothing (and is no error).
|
||||
store.write(OPEN_PANELS_FILE, "{not json").unwrap();
|
||||
let mut s = session(&store);
|
||||
assert_eq!(s.execute("window.restoreScriptPanels", json!({})).unwrap(), json!({"opened": [], "failed": []}));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Scripts see the host's files: existence checks go through `Services`, like reads, so a host
|
||||
//! that keeps files in memory (the web editor's browser storage) answers for its own files.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use effectcraft_engine::{Services, Session};
|
||||
|
||||
use crate::run_code;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Mem(Mutex<HashMap<String, Vec<u8>>>);
|
||||
|
||||
impl Services for Mem {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.get(path)
|
||||
.cloned()
|
||||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string()))
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.0.lock().unwrap_or_else(PoisonError::into_inner).insert(path.into(), data.into());
|
||||
Ok(())
|
||||
}
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
self.0.lock().unwrap_or_else(PoisonError::into_inner).contains_key(path)
|
||||
}
|
||||
fn is_dir(&self, path: &str) -> bool {
|
||||
path == "/browser/footage"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_opens_a_file_only_the_host_holds() {
|
||||
let mem = Arc::new(Mem::default());
|
||||
mem.write_file("/browser/data.txt", b"hello").unwrap();
|
||||
let mut s = Session { services: mem, ..Default::default() };
|
||||
crate::install(&mut s);
|
||||
// A project in a folder that isn't on disk: scripts may read files next to it.
|
||||
s.path = Some("/browser/project.ecproj".into());
|
||||
let out = run_code(
|
||||
&mut s,
|
||||
r#"var f = new File("/browser/data.txt"); var ok = f.open("r");
|
||||
writeLn(ok + " " + (ok ? f.read() : f.error));
|
||||
writeLn(new File("/browser/missing.txt").exists + " " + new Folder("/browser/footage").exists);"#,
|
||||
"t.jsx",
|
||||
);
|
||||
assert!(out.error.is_none(), "{:?}", out.error);
|
||||
assert_eq!(out.output, vec!["true hello".to_string(), "false true".to_string()]);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! `app.scheduleTask` / `app.cancelTask` (#773): repeating tasks run whenever they are due (frontends
|
||||
//! tick [`crate::run_tasks`] every frame) until they are cancelled, throw, or their owner goes
|
||||
//! away; one-shot tasks scheduled later run when due too.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::{ScriptRequest, Session};
|
||||
use serde_json::{Value, json};
|
||||
use web_time::{Duration, Instant};
|
||||
|
||||
use crate::run_code;
|
||||
use crate::tasks::run_tasks_at;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() };
|
||||
crate::install(&mut s);
|
||||
s.config = Some(Arc::new(effectcraft_engine::config::MemoryConfig::default()));
|
||||
s
|
||||
}
|
||||
|
||||
fn ok(s: &mut Session, code: &str) {
|
||||
let o = run_code(s, code, "tasks.jsx");
|
||||
assert!(o.error.is_none(), "script failed: {:?}\noutput: {:?}", o.error, o.output);
|
||||
}
|
||||
|
||||
fn console(s: &mut Session, code: &str) -> crate::Outcome {
|
||||
let o = crate::run(s, &ScriptRequest { code, name: "console", console: true });
|
||||
assert!(o.error.is_none(), "console failed: {:?}", o.error);
|
||||
o
|
||||
}
|
||||
|
||||
fn ms(n: u64) -> Duration {
|
||||
Duration::from_millis(n)
|
||||
}
|
||||
|
||||
fn output(r: &Value) -> Vec<String> {
|
||||
r["output"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
fn comps(s: &Session) -> usize {
|
||||
s.project.items.values().filter(|i| i.as_comp().is_some()).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_task_runs_every_interval_until_cancelled() {
|
||||
let mut s = session();
|
||||
ok(
|
||||
&mut s,
|
||||
"var n = 0;\nvar id = app.scheduleTask(\"n++; writeLn('tick ' + n); app.project.items.addComp('C' + n, 64, 64, 1, 1, 25); if (n == 3) app.cancelTask(id);\", 1000, true);",
|
||||
);
|
||||
let t0 = Instant::now();
|
||||
// Not due yet: nothing runs, and the frontend learns when to come back.
|
||||
let r = run_tasks_at(&mut s, t0);
|
||||
assert_eq!(r["ran"], json!(0));
|
||||
assert!(r["next"].as_u64().is_some_and(|n| n <= 1000), "{r}");
|
||||
let r = run_tasks_at(&mut s, t0 + ms(1500));
|
||||
assert_eq!((r["ran"].clone(), output(&r)), (json!(1), vec!["tick 1".to_string()]), "{r}");
|
||||
assert_eq!(comps(&s), 1, "the task edits the project through engine commands");
|
||||
// Due again an interval after it ran, once per pass (no catch-up burst).
|
||||
assert_eq!(run_tasks_at(&mut s, t0 + ms(1500))["ran"], json!(0));
|
||||
assert_eq!(run_tasks_at(&mut s, t0 + ms(2600))["ran"], json!(1));
|
||||
let r = run_tasks_at(&mut s, t0 + ms(9000));
|
||||
assert_eq!(output(&r), vec!["tick 3"]);
|
||||
// Cancelled: the script (no windows, no tasks) has ended.
|
||||
let r = run_tasks_at(&mut s, t0 + ms(20_000));
|
||||
assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}");
|
||||
assert_eq!(comps(&s), 3);
|
||||
assert!(s.history.undo.len() >= 3, "every run is undoable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panels_task_refreshes_it_until_the_panel_closes() {
|
||||
let mut s = session();
|
||||
ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var w = new Window("palette", "Clock");
|
||||
var label = w.add("statictext", undefined, "0");
|
||||
var n = 0;
|
||||
app.scheduleTask("n++; label.text = String(n);", 200, true);
|
||||
w.show();
|
||||
"#,
|
||||
);
|
||||
let win = s.script_ui.windows[0].id;
|
||||
let text = |s: &Session| s.script_ui.window(win).map(|w| w.root.children[0].text.clone());
|
||||
let t0 = Instant::now();
|
||||
run_tasks_at(&mut s, t0 + ms(500));
|
||||
run_tasks_at(&mut s, t0 + ms(1000));
|
||||
assert_eq!(text(&s).as_deref(), Some("2"), "the window shows what the task set");
|
||||
// A project change doesn't stop a script whose window is open.
|
||||
s.execute("file.closeProject", json!({})).unwrap();
|
||||
assert_eq!(run_tasks_at(&mut s, t0 + ms(1500))["ran"], json!(1));
|
||||
assert_eq!(text(&s).as_deref(), Some("3"));
|
||||
// Closing the window ends the script and its tasks.
|
||||
s.execute("scriptui.close", json!({"window": win})).unwrap();
|
||||
let r = run_tasks_at(&mut s, t0 + ms(5000));
|
||||
assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_throwing_task_is_reported_once_and_cancelled() {
|
||||
let mut s = session();
|
||||
ok(&mut s, "app.scheduleTask('undefinedThing.go()', 100, true); app.scheduleTask('writeLn(\"fine\")', 100, true);");
|
||||
let t0 = Instant::now();
|
||||
let r = run_tasks_at(&mut s, t0 + ms(500));
|
||||
assert_eq!(r["ran"], json!(2));
|
||||
let errors = r["errors"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(errors.len(), 1, "{r}");
|
||||
assert!(errors[0]["message"].as_str().is_some_and(|m| m.contains("undefinedThing") && m.contains("cancelled")), "{r}");
|
||||
// The other task goes on; the failing one doesn't run again.
|
||||
let r = run_tasks_at(&mut s, t0 + ms(1000));
|
||||
assert_eq!((r["ran"].clone(), output(&r), r["errors"].clone()), (json!(1), vec!["fine".to_string()], json!([])), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tasks_are_bounded() {
|
||||
let mut s = session();
|
||||
// Repeating tasks run at most every MIN_REPEAT_MS.
|
||||
ok(&mut s, "var hits = 0; app.scheduleTask('hits++', 0, true);");
|
||||
let t0 = Instant::now();
|
||||
assert_eq!(run_tasks_at(&mut s, t0 + ms(crate::MIN_REPEAT_MS) + ms(20))["ran"], json!(1));
|
||||
assert_eq!(run_tasks_at(&mut s, t0 + ms(crate::MIN_REPEAT_MS) + ms(30))["ran"], json!(0));
|
||||
// At most MAX_TASKS pending tasks per script.
|
||||
let o = run_code(&mut s, "for (var i = 0; i < 1000; i++) app.scheduleTask('1', 1000, true);", "many.jsx");
|
||||
assert!(o.error.as_ref().is_some_and(|e| e.message.contains(&format!("at most {}", crate::MAX_TASKS))), "{:?}", o.error);
|
||||
// Task code nested too deeply for the parser is refused up front.
|
||||
let deep = format!("app.scheduleTask('{}', 10, false);", "(".repeat(10_000));
|
||||
let o = run_code(&mut s, &deep, "deep.jsx");
|
||||
assert!(o.error.as_ref().is_some_and(|e| e.message.contains("nested too deeply")), "{:?}", o.error);
|
||||
// Odd delays and ids are harmless.
|
||||
ok(
|
||||
&mut s,
|
||||
"app.cancelTask(-1); app.cancelTask(NaN); app.cancelTask(1e20); app.scheduleTask('1', NaN, false); app.scheduleTask('1', -5, false); app.scheduleTask('1', 1e300, true);",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_project_stops_tasks_of_scripts_without_windows() {
|
||||
let mut s = session();
|
||||
ok(&mut s, "app.scheduleTask('writeLn(1)', 100, true);");
|
||||
console(&mut s, "var cid = app.scheduleTask('writeLn(2)', 100, true);");
|
||||
let t0 = Instant::now();
|
||||
assert_eq!(run_tasks_at(&mut s, t0 + ms(500))["ran"], json!(2));
|
||||
s.execute("file.closeProject", json!({})).unwrap();
|
||||
let r = run_tasks_at(&mut s, t0 + ms(5000));
|
||||
assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn console_tasks_run_in_the_console_and_cancel_there() {
|
||||
let mut s = session();
|
||||
console(&mut s, "var hits = 0; var tid = app.scheduleTask('hits++', 100, true); app.scheduleTask('writeLn(\"once \" + hits)', 300, false);");
|
||||
let t0 = Instant::now();
|
||||
assert_eq!(run_tasks_at(&mut s, t0 + ms(200))["ran"], json!(1));
|
||||
let r = run_tasks_at(&mut s, t0 + ms(400));
|
||||
assert_eq!((r["ran"].clone(), output(&r)), (json!(2), vec!["once 2".to_string()]), "{r}");
|
||||
assert_eq!(console(&mut s, "app.cancelTask(tid); hits").result, json!(2));
|
||||
let r = run_tasks_at(&mut s, t0 + ms(5000));
|
||||
assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handlers_schedule_one_shot_tasks_that_run_when_due() {
|
||||
let mut s = session();
|
||||
ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var w = new Window("palette", "Later");
|
||||
var b = w.add("button", undefined, "Go", { name: "go" });
|
||||
b.onClick = function () { app.scheduleTask("writeLn('later')", 100, false); };
|
||||
w.show();
|
||||
"#,
|
||||
);
|
||||
s.execute("scriptui.click", json!({"widget": "go"})).unwrap();
|
||||
let r = run_tasks_at(&mut s, Instant::now() + ms(500));
|
||||
assert_eq!(output(&r), vec!["later"], "{r}");
|
||||
// The engine command runs the same pass (headless hosts, agents).
|
||||
assert_eq!(s.execute("script.runTasks", json!({})).unwrap()["ran"], json!(0));
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
//! ScriptUI: windows built by scripts are published to the session, drawn by frontends and
|
||||
//! driven through the `scriptui.*` commands; dialogs block `show()` until they close; dockable
|
||||
//! panels; File ▸ Scripts (installed and sample scripts).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::scriptui::{WidgetKind, WindowKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::run_code;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() };
|
||||
crate::install(&mut s);
|
||||
s.config = Some(Arc::new(effectcraft_engine::config::MemoryConfig::default()));
|
||||
s
|
||||
}
|
||||
|
||||
fn comp_names(s: &Session) -> Vec<String> {
|
||||
s.project.items.values().filter(|i| i.as_comp().is_some()).map(|i| i.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn layer_names(s: &Session) -> Vec<String> {
|
||||
s.active_comp().map(|c| c.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
const PALETTE: &str = r#"
|
||||
var made = 0;
|
||||
var w = new Window("palette", "Maker");
|
||||
w.orientation = "column";
|
||||
w.alignChildren = ["fill", "top"];
|
||||
var name = w.add("edittext", undefined, "Box");
|
||||
name.characters = 12;
|
||||
var big = w.add("checkbox", undefined, "Big");
|
||||
var row = w.add("group");
|
||||
var make = row.add("button", undefined, "Make", { name: "make" });
|
||||
var label = row.add("statictext", undefined, "none yet");
|
||||
var size = w.add("slider", undefined, 50, 10, 200);
|
||||
var kind = w.add("dropdownlist", undefined, ["Solid", "Null"]);
|
||||
kind.selection = 0;
|
||||
var echo = "";
|
||||
name.onChange = function () { echo = name.text; };
|
||||
make.onClick = function () {
|
||||
var c = app.project.activeItem;
|
||||
var s = big.value ? 200 : Math.round(size.value);
|
||||
if (kind.selection.index === 0) c.layers.addSolid([1, 0, 0], name.text, s, s, 1);
|
||||
else c.layers.addNull().name = name.text;
|
||||
made++;
|
||||
label.text = "made " + made;
|
||||
writeLn("made " + name.text + " " + s);
|
||||
};
|
||||
w.show();
|
||||
"palette up"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn palette_controls_run_their_handlers() {
|
||||
let mut s = session();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 2})).unwrap();
|
||||
let o = run_code(&mut s, PALETTE, "maker.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert_eq!(o.result, json!("palette up"));
|
||||
assert_eq!(s.script_ui.windows.len(), 1);
|
||||
let w = s.script_ui.windows[0].clone();
|
||||
assert_eq!((w.kind, w.title.as_str(), w.script.as_str()), (WindowKind::Palette, "Maker", "maker.jsx"));
|
||||
let kinds: Vec<WidgetKind> = w.root.children.iter().map(|c| c.kind).collect();
|
||||
assert_eq!(kinds, [WidgetKind::EditText, WidgetKind::Checkbox, WidgetKind::Group, WidgetKind::Slider, WidgetKind::DropDownList]);
|
||||
// Laid out: the window has a size, the fill-aligned controls span its width.
|
||||
assert!(w.root.bounds[2] > 100.0 && w.root.bounds[3] > 100.0, "{:?}", w.root.bounds);
|
||||
let edit = &w.root.children[0];
|
||||
assert_eq!(edit.bounds[2], w.root.bounds[2] - 30.0);
|
||||
assert_eq!(w.root.children[3].value, 50.0);
|
||||
assert_eq!((w.root.children[3].min, w.root.children[3].max), (10.0, 200.0));
|
||||
assert_eq!(w.root.children[4].items, ["Solid", "Null"]);
|
||||
assert_eq!(w.root.children[4].selection, [0]);
|
||||
assert!(w.root.children[2].children[0].handlers.contains(&"onClick".to_string()));
|
||||
// Agents: list, read, click.
|
||||
let list = s.execute("scriptui.list", json!({})).unwrap();
|
||||
assert_eq!(list[0]["title"], "Maker");
|
||||
let tree = s.execute("scriptui.get", json!({"window": w.id})).unwrap();
|
||||
assert_eq!(tree["root"]["children"][2]["children"][0]["name"], "make");
|
||||
let r = s.execute("scriptui.click", json!({"widget": "make"})).unwrap();
|
||||
assert_eq!(r["output"], "made Box 50", "{r}");
|
||||
assert_eq!(layer_names(&s), ["Box"]);
|
||||
// Handlers' edits are undoable engine commands.
|
||||
assert!(s.undo());
|
||||
assert!(layer_names(&s).is_empty());
|
||||
// Set values like a user: text (onChange), checkbox, slider, list by item text.
|
||||
s.execute("scriptui.set", json!({"widget": "#1", "value": "Crate"})).unwrap();
|
||||
s.execute("scriptui.click", json!({"widget": "Big"})).unwrap();
|
||||
s.execute("scriptui.set", json!({"window": "Maker", "widget": 7, "value": "Null"})).unwrap();
|
||||
let r = s.execute("scriptui.click", json!({"widget": "make"})).unwrap();
|
||||
assert_eq!(r["output"], "made Crate 200");
|
||||
assert_eq!(layer_names(&s), ["Crate"]);
|
||||
assert!(s.active_comp().unwrap().layers[0].source == effectcraft_engine::project::LayerSource::Null);
|
||||
// The handler updated a label: the published tree follows.
|
||||
let w = s.script_ui.windows[0].clone();
|
||||
assert_eq!(w.root.children[2].children[1].text, "made 2");
|
||||
assert!(w.root.children[1].checked);
|
||||
assert_eq!(w.root.children[4].selection, [1]);
|
||||
// Errors.
|
||||
assert!(s.execute("scriptui.click", json!({"widget": "nope"})).is_err());
|
||||
assert!(s.execute("scriptui.get", json!({"window": 999_999})).is_err());
|
||||
// Closing the window ends the script host.
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
assert!(s.script_ui.windows.is_empty());
|
||||
assert!(s.execute("scriptui.list", json!({})).unwrap().as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
const DIALOG: &str = r#"
|
||||
var d = new Window("dialog", "New Comp");
|
||||
d.alignChildren = "left";
|
||||
var e = d.add("edittext", undefined, "Untitled");
|
||||
var fps = d.add("radiobutton", undefined, "24 fps");
|
||||
var fps30 = d.add("radiobutton", undefined, "30 fps");
|
||||
fps.value = true;
|
||||
var g = d.add("group");
|
||||
g.add("button", undefined, "Cancel", { name: "cancel" });
|
||||
var ok = g.add("button", undefined, "OK", { name: "ok" });
|
||||
writeLn("before");
|
||||
var r = d.show();
|
||||
writeLn("result " + r + " " + e.text + " " + (fps30.value ? 30 : 24));
|
||||
if (r === 1) app.project.items.addComp(e.text, 100, 100, 1, 1, fps30.value ? 30 : 24);
|
||||
r
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn dialogs_block_show_until_they_close() {
|
||||
let mut s = session();
|
||||
let o = run_code(&mut s, DIALOG, "dialog.jsx");
|
||||
// The script waits in show(); the session is back here meanwhile.
|
||||
assert!(o.waiting, "{o:?}");
|
||||
assert_eq!(o.output, ["before"]);
|
||||
assert_eq!(s.script_ui.windows.len(), 1);
|
||||
let w = s.script_ui.windows[0].clone();
|
||||
assert!(w.modal && w.kind == WindowKind::Dialog);
|
||||
// The app keeps working while the dialog is up.
|
||||
s.execute("comp.new", json!({"name": "Other", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
s.execute("scriptui.set", json!({"widget": "#1", "value": "Shot 010"})).unwrap();
|
||||
s.execute("scriptui.click", json!({"widget": "30 fps"})).unwrap();
|
||||
let w = s.script_ui.windows[0].clone();
|
||||
assert!(!w.root.children[1].checked && w.root.children[2].checked, "radio buttons are exclusive");
|
||||
// OK closes the dialog with 1: the script runs on.
|
||||
let r = s.execute("scriptui.click", json!({"widget": "ok"})).unwrap();
|
||||
assert_eq!(r["output"], "result 1 Shot 010 30", "{r}");
|
||||
assert_eq!(r["result"], 1);
|
||||
assert!(r.get("waiting").is_none());
|
||||
assert!(s.script_ui.windows.is_empty());
|
||||
let names = comp_names(&s);
|
||||
assert!(names.contains(&"Shot 010".to_string()) && names.contains(&"Other".to_string()), "{names:?}");
|
||||
// Cancel (or closing the window) returns 2.
|
||||
let o = run_code(&mut s, DIALOG, "dialog.jsx");
|
||||
assert!(o.waiting);
|
||||
let r = s.execute("scriptui.close", json!({})).unwrap();
|
||||
assert_eq!(r["output"], "result 2 Untitled 24");
|
||||
assert_eq!(comp_names(&s).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_and_handler_errors() {
|
||||
let mut s = session();
|
||||
let code = r#"
|
||||
var w = new Window("palette", "L");
|
||||
var g = w.add("group");
|
||||
g.orientation = "row";
|
||||
var a = g.add("button", undefined, "A");
|
||||
var b = g.add("button", undefined, "B");
|
||||
b.preferredSize = [120, 30];
|
||||
var p = w.add("panel", undefined, "Options");
|
||||
p.add("checkbox", undefined, "One");
|
||||
var bad = w.add("button", undefined, "Bad");
|
||||
bad.onClick = function () { nope(); };
|
||||
w.layout.layout(true);
|
||||
w.show();
|
||||
[a.bounds.width, b.size.width, b.size.height, b.bounds[0] - a.bounds[0], w.bounds.width > 200, p.bounds.top > a.bounds.bottom]
|
||||
"#;
|
||||
let o = run_code(&mut s, code, "layout.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert_eq!(o.result, json!([80, 120, 30, 90, true, true]));
|
||||
// An error in a handler is reported, the window stays.
|
||||
let r = s.execute("scriptui.click", json!({"widget": "Bad"})).unwrap();
|
||||
assert_eq!(r["ok"], false);
|
||||
assert!(r["error"]["message"].as_str().unwrap().contains("nope"), "{r}");
|
||||
assert_eq!(s.script_ui.windows.len(), 1);
|
||||
// Resource strings build windows too (see tests_ui_more).
|
||||
let o = run_code(&mut s, "new Window(\"dialog { text: 'x' }\").text", "res.jsx");
|
||||
assert_eq!(o.result, json!("x"));
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn console_windows_run_inline() {
|
||||
let mut s = session();
|
||||
let req = effectcraft_engine::ScriptRequest {
|
||||
code: "var cw = new Window('palette', 'Console UI'); var cb = cw.add('button', undefined, 'Hi'); var hits = 0; cb.onClick = function () { hits++; writeLn('hit ' + hits); }; cw.show(); 1",
|
||||
name: "Script Console",
|
||||
console: true,
|
||||
};
|
||||
let o = crate::run(&mut s, &req);
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert_eq!(s.script_ui.windows.len(), 1);
|
||||
let r = s.execute("scriptui.click", json!({"widget": "Hi"})).unwrap();
|
||||
assert_eq!(r["output"], "hit 1");
|
||||
// The console context sees the handler's state.
|
||||
let o = crate::run(&mut s, &effectcraft_engine::ScriptRequest { code: "hits", name: "Script Console", console: true });
|
||||
assert_eq!(o.result, json!(1));
|
||||
// Dialogs don't block in the console.
|
||||
let o = crate::run(
|
||||
&mut s,
|
||||
&effectcraft_engine::ScriptRequest { code: "var dd = new Window('dialog', 'D'); dd.show(); 'went on'", name: "Script Console", console: true },
|
||||
);
|
||||
assert_eq!(o.result, json!("went on"));
|
||||
assert_eq!(s.script_ui.windows.len(), 2);
|
||||
s.execute("scriptui.close", json!({"window": "D"})).unwrap();
|
||||
s.execute("scriptui.close", json!({"window": "Console UI"})).unwrap();
|
||||
assert!(s.script_ui.windows.is_empty());
|
||||
}
|
||||
|
||||
fn solid(s: &mut Session, name: &str, at: f64) -> u64 {
|
||||
let id = s.execute("layer.newSolid", json!({"name": name, "color": "#808080", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.timing", json!({"layers": [id], "in": at})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_scripts_and_installing() {
|
||||
let mut s = session();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 400, "height": 200, "duration": 10})).unwrap();
|
||||
for (n, at) in [("C", 2.0), ("A", 0.0), ("B", 1.0)] {
|
||||
solid(&mut s, n, at);
|
||||
}
|
||||
// File ▸ Scripts lists the samples.
|
||||
let list = s.execute("file.scripts.list", json!({})).unwrap();
|
||||
let names: Vec<&str> = list.as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap()).collect();
|
||||
for want in ["Rename Layers.jsx", "Sort Layers by In Point.jsx", "Create Null at Selected Layers.jsx", "Render Queue Batch.jsx", "Layer Tools.jsx"] {
|
||||
assert!(names.contains(&want), "{names:?}");
|
||||
}
|
||||
// Sort Layers by In Point (all layers when none are selected).
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
s.execute("file.runScript", json!({"name": "Sort Layers by In Point.jsx"})).unwrap();
|
||||
assert_eq!(layer_names(&s), ["A", "B", "C"]);
|
||||
assert!(s.undo());
|
||||
assert_eq!(layer_names(&s), ["B", "A", "C"]);
|
||||
// Create Null at Selected Layers.
|
||||
let ids: Vec<u64> = s.active_comp().unwrap().layers.iter().take(2).map(|l| l.id.0).collect();
|
||||
s.execute("layer.select", json!({"layers": ids})).unwrap();
|
||||
s.execute("file.runScript", json!({"name": "Create Null at Selected Layers.jsx"})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
let null = c.layers.iter().find(|l| l.name == "Centroid").unwrap();
|
||||
assert!(c.layers.iter().filter(|l| ids.contains(&l.id.0)).all(|l| l.parent == Some(null.id)));
|
||||
// Rename Layers: a dialog; fill it in and press OK.
|
||||
s.execute("layer.select", json!({"layers": ids})).unwrap();
|
||||
let r = s.execute("file.runScript", json!({"name": "Rename Layers.jsx"})).unwrap();
|
||||
assert_eq!(r["waiting"], true);
|
||||
s.execute("scriptui.set", json!({"widget": "#11", "value": "Shot_"})).unwrap();
|
||||
s.execute("scriptui.click", json!({"widget": "Number the layers"})).unwrap();
|
||||
s.execute("scriptui.click", json!({"widget": "ok"})).unwrap();
|
||||
let names = layer_names(&s);
|
||||
assert!(names.contains(&"Shot_B 1".to_string()) && names.contains(&"Shot_A 2".to_string()), "{names:?}");
|
||||
// Install a script and a ScriptUI panel (they live in the settings store).
|
||||
let dir = std::env::temp_dir().join(format!("ec-scripts-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("Hello.jsx");
|
||||
std::fs::write(&file, "writeLn('hello from ' + $.fileName); 42").unwrap();
|
||||
s.execute("file.installScript", json!({"path": file.to_string_lossy()})).unwrap();
|
||||
let panel = dir.join("Mini Panel.jsx");
|
||||
std::fs::write(&panel, "var p = this; p.add('button', undefined, 'Go').onClick = function () { writeLn('go'); }; p.layout.layout(true);").unwrap();
|
||||
s.execute("file.installScriptUIPanel", json!({"path": panel.to_string_lossy()})).unwrap();
|
||||
let list = s.execute("file.scripts.list", json!({})).unwrap();
|
||||
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Hello.jsx" && e["source"] == "installed" && e["panel"] == false));
|
||||
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Mini Panel.jsx" && e["panel"] == true));
|
||||
let r = s.execute("file.runScript", json!({"name": "Hello.jsx"})).unwrap();
|
||||
assert_eq!(r["result"], 42);
|
||||
// Window ▸ Mini Panel.jsx: a dockable panel; `this` is the panel.
|
||||
let r = s.execute("window.scriptPanel", json!({"name": "Mini Panel.jsx"})).unwrap();
|
||||
let wid = r["window"].as_u64().unwrap();
|
||||
let w = s.script_ui.window(wid as u32).unwrap().clone();
|
||||
assert_eq!((w.kind, w.title.as_str()), (WindowKind::Panel, "Mini Panel"));
|
||||
let r = s.execute("scriptui.click", json!({"window": wid, "widget": "Go"})).unwrap();
|
||||
assert_eq!(r["output"], "go");
|
||||
// Opening it again brings the same panel forward.
|
||||
let r = s.execute("window.scriptPanel", json!({"name": "Mini Panel.jsx"})).unwrap();
|
||||
assert_eq!(r["window"], wid);
|
||||
// The sample panel docks too, and works.
|
||||
let r = s.execute("window.scriptPanel", json!({"name": "Layer Tools.jsx"})).unwrap();
|
||||
let tools = r["window"].as_u64().unwrap();
|
||||
let before = s.active_comp().unwrap().layers.len();
|
||||
s.execute("layer.select", json!({"layers": ids})).unwrap();
|
||||
s.execute("scriptui.click", json!({"window": tools, "widget": "Null at Centroid"})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), before + 1);
|
||||
s.execute("scriptui.close", json!({"window": tools})).unwrap();
|
||||
s.execute("scriptui.close", json!({"window": wid})).unwrap();
|
||||
assert!(s.execute("file.installScript", json!({"path": "/nope/x.txt"})).is_err());
|
||||
s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap();
|
||||
assert!(s.execute("file.runScript", json!({"name": "Hello.jsx"})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _: Value = json!(null);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
//! ScriptUI resource strings, onDraw graphics, live onChanging updates and sockets.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::scriptui::{DrawOp, PathSeg, WidgetKind, WindowKind};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::run_code;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() };
|
||||
crate::install(&mut s);
|
||||
s.config = Some(Arc::new(effectcraft_engine::config::MemoryConfig::default()));
|
||||
s
|
||||
}
|
||||
|
||||
const RESOURCE: &str = r#"
|
||||
var w = new Window("palette { text: 'Res', orientation: 'column', alignChildren: ['fill', 'top'], \
|
||||
g: Group { orientation: 'row', b: Button { text: 'OK', properties: { name: 'okb' } }, cancel: Button { text: \"Can't\" } }, \
|
||||
nm: EditText { text: 'abc', characters: 10 }, \
|
||||
s: Slider { minvalue: 0, maxvalue: 10, value: 4 }, \
|
||||
dd: DropDownList { properties: { items: ['A', 'B'] }, selection: 1 }, \
|
||||
/* a panel */ p: Panel { text: 'Opts', preferredSize: [200, -1], c: Checkbox { text: 'On', value: true } } }");
|
||||
var extra = w.p.add("group { orientation: 'column', t: StaticText { text: 'hi' } }");
|
||||
w.g.b.onClick = function () { writeLn("ok " + w.nm.text + " " + w.s.value); };
|
||||
w.show();
|
||||
[w.text, w.g.orientation, w.g.b.text, w.g.cancel.text, w.s.maxvalue, w.dd.selection.text, w.p.c.value, extra.t.text, w.g.children.length, w.p.preferredSize.width].join("|")
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn resource_strings_build_windows_and_controls() {
|
||||
let mut s = session();
|
||||
let o = run_code(&mut s, RESOURCE, "res.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert_eq!(o.result, json!("Res|row|OK|Can't|10|B|true|hi|2|200"));
|
||||
let w = s.script_ui.windows[0].clone();
|
||||
assert_eq!((w.kind, w.title.as_str()), (WindowKind::Palette, "Res"));
|
||||
let kinds: Vec<WidgetKind> = w.root.children.iter().map(|c| c.kind).collect();
|
||||
assert_eq!(kinds, [WidgetKind::Group, WidgetKind::EditText, WidgetKind::Slider, WidgetKind::DropDownList, WidgetKind::Panel]);
|
||||
assert_eq!(w.root.children[2].value, 4.0);
|
||||
assert_eq!(w.root.children[3].items, ["A", "B"]);
|
||||
assert_eq!(w.root.children[3].selection, [1]);
|
||||
assert!(w.root.children[4].children[0].checked);
|
||||
assert_eq!(w.root.children[4].children[1].children[0].text, "hi");
|
||||
// Elements are named after their resource keys (agents address them by it).
|
||||
assert_eq!(w.root.children[1].name, "nm");
|
||||
s.execute("scriptui.set", json!({"widget": "nm", "value": "xyz"})).unwrap();
|
||||
let r = s.execute("scriptui.click", json!({"widget": "okb"})).unwrap();
|
||||
assert_eq!(r["output"], "ok xyz 4", "{r}");
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
// Syntax errors say where.
|
||||
let o = run_code(&mut s, "new Window(\"dialog { text: 'x' oops }\")", "bad.jsx");
|
||||
assert!(o.error.unwrap().message.contains("resource string"));
|
||||
let o = run_code(&mut s, "new Window(\"panel { }\")", "bad.jsx");
|
||||
assert!(o.error.unwrap().message.contains("Bad window type"));
|
||||
}
|
||||
|
||||
const DRAW: &str = r#"
|
||||
var w = new Window("palette", "Draw");
|
||||
var c = w.add("group");
|
||||
c.preferredSize = [100, 50];
|
||||
c.onDraw = function () {
|
||||
var g = this.graphics;
|
||||
var b = g.newBrush(g.BrushType.SOLID_COLOR, [1, 0, 0, 1]);
|
||||
g.newPath();
|
||||
g.rectPath(0, 0, this.size.width, this.size.height);
|
||||
g.fillPath(b);
|
||||
var p = g.newPen(g.PenType.SOLID_COLOR, [0, 0, 1], 2);
|
||||
g.newPath();
|
||||
g.moveTo(0, 0);
|
||||
g.lineTo(10, 10);
|
||||
g.strokePath(p);
|
||||
g.ellipsePath(20, 20, 10, 10);
|
||||
g.drawString("Hi", p, 5, 6, ScriptUI.newFont("dialog", "BOLD", 20));
|
||||
};
|
||||
var m = c.graphics.measureString("Hello", ScriptUI.newFont("dialog", "REGULAR", 12));
|
||||
w.show();
|
||||
[m.width, m[1]].join(",")
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn on_draw_handlers_record_a_draw_list() {
|
||||
let mut s = session();
|
||||
let o = run_code(&mut s, DRAW, "draw.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert_eq!(o.result, json!("35,15"));
|
||||
let w = s.script_ui.windows[0].clone();
|
||||
let g = &w.root.children[0];
|
||||
assert!(g.handlers.contains(&"onDraw".to_string()));
|
||||
assert_eq!(g.draw.len(), 3, "{:?}", g.draw);
|
||||
// The handler saw the laid-out size.
|
||||
assert_eq!(g.draw[0], DrawOp::Fill { color: Some([1.0, 0.0, 0.0, 1.0]), path: vec![PathSeg::R { x: 0.0, y: 0.0, w: 100.0, h: 50.0 }] });
|
||||
match &g.draw[1] {
|
||||
DrawOp::Stroke { color, width, path } => {
|
||||
assert_eq!((*color, *width), (Some([0.0, 0.0, 1.0, 1.0]), 2.0));
|
||||
assert_eq!(path.len(), 2);
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
assert!(matches!(&g.draw[2], DrawOp::Text { text, size, style, .. } if text == "Hi" && *size == 20.0 && style == "BOLD"));
|
||||
// scriptui.get carries it for agents.
|
||||
let tree = s.execute("scriptui.get", json!({})).unwrap();
|
||||
assert_eq!(tree["root"]["children"][0]["draw"][0]["op"], "fill");
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
|
||||
const LIVE: &str = r#"
|
||||
var w = new Window("palette", "Live");
|
||||
var e = w.add("edittext", undefined, "");
|
||||
var sl = w.add("slider", undefined, 0, 0, 100);
|
||||
var log = w.add("statictext", undefined, "-");
|
||||
e.onChanging = function () { writeLn("changing " + e.text); log.text = "typing " + e.text; };
|
||||
e.onChange = function () { writeLn("change " + e.text); };
|
||||
sl.onChanging = function () { writeLn("slide " + Math.round(sl.value)); };
|
||||
sl.onChange = function () { writeLn("slid " + Math.round(sl.value)); };
|
||||
w.show();
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn on_changing_fires_per_keystroke_and_slider_step() {
|
||||
let mut s = session();
|
||||
let o = run_code(&mut s, LIVE, "live.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "a", "changing": true})).unwrap();
|
||||
assert_eq!(r["output"], "changing a");
|
||||
let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "ab", "changing": true})).unwrap();
|
||||
assert_eq!(r["output"], "changing ab");
|
||||
assert_eq!(s.script_ui.windows[0].root.children[2].text, "typing ab");
|
||||
// Committing the same text runs onChange only.
|
||||
let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "ab"})).unwrap();
|
||||
assert_eq!(r["output"], "change ab");
|
||||
// A committed edit without live updates runs both.
|
||||
let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "abc"})).unwrap();
|
||||
assert_eq!(r["output"], "changing abc\nchange abc");
|
||||
// Slider drags update the value live.
|
||||
let r = s.execute("scriptui.set", json!({"widget": "#2", "value": 40, "changing": true})).unwrap();
|
||||
assert_eq!(r["output"], "slide 40");
|
||||
assert_eq!(s.script_ui.windows[0].root.children[1].value, 40.0);
|
||||
let r = s.execute("scriptui.set", json!({"widget": "#2", "value": 41})).unwrap();
|
||||
assert_eq!(r["output"], "slide 41\nslid 41");
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
|
||||
const MOGRT: &str = r#"
|
||||
var c = app.project.activeItem;
|
||||
var t = c.layer(1);
|
||||
var st = t.property("ADBE Text Properties").property("ADBE Text Document");
|
||||
var op = t.property("ADBE Transform Group").property("ADBE Opacity");
|
||||
var r = [st.canAddToMotionGraphicsTemplate(c), st.addToMotionGraphicsTemplate(c), op.addToMotionGraphicsTemplateAs(c, "Fade")];
|
||||
c.motionGraphicsTemplateName = "Lower Third";
|
||||
c.setMotionGraphicsControllerName(1, "Title");
|
||||
r.concat([c.motionGraphicsTemplateName, c.motionGraphicsTemplateControllerCount, c.getMotionGraphicsTemplateControllerName(1), c.getMotionGraphicsTemplateControllerName(2)]).join("|")
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn essential_graphics_scripting_hooks() {
|
||||
let mut s = session();
|
||||
s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
s.execute("layer.newText", json!({"text": "Hi"})).unwrap();
|
||||
let o = run_code(&mut s, MOGRT, "mogrt.jsx");
|
||||
assert!(o.error.is_none(), "{:?} {:?}", o.error, o.output);
|
||||
assert_eq!(o.result, json!("true|true|true|Lower Third|2|Title|Fade"));
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
assert_eq!(s.project.comp(cid).unwrap().essential.as_ref().unwrap().name, "Lower Third");
|
||||
// Another comp can't take this comp's properties.
|
||||
s.execute("comp.new", json!({"name": "Other", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let code = "var card = null; for (var i = 1; i <= app.project.numItems; i++) if (app.project.item(i).name === 'Card') card = app.project.item(i); var o = app.project.activeItem; var p = card.layer(1).property('ADBE Transform Group').property('ADBE Position'); [p.canAddToMotionGraphicsTemplate(o), p.canAddToMotionGraphicsTemplate(card)].join('|')";
|
||||
let o = run_code(&mut s, code, "x.jsx");
|
||||
assert_eq!(o.result, json!("false|true"), "{:?}", o.error);
|
||||
// Export (behind the file-write preference).
|
||||
let dir = std::env::temp_dir().join(format!("ec-mogrt-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("lower.ectemplate");
|
||||
let export = format!(
|
||||
"var card = null; for (var i = 1; i <= app.project.numItems; i++) if (app.project.item(i).name === 'Card') card = app.project.item(i); card.exportAsMotionGraphicsTemplate(false, {:?})",
|
||||
path.to_string_lossy()
|
||||
);
|
||||
assert!(run_code(&mut s, &export, "x.jsx").error.is_some());
|
||||
s.prefs.scripting.allow_scripts_write_files = true;
|
||||
assert_eq!(run_code(&mut s, &export, "x.jsx").result, json!(true));
|
||||
assert!(std::fs::metadata(&path).unwrap().len() > 100);
|
||||
// It exists now: no overwrite → false.
|
||||
assert_eq!(run_code(&mut s, &export, "x.jsx").result, json!(false));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_nulls_from_paths_expressions_evaluate() {
|
||||
let mut s = session();
|
||||
s.execute("comp.new", json!({"name": "Paths", "width": 100, "height": 80, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("mask.new", json!({"layer": l, "vertices": [[0, 0], [40, 0], [40, 40], [0, 40]], "closed": true})).unwrap();
|
||||
s.execute("paths.nullsFollowPoints", json!({"layer": l})).unwrap();
|
||||
s.execute("paths.tracePath", json!({"layer": l})).unwrap();
|
||||
// Layer 1: the trace null; layers 2..5: the vertex nulls (top = last vertex).
|
||||
let pos = |i: u32, t: f64| format!("app.project.activeItem.layer({i}).property('ADBE Transform Group').property('ADBE Position').valueAtTime({t}, false)");
|
||||
let o = run_code(&mut s, &format!("[{}, {}, {}].join('|')", pos(2, 0.0), pos(1, 0.0), pos(1, 0.5)), "x.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
let parts: Vec<Vec<f64>> = o.result.as_str().unwrap().split('|').map(|p| p.split(',').map(|x| x.parse().unwrap()).collect()).collect();
|
||||
assert_eq!(&parts[0][..2], &[30.0, 60.0], "vertex 4 (0, 40) in comp space");
|
||||
assert_eq!(&parts[1][..2], &[30.0, 20.0], "progress 0: the first vertex");
|
||||
// Halfway in time along the closed square: the opposite corner.
|
||||
assert!((parts[2][0] - 70.0).abs() < 0.5 && (parts[2][1] - 60.0).abs() < 0.5, "{:?}", parts[2]);
|
||||
// Points Follow Nulls: moving the last vertex's null moves the vertex.
|
||||
let r = s.execute("paths.pointsFollowNulls", json!({"layer": l})).unwrap();
|
||||
let last = r["nulls"][3].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": last, "path": "transform/position", "value": [10, 10]})).unwrap();
|
||||
let code = "var c = app.project.activeItem; var m = c.layer(c.numLayers).property('ADBE Mask Parade').property(1).property('ADBE Mask Shape').valueAtTime(0, false); [m.vertices[3][0], m.vertices[3][1], m.vertices[0][0], m.vertices.length].join(',')";
|
||||
let o = run_code(&mut s, code, "x.jsx");
|
||||
assert_eq!(o.result, json!("-20,-10,0,4"), "{:?}", o.error);
|
||||
}
|
||||
|
||||
fn pin_at(s: &mut Session, id: u64, i: usize) -> [f64; 2] {
|
||||
let info = s.execute("puppet.info", json!({"layer": id})).unwrap();
|
||||
let v = &info["meshes"][0]["pins"][i]["position"];
|
||||
[v[0].as_f64().unwrap(), v[1].as_f64().unwrap()]
|
||||
}
|
||||
|
||||
/// A layer's Position now, with its expression applied (what renders).
|
||||
fn position_now(s: &Session, layer: u64) -> [f64; 2] {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
let l = comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap();
|
||||
let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None };
|
||||
let v = ctx.value(l, l.props.prop("transform/position").unwrap()).components();
|
||||
[v[0], v[1]]
|
||||
}
|
||||
|
||||
fn near(a: [f64; 2], b: [f64; 2]) -> bool {
|
||||
(a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puppet_pin_rigs_follow_and_lead_with_live_expressions() {
|
||||
let mut s = session();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 200, "height": 100, "duration": 2, "frameRate": 30})).unwrap();
|
||||
let id = s.execute("layer.newSolid", json!({"width": 100, "height": 60, "color": [0, 0, 1]})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap();
|
||||
s.execute("puppet.selectPins", json!({"layer": id, "pins": []})).unwrap();
|
||||
let r = s.execute("paths.pointsFollowNulls", json!({"layer": id})).unwrap();
|
||||
let nulls: Vec<u64> = r["nulls"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
||||
// Rigged at rest: the pins haven't moved. Layer (10, 30) is comp (60, 50).
|
||||
assert!(near(pin_at(&mut s, id, 0), [10.0, 30.0]), "{:?}", pin_at(&mut s, id, 0));
|
||||
// Moving the null moves its pin.
|
||||
s.execute("prop.set", json!({"layer": nulls[0], "path": "transform/position", "value": [60, 80]})).unwrap();
|
||||
assert!(near(pin_at(&mut s, id, 0), [10.0, 60.0]), "{:?}", pin_at(&mut s, id, 0));
|
||||
// Parented nulls rig a limb: pin 2's null rides on pin 1's null.
|
||||
s.execute("layer.setParent", json!({"layers": [nulls[1]], "parent": nulls[0]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": nulls[0], "path": "transform/position", "value": [60, 90]})).unwrap();
|
||||
assert!(near(pin_at(&mut s, id, 1), [90.0, 40.0]), "{:?}", pin_at(&mut s, id, 1));
|
||||
// Nulls Follow Points: a null riding on pin 1, now at comp (60, 90).
|
||||
let r = s.execute("paths.nullsFollowPoints", json!({"layer": id, "pins": ["Puppet Pin 1"]})).unwrap();
|
||||
let rider = r["nulls"][0].as_u64().unwrap();
|
||||
assert!(near(position_now(&s, rider), [60.0, 90.0]), "{:?}", position_now(&s, rider));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puppet_follow_through_delays_the_leaders_motion() {
|
||||
let mut s = session();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 200, "height": 100, "duration": 2, "frameRate": 30})).unwrap();
|
||||
let id = s.execute("layer.newSolid", json!({"width": 100, "height": 60, "color": [0, 0, 1]})).unwrap()["layer"].as_u64().unwrap();
|
||||
let lead = s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap()["pin"].clone();
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [50, 30]})).unwrap();
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap();
|
||||
// The leader drops 20 px between 0 and 0.5 s.
|
||||
s.execute("puppet.movePin", json!({"layer": id, "pin": lead, "position": [10, 50], "time": 0.5})).unwrap();
|
||||
s.execute("puppet.follow", json!({"layer": id, "leader": "Puppet Pin 1", "pins": ["Puppet Pin 2", "Puppet Pin 3"], "delay": 0.2})).unwrap();
|
||||
let y = |s: &mut Session, pin: usize, t: f64| {
|
||||
let info = s.execute("puppet.info", json!({"layer": id, "time": t})).unwrap();
|
||||
info["meshes"][0]["pins"][pin]["position"][1].as_f64().unwrap()
|
||||
};
|
||||
// At rest before anything moves; pin 2 trails 0.2 s and pin 3 (farther) 0.4 s.
|
||||
assert_eq!((y(&mut s, 1, 0.0), y(&mut s, 2, 0.0)), (30.0, 30.0));
|
||||
let (lead_y, p2, p3) = (y(&mut s, 0, 0.5), y(&mut s, 1, 0.5), y(&mut s, 2, 0.5));
|
||||
assert!(lead_y > p2 && p2 > p3 && p3 > 30.0, "{lead_y} {p2} {p3}");
|
||||
assert!((y(&mut s, 1, 0.7) - 50.0).abs() < 1e-6, "pin 2 arrives 0.2 s after the leader");
|
||||
assert!(y(&mut s, 2, 0.7) < 50.0 - 1e-3);
|
||||
assert!((y(&mut s, 2, 0.9) - 50.0).abs() < 1e-6, "pin 3 arrives 0.4 s after the leader");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sockets_talk_tcp_behind_the_network_preference() {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
let mut s = session();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut c, _) = listener.accept().unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(c.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||||
c.write_all(format!("pong:{}\nrest", line.trim()).as_bytes()).unwrap();
|
||||
});
|
||||
let code = format!(
|
||||
"var s = new Socket(); var ok = s.open('127.0.0.1:{port}'); s.writeln('ping'); var r = s.readln(); var rest = s.read(); var e = s.eof; s.close(); [ok, r, rest, e, s.connected].join('|')"
|
||||
);
|
||||
// Off: no network.
|
||||
let o = run_code(&mut s, &code, "net.jsx");
|
||||
assert!(o.error.unwrap().message.contains("Access Network"));
|
||||
s.prefs.scripting.allow_scripts_write_files = true;
|
||||
let o = run_code(&mut s, &code, "net.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert_eq!(o.result, json!("true|pong:ping|rest|true|false"));
|
||||
server.join().unwrap();
|
||||
// Refused connections return false with an error; listen + poll serve clients.
|
||||
let o = run_code(&mut s, "var s = new Socket(); s.timeout = 1; var ok = s.open('127.0.0.1:1'); [ok, s.error.length > 0].join('|')", "net.jsx");
|
||||
assert_eq!(o.result, json!("false|true"));
|
||||
let o = run_code(&mut s, "var l = new Socket(); l.listen(0); var r = [l.port > 0, l.poll() === null].join('|'); l.close(); r", "net.jsx");
|
||||
assert_eq!(o.result, json!("true|true"));
|
||||
}
|
||||
|
||||
/// `__uiParseRes(src)` as JSON, or the error message.
|
||||
fn parse_res(s: &mut Session, src: &str) -> Result<serde_json::Value, String> {
|
||||
let code = format!("JSON.stringify(__uiParseRes({}))", serde_json::to_string(src).unwrap());
|
||||
let o = run_code(s, &code, "res.jsx");
|
||||
match o.error {
|
||||
Some(e) => Err(e.message),
|
||||
None => Ok(serde_json::from_str(o.result.as_str().unwrap_or("null")).unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_string_parser_edge_cases() {
|
||||
let mut s = session();
|
||||
// Accepted: trailing commas, quoted keys, number forms, escapes, comments, empty bodies,
|
||||
// null / undefined, bare words, nested arrays and object literals.
|
||||
let ok = parse_res(
|
||||
&mut s,
|
||||
"group { 'quoted key': 1, \"dq\": -.5, n: +3, e: 1e2, arr: [[1, [2]], 'x', ], \
|
||||
esc: 'it\\'s\\n\\\\', /* block */ nul: null, u: undefined, w: fill, // line\n \
|
||||
obj: { a: { b: true } }, kid: Button {}, }",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ok["type"], "group");
|
||||
let list = ok["list"].as_array().unwrap();
|
||||
let get = |k: &str| list.iter().find(|e| e[0] == k).map(|e| e[1].clone()).unwrap();
|
||||
assert_eq!(get("quoted key"), json!(1));
|
||||
assert_eq!(get("dq"), json!(-0.5));
|
||||
assert_eq!(get("n"), json!(3));
|
||||
assert_eq!(get("e"), json!(100));
|
||||
assert_eq!(get("arr"), json!([[1, [2]], "x"]));
|
||||
assert_eq!(get("esc"), json!("it's\n\\"));
|
||||
assert_eq!(get("nul"), json!(null));
|
||||
assert_eq!(get("w"), json!("fill"));
|
||||
assert_eq!(get("kid"), json!({"__element": "Button", "list": []}));
|
||||
// Rejected with a located message (never a crash).
|
||||
for bad in [
|
||||
"",
|
||||
" ",
|
||||
"dialog",
|
||||
"dialog {",
|
||||
"dialog { text: 'unterminated }",
|
||||
"dialog { text 'x' }",
|
||||
"dialog { a: [1, 2 }",
|
||||
"dialog { a: 1 } trailing",
|
||||
"dialog { /* unterminated comment",
|
||||
"dialog { a: 'x\\",
|
||||
"{ }",
|
||||
"123 { }",
|
||||
"dialog { a: 1 b: 2 }",
|
||||
"dialog { : 1 }",
|
||||
"dialog { a: }",
|
||||
"dialog { a: Button { }",
|
||||
] {
|
||||
let e = parse_res(&mut s, bad).expect_err(bad);
|
||||
assert!(e.contains("resource string"), "{bad:?}: {e}");
|
||||
assert!(e.contains("offset"), "{bad:?}: {e}");
|
||||
}
|
||||
// Deep nesting builds; absurd nesting fails cleanly (the script engine's recursion limit).
|
||||
let deep = |n: usize| format!("group {{ {}{} }}", "g: Group { ".repeat(n), "}".repeat(n));
|
||||
let d = parse_res(&mut s, &deep(40)).unwrap();
|
||||
assert_eq!(d["list"][0][1]["__element"], "Group");
|
||||
let _ = parse_res(&mut s, &deep(5000));
|
||||
// A window from a deep resource.
|
||||
let o = run_code(
|
||||
&mut s,
|
||||
&format!(
|
||||
"var w = new Window({}); w.children.length",
|
||||
serde_json::to_string(&format!("palette {{ {}{} }}", "g: Group { ".repeat(20), "}".repeat(20))).unwrap()
|
||||
),
|
||||
"deep.jsx",
|
||||
);
|
||||
assert_eq!(o.result, json!(1), "{:?}", o.error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_edge_cases() {
|
||||
let mut s = session();
|
||||
s.prefs.scripting.allow_scripts_write_files = true;
|
||||
// Bad addresses and ports: false with an error, never a throw or crash.
|
||||
for host in ["", "nohostport", "127.0.0.1", "127.0.0.1:99999", "[::1", "a:b:c", "127.0.0.1:-5"] {
|
||||
let code = format!(
|
||||
"var s = new Socket(); s.timeout = 1; var ok = s.open({}); [ok, s.error.length > 0, s.connected, s.eof].join('|')",
|
||||
serde_json::to_string(host).unwrap()
|
||||
);
|
||||
let o = run_code(&mut s, &code, "net.jsx");
|
||||
assert_eq!(o.result, json!("false|true|false|true"), "{host:?}: {:?}", o.error);
|
||||
}
|
||||
for port in ["70000", "-1", "1.5"] {
|
||||
let o = run_code(&mut s, &format!("var l = new Socket(); [l.listen({port}), l.error.length > 0].join('|')"), "net.jsx");
|
||||
assert_eq!(o.result, json!("false|true"), "{port}: {:?}", o.error);
|
||||
}
|
||||
// Never opened or closed: reads are empty, writes fail, close is idempotent.
|
||||
let o = run_code(
|
||||
&mut s,
|
||||
"var s = new Socket(); var r = [s.read(), s.read(5), s.readln(), s.write('x'), s.connected, s.eof, s.poll(), s.close(), s.close()]; r.join('|')",
|
||||
"net.jsx",
|
||||
);
|
||||
assert_eq!(o.result, json!("|||false|false|true||true|true"), "{:?}", o.error);
|
||||
// Binary bytes round trip in BINARY encoding; invalid UTF-8 is replaced in UTF-8; partial
|
||||
// and zero-length reads; read stops at the timeout without data.
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = std::thread::spawn(move || {
|
||||
use std::io::Write;
|
||||
for _ in 0..2 {
|
||||
let (mut c, _) = listener.accept().unwrap();
|
||||
let bytes: Vec<u8> = (0..=255u8).collect();
|
||||
c.write_all(&bytes).unwrap();
|
||||
}
|
||||
});
|
||||
let code = format!(
|
||||
"var s = new Socket(); s.open('127.0.0.1:{port}', 'BINARY'); var z = s.read(0); var a = s.read(10); var b = s.read(); s.close(); \
|
||||
var ok = true; for (var i = 0; i < 10; i++) ok = ok && a.charCodeAt(i) === i; for (var j = 0; j < 246; j++) ok = ok && b.charCodeAt(j) === j + 10; \
|
||||
[z.length, a.length, b.length, ok].join('|')"
|
||||
);
|
||||
let o = run_code(&mut s, &code, "net.jsx");
|
||||
assert_eq!(o.result, json!("0|10|246|true"), "{:?}", o.error);
|
||||
let code = format!(
|
||||
"var s = new Socket(); s.open('127.0.0.1:{port}', 'UTF-8'); var d = s.read(); s.close(); [d.charCodeAt(0), d.charCodeAt(127), d.indexOf('\\ufffd') > 0].join('|')"
|
||||
);
|
||||
let o = run_code(&mut s, &code, "net.jsx");
|
||||
assert_eq!(o.result, json!("0|127|true"), "{:?}", o.error);
|
||||
server.join().unwrap();
|
||||
// A listener with nobody connecting: a short read timeout returns quickly.
|
||||
let t = std::time::Instant::now();
|
||||
let o = run_code(
|
||||
&mut s,
|
||||
"var l = new Socket(); l.listen(0); l.timeout = 0; var c = new Socket(); c.timeout = 0.2; c.open(l.host); var x = c.read(); c.close(); l.close(); x.length",
|
||||
"net.jsx",
|
||||
);
|
||||
assert_eq!(o.result, json!(0), "{:?}", o.error);
|
||||
assert!(t.elapsed().as_secs_f64() < 5.0);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! ScriptUI mouse events (#766): `addEventListener("mousedown" | "mouseup" | "mousemove" |
|
||||
//! "mouseover" | "mouseout" | "click", handler)` listeners receive a MouseEvent through
|
||||
//! `scriptui.mouse`, with capture / target / bubble phases as the JavaScript Tools Guide
|
||||
//! documents.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::run_code;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() };
|
||||
crate::install(&mut s);
|
||||
s.config = Some(Arc::new(effectcraft_engine::config::MemoryConfig::default()));
|
||||
s
|
||||
}
|
||||
|
||||
const CANVAS: &str = r#"
|
||||
var w = new Window("palette", "Canvas");
|
||||
var g = w.add("group", undefined, undefined, { name: "row" });
|
||||
var pad = g.add("group", undefined, undefined, { name: "pad" });
|
||||
pad.preferredSize = [120, 80];
|
||||
var box = w.add("checkbox", undefined, "Snap", { name: "snap" });
|
||||
var plain = w.add("statictext", undefined, "no listeners", { name: "plain" });
|
||||
function rec(tag) {
|
||||
return function (e) {
|
||||
writeLn(tag + ":" + e.type + "@" + e.eventPhase + " " + e.clientX + "," + e.clientY + " b" + e.button + " d" + e.detail +
|
||||
(e.shiftKey ? " shift" : "") + (e.altKey ? " alt" : "") + (e.getModifierState("Control") ? " ctrl" : "") +
|
||||
(e.currentTarget === this ? "" : " WRONG-THIS") + " " + e.target.properties.name);
|
||||
};
|
||||
}
|
||||
pad.addEventListener("mousedown", rec("pad"));
|
||||
pad.addEventListener("mousemove", rec("pad"));
|
||||
pad.addEventListener("mouseover", rec("pad"));
|
||||
pad.addEventListener("mouseout", rec("pad"));
|
||||
pad.addEventListener("mouseup", rec("pad"));
|
||||
g.addEventListener("mousedown", rec("row"));
|
||||
w.addEventListener("mousedown", rec("win"), true);
|
||||
box.onClick = function () { writeLn("onClick " + box.value); };
|
||||
box.addEventListener("click", rec("box"));
|
||||
w.show();
|
||||
"#;
|
||||
|
||||
fn lines(r: &Value) -> Vec<String> {
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
r["output"].as_str().unwrap_or_default().lines().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_events_reach_listeners_with_pointer_details() {
|
||||
let mut s = session();
|
||||
let o = run_code(&mut s, CANVAS, "canvas.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
let w = s.script_ui.windows[0].clone();
|
||||
// The published tree says which controls want which mouse events (containers' listeners
|
||||
// reach their children: events bubble).
|
||||
let pad = w.root.lookup(&json!("pad")).unwrap();
|
||||
assert_eq!(pad.mouse, ["mousedown", "mouseup", "mousemove", "mouseover", "mouseout"]);
|
||||
assert_eq!(w.root.lookup(&json!("snap")).unwrap().mouse, ["mousedown", "click"], "the window's capture listener + its own");
|
||||
assert_eq!(w.root.lookup(&json!("plain")).unwrap().mouse, ["mousedown"]);
|
||||
// A press: capture on the window, the target, then bubbling up through the group.
|
||||
let r = s
|
||||
.execute(
|
||||
"scriptui.mouse",
|
||||
json!({"widget": "pad", "type": "mousedown", "x": 12.5, "y": 30, "button": 2, "detail": 2, "shiftKey": true, "ctrlKey": true}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
lines(&r),
|
||||
[
|
||||
"win:mousedown@capture 12.5,30 b2 d2 shift ctrl pad",
|
||||
"pad:mousedown@target 12.5,30 b2 d2 shift ctrl pad",
|
||||
"row:mousedown@bubble 12.5,30 b2 d2 shift ctrl pad",
|
||||
]
|
||||
);
|
||||
// Hover and moves carry no click count; x / y default to the control's centre.
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "pad", "type": "mouseover"})).unwrap();
|
||||
assert_eq!(lines(&r), ["pad:mouseover@target 60,40 b0 d0 pad"]);
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "pad", "type": "MouseMove", "x": 3, "y": 4, "altKey": true})).unwrap();
|
||||
assert_eq!(lines(&r), ["pad:mousemove@target 3,4 b0 d0 alt pad"]);
|
||||
// An event nobody listens for on a control runs nothing (but still answers).
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "plain", "type": "mouseup"})).unwrap();
|
||||
assert!(lines(&r).is_empty());
|
||||
// A mouse click does what a click does (the checkbox toggles once, onClick runs) and its
|
||||
// listeners get the pointer.
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "snap", "type": "click", "x": 5, "y": 6})).unwrap();
|
||||
assert_eq!(lines(&r), ["onClick true", "box:click@target 5,6 b0 d1 snap"]);
|
||||
assert!(s.script_ui.windows[0].root.lookup(&json!("snap")).unwrap().checked);
|
||||
// Agents' plain click still fires the click listeners.
|
||||
let r = s.execute("scriptui.click", json!({"widget": "snap"})).unwrap();
|
||||
assert_eq!(r["output"].as_str().unwrap_or_default().lines().next(), Some("onClick false"));
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_propagation_capture_and_removal() {
|
||||
let mut s = session();
|
||||
let code = r#"
|
||||
var w = new Window("palette", "Stops");
|
||||
var g = w.add("group", undefined, undefined, { name: "outer" });
|
||||
var b = g.add("button", undefined, "Go", { name: "go" });
|
||||
var stopHere = function (e) { writeLn("capture " + e.currentTarget.properties.name); e.stopPropagation(); };
|
||||
g.addEventListener("mouseup", stopHere, true);
|
||||
b.addEventListener("mouseup", function () { writeLn("never"); });
|
||||
var bubble = function (e) { writeLn("bubble " + e.eventPhase); };
|
||||
g.addEventListener("mousedown", bubble);
|
||||
b.addEventListener("mousedown", function (e) { writeLn("target"); e.stopPropagation(); e.preventDefault(); writeLn("prevented " + e.defaultPrevented); });
|
||||
b.addEventListener("mousemove", function () { writeLn("move"); });
|
||||
b.removeEventListener("mousemove", b.__listeners.mousemove[0]);
|
||||
w.show();
|
||||
"#;
|
||||
let o = run_code(&mut s, code, "stops.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
// A capture listener that stops: the target never hears it.
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "go", "type": "mouseup"})).unwrap();
|
||||
assert_eq!(lines(&r), ["capture outer"]);
|
||||
// Stopped at the target: no bubbling to the group.
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "go", "type": "mousedown"})).unwrap();
|
||||
assert_eq!(lines(&r), ["target", "prevented true"]);
|
||||
// The group itself as the target.
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "outer", "type": "mousedown"})).unwrap();
|
||||
assert_eq!(lines(&r), ["bubble target"]);
|
||||
// Removed listeners don't run, and no longer ask for the event.
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "go", "type": "mousemove"})).unwrap();
|
||||
assert!(lines(&r).is_empty());
|
||||
assert!(!s.script_ui.windows[0].root.lookup(&json!("go")).unwrap().mouse.iter().any(|m| m == "mousemove"));
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
|
||||
/// Hostile input: unknown events, disabled controls, absurd coordinates, a throwing listener,
|
||||
/// a script that makes a control its own parent.
|
||||
#[test]
|
||||
fn mouse_events_survive_bad_input_and_throwing_listeners() {
|
||||
let mut s = session();
|
||||
let code = r#"
|
||||
var w = new Window("palette", "Bad");
|
||||
var b = w.add("button", undefined, "Boom", { name: "boom" });
|
||||
var off = w.add("button", undefined, "Off", { name: "off" });
|
||||
off.enabled = false;
|
||||
off.addEventListener("mousedown", function () { writeLn("disabled ran"); });
|
||||
var n = 0;
|
||||
b.addEventListener("mousedown", function (e) { n++; writeLn("at " + e.clientX + "," + e.clientY + " b" + e.button + " d" + e.detail); if (n === 2) throw new Error("bad press"); });
|
||||
var loop = w.add("group", undefined, undefined, { name: "loop" });
|
||||
loop.addEventListener("mousedown", function () { writeLn("loop ok"); });
|
||||
w.show();
|
||||
loop.parent = loop;
|
||||
"#;
|
||||
let o = run_code(&mut s, code, "bad.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert!(s.execute("scriptui.mouse", json!({"widget": "boom", "type": "dblclick"})).is_err());
|
||||
assert!(s.execute("scriptui.mouse", json!({"widget": "off", "type": "mousedown"})).is_err(), "disabled controls get no events");
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "boom", "type": "mousedown", "x": 1e300, "y": -1e300, "button": 99, "detail": -4})).unwrap();
|
||||
assert_eq!(lines(&r), ["at 1000000,-1000000 b2 d0"]);
|
||||
// A throwing listener reports its error; the window keeps working.
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "boom", "type": "mousedown"})).unwrap();
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(r["error"]["message"].as_str().unwrap_or_default().contains("bad press"), "{r}");
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "boom", "type": "mousedown"})).unwrap();
|
||||
let at = s.script_ui.windows[0].root.lookup(&json!("boom")).unwrap().bounds;
|
||||
assert_eq!(lines(&r), [format!("at {},{} b0 d1", at[2] / 2.0, at[3] / 2.0)], "x / y default to the centre");
|
||||
// A parent cycle made by the script ends instead of hanging.
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "loop", "type": "mousedown"})).unwrap();
|
||||
assert!(lines(&r).iter().any(|l| l == "loop ok"), "{r}");
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user