Files
vectorcraft/crates/engine/tests/model_based.rs
T
bflatastic 355bb5c4f6 Fix #1697: a failed command.batch restores each document whole
The rollback undid the batch's interaction, which holds the art, the selection
and panel state only: a Save As step's new file, format and saved state stayed,
and an Undo step (which ends the interaction) left the art and the history
undone. The batch now keeps the documents as they were and puts them back,
and settles a drag or typing in progress first, as Undo does, instead of
running inside it and keeping its baseline as the batch's step. Files the
steps wrote stay written.
2026-10-14 08:47:10 +07:00

204 lines
9.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Model-based / stateful property tests: random command sequences drawn from the registry, with
//! structural invariants checked after every step and history/serialization invariants at the end.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use proptest::prelude::*;
use serde_json::json;
use vectorcraft_testkit::fixtures::{self, exec};
use vectorcraft_testkit::invariants::{self, check_all, check_session, doc_json, first_diff, redo_all, undo_all};
use vectorcraft_testkit::strategies::{Op, arb_ops};
fn run_sequence(ops: &[Op], full_checks_every: usize) -> Result<(), TestCaseError> {
let mut s = fixtures::session();
let initial = doc_json(&s.doc().unwrap().doc);
for (i, op) in ops.iter().enumerate() {
let (id, params) = op.command(&s);
// Any result is fine; panics are not.
let _ = s.execute(&id, &params);
let check = if i % full_checks_every == 0 || i + 1 == ops.len() { check_all(&mut s) } else { check_session(&s) };
if let Err(e) = check {
return Err(TestCaseError::fail(format!("after step {i} `{id}` {params}: {e}")));
}
}
// Reach the fully redone state first (the sequence may end with undos).
redo_all(&mut s);
let fin = doc_json(&s.doc().unwrap().doc);
let n = undo_all(&mut s);
let back = doc_json(&s.doc().unwrap().doc);
prop_assert!(back == initial, "undo all ({n} steps) != initial: {}", first_diff(&initial, &back, "$"));
check_session(&s).map_err(TestCaseError::fail)?;
let m = redo_all(&mut s);
prop_assert_eq!(n, m, "redo count differs from undo count");
let again = doc_json(&s.doc().unwrap().doc);
prop_assert!(again == fin, "redo all != final: {}", first_diff(&fin, &again, "$"));
check_all(&mut s).map_err(TestCaseError::fail)?;
Ok(())
}
proptest! {
#![proptest_config(ProptestConfig { cases: 96, failure_persistence: None, max_shrink_iters: 2000, ..ProptestConfig::default() })]
/// ~60 random commands; cheap invariants every step, round trips every 10 steps.
#[test]
fn random_command_sequences(ops in arb_ops(40..80)) {
run_sequence(&ops, 10)?;
}
/// Shorter sequences with the full check (save/load, SVG, inspect) after every step.
#[test]
fn short_sequences_full_checks(ops in arb_ops(5..20)) {
run_sequence(&ops, 1)?;
}
/// Replaying the journal of a random session into a fresh session reproduces the document.
#[test]
fn journal_replay_reproduces_document(ops in arb_ops(10..40)) {
let mut s = fixtures::session();
for op in &ops {
let _ = op.apply(&mut s);
}
let want = doc_json(&s.doc().unwrap().doc);
let journal = s.journal.clone();
let mut r = vectorcraft_engine::Session::new();
for (id, p) in &journal {
let _ = r.execute(id, p);
}
// The journal includes clipboard state implicitly (copy/cut are journaled), so the replay
// must land on the same document.
let got = doc_json(&r.doc().unwrap().doc);
prop_assert!(got == want, "replay differs: {}", first_diff(&want, &got, "$"));
}
}
#[test]
fn rich_fixture_satisfies_invariants_and_undoes_to_blank() {
let mut s = fixtures::rich_session();
check_all(&mut s).unwrap();
let fin = doc_json(&s.doc().unwrap().doc);
let n = undo_all(&mut s);
assert!(n >= 10, "expected a real history, got {n} steps");
let blank = doc_json(&fixtures::session_with(500.0, 400.0).doc().unwrap().doc);
// Titles differ ("Untitled-1" in both sessions since each has its own counter).
assert_eq!(doc_json(&s.doc().unwrap().doc), blank, "{}", first_diff(&blank, &doc_json(&s.doc().unwrap().doc), "$"));
redo_all(&mut s);
assert_eq!(doc_json(&s.doc().unwrap().doc), fin);
}
#[test]
fn batch_is_one_undo_step_and_rolls_back_on_error() {
let mut s = fixtures::session();
let before = s.doc().unwrap().history.undo.len();
exec(
&mut s,
"command.batch",
json!({"commands": [
{"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 10, "height": 10}},
{"command": "shape.ellipse", "params": {"x": 20, "y": 0, "width": 10, "height": 10}},
{"command": "select.all", "params": {}},
{"command": "object.group", "params": {}},
]}),
);
assert_eq!(s.doc().unwrap().history.undo.len(), before + 1);
assert_eq!(s.doc().unwrap().doc.node_count(), 4);
let snapshot = doc_json(&s.doc().unwrap().doc);
// A failing step rolls the whole batch back and leaves no interaction open.
let r = s.execute(
"command.batch",
&json!({"commands": [
{"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 10, "height": 10}},
{"command": "no.such.command", "params": {}},
]}),
);
assert!(r.is_err());
assert_eq!(doc_json(&s.doc().unwrap().doc), snapshot);
assert!(!s.in_interaction());
// Nested batches are rejected, also without leaking an interaction.
let r = s.execute("command.batch", &json!({"commands": [{"command": "command.batch", "params": {"commands": []}}]}));
assert!(r.is_err());
assert!(!s.in_interaction());
exec(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().doc.node_count(), 1);
}
/// #1697: a failed batch brings back the whole document, not only its art: the file a Save As
/// step named and the history an Undo step moved; and a batch leaves a drag in progress its own
/// step.
#[test]
fn a_failed_batch_restores_the_file_the_history_and_a_drag_in_progress() {
let mut s = fixtures::session();
let rect = |x: i32| json!({"command": "shape.rectangle", "params": {"x": x, "y": 0, "width": 10, "height": 10}});
let fail = json!({"command": "no.such.command", "params": {}});
exec(&mut s, "shape.rectangle", json!({"x": 50, "y": 0, "width": 10, "height": 10}));
let before = (doc_json(&s.doc().unwrap().doc), s.doc().unwrap().history.undo.len(), s.doc().unwrap().history.redo.len());
let state = |s: &vectorcraft_engine::Session| {
let st = s.doc().unwrap();
(doc_json(&st.doc), st.history.undo.len(), st.history.redo.len())
};
// Save As, then a failing step: the document keeps no file and stays modified.
let path = vectorcraft_testkit::temp_dir("batch-rollback").join("named.vectorcraft").to_string_lossy().to_string();
let save = json!({"command": "file.saveAs", "params": {"path": path}});
assert!(s.execute("command.batch", &json!({"commands": [rect(0), save, fail]})).is_err());
assert_eq!(state(&s), before);
assert_eq!((s.doc().unwrap().path.as_deref(), s.doc().unwrap().is_dirty()), (None, true));
// Undo, then a failing step: the art and both history stacks are as before.
let undo = json!({"command": "edit.undo", "params": {}});
assert!(s.execute("command.batch", &json!({"commands": [undo, fail]})).is_err());
assert_eq!(state(&s), before);
// A batch during a drag: the drag is kept as a step, the batch is the next one.
exec(&mut s, "select.all", json!({}));
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
let moved = doc_json(&s.doc().unwrap().doc);
exec(&mut s, "command.batch", json!({"commands": [rect(100)]}));
assert!(!s.in_interaction());
assert_eq!(s.doc().unwrap().history.undo.len(), before.1 + 2);
exec(&mut s, "edit.undo", json!({}));
assert_eq!(doc_json(&s.doc().unwrap().doc), moved, "undoing the batch leaves the drag's move");
exec(&mut s, "edit.undo", json!({}));
assert_eq!(doc_json(&s.doc().unwrap().doc), before.0);
}
#[test]
fn empty_batch_is_harmless() {
let mut s = fixtures::session();
let before = doc_json(&s.doc().unwrap().doc);
let _ = s.execute("command.batch", &json!({"commands": []}));
assert!(!s.in_interaction());
assert_eq!(doc_json(&s.doc().unwrap().doc), before);
check_all(&mut s).unwrap();
}
#[test]
fn save_open_via_commands_roundtrips() {
let mut s = fixtures::rich_session();
let dir = vectorcraft_testkit::temp_dir("engine-save");
let path = dir.join("rich.vectorcraft");
exec(&mut s, "document.save", json!({"path": path.to_str().unwrap()}));
let want = doc_json(&s.doc().unwrap().doc);
exec(&mut s, "document.open", json!({"path": path.to_str().unwrap()}));
assert_eq!(s.documents().len(), 2);
let got = doc_json(&s.doc().unwrap().doc);
// Numbers to 1e-12 (bit-exactness is a known bug: known_bugs.rs::bug_native_roundtrip_not_bit_exact).
assert!(invariants::json_approx_eq(&got, &want, 1e-12), "{}", first_diff(&want, &got, "$"));
check_all(&mut s).unwrap();
}
#[test]
fn serialize_all_formats_on_rich_doc() {
let mut s = fixtures::rich_session();
for f in ["vectorcraft", "svg", "pdf", "png"] {
let v = exec(&mut s, "document.serialize", json!({"format": f}));
assert!(v.get("dataBase64").or(v.get("text")).is_some(), "{f}: {v}");
}
// The SVG re-imports with the same number of visible art objects or more (groups may nest).
let svg = exec(&mut s, "document.serialize", json!({"format": "svg"}));
let text = svg["text"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap());
let back = vectorcraft_svg::import(&text).unwrap();
invariants::check_document(&back).unwrap();
assert!(back.node_count() >= 5);
}