mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 21:57:12 -06:00
Each copy is saved with images embedded and artboards together, so a document last saved with linked SVG images writes no image files beside its copy; any other file a save reports is reserved for the copies still to come.
243 lines
12 KiB
Rust
243 lines
12 KiB
Rust
//! Document Tools' commands: the timestamped copy's name and place with the document left as it
|
|
//! was, symbols made of layers, and bad params refused.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_doc::{NodeId, NodeKind};
|
|
|
|
use crate::Session;
|
|
|
|
/// 2026-10-05 14:03:00 UTC.
|
|
const TIME: i64 = 1_791_208_980;
|
|
|
|
/// A new, empty folder of its own for test `name`.
|
|
fn folder(name: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("vc-addon-document-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
fn session() -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
|
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
|
s
|
|
}
|
|
|
|
fn stamp(s: &mut Session, p: Value) -> crate::Result<Value> {
|
|
s.execute("addon.document.saveTimestampedCopy", &p)
|
|
}
|
|
|
|
#[test]
|
|
fn a_timestamped_copy_goes_next_to_the_document_which_stays_as_it_was() {
|
|
let dir = folder("next-to");
|
|
let mut s = session();
|
|
let own = dir.join("poster.vectorcraft");
|
|
s.execute("document.save", &json!({"path": own})).unwrap();
|
|
s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
|
let (steps, title) = (s.doc().unwrap().history.undo.len(), s.doc().unwrap().doc.title.clone());
|
|
let r = stamp(&mut s, json!({"time": TIME})).unwrap();
|
|
let copy = dir.join("poster_2026-10-05_14-03.vectorcraft");
|
|
assert_eq!(Path::new(r["path"].as_str().unwrap()), copy);
|
|
assert!(copy.is_file());
|
|
let st = s.doc().unwrap();
|
|
assert_eq!(st.path.as_deref().map(Path::new), Some(own.as_path()), "the document keeps its path");
|
|
assert_eq!((st.history.undo.len(), &st.doc.title, st.is_dirty()), (steps, &title, true), "no step; still modified");
|
|
// The copy holds the document as it is now: the ellipse too.
|
|
let mut other = Session::new();
|
|
other.execute("document.open", &json!({"path": copy})).unwrap();
|
|
assert_eq!(other.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
|
|
// The same minute again: numbered.
|
|
let r = stamp(&mut s, json!({"time": TIME})).unwrap();
|
|
assert!(r["path"].as_str().unwrap().ends_with("poster_2026-10-05_14-03-2.vectorcraft"), "{r}");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unsaved_document_needs_a_folder_and_other_formats_take_their_extension() {
|
|
let dir = folder("unsaved");
|
|
let mut s = session();
|
|
let before = s.doc().unwrap().doc.clone();
|
|
assert!(stamp(&mut s, json!({"time": TIME})).is_err(), "no folder to put it in");
|
|
let r = stamp(&mut s, json!({"time": TIME, "folder": dir, "format": "svg"})).unwrap();
|
|
let name = Path::new(r["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().into_owned();
|
|
assert!(name.ends_with("_2026-10-05_14-03.svg") && dir.join(&name).is_file(), "{name}");
|
|
let st = s.doc().unwrap();
|
|
assert!(st.path.is_none() && *st.doc == *before, "the document is unchanged");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn bad_params_are_refused_and_writes_stay_inside_the_automation_roots() {
|
|
let dir = folder("bad");
|
|
let mut s = session();
|
|
for p in [
|
|
json!({"folder": dir, "format": "png"}),
|
|
json!({"folder": dir, "format": "dxf"}),
|
|
json!({"folder": dir, "time": "now"}),
|
|
json!({"folder": dir, "time": 1.5}),
|
|
json!({"folder": dir.join("missing").join("deeper"), "time": TIME}),
|
|
] {
|
|
assert!(stamp(&mut s, p.clone()).is_err(), "{p}");
|
|
}
|
|
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "nothing written");
|
|
let allowed = dir.join("allowed");
|
|
std::fs::create_dir_all(&allowed).unwrap();
|
|
let roots = crate::file_access::AutomationRoots::new(&[&dir], &[&allowed]).unwrap();
|
|
let outside = crate::file_access::confine(roots.as_ref(), || stamp(&mut s, json!({"folder": dir, "time": TIME})));
|
|
assert!(outside.is_err(), "outside the write root");
|
|
let inside = crate::file_access::confine(roots.as_ref(), || stamp(&mut s, json!({"folder": allowed, "time": TIME})));
|
|
assert!(inside.is_ok(), "{inside:?}");
|
|
assert!(!enabled_without_document(), "off with no document");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
fn enabled_without_document() -> bool {
|
|
Session::new().commands().into_iter().find(|c| c.id == "addon.document.saveTimestampedCopy").unwrap().enabled
|
|
}
|
|
|
|
fn layer_ids(s: &Session) -> Vec<u64> {
|
|
s.doc().unwrap().doc.layers.iter().map(|l| l.id.0).collect()
|
|
}
|
|
|
|
fn symbol_names(s: &Session) -> Vec<String> {
|
|
s.doc().unwrap().doc.symbols.iter().map(|x| x.name.clone()).collect()
|
|
}
|
|
|
|
/// A document of three layers: `Layer 1` with two rectangles, an empty one, and `Icons` with one
|
|
/// ellipse.
|
|
fn layered() -> Session {
|
|
let mut s = session();
|
|
s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 50, "height": 50})).unwrap();
|
|
s.execute("layer.new", &json!({})).unwrap();
|
|
s.execute("layer.new", &json!({"name": "Icons"})).unwrap();
|
|
s.execute("shape.ellipse", &json!({"x": 0, "y": 100, "width": 40, "height": 20})).unwrap();
|
|
s
|
|
}
|
|
|
|
#[test]
|
|
fn a_symbol_is_made_of_each_layer_and_empty_layers_are_reported() {
|
|
let mut s = layered();
|
|
let before = symbol_names(&s).len();
|
|
let n = s.doc().unwrap().history.undo.len();
|
|
let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap();
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "one undo step");
|
|
let names: Vec<String> = serde_json::from_value(r["symbols"].clone()).unwrap();
|
|
assert_eq!(names.len(), 2, "{r}");
|
|
assert!(names.contains(&"Icons".to_string()));
|
|
assert_eq!(symbol_names(&s).len(), before + 2);
|
|
assert_eq!(r["skipped"].as_array().unwrap().len(), 1, "the empty layer: {r}");
|
|
assert_eq!(r["skipped"][0]["reason"], json!("empty"));
|
|
assert_eq!(r["ids"], json!([]), "without replace the art stays");
|
|
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
|
|
// Again: the names are taken, so they get numbers.
|
|
let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap();
|
|
assert!(r["symbols"].as_array().unwrap().contains(&json!("Icons 2")), "{r}");
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(symbol_names(&s).len(), before);
|
|
}
|
|
|
|
#[test]
|
|
fn replace_puts_an_instance_where_the_layers_art_was() {
|
|
let mut s = layered();
|
|
let first = layer_ids(&s)[0];
|
|
let bounds = |s: &Session, id: NodeId| s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
|
|
let layer = NodeId(first);
|
|
let art = bounds(&s, layer);
|
|
let r = s.execute("addon.document.symbolsFromLayers", &json!({"layers": [first], "replace": true})).unwrap();
|
|
let ids = r["ids"].as_array().unwrap();
|
|
assert_eq!(ids.len(), 1);
|
|
let inst = NodeId(ids[0].as_u64().unwrap());
|
|
let st = s.doc().unwrap();
|
|
let kids = st.doc.layers[0].children().unwrap();
|
|
assert_eq!(kids.len(), 1);
|
|
assert!(matches!(&kids[0].kind, NodeKind::SymbolInstance { .. }));
|
|
assert_eq!(st.selection.objects, [inst]);
|
|
let now = bounds(&s, layer);
|
|
assert!((now.x0 - art.x0).abs() < 1e-6 && (now.y1 - art.y1).abs() < 1e-6 && (now.width() - art.width()).abs() < 1e-6, "{art:?} {now:?}");
|
|
assert_eq!(s.doc().unwrap().doc.layers.len(), 3, "the other layers untouched");
|
|
}
|
|
|
|
#[test]
|
|
fn locked_layers_keep_their_art_and_bad_layers_are_refused() {
|
|
let mut s = layered();
|
|
let ids = layer_ids(&s);
|
|
s.execute("layer.setProps", &json!({"id": ids[2], "locked": true})).unwrap();
|
|
let r = s.execute("addon.document.symbolsFromLayers", &json!({"replace": true})).unwrap();
|
|
let skipped: Vec<&str> = r["skipped"].as_array().unwrap().iter().map(|k| k["reason"].as_str().unwrap()).collect();
|
|
assert_eq!(skipped, ["empty", "locked"]);
|
|
let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
|
let rect = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
|
|
for p in [json!({"layers": [rect]}), json!({"layers": [u64::MAX]}), json!({"layers": "all"}), json!({"layers": [-1]}), json!({"replace": "yes"})]
|
|
{
|
|
assert!(s.execute("addon.document.symbolsFromLayers", &p).is_err(), "{p}");
|
|
}
|
|
assert!(*s.doc().unwrap().doc == *before.0 && s.doc().unwrap().history.undo.len() == before.1);
|
|
// Only an empty layer: nothing made, no step.
|
|
let r = s.execute("addon.document.symbolsFromLayers", &json!({"layers": [ids[1]]})).unwrap();
|
|
assert_eq!((r["symbols"].clone(), s.doc().unwrap().history.undo.len()), (json!([]), before.1));
|
|
}
|
|
|
|
#[test]
|
|
fn the_layers_highlighted_in_the_layers_panel_are_the_default() {
|
|
let mut s = layered();
|
|
let ids = layer_ids(&s);
|
|
// One highlighted row is just the current layer: every layer.
|
|
s.execute("layer.setCurrent", &json!({"id": ids[0]})).unwrap();
|
|
let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap();
|
|
assert_eq!(r["symbols"].as_array().unwrap().len(), 2);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
s.execute("layer.highlight", &json!({"ids": [ids[1], ids[2]]})).unwrap();
|
|
let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap();
|
|
assert_eq!((r["symbols"].clone(), r["skipped"].as_array().unwrap().len()), (json!(["Icons"]), 1));
|
|
}
|
|
|
|
/// #4920: Export All Open Documents writes an SVG of each open document; a folder that can't be
|
|
/// written lists every document in `failed`, and either way the active document stays active.
|
|
#[test]
|
|
fn export_all_open_documents_writes_each_and_reports_failures() {
|
|
let dir = std::env::temp_dir().join(format!("vc-export-all-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 100, "height": 100, "name": "one"})).unwrap();
|
|
s.execute("file.new", &json!({"width": 100, "height": 100, "name": "two"})).unwrap();
|
|
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
|
let r = s.execute("addon.document.exportAllOpen", &json!({"folder": dir, "format": "svg"})).unwrap();
|
|
assert_eq!(r["files"].as_array().unwrap().len(), 2, "{r}");
|
|
assert!(dir.join("one.svg").exists() && dir.join("two.svg").exists());
|
|
assert_eq!(s.active_index(), Some(0));
|
|
assert!(s.documents().iter().all(|d| d.path.is_none()), "the documents stay unsaved");
|
|
let missing = dir.join("missing").join("deeper");
|
|
let r = s.execute("addon.document.exportAllOpen", &json!({"folder": missing, "format": "svg"})).unwrap();
|
|
assert_eq!((r["files"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (0, 2), "{r}");
|
|
assert_eq!(s.active_index(), Some(0));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// An SVG document last saved with linked images exports as one SVG with its images embedded: no
|
|
/// image file is written beside the copies (#4920 review).
|
|
#[test]
|
|
fn export_all_open_documents_writes_one_file_each() {
|
|
let dir = std::env::temp_dir().join(format!("vc-export-all-one-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(dir.join("out")).unwrap();
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 100, "height": 100, "name": "pic"})).unwrap();
|
|
let mut png = vec![];
|
|
image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
|
s.execute("file.place", &json!({"name": "a.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
|
s.execute("document.save", &json!({"path": dir.join("pic.svg"), "format": "svg", "options": {"images": "link"}})).unwrap();
|
|
let saved: Vec<_> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).filter(|e| e.path().is_file()).collect();
|
|
assert!(saved.len() > 1, "the linked save wrote its image beside it: {saved:?}");
|
|
let out = dir.join("out");
|
|
let r = s.execute("addon.document.exportAllOpen", &json!({"folder": out, "format": "svg"})).unwrap();
|
|
let names: Vec<String> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
|
assert_eq!(names, ["pic.svg"], "{r}");
|
|
assert!(std::fs::read_to_string(out.join("pic.svg")).unwrap().contains("data:image/png;base64,"), "the image is embedded");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|