mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
655 lines
31 KiB
Rust
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");
|
|
}
|