Files
effectcraft/crates/engine/src/tests_history.rs
T
bflatasticandClaude Opus 5.5 a12d4ae1fa M3.9: a browser session restored with unsaved changes stays modified
The web app reopens the last visit's project and, when it had unsaved changes, marked it
modified with `bump()`. Since M3.9 the modified mark follows the saved snapshot, and a bump on
the very project just opened counts as "back at the saved state", so restored changes looked
saved (no `*`, no unsaved-changes prompt). `Session::mark_unsaved` forgets the saved snapshot
instead: the project stays modified until saved, whatever is undone. Regression test included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:33:40 +07:00

193 lines
8.1 KiB
Rust

//! Branching history: undo then a new step keeps the undone line as a branch; the tree lists
//! every state; jumping to any state (on any branch) restores it and keeps everything else.
use serde_json::{Value, json};
use crate::Session;
fn names(s: &Session) -> Vec<String> {
s.project.comps().next().map(|(_, c)| c.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default()
}
fn solid(s: &mut Session, name: &str) {
s.execute("layer.newSolid", json!({"name": name, "color": "#ff0000", "width": 10, "height": 10})).unwrap();
}
fn list(s: &mut Session) -> Vec<Value> {
s.execute("edit.history.list", json!({})).unwrap()["states"].as_array().cloned().unwrap()
}
fn index_of(states: &[Value], label: &str, nth: usize) -> u64 {
states.iter().filter(|n| n["label"] == label).nth(nth).unwrap_or_else(|| panic!("no state {label} in {states:?}"))["index"].as_u64().unwrap()
}
#[test]
fn undo_then_new_step_keeps_a_branch() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "H", "width": 64, "height": 64, "duration": 2})).unwrap();
solid(&mut s, "A");
solid(&mut s, "B");
solid(&mut s, "C");
// Linear so far: Original, New Composition, 3 solids.
let st = list(&mut s);
assert_eq!(st.len(), 5, "{st:?}");
assert!(st.iter().all(|n| n["depth"] == 0));
assert_eq!(st.last().unwrap()["current"], true);
// Undo twice (B and C undone), then a new step: AE would forget B and C.
assert!(s.undo() && s.undo());
let st = list(&mut s);
assert_eq!(st.iter().filter(|n| n["future"] == true).count(), 2);
solid(&mut s, "D");
assert!(s.history.redo.is_empty(), "the new step clears Redo, as in After Effects");
assert_eq!(s.history.branches.len(), 1);
assert_eq!(names(&s), ["D", "A"]);
let st = list(&mut s);
assert_eq!(st.len(), 6, "the undone states are still listed: {st:?}");
let branch: Vec<&Value> = st.iter().filter(|n| n["depth"] == 1).collect();
assert_eq!(branch.len(), 2, "B and C sit on a branch: {st:?}");
// Jump to C on the old branch.
let c = st.iter().rfind(|n| n["depth"] == 1).unwrap()["index"].as_u64().unwrap();
let r = s.execute("edit.history.goto", json!({"index": c})).unwrap();
assert_eq!(r["label"], "New Solid", "{r}");
assert_eq!(names(&s), ["C", "B", "A"]);
// Now D is the branch, and Undo walks the B/C line.
assert_eq!(s.history.branches.len(), 1);
assert_eq!(s.history.branches[0].steps.len(), 1);
assert!(s.undo());
assert_eq!(names(&s), ["B", "A"]);
assert!(s.redo());
// ...and back to D by id.
let st = list(&mut s);
let d = st.iter().find(|n| n["depth"] == 1).unwrap()["id"].as_str().unwrap().to_string();
s.execute("edit.history.goto", json!({"id": d})).unwrap();
assert_eq!(names(&s), ["D", "A"]);
let st = list(&mut s);
assert_eq!(st.len(), 6);
assert_eq!(st.iter().filter(|n| n["current"] == true).count(), 1);
// The original state.
let root = index_of(&st, "Original", 0);
s.execute("edit.history.goto", json!({"index": root})).unwrap();
assert!(s.project.comps().next().is_none());
assert!(s.history.undo.is_empty());
assert_eq!(list(&mut s).len(), 6, "nothing is lost by jumping");
// Relative steps along the line.
s.execute("edit.history.goto", json!({"steps": 2})).unwrap();
assert_eq!(names(&s), ["A"]);
assert!(s.execute("edit.history.goto", json!({"id": "nope"})).is_err());
assert!(s.execute("edit.history.goto", json!({})).is_err());
}
#[test]
fn branches_of_branches_and_purge() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "H", "width": 64, "height": 64, "duration": 2})).unwrap();
solid(&mut s, "A");
solid(&mut s, "B");
s.undo();
solid(&mut s, "C"); // branch 1: B
s.undo();
solid(&mut s, "D"); // branch 2: C
let st = list(&mut s);
assert_eq!(st.len(), 6, "{st:?}");
// Go to B, undo to A, make E: D's line becomes a branch, and a branch of the new line.
let b = st.iter().find(|n| n["depth"] == 1 && n["label"] == "New Solid").unwrap()["index"].as_u64().unwrap();
s.execute("edit.history.goto", json!({"index": b})).unwrap();
let which = names(&s);
assert!(which == ["B", "A"] || which == ["C", "A"], "{which:?}");
s.undo();
solid(&mut s, "E");
let st = list(&mut s);
assert_eq!(st.len(), 7, "{st:?}");
// Every state is reachable and restores its own layers.
let mut seen = std::collections::BTreeSet::new();
for i in 0..st.len() {
s.execute("edit.history.goto", json!({"index": i})).unwrap();
seen.insert(names(&s).join(","));
assert_eq!(list(&mut s).len(), 7);
}
for want in ["", "A", "B,A", "C,A", "D,A", "E,A"] {
assert!(seen.contains(want), "{want:?} not reachable: {seen:?}");
}
s.execute("edit.purgeUndo", json!({})).unwrap();
assert_eq!(list(&mut s).len(), 1);
}
#[test]
fn undo_levels_trim_branches() {
let mut s = Session::default();
s.prefs.general.undo_levels = 3;
s.execute("comp.new", json!({"name": "H", "width": 64, "height": 64, "duration": 2})).unwrap();
for n in ["A", "B", "C", "D", "E"] {
solid(&mut s, n);
}
assert_eq!(s.history.undo.len(), 3);
s.undo();
s.undo();
solid(&mut s, "F");
// The branch (D, E) grew from a state still kept.
assert_eq!(s.history.branch_states(), 2);
for n in ["G", "H", "I"] {
solid(&mut s, n);
}
// Its parent fell off the end of the undo levels: the branch goes too.
assert_eq!(s.history.branch_states(), 0);
assert_eq!(list(&mut s).len(), 4);
}
#[test]
fn modified_mark_follows_undo_and_no_op_edits_record_nothing() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "H", "width": 64, "height": 64, "duration": 2})).unwrap();
solid(&mut s, "A");
let path = std::env::temp_dir().join(format!("ec-dirty-{}.ecproj", std::process::id()));
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
assert!(!s.is_dirty());
// Setting a value to what it already is: no undo step, still saved.
let steps = s.history.undo.len();
s.execute("prop.set", json!({"layer": "A", "path": "transform/opacity", "value": 100})).unwrap();
assert_eq!(s.history.undo.len(), steps);
assert!(!s.is_dirty());
// A real change, then undo back to the saved state: not modified; redo: modified again.
s.execute("prop.set", json!({"layer": "A", "path": "transform/opacity", "value": 50})).unwrap();
assert!(s.is_dirty());
s.execute("edit.undo", json!({})).unwrap();
assert!(!s.is_dirty(), "undo back to the saved state");
s.execute("edit.redo", json!({})).unwrap();
assert!(s.is_dirty());
// History jumps: the saved state is the parent of the current (changed) one.
let states = list(&mut s);
let cur = states.iter().find(|n| n["current"] == true).unwrap();
let (changed, saved) = (cur["index"].as_u64().unwrap(), cur["parent"].as_u64().unwrap());
s.execute("edit.history.goto", json!({"index": saved})).unwrap();
assert!(!s.is_dirty(), "jumped to the saved state");
s.execute("edit.history.goto", json!({"index": changed})).unwrap();
assert!(s.is_dirty());
s.execute("edit.undo", json!({})).unwrap();
assert!(!s.is_dirty());
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "prop.set", "params": {"layer": "A", "path": "transform/opacity", "value": 10}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert!(!s.is_dirty(), "rolled back");
let _ = std::fs::remove_file(path);
}
#[test]
fn a_session_restored_with_unsaved_changes_stays_modified() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "H", "width": 64, "height": 64, "duration": 2})).unwrap();
let p = (*s.project).clone();
// The web app reopens the last visit's project, which had unsaved changes.
s.replace_project(p, None);
assert!(!s.is_dirty());
s.mark_unsaved();
assert!(s.is_dirty());
solid(&mut s, "A");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.is_dirty(), "undo doesn't reach a saved state");
}