refactor(script): move loose test files to src/tests/

This commit is contained in:
aiongg
2026-10-26 21:49:11 +00:00
parent 9526833a62
commit 48253ff5e1
12 changed files with 12 additions and 21 deletions
+103
View File
@@ -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]));
}
+610
View File
@@ -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);
}
+99
View File
@@ -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:?}");
}
+94
View File
@@ -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);
}
+88
View File
@@ -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": []}));
}
+52
View File
@@ -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()]);
}
+180
View File
@@ -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();
}
+299
View File
@@ -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);
}
+445
View File
@@ -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);
}
+167
View File
@@ -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();
}