//! 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"); }