Files
effectcraft/crates/engine/src/tests_history.rs
T
2026-10-03 04:50:59 -04:00

136 lines
5.5 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);
}