mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 20:36:55 -06:00
SaveFiles named each test's folder after the process id and the clock in nanoseconds. The tests run in parallel and the macOS clock counts microseconds, so two of them often got the same folder and one failed with AlreadyExists (14 of 30 runs here). A counter now keeps the names apart.
411 lines
19 KiB
Rust
411 lines
19 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 {
|
|
// Tests run in parallel and the clock can read the same for two of them (macOS counts
|
|
// microseconds), so a counter keeps their folders apart.
|
|
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
|
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
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}-{n}", 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);
|
|
}
|
|
}
|