Files
figcraft/crates/engine/src/batch_step_ref_tests.rs
T

655 lines
31 KiB
Rust

use serde_json::json;
use crate::{EngineError, Session};
fn id(value: &serde_json::Value) -> crate::NodeId {
crate::params::id_of(&value["id"]).unwrap()
}
#[test]
fn later_steps_target_layers_created_by_earlier_steps() {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({
"label": "Card",
"commands": [
{"command": "node.create", "params": {"type": "frame", "x": 0, "y": 0, "width": 200, "height": 100}},
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}},
{"command": "node.set", "params": {"ids": [{"$step": 1}], "props": {"name": "Swatch"}}}
]
}),
)
.unwrap();
let frame = id(&results[0]);
let rect = id(&results[1]);
let doc = s.doc().unwrap();
assert_eq!(doc.node(rect).unwrap().parent, Some(frame));
assert_eq!(doc.node(rect).unwrap().name, "Swatch");
assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step");
}
#[test]
fn field_picks_another_part_of_the_result() {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({"commands": [
{"command": "node.create", "params": {"type": "frame", "width": 50, "height": 50}},
{"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0, "field": "id"}}}
]}),
)
.unwrap();
assert_eq!(s.doc().unwrap().node(id(&results[1])).unwrap().parent, Some(id(&results[0])));
}
#[test]
fn bad_references_fail_and_roll_back() {
let mut s = Session::new();
let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len();
let frame = json!({"command": "node.create", "params": {"type": "frame", "width": 10, "height": 10}});
for (bad, needle) in [
(json!({"$step": 1}), "earlier step"),
(json!({"$step": 5}), "earlier step"),
(json!({"$step": -1}), "step index"),
(json!({"$step": "0"}), "step index"),
(json!({"$step": 0, "field": "nope"}), "no `nope`"),
(json!({"$step": 0, "field": 3}), "`field` must be a string"),
(json!({"$step": 0, "extra": true}), "not `extra`"),
] {
let r = s.execute("command.batch", &json!({"commands": [frame, {"command": "node.set", "params": {"ids": [bad], "props": {"name": "x"}}}]}));
match r {
Err(EngineError::BadParams { msg, .. }) => assert!(msg.contains(needle), "{msg} should mention {needle}"),
other => panic!("expected BadParams for {bad}, got {other:?}"),
}
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back");
}
}
#[test]
fn deep_params_are_refused_not_overflowed() {
let mut s = Session::new();
let mut deep = json!(1);
for _ in 0..200 {
deep = json!([deep]);
}
let r = s.execute("command.batch", &json!({"commands": [{"command": "select.none", "params": {"x": deep}}]}));
assert!(matches!(r, Err(EngineError::BadParams { .. })));
}
#[test]
fn text_that_looks_like_a_reference_is_left_alone() {
let mut s = Session::new();
let results = s
.execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": "$step 0 {\"$step\": 0}"}}]}))
.unwrap();
let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone();
assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == "$step 0 {\"$step\": 0}"));
}
#[test]
fn a_nested_batch_refers_to_its_own_steps() {
let mut s = Session::new();
let frame = |name: &str| json!({"command": "node.create", "params": {"type": "frame", "name": name, "width": 100, "height": 100}});
let results = s
.execute(
"command.batch",
&json!({"commands": [
frame("Outer"),
{"command": "command.batch", "params": {"commands": [
frame("Inner"),
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "width": 10, "height": 10}}
]}}
]}),
)
.unwrap();
let inner = &results[1];
let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone();
assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one");
}
/// Runs `commands` as a batch and returns the error message, which must be `BadParams`.
fn batch_error(s: &mut Session, commands: serde_json::Value) -> String {
match s.execute("command.batch", &json!({"commands": commands})) {
Err(EngineError::BadParams { msg, .. }) => msg,
other => panic!("expected BadParams, got {other:?}"),
}
}
#[test]
fn a_named_step_is_referred_to_by_its_name() {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({"commands": [
{"command": "node.create", "as": "card", "params": {"type": "frame", "width": 200, "height": 100}},
{"command": "node.create", "as": "swatch", "params": {"type": "rectangle", "parent": {"$ref": "card"}, "width": 20, "height": 20}},
{"command": "node.set", "params": {"ids": [{"$ref": "swatch", "field": "id"}], "props": {"name": "Swatch"}}},
{"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0}}}
]}),
)
.unwrap();
let (frame, rect, text) = (id(&results[0]), id(&results[1]), id(&results[3]));
let doc = s.doc().unwrap();
assert_eq!(doc.node(rect).unwrap().parent, Some(frame));
assert_eq!(doc.node(rect).unwrap().name, "Swatch");
assert_eq!(doc.node(text).unwrap().parent, Some(frame), "`$step` and `$ref` mix in one batch");
assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step");
}
/// `edit.duplicate` answers `{"ids": [...]}`: a slash path in `field` takes one of them.
#[test]
fn a_field_path_indexes_into_a_list_in_the_result() {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({"commands": [
{"command": "node.create", "as": "a", "params": {"type": "frame", "name": "A", "width": 10, "height": 10}},
{"command": "node.create", "as": "b", "params": {"type": "frame", "name": "B", "width": 10, "height": 10}},
{"command": "edit.duplicate", "as": "copies", "params": {"ids": [{"$ref": "a"}, {"$ref": "b"}]}},
{"command": "node.set", "params": {"ids": [{"$ref": "copies", "field": "ids/1"}], "props": {"name": "B copy"}}},
{"command": "node.set", "params": {"ids": [{"$step": 2, "field": "/ids/0"}], "props": {"name": "A copy"}}}
]}),
)
.unwrap();
let copies = results[2]["ids"].as_array().unwrap().clone();
let doc = s.doc().unwrap();
assert_eq!(doc.node(crate::params::id_of(&copies[0]).unwrap()).unwrap().name, "A copy", "a leading `/` works too");
assert_eq!(doc.node(crate::params::id_of(&copies[1]).unwrap()).unwrap().name, "B copy");
}
#[test]
fn a_field_path_walks_nested_objects_and_takes_whole_values() {
let mut scope = crate::BatchScope::default();
scope.push(json!({"ids": [7, 8], "bounds": {"x": 1.5, "corners": [{"r": 4}]}, "a/b": "slash key"}));
let resolved = scope
.resolve(&json!({
"x": {"$step": 0, "field": "bounds/x"},
"r": {"$step": 0, "field": "bounds/corners/0/r"},
"all": {"$step": 0, "field": "ids"},
"key": {"$step": 0, "field": "a/b"},
"pointer": {"$step": 0, "field": "/ids"}
}))
.unwrap();
assert_eq!(resolved, json!({"x": 1.5, "r": 4, "all": [7, 8], "key": "slash key", "pointer": [7, 8]}), "a key holding `/` is still read as a key");
}
#[test]
fn bad_names_and_paths_fail_and_roll_back() {
let mut s = Session::new();
let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len();
let frame = |name: &str| json!({"command": "node.create", "as": name, "params": {"type": "frame", "width": 10, "height": 10}});
let dup = json!({"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}});
let set = |r: serde_json::Value| json!({"command": "node.set", "params": {"ids": [r], "props": {"name": "x"}}});
let later = json!({"command": "node.create", "as": "later", "params": {"type": "frame", "width": 10, "height": 10}});
for (commands, needle) in [
(json!([frame("card"), frame("card")]), "step 1: `as` \"card\" is already step 0's name"),
(json!([frame("card"), set(json!({"$ref": "nope"}))]), "step 1: `$ref` \"nope\" names no step (\"card\" is step 0)"),
(json!([{"command": "node.create", "params": {"type": "frame"}}, set(json!({"$ref": "nope"}))]), "no step has an `as` name"),
(json!([frame("card"), set(json!({"$ref": "later"})), later]), "step 1: `$ref` \"later\" is step 2, not an earlier step"),
(
json!([frame("card"), {"command": "node.set", "as": "self", "params": {"ids": [{"$ref": "self"}], "props": {}}}]),
"is step 1, not an earlier step",
),
(json!([frame("card"), set(json!({"$ref": 0}))]), "`$ref` must be a step name"),
(json!([frame("card"), set(json!({"$ref": "card", "id": 3}))]), "a `$ref` reference takes only `field`, not `id`"),
(json!([frame("card"), set(json!({"$ref": "card", "$step": 0}))]), "a `$step` reference takes only `field`, not `$ref`"),
(json!([frame("card"), {"command": "node.create", "as": "", "params": {}}]), "step 1: `as` must be a name"),
(json!([frame("card"), {"command": "node.create", "as": 3, "params": {}}]), "step 1: `as` must be a name"),
(
json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/1"}))]),
"step 2: step 1 (\"copy\")'s result has no `ids/1`: `ids` has 1 item",
),
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/first"}))]), "`ids` is a list, so `first` must be an index"),
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/0/x"}))]), "has no `ids/0/x`: `ids/0` is a number"),
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "map/0"}))]), "step 1 (\"copy\")'s result has no `map/0`"),
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids.0"}))]), "has no `ids.0` (paths use `/`, as in `ids/0`)"),
(json!([frame("card"), set(json!({"$step": 0, "field": "id/0"}))]), "has no `id/0`: `id` is a number"),
] {
let msg = batch_error(&mut s, commands);
assert!(msg.contains(needle), "{msg:?} should contain {needle:?}");
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back");
}
}
#[test]
fn text_that_looks_like_a_named_reference_is_left_alone() {
let mut s = Session::new();
let text = "{\"$ref\": \"card\"} $ref";
let results = s.execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": text}}]})).unwrap();
let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone();
assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == text));
}
/// A nested batch has its own names: it can't see the outer batch's, may reuse them, and the
/// outer batch can't see its.
#[test]
fn a_nested_batch_has_its_own_names() {
let mut s = Session::new();
let frame = |name: &str| json!({"command": "node.create", "as": "frame", "params": {"type": "frame", "name": name, "width": 100, "height": 100}});
let results = s
.execute(
"command.batch",
&json!({"commands": [
frame("Outer"),
{"command": "command.batch", "as": "inner", "params": {"commands": [
frame("Inner"),
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}}
]}},
{"command": "node.set", "params": {"ids": [{"$ref": "inner", "field": "1/id"}], "props": {"name": "In inner"}}}
]}),
)
.unwrap();
let inner = &results[1];
let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone();
assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own frame, not the outer one");
assert_eq!(rect.name, "In inner", "a path indexes into a nested batch's result list");
let msg = batch_error(
&mut s,
json!([frame("Outer"), {"command": "command.batch", "params": {"commands": [
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}}
]}}]),
);
assert!(msg.contains("`$ref` \"frame\" names no step"), "the outer names aren't visible inside: {msg}");
}
#[test]
fn a_failing_command_names_its_step_and_rolls_back() {
let mut s = Session::new();
let frame = id(&s.execute("node.create", &json!({"type": "frame", "width": 50, "height": 50})).unwrap());
let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap());
let undo = s.ds().unwrap().history.undo.len();
let name = s.doc().unwrap().node(s.page().unwrap()).unwrap().name.clone();
let msg = batch_error(
&mut s,
json!([
{"command": "text.set", "params": {"ids": [text.0], "text": "novo"}},
{"command": "text.set", "params": {"ids": [frame.0], "text": "erro"}},
{"command": "page.rename", "params": {"name": "Depois do erro"}}
]),
);
// Still the command's own kind of error, its message saying which step.
assert!(msg.starts_with("step 1: ") && msg.contains("is not text"), "{msg}");
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig"));
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, name);
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
}
#[test]
fn a_continue_batch_can_undo_then_redo_between_steps() {
for mode in ["abort", "continue"] {
let mut s = Session::new();
let rect = id(&s.execute("node.create", &json!({"type": "rectangle"})).unwrap());
let before = s.doc().unwrap().clone();
let results =
s.execute("command.batch", &json!({"onError": mode, "commands": [{"command": "edit.undo"}, {"command": "edit.redo"}]})).unwrap();
let undo = if mode == "continue" { &results[0]["result"] } else { &results[0] };
let redo = if mode == "continue" { &results[1]["result"] } else { &results[1] };
assert_eq!(undo["undone"], true, "{mode}: {results}");
assert_eq!(redo["redone"], true, "{mode}: {results}");
assert!(s.doc().unwrap().contains(rect));
assert_eq!(s.doc().unwrap(), &before);
}
}
#[test]
fn a_continue_batch_can_open_a_document_after_closing_the_last_tab() {
let mut s = Session::new();
let uid = s.ds().unwrap().uid;
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "file.close", "params": {"force": true}},
{"command": "file.new"},
{"command": "page.rename", "params": {"name": "New page"}}
]}),
)
.unwrap();
assert!(results.as_array().unwrap().iter().all(|r| r["ok"] == true), "{results}");
assert_eq!(s.docs.len(), 1);
assert_ne!(s.ds().unwrap().uid, uid);
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "New page");
assert!(s.closed_in_group.is_empty());
}
#[test]
fn a_continue_batch_repeats_the_last_successful_command_after_a_failed_step() {
let mut s = Session::new();
s.execute("node.create", &json!({"type": "rectangle"})).unwrap();
let last = s.last_command.clone();
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "command.batch", "params": {"commands": [
{"command": "node.create", "params": {"type": "text", "text": "Rolled back"}},
{"command": "no.such.command"}
]}},
{"command": "edit.repeat"}
]}),
)
.unwrap();
assert_eq!(results[0]["ok"], false);
assert_eq!(results[1]["ok"], true);
let repeated = s.doc().unwrap().node(id(&results[1]["result"])).unwrap();
assert!(matches!(repeated.kind, figcraft_doc::NodeKind::Rectangle), "{repeated:?}");
assert_eq!(s.last_command, last);
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), 2);
}
#[test]
fn a_continue_batch_keeps_successful_steps_as_one_undo_entry() {
let mut s = Session::new();
let frame = id(&s.execute("node.create", &json!({"type": "frame", "width": 50, "height": 50})).unwrap());
let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap());
assert_eq!((frame.0, text.0), (2, 3));
let undo = s.undo_depth().0;
let name = s.doc().unwrap().node(s.page().unwrap()).unwrap().name.clone();
let results = s
.execute(
"command.batch",
&json!({"label": "Continue", "onError": "continue", "commands": [
{"command": "text.set", "params": {"ids": [3], "text": "novo"}},
{"command": "text.set", "params": {"ids": [2], "text": "erro"}},
{"command": "page.rename", "params": {"name": "Depois do erro"}}
]}),
)
.unwrap();
assert_eq!(results.as_array().unwrap().len(), 3);
assert_eq!(results[0], json!({"step": 0, "command": "text.set", "ok": true, "result": {"ok": true}}));
assert_eq!(results[2], json!({"step": 2, "command": "page.rename", "ok": true, "result": {"ok": true}}));
assert_eq!(results[1]["step"], 1);
assert_eq!(results[1]["command"], "text.set");
assert_eq!(results[1]["ok"], false);
let error = results[1]["error"].as_str().unwrap();
assert!(error.contains("step 1:") && error.contains("is not text"), "{error}");
assert!(results[1].get("result").is_none());
undo_and_redo_the_continued_batch(&mut s, frame, text, &name, undo);
}
/// The successful steps of the batch above undo and redo as one step.
fn undo_and_redo_the_continued_batch(s: &mut Session, frame: figcraft_doc::NodeId, text: figcraft_doc::NodeId, name: &str, undo: usize) {
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "novo"));
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Depois do erro");
assert_eq!(s.undo_depth(), (undo + 1, 0));
assert_eq!(s.undo_labels(1), ["Continue"]);
assert!(s.undo().unwrap());
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig"));
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, name);
assert!(s.doc().unwrap().contains(frame), "undo leaves earlier edits alone");
assert_eq!(s.undo_depth(), (undo, 1));
assert!(s.redo().unwrap());
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "novo"));
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Depois do erro");
assert_eq!(s.undo_depth(), (undo + 1, 0));
}
#[test]
fn a_continue_batch_rolls_back_partial_steps_and_keeps_reference_positions() {
let mut s = Session::new();
let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap());
let frame = id(&s.execute("node.create", &json!({"type": "frame"})).unwrap());
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "text.set", "as": "bad", "params": {"ids": [text.0, frame.0], "text": "partial"}},
{"command": "node.create", "as": "card", "params": {"type": "frame"}},
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}}},
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "bad"}}},
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 1}}},
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "card"}}},
{"command": "node.set", "params": {"ids": [{"$step": 4}], "props": {"name": "Child"}}}
]}),
)
.unwrap();
assert_eq!(results.as_array().unwrap().len(), 7);
for (i, result) in results.as_array().unwrap().iter().enumerate() {
assert_eq!(result["step"], i);
assert_eq!(result["ok"], ![0, 2, 3].contains(&i));
}
assert!(results[2]["error"].as_str().unwrap().contains("step 2: references step 0, which failed"));
assert!(results[3]["error"].as_str().unwrap().contains("step 3: references step 0 (\"bad\"), which failed"));
assert!(
matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig"),
"the failed step's partial edit was rolled back"
);
let card = id(&results[1]["result"]);
for i in [4, 5] {
assert_eq!(s.doc().unwrap().node(id(&results[i]["result"])).unwrap().parent, Some(card), "references use raw results, not outcomes");
}
assert_eq!(s.doc().unwrap().node(id(&results[4]["result"])).unwrap().name, "Child");
}
#[test]
fn invalid_batch_policies_run_nothing() {
for mode in [json!("skip"), json!(""), json!(true), json!(3), json!(["continue"]), json!({})] {
let mut s = Session::new();
let before = s.doc().unwrap().clone();
let journal = s.journal.clone();
let error = s
.execute("command.batch", &json!({"onError": mode, "commands": [{"command": "page.rename", "params": {"name": "Changed"}}]}))
.unwrap_err();
assert!(matches!(error, EngineError::BadParams { .. }));
assert!(error.to_string().contains("`onError` must be \"abort\" or \"continue\""), "{error}");
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.journal, journal);
assert_eq!(s.undo_depth(), (0, 0));
}
}
#[test]
fn invalid_batch_structure_runs_nothing_in_either_mode() {
let rename = json!({"command": "page.rename", "params": {"name": "Changed"}});
for mode in ["abort", "continue"] {
for commands in [
json!(null),
json!({}),
json!([rename, null]),
json!([rename, {}]),
json!([rename, {"command": 3}]),
json!([rename, {"command": "page.rename", "as": ""}]),
json!([rename, {"command": "page.rename", "as": 3}]),
json!([{"command": "page.rename", "as": "same", "params": {"name": "Changed"}}, {"command": "select.none", "as": "same"}]),
] {
let mut s = Session::new();
let before = s.doc().unwrap().clone();
let journal = s.journal.clone();
let result = s.execute("command.batch", &json!({"onError": mode, "commands": commands}));
assert!(matches!(result, Err(EngineError::BadParams { .. })), "{mode}: {result:?}");
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.journal, journal);
assert_eq!(s.undo_depth(), (0, 0));
}
}
}
#[test]
fn an_explicit_abort_batch_keeps_the_default_results_and_errors() {
let commands = json!([
{"command": "node.create", "params": {"type": "frame"}},
{"command": "node.create", "params": {"type": "text", "text": "orig"}}
]);
let mut default = Session::new();
let mut abort = Session::new();
let raw = default.execute("command.batch", &json!({"commands": commands})).unwrap();
assert_eq!(abort.execute("command.batch", &json!({"onError": "abort", "commands": commands})).unwrap(), raw);
assert!(raw[0].get("id").is_some() && raw[0].get("ok").is_none(), "still raw results");
let commands = json!([
{"command": "text.set", "params": {"ids": [3], "text": "novo"}},
{"command": "text.set", "params": {"ids": [2], "text": "erro"}},
{"command": "page.rename", "params": {"name": "Depois do erro"}}
]);
let before = abort.doc().unwrap().clone();
let undo = abort.undo_depth();
let error = default.execute("command.batch", &json!({"commands": commands})).unwrap_err();
let explicit = abort.execute("command.batch", &json!({"onError": "abort", "commands": commands})).unwrap_err();
assert_eq!(explicit.to_string(), error.to_string());
assert!(matches!(explicit, EngineError::BadParams { .. }));
assert_eq!(abort.doc().unwrap(), &before);
assert_eq!(abort.undo_depth(), undo);
}
#[test]
fn nested_batch_error_policies_are_local() {
for outer in ["abort", "continue"] {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({"onError": outer, "commands": [
{"command": "command.batch", "as": "inner", "params": {"onError": "continue", "commands": [
{"command": "node.create", "as": "card", "params": {"type": "frame"}},
{"command": "no.such.command"},
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "card"}}}
]}},
{"command": "node.set", "params": {"ids": [{"$ref": "inner", "field": "2/result/id"}], "props": {"name": "Inner child"}}}
]}),
)
.unwrap();
let inner = if outer == "continue" { &results[0]["result"] } else { &results[0] };
assert_eq!(inner[1]["ok"], false);
let child = s.doc().unwrap().node(id(&inner[2]["result"])).unwrap();
assert_eq!(child.name, "Inner child");
assert_eq!(child.parent, Some(id(&inner[0]["result"])));
assert_eq!(s.undo_depth(), (1, 0));
assert!(s.undo().unwrap());
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), 0);
assert!(s.redo().unwrap());
assert!(s.doc().unwrap().contains(id(&inner[2]["result"])));
}
}
#[test]
fn an_outer_continue_batch_rolls_back_a_failed_inner_abort_batch() {
for explicit in [false, true] {
let mut s = Session::new();
let mut inner = json!({"commands": [
{"command": "file.close", "params": {"force": true}},
{"command": "file.new", "params": {"name": "Temporary"}},
{"command": "node.create", "params": {"type": "frame"}},
{"command": "no.such.command"}
]});
if explicit {
inner["onError"] = json!("abort");
}
let uid = s.ds().unwrap().uid;
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "page.rename", "params": {"name": "Before"}},
{"command": "command.batch", "params": inner},
{"command": "page.rename", "params": {"name": "After"}}
]}),
)
.unwrap();
assert_eq!(results[1]["ok"], false);
assert!(results[1]["error"].as_str().unwrap().contains("unknown command"));
assert_eq!(results[2]["ok"], true);
assert_eq!(s.docs.len(), 1);
assert_eq!(s.ds().unwrap().uid, uid);
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "After");
assert!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.is_empty());
assert!(s.closed_in_group.is_empty());
assert_eq!(s.undo_depth(), (1, 0));
}
}
#[test]
fn an_outer_abort_batch_rolls_back_successes_of_an_inner_continue_batch() {
let mut s = Session::new();
let before = s.doc().unwrap().clone();
let result = s.execute(
"command.batch",
&json!({"commands": [
{"command": "command.batch", "params": {"onError": "continue", "commands": [
{"command": "node.create", "params": {"type": "frame"}},
{"command": "no.such.command"}
]}},
{"command": "no.such.command"}
]}),
);
assert!(matches!(result, Err(EngineError::UnknownCommand(_))));
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.undo_depth(), (0, 0));
}
#[test]
fn an_all_failed_continue_batch_adds_no_undo_and_keeps_redo() {
let mut s = Session::new();
let frame = id(&s.execute("node.create", &json!({"type": "frame"})).unwrap());
s.execute("page.rename", &json!({"name": "Redo name"})).unwrap();
assert!(s.undo().unwrap());
let depth = s.undo_depth();
let before = s.doc().unwrap().clone();
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "node.set", "params": {"ids": [frame.0, 999999], "props": {"name": "Partial"}}},
{"command": "no.such.command"},
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}}}
]}),
)
.unwrap();
assert!(results.as_array().unwrap().iter().all(|r| r["ok"] == false));
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.undo_depth(), depth);
assert!(s.redo().unwrap());
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Redo name");
}
#[test]
fn an_empty_continue_batch_returns_an_empty_array_without_undo() {
let mut s = Session::new();
assert_eq!(s.execute("command.batch", &json!({"onError": "continue", "commands": []})).unwrap(), json!([]));
assert_eq!(s.undo_depth(), (0, 0));
}
#[test]
fn a_null_batch_result_is_not_a_failed_slot() {
let mut scope = crate::BatchScope::default();
scope.push(serde_json::Value::Null);
scope.push_failed();
let null = scope.resolve(&json!({"$step": 0})).unwrap_err().to_string();
assert!(null.contains("step 0's result has no `id`") && !null.contains("which failed"), "{null}");
let failed = scope.resolve(&json!({"$step": 1})).unwrap_err().to_string();
assert!(failed.contains("step 2: references step 1, which failed"), "{failed}");
assert_eq!(scope.next_step(), 2);
}
#[test]
fn a_continue_batch_reports_runtime_errors_and_runs_later_steps() {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "no.such.command"},
{"command": "node.create", "params": {"type": "no-such-type"}},
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "missing"}}},
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 99}}},
{"command": "node.create", "params": {"type": "frame"}},
{"command": "node.set", "params": {"ids": [{"$step": 4, "field": "missing"}], "props": {"name": "No"}}},
{"command": "page.rename", "params": {"name": "Done"}}
]}),
)
.unwrap();
assert_eq!(results.as_array().unwrap().len(), 7);
for (i, needle) in [(0, "unknown command"), (1, "unknown type"), (2, "names no step"), (3, "earlier step"), (5, "has no `missing`")] {
assert_eq!(results[i]["ok"], false);
assert!(results[i]["error"].as_str().unwrap().contains(needle), "{}", results[i]);
}
assert_eq!(results[4]["ok"], true);
assert_eq!(results[6]["ok"], true);
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Done");
}