effectcraft/crates/script/src/tests_tasks.rs

181 lines
7.8 KiB
Rust

//! `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();
}