mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 11:26:50 -06:00
407 lines
18 KiB
Rust
407 lines
18 KiB
Rust
//! Save As can write an experimental `.fig` copy without replacing native save state.
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use figcraft_format::Version;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::{DocState, EngineError, Session};
|
|
|
|
struct SaveFiles {
|
|
dir: PathBuf,
|
|
files: Vec<PathBuf>,
|
|
}
|
|
|
|
impl SaveFiles {
|
|
fn new() -> Self {
|
|
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
|
let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}", std::process::id()));
|
|
std::fs::create_dir(&dir).unwrap();
|
|
Self { dir, files: Vec::new() }
|
|
}
|
|
|
|
fn path(&mut self, name: &str) -> PathBuf {
|
|
let path = self.dir.join(name);
|
|
self.files.push(path.clone());
|
|
path
|
|
}
|
|
}
|
|
|
|
impl Drop for SaveFiles {
|
|
fn drop(&mut self) {
|
|
for path in &self.files {
|
|
let _ = std::fs::remove_file(path);
|
|
let _ = std::fs::remove_dir(path);
|
|
}
|
|
let _ = std::fs::remove_dir(&self.dir);
|
|
}
|
|
}
|
|
|
|
fn rect(s: &mut Session, name: &str) {
|
|
s.execute("shape.rectangle", &json!({"name": name, "x": 10, "y": 20, "width": 30, "height": 40})).unwrap();
|
|
}
|
|
|
|
fn assert_versions_unchanged(after: &[Version], before: &[Version]) {
|
|
assert_eq!(after.len(), before.len());
|
|
for (after, before) in after.iter().zip(before) {
|
|
assert_eq!(
|
|
(&after.id, &after.name, &after.description, after.time, after.auto),
|
|
(&before.id, &before.name, &before.description, before.time, before.auto)
|
|
);
|
|
assert_eq!(*after.document().unwrap(), *before.document().unwrap());
|
|
}
|
|
}
|
|
|
|
fn assert_save_state_unchanged(s: &Session, before: &DocState, recent: &[String]) {
|
|
let after = s.ds().unwrap();
|
|
assert_eq!(after.path, before.path);
|
|
assert_eq!(after.is_dirty(), before.is_dirty());
|
|
assert_eq!(after.versions_changed, before.versions_changed);
|
|
assert!(Arc::ptr_eq(&after.doc, &before.doc));
|
|
assert!(Arc::ptr_eq(&after.saved_doc, &before.saved_doc));
|
|
assert_eq!(
|
|
after.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page)),
|
|
before.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page))
|
|
);
|
|
if let (Some(after), Some(before)) = (&after.preview, &before.preview) {
|
|
assert!(Arc::ptr_eq(&after.doc, &before.doc));
|
|
}
|
|
assert_eq!((&after.selection, after.page), (&before.selection, before.page));
|
|
assert_versions_unchanged(&after.versions, &before.versions);
|
|
assert_eq!(after.branches.len(), before.branches.len());
|
|
for (after, before) in after.branches.iter().zip(&before.branches) {
|
|
assert_eq!(
|
|
(&after.id, &after.name, after.time, after.client, after.state),
|
|
(&before.id, &before.name, before.time, before.client, before.state)
|
|
);
|
|
assert_eq!(*after.base, *before.base);
|
|
assert_eq!(*after.head, *before.head);
|
|
assert_versions_unchanged(&after.versions, &before.versions);
|
|
}
|
|
for (after, before) in [(&after.history.undo, &before.history.undo), (&after.history.redo, &before.history.redo)] {
|
|
assert_eq!(after.len(), before.len());
|
|
for (after, before) in after.iter().zip(before) {
|
|
assert_eq!((&after.label, &after.selection, after.page), (&before.label, &before.selection, before.page));
|
|
assert!(Arc::ptr_eq(&after.doc, &before.doc));
|
|
}
|
|
}
|
|
assert_eq!(s.prefs.recent_files, recent);
|
|
}
|
|
|
|
#[test]
|
|
fn first_native_save_uses_chosen_filename_for_an_untitled_document() {
|
|
let mut files = SaveFiles::new();
|
|
let path = files.path("Redis.figcraft");
|
|
let copy = files.path("Redis copy.figcraft");
|
|
let manual_copy = files.path("Other.figcraft");
|
|
let mut s = Session::new();
|
|
s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
|
|
assert_eq!(s.ds().unwrap().doc.name, "Redis");
|
|
assert!(!s.ds().unwrap().is_dirty());
|
|
assert_eq!(figcraft_format::load_bytes(&std::fs::read(&path).unwrap()).unwrap().0.name, "Redis");
|
|
|
|
let mut reopened = Session::empty();
|
|
reopened.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
|
assert_eq!(reopened.ds().unwrap().doc.name, "Redis");
|
|
|
|
s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
|
|
assert_eq!(s.ds().unwrap().doc.name, "Redis copy");
|
|
s.execute("file.rename", &json!({"name": "Chosen title"})).unwrap();
|
|
s.execute("file.saveAs", &json!({"path": manual_copy.to_string_lossy()})).unwrap();
|
|
assert_eq!(s.ds().unwrap().doc.name, "Chosen title");
|
|
}
|
|
|
|
#[test]
|
|
fn opening_an_older_file_uses_its_filename_only_for_the_default_title() {
|
|
let mut files = SaveFiles::new();
|
|
let old_path = files.path("Redis.figcraft");
|
|
let named_path = files.path("Other.figcraft");
|
|
let mut original = Session::new();
|
|
std::fs::write(&old_path, crate::cmd::file::file_bytes(original.ds().unwrap()).unwrap()).unwrap();
|
|
original.execute("file.rename", &json!({"name": "Chosen title"})).unwrap();
|
|
std::fs::write(&named_path, crate::cmd::file::file_bytes(original.ds().unwrap()).unwrap()).unwrap();
|
|
|
|
let mut reopened = Session::empty();
|
|
reopened.execute("file.open", &json!({"path": old_path.to_string_lossy()})).unwrap();
|
|
assert_eq!(reopened.ds().unwrap().doc.name, "Redis");
|
|
reopened.execute("file.open", &json!({"path": named_path.to_string_lossy()})).unwrap();
|
|
assert_eq!(reopened.ds().unwrap().doc.name, "Chosen title");
|
|
}
|
|
|
|
fn read_fig(path: &std::path::Path, result: &Value) -> figcraft_doc::Document {
|
|
let bytes = std::fs::read(path).unwrap();
|
|
assert_eq!(result["format"], "fig");
|
|
assert_eq!(result["bytes"], bytes.len());
|
|
assert!(result["warnings"].is_array());
|
|
assert!(figcraft_format::load_bytes(&bytes).is_err(), "a .fig copy must not contain native FigCraft data");
|
|
figcraft_format::fig::import(&bytes, "Copy").unwrap().0
|
|
}
|
|
|
|
#[test]
|
|
fn fig_save_as_keeps_native_path_dirty_state_and_history() {
|
|
let mut files = SaveFiles::new();
|
|
let native = files.path("design.figcraft");
|
|
let copy = files.path("copy.fig");
|
|
let mut s = Session::new();
|
|
rect(&mut s, "Saved rectangle");
|
|
s.execute("file.saveAs", &json!({"path": native.to_string_lossy()})).unwrap();
|
|
let saved = std::fs::read(&native).unwrap();
|
|
rect(&mut s, "New rectangle");
|
|
s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Copy warning"})).unwrap();
|
|
s.execute("file.saveVersion", &json!({"name": "Before copy"})).unwrap();
|
|
rect(&mut s, "Undone rectangle");
|
|
s.undo().unwrap();
|
|
let before = s.ds().unwrap().clone();
|
|
let recent = s.prefs.recent_files.clone();
|
|
assert!(before.is_dirty());
|
|
assert!(!before.history.redo.is_empty());
|
|
|
|
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
|
|
let imported = read_fig(©, &result);
|
|
assert!(imported.nodes().any(|n| n.name == "New rectangle"));
|
|
assert!(!imported.nodes().any(|n| n.name == "Undone rectangle"));
|
|
assert!(result["warnings"].as_array().unwrap().iter().any(|w| w == "comments were left out"));
|
|
assert_save_state_unchanged(&s, &before, &recent);
|
|
assert_eq!(std::fs::read(&native).unwrap(), saved);
|
|
|
|
s.execute("file.save", &json!({})).unwrap();
|
|
assert!(!s.ds().unwrap().is_dirty());
|
|
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
|
|
let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
|
|
assert!(persisted.doc.nodes().any(|n| n.name == "New rectangle"));
|
|
assert_eq!(persisted.versions[0].name, "Before copy");
|
|
assert_eq!(std::fs::read(©).unwrap().len(), result["bytes"].as_u64().unwrap() as usize);
|
|
}
|
|
|
|
#[test]
|
|
fn unsaved_fig_copy_remains_unsaved() {
|
|
let mut files = SaveFiles::new();
|
|
let copy = files.path("unsaved.fig");
|
|
let mut s = Session::new();
|
|
rect(&mut s, "Unsaved rectangle");
|
|
let before = s.ds().unwrap().clone();
|
|
let recent = s.prefs.recent_files.clone();
|
|
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
|
|
assert!(read_fig(©, &result).nodes().any(|n| n.name == "Unsaved rectangle"));
|
|
assert_save_state_unchanged(&s, &before, &recent);
|
|
assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..))));
|
|
assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs");
|
|
}
|
|
|
|
#[test]
|
|
fn empty_document_fig_copy_keeps_clean_state() {
|
|
let mut files = SaveFiles::new();
|
|
let copy = files.path("empty.fig");
|
|
let mut s = Session::new();
|
|
let before = s.ds().unwrap().clone();
|
|
assert!(!before.is_dirty());
|
|
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
|
|
let imported = read_fig(©, &result);
|
|
assert_eq!(imported.pages.len(), 1);
|
|
assert_eq!(imported.node_count(), 1);
|
|
assert_save_state_unchanged(&s, &before, &[]);
|
|
}
|
|
|
|
#[test]
|
|
fn fig_save_commands_accept_uppercase_extension() {
|
|
let mut files = SaveFiles::new();
|
|
for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() {
|
|
let copy = files.path(&format!("copy-{index}.FIG"));
|
|
let mut s = Session::new();
|
|
rect(&mut s, command);
|
|
let before = s.ds().unwrap().clone();
|
|
let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap();
|
|
assert!(read_fig(©, &result).nodes().any(|n| n.name == command));
|
|
assert_save_state_unchanged(&s, &before, &[]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fig_copy_from_branch_exports_active_document_without_saving_main() {
|
|
let mut files = SaveFiles::new();
|
|
let native = files.path("main.figcraft");
|
|
let copy = files.path("branch.fig");
|
|
let mut s = Session::new();
|
|
rect(&mut s, "Main rectangle");
|
|
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
|
|
let saved = std::fs::read(&native).unwrap();
|
|
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
|
|
rect(&mut s, "Branch rectangle");
|
|
s.execute("file.saveVersion", &json!({"name": "Branch version"})).unwrap();
|
|
// Branch edits normally sync to main before commands; capture that state before exporting.
|
|
s.execute("branch.list", &json!({})).unwrap();
|
|
let branch = s.ds().unwrap().clone();
|
|
let main = s.docs[0].clone();
|
|
let recent = s.prefs.recent_files.clone();
|
|
|
|
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
|
|
assert!(read_fig(©, &result).nodes().any(|n| n.name == "Branch rectangle"));
|
|
assert_save_state_unchanged(&s, &branch, &recent);
|
|
assert_eq!(s.docs[0].path, main.path);
|
|
assert_eq!(s.docs[0].is_dirty(), main.is_dirty());
|
|
assert!(Arc::ptr_eq(&s.docs[0].saved_doc, &main.saved_doc));
|
|
assert!(Arc::ptr_eq(&s.docs[0].doc, &main.doc));
|
|
assert_eq!(std::fs::read(&native).unwrap(), saved);
|
|
|
|
s.execute("file.save", &json!({})).unwrap();
|
|
let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
|
|
assert!(!persisted.doc.nodes().any(|n| n.name == "Branch rectangle"));
|
|
assert!(persisted.branches[0].head.nodes().any(|n| n.name == "Branch rectangle"));
|
|
assert_eq!(persisted.branches[0].versions[0].name, "Branch version");
|
|
assert!(!s.ds().unwrap().is_dirty());
|
|
assert!(!s.docs[0].is_dirty());
|
|
}
|
|
|
|
#[test]
|
|
fn fig_copy_write_failure_keeps_save_state() {
|
|
let mut files = SaveFiles::new();
|
|
let blocked = files.path("blocked.fig");
|
|
std::fs::create_dir(&blocked).unwrap();
|
|
let missing = files.path("missing/copy.fig");
|
|
let mut s = Session::new();
|
|
rect(&mut s, "Kept rectangle");
|
|
s.execute("file.saveVersion", &json!({"name": "Kept version"})).unwrap();
|
|
rect(&mut s, "Later rectangle");
|
|
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
|
let before = s.ds().unwrap().clone();
|
|
for command in ["file.save", "file.saveAs"] {
|
|
for path in [&blocked, &missing] {
|
|
assert!(s.execute(command, &json!({"path": path.to_string_lossy()})).is_err());
|
|
assert_save_state_unchanged(&s, &before, &[]);
|
|
assert!(!PathBuf::from(format!("{}.tmp-figcraft", path.display())).exists());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn save_as_without_extension_stays_native() {
|
|
let mut files = SaveFiles::new();
|
|
let requested = files.dir.join("native");
|
|
let native = files.path("native.figcraft");
|
|
let mut s = Session::new();
|
|
rect(&mut s, "Native rectangle");
|
|
let result = s.execute("file.saveAs", &json!({"path": requested.to_string_lossy()})).unwrap();
|
|
assert_eq!(result["path"], native.to_string_lossy().as_ref());
|
|
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
|
|
assert!(!s.ds().unwrap().is_dirty());
|
|
assert!(!requested.exists());
|
|
let loaded = figcraft_format::load_bytes(&std::fs::read(native).unwrap()).unwrap();
|
|
assert!(loaded.0.nodes().any(|n| n.name == "Native rectangle"));
|
|
}
|
|
|
|
#[test]
|
|
fn fig_copy_of_version_preview_keeps_visible_version_and_save_state() {
|
|
let mut files = SaveFiles::new();
|
|
for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() {
|
|
let native = files.path(&format!("current-{index}.figcraft"));
|
|
let copy = files.path(&format!("version-{index}.FIG"));
|
|
let mut s = Session::new();
|
|
rect(&mut s, "A");
|
|
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
|
|
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
|
|
let saved = std::fs::read(&native).unwrap();
|
|
rect(&mut s, "B");
|
|
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
|
let before = s.ds().unwrap().clone();
|
|
let recent = s.prefs.recent_files.clone();
|
|
assert!(before.preview.is_some());
|
|
assert!(before.is_dirty());
|
|
|
|
let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap();
|
|
let imported = read_fig(©, &result);
|
|
assert!(imported.nodes().any(|n| n.name == "A"));
|
|
assert!(!imported.nodes().any(|n| n.name == "B"), "the copy must export the version shown");
|
|
assert_save_state_unchanged(&s, &before, &recent);
|
|
assert_eq!(std::fs::read(&native).unwrap(), saved);
|
|
s.execute("version.exitPreview", &json!({})).unwrap();
|
|
assert!(s.doc().unwrap().nodes().any(|n| n.name == "B"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pending_save_choice_keeps_version_preview() {
|
|
let mut files = SaveFiles::new();
|
|
for (index, (command, native_path)) in [("file.saveAs", false), ("file.saveAs", true), ("file.save", false)].into_iter().enumerate() {
|
|
let mut s = Session::new();
|
|
rect(&mut s, "A");
|
|
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
|
|
if native_path {
|
|
let native = files.path(&format!("saved-{index}.figcraft"));
|
|
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
|
|
}
|
|
rect(&mut s, "B");
|
|
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
|
let before = s.ds().unwrap().clone();
|
|
let recent = s.prefs.recent_files.clone();
|
|
assert!(matches!(s.execute(command, &json!({})), Err(EngineError::Disabled(..))));
|
|
assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs");
|
|
assert_save_state_unchanged(&s, &before, &recent);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn native_save_from_version_preview_writes_current_document() {
|
|
let mut files = SaveFiles::new();
|
|
for (index, (command, explicit)) in [("file.save", false), ("file.save", true), ("file.saveAs", true)].into_iter().enumerate() {
|
|
let native = files.path(&format!("native-version-{index}.figcraft"));
|
|
let mut s = Session::new();
|
|
rect(&mut s, "A");
|
|
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
|
|
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
|
|
rect(&mut s, "B");
|
|
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
|
let undo = s.ds().unwrap().history.undo.len();
|
|
let params = if explicit { json!({"path": native.to_string_lossy()}) } else { json!({}) };
|
|
s.execute(command, ¶ms).unwrap();
|
|
assert!(s.ds().unwrap().preview.is_none());
|
|
assert!(!s.ds().unwrap().is_dirty());
|
|
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
|
|
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
|
|
let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
|
|
assert!(loaded.doc.nodes().any(|n| n.name == "A"));
|
|
assert!(loaded.doc.nodes().any(|n| n.name == "B"));
|
|
assert!(!loaded.versions[0].document().unwrap().nodes().any(|n| n.name == "B"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn implicit_save_protects_legacy_fig_destination() {
|
|
let mut files = SaveFiles::new();
|
|
for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() {
|
|
let legacy = files.path(&format!("legacy-{index}.FIG"));
|
|
let native = files.path(&format!("corrected-{index}.figcraft"));
|
|
let mut s = Session::new();
|
|
rect(&mut s, "A");
|
|
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
|
|
// Earlier builds could write native data under .fig; keep those bytes safe on plain Save.
|
|
let sentinel = crate::cmd::file::file_bytes(s.ds().unwrap()).unwrap();
|
|
std::fs::write(&legacy, &sentinel).unwrap();
|
|
s.ds_mut().unwrap().path = Some(legacy.to_string_lossy().into_owned());
|
|
s.ds_mut().unwrap().mark_saved();
|
|
rect(&mut s, "B");
|
|
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
|
let before = s.ds().unwrap().clone();
|
|
let recent = s.prefs.recent_files.clone();
|
|
|
|
assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..))));
|
|
assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs");
|
|
assert_save_state_unchanged(&s, &before, &recent);
|
|
assert_eq!(std::fs::read(&legacy).unwrap(), sentinel);
|
|
|
|
s.execute(command, &json!({"path": native.to_string_lossy()})).unwrap();
|
|
assert!(s.ds().unwrap().preview.is_none());
|
|
assert!(!s.ds().unwrap().is_dirty());
|
|
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
|
|
let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
|
|
assert!(loaded.doc.nodes().any(|n| n.name == "A"));
|
|
assert!(loaded.doc.nodes().any(|n| n.name == "B"));
|
|
assert_eq!(loaded.versions[0].name, "A only");
|
|
rect(&mut s, "C");
|
|
s.execute("file.save", &json!({})).unwrap();
|
|
let loaded = figcraft_format::load_bytes(&std::fs::read(&native).unwrap()).unwrap();
|
|
assert!(loaded.0.nodes().any(|n| n.name == "C"));
|
|
assert_eq!(std::fs::read(&legacy).unwrap(), sentinel);
|
|
}
|
|
}
|