Files
vectorcraft/crates/engine/src/tests/history_document.rs
T
dotCooCoo 0ce3147aab History: the New Document button makes a document from the selected state
The History panel gets a New Document from State button at its bottom,
which runs the new edit.newDocumentFromState command. The command makes
a new document holding the art of a History panel row ({index?}, the
current state when omitted) and makes it the active document. An index
that names no state is an error and changes nothing.

The new document takes the next Untitled-N name, is marked unsaved, has
nothing selected, no steps to undo or redo and no modified date, and
leaves symbol, opacity-mask and pattern editing; the art drawn in a
pattern's tile is stored in the pattern, as Done stores it. Its links point at the files the
source finds, including files found in the source's folder away from the
links' paths. Those files are looked for only within the folders the
asking caller may read; for a caller without limits, within the folders
the caller that opened the source may read. The source
document, its steps and its saved state stay as they are; typing or a
drag in progress is first kept as a step, as Undo keeps it. While an IME
composes, the command waits, as Undo does.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-11-08 12:20:59 +07:00

362 lines
16 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.
//! History panel › New Document from State (`edit.newDocumentFromState`).
use std::sync::Arc;
use serde_json::{Value, json};
use super::{rect, session};
use crate::tests_links::{Folder, RED, place, png, write};
use crate::*;
const CMD: &str = "edit.newDocumentFromState";
fn objects(s: &Session) -> usize {
s.doc().unwrap().doc.layers[0].children().unwrap().len()
}
fn x(s: &Session, id: NodeId) -> f64 {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0
}
fn steps(s: &Session) -> (usize, usize) {
let h = &s.doc().unwrap().history;
(h.undo.len(), h.redo.len())
}
/// Three rectangles, the last one undone: rows 0 to 3 hold 0 to 3 of them, the current one row 2.
fn three_rectangles_one_undone() -> Session {
let mut s = session();
for x in [10.0, 200.0, 400.0] {
rect(&mut s, x, 10.0, 100.0, 50.0);
}
s.execute("edit.undo", &json!({})).unwrap();
s
}
/// Each row gives the art of its state, the steps to redo included. An omitted or null index gives
/// the current state. Each new document is added and made active.
#[test]
fn a_new_document_holds_the_art_of_the_state() {
let params = [
(json!({"index": 0}), 0),
(json!({"index": 1}), 1),
(json!({"index": 2}), 2),
(json!({"index": 3}), 3),
(json!({}), 2),
(json!({"index": null}), 2),
];
for (p, art) in params {
let mut s = three_rectangles_one_undone();
let r = s.execute(CMD, &p).unwrap();
assert_eq!((r["index"].as_u64(), s.active_index()), (Some(1), Some(1)), "{p}");
assert_eq!(objects(&s), art, "{p}");
assert_eq!(s.documents().len(), 2);
}
}
/// The source's document, steps, selection and saved state stay as they are, for a modified source
/// and for one at its saved state.
#[test]
fn the_source_stays_as_it_is() {
let mut s = three_rectangles_one_undone();
let source = s.doc().unwrap().doc.clone();
let selection = s.doc().unwrap().selection.clone();
assert!(s.doc().unwrap().is_dirty());
s.execute(CMD, &json!({"index": 0})).unwrap();
let st = &s.documents()[0];
assert!(Arc::ptr_eq(&st.doc, &source));
assert_eq!((st.history.undo.len(), st.history.redo.len()), (2, 1));
assert_eq!(st.selection, selection);
assert!(st.is_dirty());
let mut s = session();
rect(&mut s, 10.0, 10.0, 100.0, 50.0);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.doc().unwrap().is_dirty());
s.execute(CMD, &json!({"index": 1})).unwrap();
assert!(!s.documents()[0].is_dirty(), "the source stays at its saved state");
assert!(s.doc().unwrap().is_dirty(), "the new document is unsaved");
}
/// An index that names no state, or is not a whole number, is an error and changes nothing: no
/// document, no step and no Untitled number is used up.
#[test]
fn a_bad_index_is_an_error_and_changes_nothing() {
let mut s = three_rectangles_one_undone();
let source = s.doc().unwrap().doc.clone();
let untitled = s.peek_untitled();
let bad: [Value; 8] = [json!(4), json!(99), json!(-1), json!(1.5), json!("1"), json!(true), json!([1]), json!(u64::MAX)];
for index in bad {
let e = s.execute(CMD, &json!({ "index": index })).unwrap_err();
assert!(matches!(e, EngineError::BadParams { .. }), "{index}: {e}");
assert!(e.to_string().contains(&format!("no history state {index}")), "{e}");
assert_eq!(s.documents().len(), 1, "{index}");
assert_eq!(steps(&s), (2, 1), "{index}");
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &source), "{index}");
assert_eq!(s.peek_untitled(), untitled, "{index}");
}
let e = s.execute(CMD, &json!({"created": "today"})).unwrap_err();
assert!(matches!(e, EngineError::BadParams { .. }), "{e}");
assert_eq!(s.documents().len(), 1);
}
/// With no document the command is disabled.
#[test]
fn it_needs_a_document() {
let mut s = Session::new();
assert!(matches!(s.execute(CMD, &json!({})), Err(EngineError::Disabled(..))));
}
/// An edit in either document leaves the other as it is, and so does taking an artboard id.
#[test]
fn the_two_documents_are_independent() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
rect(&mut s, 200.0, 10.0, 100.0, 50.0);
s.execute(CMD, &json!({"index": 1})).unwrap();
s.execute("object.move", &json!({"ids": [a.0], "dx": 30, "dy": 0})).unwrap();
rect(&mut s, 400.0, 10.0, 100.0, 50.0);
assert_eq!((x(&s, a), objects(&s)), (40.0, 2));
s.execute("document.activate", &json!({"index": 0})).unwrap();
assert_eq!((x(&s, a), objects(&s)), (10.0, 2), "the source");
s.execute("object.move", &json!({"ids": [a.0], "dx": 5, "dy": 0})).unwrap();
assert_eq!(x(&s, a), 15.0);
let source_board = s.doc().unwrap().doc.next_artboard_id();
s.execute("document.activate", &json!({"index": 1})).unwrap();
assert_eq!((x(&s, a), objects(&s)), (40.0, 2), "the new document");
assert!(s.doc().unwrap().doc.retired_artboard_id.get() < source_board, "an artboard id the source took");
}
/// The new document starts with no steps, no selection, no path and the next untitled name, and is
/// unsaved. Undo and Redo then work in it alone, and undoing back to the start leaves it unsaved.
#[test]
fn undo_in_the_new_document() {
let mut s = three_rectangles_one_undone();
s.execute("select.all", &json!({})).unwrap();
let r = s.execute(CMD, &json!({"index": 1})).unwrap();
assert_eq!(r["title"], "Untitled-2");
let st = s.doc().unwrap();
assert_eq!(st.title(), "Untitled-2");
assert_eq!(st.path, None);
assert!(st.selection.objects.is_empty());
assert!(st.is_dirty());
let start = st.doc.clone();
assert_eq!(steps(&s), (0, 0));
assert!(matches!(s.execute("edit.undo", &json!({})), Err(EngineError::Disabled(..))), "the source's steps are not its own");
rect(&mut s, 600.0, 10.0, 100.0, 50.0);
assert_eq!(objects(&s), 2);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(objects(&s), 1);
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &start));
assert!(s.doc().unwrap().is_dirty(), "still unsaved at the state it started from");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(objects(&s), 2);
assert_eq!(s.documents()[0].history.undo.len(), 2, "the source's steps");
}
/// A drag in progress is first kept as a step and its change goes into the new document. An index
/// names the state as listed before that, and a bad one leaves the drag going on.
#[test]
fn a_drag_in_progress_is_kept_first() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 30, "dy": 0})).unwrap();
assert!(s.execute(CMD, &json!({"index": 2})).is_err());
assert!(s.in_interaction(), "a bad index ends nothing");
assert_eq!(steps(&s), (1, 0));
s.execute(CMD, &json!({"index": 0})).unwrap();
assert_eq!(objects(&s), 0, "the row listed as 0");
assert_eq!(x_of_source(&s, a), 40.0);
assert_eq!(s.documents()[0].history.undo.len(), 2, "the drag is a step in the source");
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 30, "dy": 0})).unwrap();
s.execute(CMD, &json!({})).unwrap();
assert_eq!(x(&s, a), 40.0, "the current state holds the drag");
assert_eq!(x_of_source(&s, a), 40.0);
assert!(s.documents()[0].interaction.is_none());
}
fn x_of_source(s: &Session, id: NodeId) -> f64 {
s.documents()[0].doc.node(id).unwrap().geometric_bounds().unwrap().x0
}
/// A state saved while editing a symbol gives a document out of that mode and without the
/// temporary editing layer. The source stays in the mode.
#[test]
fn a_new_document_leaves_the_editing_modes() {
let mut s = session();
rect(&mut s, 10.0, 10.0, 100.0, 50.0);
s.execute("symbol.new", &json!({"name": "Box"})).unwrap();
let layers = s.doc().unwrap().doc.layers.len();
s.execute("symbol.edit", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.symbol_edit.is_some());
assert_eq!(s.doc().unwrap().doc.layers.len(), layers + 1);
s.execute(CMD, &json!({})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.symbol_edit.is_none());
assert_eq!(st.doc.layers.len(), layers);
assert!(s.documents()[0].doc.symbol_edit.is_some());
}
/// A state saved while editing a pattern gives a document out of that mode, without the editing
/// layer, and with the art drawn in the tile stored in the pattern, as Done stores it. The source
/// stays in the mode.
#[test]
fn a_new_document_finishes_pattern_editing() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
let layers = s.doc().unwrap().doc.layers.len();
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
rect(&mut s, 2.0, 2.0, 3.0, 3.0);
assert_eq!(s.doc().unwrap().doc.layers.len(), layers + 1);
s.execute(CMD, &json!({})).unwrap();
assert_eq!(s.active_index(), Some(1), "the new document");
let st = s.doc().unwrap();
assert!(st.doc.pattern_edit.is_none());
assert_eq!(st.doc.layers.len(), layers);
assert_eq!(st.doc.pattern("P").map(|p| p.art.len()), Some(2));
assert_eq!(st.isolation, None);
assert!(s.documents()[0].doc.pattern_edit.is_some());
}
/// A linked image still finds its file from the new document, which has no folder of its own, and
/// its pixels come along.
#[test]
fn links_resolve_in_the_new_document() {
let dir = Folder::new("history-document-links");
let pic = dir.file("photo.png");
write(&pic, &png(60, 30, RED));
let mut s = session();
let id = place(&mut s, &pic);
let list = |s: &mut Session| s.execute("links.list", &json!({})).unwrap();
assert_eq!(list(&mut s)["links"][0]["status"], "ok");
s.execute(CMD, &json!({})).unwrap();
assert_eq!(s.active_index(), Some(1), "the new document");
let links = list(&mut s);
assert_eq!((&links["links"][0]["status"], &links["links"][0]["path"]), (&json!("ok"), &json!(pic)), "{links}");
let st = s.doc().unwrap();
assert!(st.doc.node(id).is_some());
assert_eq!(st.doc.images.len(), s.documents()[0].doc.images.len());
}
/// A linked file the source finds in its own folder, away from the link's path, is found from the
/// new document too: its link takes the path where the source finds the file.
#[test]
fn a_file_the_source_finds_in_its_folder_is_found_from_the_new_document() {
let a = Folder::new("history-document-moved-a");
let b = Folder::new("history-document-moved-b");
let pic = a.file("photo.png");
write(&pic, &png(60, 30, RED));
let mut s = session();
place(&mut s, &pic);
s.execute("document.save", &json!({"path": b.file("poster.vectorcraft")})).unwrap();
let moved = b.file("photo.png");
std::fs::rename(&pic, &moved).unwrap();
let check = |s: &mut Session| s.execute("links.check", &json!({})).unwrap();
assert_eq!(check(&mut s)["links"][0]["status"], "ok", "the source finds it by name in its folder");
s.execute(CMD, &json!({})).unwrap();
assert_eq!(s.active_index(), Some(1), "the new document");
let c = check(&mut s);
assert_eq!((&c["links"][0]["status"], &c["links"][0]["path"]), (&json!("ok"), &json!(moved)), "{c}");
}
/// The same holds for a linked image only a symbol shows.
#[test]
fn a_file_a_symbol_shows_is_found_from_the_new_document() {
let a = Folder::new("history-document-symbol-a");
let b = Folder::new("history-document-symbol-b");
let pic = a.file("photo.png");
write(&pic, &png(60, 30, RED));
let mut s = session();
place(&mut s, &pic);
s.execute("symbol.new", &json!({"name": "Photo"})).unwrap();
s.execute("document.save", &json!({"path": b.file("poster.vectorcraft")})).unwrap();
let moved = b.file("photo.png");
std::fs::rename(&pic, &moved).unwrap();
s.execute(CMD, &json!({})).unwrap();
assert_eq!(s.active_index(), Some(1), "the new document");
let d = &s.doc().unwrap().doc;
assert!(!d.symbols.is_empty());
let mut paths = vec![];
d.visit_images(|_, im| paths.extend(im.link.as_ref().map(|l| l.path.clone())));
assert!(!paths.is_empty() && paths.iter().all(|p| *p == moved), "{paths:?}");
}
/// Files are looked for only within the roots of the caller that opened the source, or of a
/// confined caller asking now: a file outside them is not linked from the new document.
#[test]
fn a_file_outside_the_callers_roots_is_not_looked_for() {
let a = Folder::new("history-document-confined-a");
let b = Folder::new("history-document-confined-b");
let other = Folder::new("history-document-confined-other");
let pic = a.file("photo.png");
write(&pic, &png(60, 30, RED));
let mut s = session();
place(&mut s, &pic);
s.execute("document.save", &json!({"path": b.file("poster.vectorcraft")})).unwrap();
let moved = b.file("photo.png");
std::fs::rename(&pic, &moved).unwrap();
let narrow = crate::file_access::AutomationRoots::new(&[&other.0], &[]).unwrap().unwrap();
let link = |s: &Session| {
let mut paths = vec![];
s.doc().unwrap().doc.visit_images(|_, im| paths.extend(im.link.as_ref().map(|l| l.path.clone())));
paths
};
crate::file_access::confine(Some(&narrow), || s.execute(CMD, &json!({}))).unwrap();
assert_eq!(s.active_index(), Some(1), "the new document");
assert!(link(&s).iter().all(|p| *p != moved), "a confined caller: {:?}", link(&s));
s.execute("document.activate", &json!({"index": 0})).unwrap();
s.active_mut().unwrap().opened_by = Some(narrow);
s.execute(CMD, &json!({})).unwrap();
assert_eq!(s.active_index(), Some(2), "the second new document");
let paths = link(&s);
assert!(!paths.is_empty() && paths.iter().all(|p| *p != moved), "opened by a confined caller: {paths:?}");
}
/// What reading the source's file left out still guards that file from the new document, and the
/// new document is read within what the caller that opened the source may read.
#[test]
fn the_new_document_keeps_what_guards_the_source() {
let dir = Folder::new("history-document-roots");
let roots = crate::file_access::AutomationRoots::new(&[&dir.0], &[]).unwrap().unwrap();
let mut s = session();
let st = s.active_mut().unwrap();
st.imported_from = Some("art.ai".into());
st.import_losses = vec!["hidden text".into()];
st.opened_by = Some(roots.clone());
s.execute(CMD, &json!({})).unwrap();
assert_eq!(s.active_index(), Some(1), "the new document");
let st = s.doc().unwrap();
assert_eq!((st.imported_from.as_deref(), st.import_losses.as_slice()), (Some("art.ai"), &["hidden text".to_string()][..]));
assert!(st.opened_by.as_ref().is_some_and(|r| Arc::ptr_eq(r, &roots)));
}
/// The created date is the new document's own and joins the journal entry, so a replay gives the
/// same date. An action leaves it out. The new document has no modified date until it is saved.
#[test]
fn the_created_date_is_the_new_documents_own() {
let mut s = session();
Arc::make_mut(&mut s.active_mut().unwrap().doc).metadata.modified = Some(500);
let source = s.doc().unwrap().doc.metadata.created;
s.execute(CMD, &json!({"created": 1000})).unwrap();
assert_eq!(s.doc().unwrap().doc.metadata.created, Some(1000));
assert_eq!(s.doc().unwrap().doc.metadata.modified, None, "the new document has not been saved");
assert_eq!(s.documents()[0].doc.metadata.modified, Some(500));
assert_eq!(s.documents()[0].doc.metadata.created, source);
s.execute("document.activate", &json!({"index": 0})).unwrap();
s.execute(CMD, &json!({"created": null})).unwrap();
assert_eq!(s.doc().unwrap().doc.metadata.created, None);
s.execute("document.activate", &json!({"index": 0})).unwrap();
s.execute(CMD, &json!({})).unwrap();
let noted = s.journal.last().unwrap().1["created"].as_i64();
assert!(noted.is_some() && noted == s.doc().unwrap().doc.metadata.created, "{noted:?}");
let mut action = s.journal.last().unwrap().1.clone();
crate::command_sets::strip_replay_only(CMD, &mut action);
assert_eq!(action, json!({}), "an action leaves the date out");
}