Files
vectorcraft/crates/engine/src/tests/script_dialogs.rs
T
dotCooCoo df0373aa26 Scripts: alert, confirm and prompt open dialogs with Stop, and headless runs take their answers from script.run
alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title])
and prompt(message, preset[, title]) are globals in a script run. In the
app the run waits at the call for the person's answer, spends no
instructions while it waits, and every dialog has Stop, which ends the
run and puts every document back. A run over MCP or the command line
takes its answers in order from script.run's answers, a list of
{kind, value} records; a missing, mismatched or unused answer ends the
run. The report lists each dialog with its answer in dialogs, with
dialogCount and dialogsTruncated.

The interpreter gains a host call that waits at the call for its
result, from the script's own code only; a dialog called in a callback
of a built-in, a getter or a conversion throws in the app.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-11-10 22:14:01 -08:00

290 lines
13 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 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);
}