Files
vectorcraft/crates/engine/src/tests/script_dialogs.rs
T
dotCooCoo 8bd437687a Scripts: a canceled request stops a sleeping or waiting run at its next slice
script_pump read vectorcraft_doc::cancel only after the guard that keeps
a run waiting, so a run asleep in $.sleep went on waiting until its wake
time (up to 1,000 ms), and a run waiting at a dialog or for approval to
grow waited until someone answered. The check now runs before that
guard, after the dialog-failure check, so a canceled request ends the
run at the next slice and every document goes back.
2026-11-11 20:01:25 -08:00

310 lines
14 KiB
Rust

//! `alert`, `confirm` and `prompt` in a script: their arguments and results, the answers a run with
//! nobody to ask takes from `answers`, the wait in the app, Stop, and the report's `dialogs`.
use serde_json::{Value, json};
use crate::script::{DialogAnswer, DialogKind, ScriptDialog, dialogs};
use crate::*;
/// One open document with one rectangle.
fn one_doc() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s
}
fn art(s: &Session) -> (String, usize) {
let d = &s.documents()[0];
(format!("{:?}", d.doc.layers), d.history.undo.len())
}
fn headless(s: &mut Session, source: &str, answers: Value) -> Result<Value> {
s.execute("script.run", &json!({"source": source, "name": "d.js", "answers": answers}))
}
/// A run in the app of `source`, one slice in.
fn framed(s: &mut Session, source: &str) {
s.script_frames = true;
assert_eq!(s.execute("script.run", &json!({"source": source, "name": "d.js"})).unwrap()["running"], true);
assert!(s.script_pump().is_none());
}
/// Pumps the run in the app until it ends or waits at a dialog (`None`).
fn pump(s: &mut Session) -> Option<Result<Value>> {
for _ in 0..10_000 {
if s.script_dialog().is_some() {
return None;
}
if let Some(r) = s.script_pump() {
return Some(r);
}
}
panic!("the run neither ended nor waited");
}
fn answers(list: &[Value]) -> Value {
Value::Array(list.to_vec())
}
#[test]
fn notification_signatures_and_returns() {
let mut s = one_doc();
let source = "vc.log(typeof alert('a')); alert(42, 'Title', 1);\
vc.log(confirm('c')); vc.log(confirm('n', true, undefined));\
vc.log(prompt('p', 'pre')); vc.log(prompt({toString: function () { return 'own'; }}, '', 'T'));";
let r = headless(
&mut s,
source,
answers(&[
json!({"kind": "alert", "value": "ok"}),
json!({"kind": "alert", "value": "ok"}),
json!({"kind": "confirm", "value": true}),
json!({"kind": "confirm", "value": false}),
json!({"kind": "prompt", "value": "typed"}),
json!({"kind": "prompt", "value": null}),
]),
)
.unwrap();
assert_eq!(r["log"], json!(["undefined", true, false, "typed", null]), "{r}");
assert_eq!(
r["dialogs"],
json!([
{"kind": "alert", "message": "a", "title": "Script Alert", "errorIcon": false, "answer": "ok"},
{"kind": "alert", "message": "42", "title": "Title", "errorIcon": true, "answer": "ok"},
{"kind": "confirm", "message": "c", "title": "Script Alert", "noAsDefault": false, "answer": true},
{"kind": "confirm", "message": "n", "title": "Script Alert", "noAsDefault": true, "answer": false},
{"kind": "prompt", "message": "p", "title": "Script Alert", "preset": "pre", "answer": "typed"},
{"kind": "prompt", "message": "own", "title": "T", "preset": "", "answer": null},
])
);
assert_eq!((r["dialogCount"].clone(), r["dialogsTruncated"].clone()), (json!(6), json!(false)));
let has = headless(&mut s, "vc.log(vc.api.has('dialogs'));", json!([])).unwrap();
assert_eq!(has["log"], json!([true]));
}
#[test]
fn headless_answers_follow_call_order() {
let mut s = one_doc();
let source = "var out = []; [1, 2].forEach(function (i) { out.push(confirm('q' + i)); }); out.push(prompt('r', 'x')); vc.log(out.join());";
let r = headless(
&mut s,
source,
answers(&[json!({"kind": "confirm", "value": false}), json!({"kind": "confirm", "value": true}), json!({"kind": "prompt", "value": "z"})]),
)
.unwrap();
assert_eq!(r["log"], json!(["false,true,z"]), "a callback of a built-in takes its answer too: {r}");
assert_eq!(headless(&mut s, "1", json!({})).unwrap()["dialogCount"], 0, "an empty object is an empty list");
}
#[test]
fn missing_or_mismatched_answer_rolls_back() {
let mut s = one_doc();
let before = art(&s);
let edit = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5});";
for (source, given, says) in [
(format!("{edit} try {{ alert('x'); }} catch (e) {{ vc.log('caught'); }}"), json!([]), "no answer left"),
(format!("{edit} alert('x');"), answers(&[json!({"kind": "confirm", "value": true})]), "is for confirm, not alert"),
(format!("{edit} alert('x');"), answers(&[json!({"kind": "alert", "value": "ok"}), json!({"kind": "alert", "value": "ok"})]), "1 answer"),
] {
let e = headless(&mut s, &source, given).unwrap_err().to_string();
assert!(e.contains(says), "{e}");
assert_eq!(art(&s), before, "{source}");
let report = s.script_report().unwrap();
assert_eq!(report["kind"], "limit", "{report}");
assert_eq!(report["log"], json!([]), "a missing answer cannot be caught");
assert_eq!(report["dialogCount"], 1);
}
assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "ok");
headless(&mut s, "alert('x')", json!([])).unwrap_err();
assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "missing");
}
#[test]
fn invalid_answers_are_rejected_before_run() {
let mut s = one_doc();
let before = art(&s);
let many: Vec<Value> = (0..=dialogs::MAX_ANSWERS).map(|_| json!({"kind": "alert", "value": "ok"})).collect();
let big: Vec<Value> = (0..80).map(|_| json!({"kind": "prompt", "value": "x".repeat(16_000)})).collect();
for given in [
json!({"a": 1}),
json!("ok"),
json!([1]),
json!([{"kind": "alert"}]),
json!([{"kind": "alert", "value": true}]),
json!([{"kind": "confirm", "value": "yes"}]),
json!([{"kind": "prompt", "value": 3}]),
json!([{"kind": "prompt", "value": "x".repeat(dialogs::MAX_PROMPT_BYTES + 1)}]),
json!([{"kind": "other", "value": "ok"}]),
json!([{"kind": "alert", "value": "ok", "extra": 1}]),
Value::Array(many),
Value::Array(big),
] {
let source = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5});";
let r = s.execute("script.run", &json!({"source": source, "answers": given}));
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}");
assert_eq!(art(&s), before);
}
s.script_frames = true;
let r = s.execute("script.run", &json!({"source": "1", "answers": [{"kind": "alert", "value": "ok"}]}));
assert!(matches!(r, Err(EngineError::BadParams { .. })), "a run in the app takes no answers: {r:?}");
assert!(!s.script_running());
}
#[test]
fn a_framed_run_waits_at_each_dialog_and_takes_the_persons_answer() {
let mut s = one_doc();
framed(
&mut s,
"vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5}); var a = confirm('Go?', true, 'Ask'); var b = prompt('Name', 'n'); vc.log([a, b]);",
);
let want = ScriptDialog {
kind: DialogKind::Confirm,
message: "Go?".into(),
title: Some("Ask".into()),
error_icon: false,
no_as_default: true,
preset: String::new(),
};
assert_eq!(s.script_dialog(), Some(want));
assert!(!s.script_answer(DialogAnswer::Ok), "an answer for another kind of dialog is refused");
assert!(s.script_answer(DialogAnswer::Confirm(true)));
assert!(!s.script_answer(DialogAnswer::Confirm(true)), "nothing waits until the run goes on");
assert!(pump(&mut s).is_none());
assert_eq!(s.script_dialog().unwrap().preset, "n");
assert!(!s.script_answer(DialogAnswer::Prompt(Some("x".repeat(dialogs::MAX_PROMPT_BYTES + 1)))));
assert!(s.script_answer(DialogAnswer::Prompt(Some("Ann".into()))));
let r = pump(&mut s).unwrap().unwrap();
assert_eq!(r["log"], json!([[true, "Ann"]]));
assert_eq!(s.documents()[0].history.undo.last().unwrap().label, "Run Script", "a finished run is one undo step");
assert_eq!(r["dialogs"][1]["answer"], "Ann");
}
#[test]
fn prompt_cancel_returns_null() {
let mut s = one_doc();
framed(&mut s, "vc.log(prompt('Name', '') === null);");
assert!(s.script_answer(DialogAnswer::Prompt(None)));
assert_eq!(pump(&mut s).unwrap().unwrap()["log"], json!([true]));
}
#[test]
fn dialog_loop_stops_or_exhausts() {
let mut s = one_doc();
let before = art(&s);
let source = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5}); while (1) { alert('x'); }";
let three: Vec<Value> = (0..3).map(|_| json!({"kind": "alert", "value": "ok"})).collect();
let e = headless(&mut s, source, Value::Array(three)).unwrap_err().to_string();
assert!(e.contains("no answer left"), "{e}");
assert_eq!(s.script_report().unwrap()["dialogCount"], 4);
assert_eq!(art(&s), before);
framed(&mut s, source);
for _ in 0..3 {
assert!(s.script_answer(DialogAnswer::Ok));
assert!(pump(&mut s).is_none());
}
assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true);
assert_eq!(s.script_dialog(), None, "a run asked to stop no longer waits");
assert!(!s.script_answer(DialogAnswer::Ok));
assert!(s.script_pump().unwrap().is_err());
let report = s.script_report().unwrap();
assert_eq!((report["kind"].as_str(), report["dialogCount"].as_u64()), (Some("stopped"), Some(4)), "{report}");
assert_eq!(report["dialogs"][3]["answer"], "stop");
assert_eq!(art(&s), before, "Stop at the first or any later dialog leaves no undo step");
framed(&mut s, source);
assert!(s.script_abandon(), "quitting while a dialog waits abandons the run");
assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "stop");
assert_eq!(art(&s), before);
}
#[test]
fn a_run_waiting_at_a_dialog_stops_at_the_next_slice_once_its_work_is_canceled() {
let mut s = one_doc();
let before = art(&s);
let token = crate::cancel::CancelToken::new();
s.script_frames = true;
let source = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5}); alert('x'); vc.log('answered');";
assert_eq!(s.execute("script.run", &json!({"source": source, "name": "d.js"})).unwrap()["running"], true);
assert!(crate::cancel::scope(&token, None, || s.script_pump()).is_none());
assert!(s.script_dialog().is_some());
token.cancel();
let ended = crate::cancel::scope(&token, None, || s.script_pump());
assert!(ended.expect("the run goes on waiting for an answer").is_err());
let report = s.script_report().unwrap();
assert_eq!((report["kind"].as_str(), &report["log"]), (Some("stopped"), &json!([])), "{report}");
assert_eq!(report["dialogs"][0]["answer"], "stop");
assert_eq!(s.script_dialog(), None);
assert_eq!(art(&s), before);
}
#[test]
fn dialog_text_limits_and_controls() {
let mut s = one_doc();
let source = format!(
"var out = [];\
function t(f) {{ try {{ f(); out.push('opened'); }} catch (e) {{ out.push(e.name); }} }}\
t(function () {{ alert('x'); }});\
t(function () {{ alert(new Array({m}).join('m')); }});\
t(function () {{ alert('m', new Array({t}).join('t')); }});\
t(function () {{ prompt('m', new Array({m}).join('p')); }});\
t(function () {{ alert(); }});\
t(function () {{ prompt('m'); }});\
t(function () {{ alert({{toString: function () {{ throw new Error('no'); }}}}); }});\
t(function () {{ alert(undefined); }});\
vc.log(out.join());",
m = dialogs::MAX_MESSAGE_BYTES + 2,
t = dialogs::MAX_TITLE_BYTES + 2,
);
let r = headless(&mut s, &source, answers(&[json!({"kind": "alert", "value": "ok"}), json!({"kind": "alert", "value": "ok"})])).unwrap();
assert_eq!(r["log"], json!(["opened,RangeError,RangeError,RangeError,TypeError,TypeError,Error,opened"]), "{r}");
assert_eq!(r["dialogs"][1]["message"], "undefined", "an explicit undefined message is a message");
assert_eq!(dialogs::shown_text("a\r\nb\rc\nd\te\u{7}\u{7f}", true), "a\nb\nc\nd\u{2409}e\u{2407}\u{2421}");
assert_eq!(dialogs::shown_text("a\nb", false), "a\u{240a}b", "a title stays on one line");
assert_eq!(dialogs::cut_to("aé", 2), "a", "a cut keeps whole characters");
}
#[test]
fn dialog_transcript_on_success_and_failure() {
let mut s = one_doc();
let r = headless(&mut s, "alert('one'); throw new Error('later');", answers(&[json!({"kind": "alert", "value": "ok"})])).unwrap_err();
assert!(r.to_string().contains("later"));
let report = s.script_report().unwrap();
assert_eq!(report["dialogs"], json!([{"kind": "alert", "message": "one", "title": "Script Alert", "errorIcon": false, "answer": "ok"}]));
let ok = headless(&mut s, "1", json!([])).unwrap();
assert_eq!((ok["dialogs"].clone(), ok["dialogCount"].clone()), (json!([]), json!(0)));
}
#[test]
fn dialog_wait_does_not_spend_fuel() {
let mut s = one_doc();
framed(&mut s, "alert('x'); 1");
for _ in 0..20 {
assert!(s.script_pump().is_none(), "the run waits");
}
assert!(s.script_answer(DialogAnswer::Ok));
let r = pump(&mut s).unwrap().unwrap();
let mut t = one_doc();
let plain = headless(&mut t, "alert('x'); 1", answers(&[json!({"kind": "alert", "value": "ok"})])).unwrap();
assert_eq!((r["fuelUsed"].clone(), r["hostCalls"].clone()), (plain["fuelUsed"].clone(), plain["hostCalls"].clone()), "{r}");
}
#[test]
fn second_run_is_busy_while_dialog_waits() {
let mut s = one_doc();
framed(&mut s, "alert('x');");
assert!(s.execute("script.run", &json!({"source": "1"})).is_err());
assert!(s.script_dialog().is_some(), "the first run still waits");
assert!(s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 1, "height": 1})).is_err(), "edits are refused while it waits");
}
#[test]
fn a_dialog_in_a_callback_throws_in_the_app() {
let mut s = one_doc();
s.script_frames = true;
let source = "var m = ''; [1].forEach(function () { try { alert('x'); } catch (e) { m = e.message; } }); vc.log(m);";
s.execute("script.run", &json!({"source": source})).unwrap();
let r = pump(&mut s).unwrap().unwrap();
assert!(r["log"][0].as_str().unwrap().contains("callback"), "{r}");
assert_eq!(r["dialogCount"], 0);
}