mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 18:36:44 -06:00
466 lines
23 KiB
Rust
466 lines
23 KiB
Rust
//! Local libraries: publish, enable, place, update, read-only copies, damaged files.
|
|
|
|
use super::*;
|
|
use crate::components::mirror_issues;
|
|
use crate::{EngineError, Session};
|
|
use serde_json::{Value, json};
|
|
|
|
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
|
match s.execute(cmd, &p) {
|
|
Ok(v) => v,
|
|
Err(e) => panic!("{cmd} {p}: {e}"),
|
|
}
|
|
}
|
|
|
|
fn idv(v: &Value) -> NodeId {
|
|
id_of(&v["id"]).unwrap()
|
|
}
|
|
|
|
fn node(s: &Session, id: NodeId) -> Node {
|
|
s.doc().unwrap().node(id).unwrap().clone()
|
|
}
|
|
|
|
fn fill(s: &Session, id: NodeId) -> String {
|
|
node(s, id).fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
|
|
}
|
|
|
|
fn chars(s: &Session, id: NodeId) -> String {
|
|
node(s, id).kind.text().map(|t| t.characters.clone()).unwrap_or_default()
|
|
}
|
|
|
|
fn healthy(s: &Session) {
|
|
let doc = s.doc().unwrap();
|
|
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
|
|
assert!(mirror_issues(doc).is_empty(), "{:?}", mirror_issues(doc));
|
|
}
|
|
|
|
/// A scratch directory for one test.
|
|
fn dir(name: &str) -> std::path::PathBuf {
|
|
let d = std::env::temp_dir().join(format!("figcraft-lib-{}-{name}", std::process::id()));
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
fn path_str(p: &std::path::Path) -> String {
|
|
p.to_string_lossy().into_owned()
|
|
}
|
|
|
|
fn dirty_library_document() -> (Document, NodeId) {
|
|
let mut doc = Document::new("Dirty library cache");
|
|
let store = doc.ensure_library_store();
|
|
let remote = doc.alloc_id();
|
|
doc.insert(Node::new(remote, NodeKind::Frame, "Remote", 10.0, 10.0), store, None).unwrap();
|
|
(doc, remote)
|
|
}
|
|
|
|
/// An imported cache is dirty until the first edit; that alone is no edit of the library copies.
|
|
#[test]
|
|
fn read_only_allows_unchanged_predirty_remote_nodes() {
|
|
let (before, remote) = dirty_library_document();
|
|
assert!(before.dirty().contains(&remote));
|
|
|
|
let mut after = before.clone();
|
|
after.add_page("Unrelated page");
|
|
assert!(after.dirty().contains(&remote), "the imported remote node remains in the pending dirty set");
|
|
check_read_only("page.add", &before, &after).unwrap();
|
|
|
|
let mut edited = before.clone();
|
|
edited.get_mut(remote).unwrap().opacity = 0.5;
|
|
assert_eq!(check_read_only("node.set", &before, &edited), Err(READ_ONLY.into()));
|
|
|
|
let mut moved = before.clone();
|
|
moved.reparent(remote, before.first_page().unwrap(), None).unwrap();
|
|
assert_eq!(check_read_only("node.reparent", &before, &moved), Err(READ_ONLY.into()));
|
|
|
|
let mut deleted = before.clone();
|
|
deleted.remove(remote).unwrap();
|
|
assert_eq!(check_read_only("edit.delete", &before, &deleted), Err(READ_ONLY.into()));
|
|
}
|
|
|
|
/// The library file's layers.
|
|
struct Kit {
|
|
button: NodeId,
|
|
label: NodeId,
|
|
card: NodeId,
|
|
}
|
|
|
|
/// A library file: "Button" (blue, "OK" label, its fill linked to the "Brand" paint style) and
|
|
/// "Card" (a frame holding a Button instance), plus a private "_Draft" component.
|
|
fn kit(s: &mut Session) -> Kit {
|
|
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Button"})));
|
|
let label = idv(&ex(s, "text.create", json!({"text": "OK", "x": 10, "y": 10, "parent": f, "props": {"name": "Label"}})));
|
|
ex(s, "node.set", json!({"id": f, "props": {"fill": "#0000ff"}}));
|
|
ex(s, "style.create", json!({"type": "paint", "name": "Brand", "from": f}));
|
|
let button = idv(&ex(s, "component.create", json!({"ids": [f]})));
|
|
let c = idv(&ex(s, "frame.create", json!({"x": 0, "y": 100, "width": 200, "height": 100, "name": "Card"})));
|
|
ex(s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": c}));
|
|
let card = idv(&ex(s, "component.create", json!({"ids": [c]})));
|
|
let d = idv(&ex(s, "frame.create", json!({"x": 300, "y": 0, "width": 10, "height": 10, "name": "_Draft"})));
|
|
ex(s, "component.create", json!({"ids": [d]}));
|
|
Kit { button, label, card }
|
|
}
|
|
|
|
/// Publish the active document and save it to `path`; returns the library id.
|
|
fn publish_to(s: &mut Session, path: &str) -> String {
|
|
let r = ex(s, "library.publish", json!({"description": "Kit"}));
|
|
ex(s, "file.saveAs", json!({"path": path}));
|
|
r["id"].as_str().unwrap().to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn publish_manifest_roundtrip() {
|
|
let mut s = Session::new();
|
|
let k = kit(&mut s);
|
|
let r = ex(&mut s, "library.publish", json!({"description": "Our kit"}));
|
|
assert_eq!(r["version"], 1);
|
|
assert_eq!((r["components"].as_u64(), r["styles"].as_u64()), (Some(2), Some(1)), "{r}");
|
|
let doc = s.doc().unwrap();
|
|
let lib = doc.published.clone().unwrap();
|
|
let m = &lib.manifest;
|
|
assert!(figcraft_format::is_valid_library_id(&m.id));
|
|
assert_eq!(m.description, "Our kit");
|
|
assert!(m.component(k.button).is_some_and(|a| a.published && a.version == 1));
|
|
assert!(m.component(k.card).is_some_and(|a| a.published));
|
|
assert!(m.components.iter().all(|a| a.name != "_Draft"), "names starting with _ stay private");
|
|
// The snapshot holds the assets with the file's ids, and nothing else of the file.
|
|
assert!(lib.doc.node(k.label).is_some());
|
|
assert!(lib.doc.validate().is_empty());
|
|
assert_eq!(lib.doc.styles.len(), 1);
|
|
// Saved and read back: the author's file and a reader of the library see the same manifest.
|
|
let bytes = figcraft_format::save_bytes(doc, None).unwrap();
|
|
let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap();
|
|
assert!(issues.is_empty(), "{issues:?}");
|
|
assert_eq!(back.published.as_ref().map(|p| &p.manifest), Some(m));
|
|
let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap();
|
|
assert_eq!(read.manifest, *m);
|
|
assert_eq!(read.doc.node_count(), lib.doc.node_count());
|
|
// Older files and files never published have no library; readers that don't know libraries
|
|
// are unaffected (the entries are extra archive members).
|
|
let plain = figcraft_format::save_bytes(&Document::new("x"), None).unwrap();
|
|
assert!(figcraft_format::read_library(std::io::Cursor::new(&plain)).unwrap().is_none());
|
|
assert!(figcraft_format::load_bytes(&plain).unwrap().0.published.is_none());
|
|
|
|
// Nothing changed: refused. A change bumps only what it touches (and what uses it).
|
|
let e = s.execute("library.publish", &json!({})).unwrap_err();
|
|
assert!(e.to_string().contains("no changes"), "{e}");
|
|
ex(&mut s, "node.set", json!({"id": k.card, "props": {"fill": "#eeeeee"}}));
|
|
let r = ex(&mut s, "library.publish", json!({}));
|
|
assert_eq!(r["version"], 2);
|
|
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
|
|
assert_eq!(m.component(k.card).map(|a| a.version), Some(2));
|
|
assert_eq!(m.component(k.button).map(|a| a.version), Some(1), "untouched assets keep their version");
|
|
assert_eq!(m.description, "Our kit", "the description carries over");
|
|
// Excluding an asset; a used component still comes along (not offered).
|
|
let r = ex(&mut s, "library.publish", json!({"exclude": ["Button"]}));
|
|
assert_eq!(r["components"], 1);
|
|
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
|
|
assert!(m.component(k.button).is_some_and(|a| !a.published));
|
|
// Unknown assets are an error, not ignored.
|
|
assert!(s.execute("library.publish", &json!({"include": ["Nope"]})).is_err());
|
|
assert!(s.execute("library.publish", &json!({"include": "Button"})).is_err());
|
|
// Undo takes the publish back.
|
|
s.undo().unwrap();
|
|
assert_eq!(s.doc().unwrap().published.as_ref().map(|p| p.manifest.version), Some(2));
|
|
}
|
|
|
|
#[test]
|
|
fn add_place_and_update_keep_overrides() {
|
|
let d = dir("flow");
|
|
let lib_path = path_str(&d.join("kit.figcraft"));
|
|
let mut lib = Session::new();
|
|
let k = kit(&mut lib);
|
|
let lid = publish_to(&mut lib, &lib_path);
|
|
|
|
let mut s = Session::new();
|
|
let r = ex(&mut s, "library.add", json!({"path": lib_path}));
|
|
assert_eq!(r["id"], lid.as_str());
|
|
assert_eq!(r["components"].as_array().map(Vec::len), Some(2));
|
|
let list = ex(&mut s, "library.list", json!({"assets": true}));
|
|
assert_eq!(list["libraries"][0]["updates"], 0);
|
|
assert_eq!(list["libraries"][0]["components"].as_array().map(Vec::len), Some(2));
|
|
|
|
// Place the Card: it comes with the Button it uses and the style the Button links to.
|
|
let r = ex(&mut s, "library.place", json!({"library": lid, "component": "Card", "x": 300, "y": 300}));
|
|
let inst = idv(&r);
|
|
let main = id_of(&r["component"]).unwrap();
|
|
{
|
|
let doc = s.doc().unwrap();
|
|
assert!(doc.is_remote(main) && !doc.is_remote(inst));
|
|
assert!(!doc.pages.contains(&doc.library_store().unwrap()), "the store is not a page");
|
|
assert_eq!(doc.libraries.components.len(), 2, "Card and the Button it uses");
|
|
assert_eq!(doc.styles.len(), 1);
|
|
assert!(is_remote_style(doc, &doc.styles[0].id));
|
|
assert!(local_components(doc).is_empty(), "library copies aren't local components");
|
|
}
|
|
healthy(&s);
|
|
// Placing again reuses the copies.
|
|
let r2 = ex(&mut s, "library.place", json!({"library": lid, "component": k.card.0, "x": 600, "y": 300}));
|
|
assert_eq!(id_of(&r2["component"]), Some(main));
|
|
assert_eq!(s.doc().unwrap().libraries.components.len(), 2);
|
|
s.undo().unwrap();
|
|
// A Button on its own, with its label overridden.
|
|
let b = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Button", "x": 0, "y": 0})));
|
|
let b_label = node(&s, b).children[0];
|
|
ex(&mut s, "text.set", json!({"id": b_label, "characters": "Buy"}));
|
|
assert!(node(&s, b_label).overrides.contains("characters"));
|
|
assert_eq!(fill(&s, b), "0000FF");
|
|
healthy(&s);
|
|
|
|
// Library copies are read-only.
|
|
let remote_button = match node(&s, b).kind {
|
|
NodeKind::Instance { component, .. } => component,
|
|
_ => panic!("not an instance"),
|
|
};
|
|
let unchanged = s.doc().unwrap().clone();
|
|
let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err();
|
|
assert!(e.to_string().contains("can't be edited"), "{e}");
|
|
assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back");
|
|
for (cmd, args) in
|
|
[("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))]
|
|
{
|
|
assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies");
|
|
assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back");
|
|
}
|
|
let style = s.doc().unwrap().styles[0].id.clone();
|
|
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
|
|
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
|
|
let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err();
|
|
assert!(e.to_string().contains("library"), "{e}");
|
|
assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected");
|
|
assert!(s.doc().unwrap().pages.contains(&s.page().unwrap()));
|
|
assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err());
|
|
assert_eq!(fill(&s, remote_button), "0000FF");
|
|
|
|
// The library changes the Button (fill via its style, and its label) and publishes again.
|
|
ex(&mut lib, "style.update", json!({"style": "Brand", "value": "#00ff00"}));
|
|
ex(&mut lib, "text.set", json!({"id": k.label, "characters": "Go"}));
|
|
ex(&mut lib, "library.publish", json!({}));
|
|
ex(&mut lib, "file.save", json!({}));
|
|
|
|
let u = ex(&mut s, "library.updates", json!({}));
|
|
assert_eq!(u["count"], 3, "Button, Card and the style: {u}");
|
|
let label_ids: Vec<NodeId> = s.doc().unwrap().subtree(b);
|
|
ex(&mut s, "library.acceptUpdates", json!({}));
|
|
assert_eq!(fill(&s, b), "00FF00", "the instance follows the updated main");
|
|
assert_eq!(chars(&s, b_label), "Buy", "its override is kept");
|
|
assert_eq!(s.doc().unwrap().subtree(b), label_ids, "layer ids are kept");
|
|
assert_eq!(fill(&s, remote_button), "00FF00");
|
|
let style = s.doc().unwrap().styles[0].clone();
|
|
assert!(matches!(&style.kind, StyleKind::Paint { paints } if paints[0].solid_color().unwrap().to_hex() == "00FF00"));
|
|
let doc = s.doc().unwrap();
|
|
assert_eq!(doc.libraries.link(&lid).map(|l| l.version), Some(2));
|
|
// The Card's nested Button follows too.
|
|
let card_inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { component, .. } if component == main)).map(|n| n.id).unwrap();
|
|
let nested = node(&s, card_inst).children[0];
|
|
assert_eq!(fill(&s, nested), "00FF00");
|
|
assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 0);
|
|
healthy(&s);
|
|
// A structural change: the Button gains a layer (the Card's nested Button too).
|
|
ex(&mut lib, "shape.ellipse", json!({"x": 80, "y": 10, "width": 8, "height": 8, "parent": k.button, "props": {"name": "Dot"}}));
|
|
ex(&mut lib, "library.publish", json!({}));
|
|
ex(&mut lib, "file.save", json!({}));
|
|
ex(&mut s, "library.acceptUpdates", json!({"ids": [lid]}));
|
|
assert_eq!(node(&s, b).children.len(), 2);
|
|
assert_eq!(node(&s, nested).children.len(), 2);
|
|
assert_eq!(chars(&s, b_label), "Buy", "overrides survive a structural update");
|
|
assert_eq!(s.doc().unwrap().libraries.link(&lid).map(|l| l.version), Some(3));
|
|
healthy(&s);
|
|
// Overrides still work after the update; resetting shows the library's text.
|
|
ex(&mut s, "component.resetInstance", json!({"ids": [b]}));
|
|
assert_eq!(chars(&s, node(&s, b).children[0]), "Go");
|
|
|
|
// Saved and reopened, the document keeps its links, copies and records.
|
|
let user_path = path_str(&d.join("app.figcraft"));
|
|
ex(&mut s, "file.saveAs", json!({"path": user_path}));
|
|
let mut t = Session::empty();
|
|
ex(&mut t, "file.open", json!({"path": user_path}));
|
|
healthy(&t);
|
|
assert_eq!(t.doc().unwrap().libraries, s.doc().unwrap().libraries);
|
|
|
|
// Removing the library keeps the copies instances use and drops the rest.
|
|
ex(&mut s, "component.detach", json!({"ids": [card_inst]}));
|
|
ex(&mut s, "library.remove", json!({"id": lid}));
|
|
let doc = s.doc().unwrap();
|
|
assert!(doc.libraries.enabled.is_empty());
|
|
assert_eq!(doc.libraries.components.len(), 1, "only the Button is still used");
|
|
assert!(doc.contains(remote_button) && !doc.contains(main));
|
|
healthy(&s);
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
}
|
|
|
|
#[test]
|
|
fn library_styles_apply_and_import_with_pages() {
|
|
let d = dir("styles");
|
|
let lib_path = path_str(&d.join("kit.figcraft"));
|
|
let mut lib = Session::new();
|
|
kit(&mut lib);
|
|
let lid = publish_to(&mut lib, &lib_path);
|
|
|
|
let mut s = Session::new();
|
|
ex(&mut s, "library.add", json!({"path": lib_path}));
|
|
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
|
|
let st = ex(&mut s, "library.applyStyle", json!({"library": lid, "style": "Brand", "ids": [r]}));
|
|
let local = st["style"].as_str().unwrap().to_string();
|
|
assert_eq!(node(&s, r).styles.fill.as_deref(), Some(local.as_str()));
|
|
assert_eq!(fill(&s, r), "0000FF");
|
|
// Applying it again reuses the copy.
|
|
ex(&mut s, "library.applyStyle", json!({"library": lid, "style": "Brand", "ids": [r]}));
|
|
assert_eq!(s.doc().unwrap().styles.len(), 1);
|
|
assert!(s.execute("library.applyStyle", &json!({"library": lid, "style": "Nope", "ids": [r]})).is_err());
|
|
let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Card", "x": 0, "y": 100})));
|
|
let path = path_str(&d.join("user.figcraft"));
|
|
ex(&mut s, "file.saveAs", json!({"path": path}));
|
|
|
|
// Importing that file's pages into another document brings the copies along.
|
|
let mut t = Session::new();
|
|
ex(&mut t, "file.import", json!({"path": path}));
|
|
healthy(&t);
|
|
let doc = t.doc().unwrap();
|
|
assert_eq!(doc.libraries.components.len(), 2);
|
|
assert!(doc.libraries.link(&lid).is_some());
|
|
let copy = doc.nodes().find(|n| n.name == node(&s, inst).name && matches!(n.kind, NodeKind::Instance { .. }) && !doc.is_remote(n.id)).unwrap();
|
|
let NodeKind::Instance { component, .. } = copy.kind else { panic!("not an instance") };
|
|
assert!(doc.is_remote(component));
|
|
let store_roots = |s: &Session| {
|
|
let doc = s.doc().unwrap();
|
|
doc.library_store().and_then(|id| doc.node(id)).map_or(0, |n| n.children.len())
|
|
};
|
|
assert_eq!(store_roots(&t), 2, "recorded copies are not copied twice");
|
|
// A second import shares the copies it already has.
|
|
ex(&mut t, "file.import", json!({"path": path}));
|
|
healthy(&t);
|
|
assert_eq!(t.doc().unwrap().libraries.components.len(), 2);
|
|
assert_eq!(store_roots(&t), 2);
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
}
|
|
|
|
#[test]
|
|
fn bad_library_files_are_errors() {
|
|
let d = dir("bad");
|
|
let mut s = Session::new();
|
|
let missing = path_str(&d.join("missing.figcraft"));
|
|
assert!(matches!(s.execute("library.add", &json!({"path": missing})), Err(EngineError::Other(_))));
|
|
let junk = d.join("junk.figcraft");
|
|
std::fs::write(&junk, b"not a zip at all").unwrap();
|
|
assert!(s.execute("library.add", &json!({"path": path_str(&junk)})).is_err());
|
|
std::fs::write(&junk, b"{\"name\": \"json\"}").unwrap();
|
|
assert!(s.execute("library.add", &json!({"path": path_str(&junk)})).is_err());
|
|
// A FigCraft file that was never published.
|
|
let plain = d.join("plain.figcraft");
|
|
std::fs::write(&plain, figcraft_format::save_bytes(&Document::new("Plain"), None).unwrap()).unwrap();
|
|
let e = s.execute("library.add", &json!({"path": path_str(&plain)})).unwrap_err();
|
|
assert!(e.to_string().contains("no published library"), "{e}");
|
|
// A truncated library (damaged entries are covered by the format crate's tests).
|
|
let mut lib = Session::new();
|
|
kit(&mut lib);
|
|
ex(&mut lib, "library.publish", json!({}));
|
|
let good = figcraft_format::save_bytes(lib.doc().unwrap(), None).unwrap();
|
|
let cut = d.join("cut.figcraft");
|
|
std::fs::write(&cut, &good[..good.len() / 2]).unwrap();
|
|
assert!(s.execute("library.add", &json!({"path": path_str(&cut)})).is_err());
|
|
// Oversized files are refused before they are read.
|
|
let ok = d.join("ok.figcraft");
|
|
std::fs::write(&ok, &good).unwrap();
|
|
let mut cache = Cache::default();
|
|
let e = cache.load_limited(&path_str(&ok), 16).unwrap_err();
|
|
assert!(e.to_string().contains("too large"), "{e}");
|
|
assert!(cache.load(&path_str(&ok)).is_ok());
|
|
// Its own library can't be added to a file.
|
|
let own = d.join("own.figcraft");
|
|
ex(&mut lib, "file.saveAs", json!({"path": path_str(&own)}));
|
|
assert!(lib.execute("library.add", &json!({"path": path_str(&own)})).is_err());
|
|
// Commands naming no library fail cleanly, and nothing was enabled.
|
|
for cmd in ["library.place", "library.import", "library.applyStyle", "library.remove"] {
|
|
assert!(s.execute(cmd, &json!({"library": "x", "id": "x", "component": 1, "style": "x"})).is_err(), "{cmd}");
|
|
}
|
|
assert!(s.doc().unwrap().libraries.is_empty());
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
}
|
|
|
|
#[test]
|
|
fn library_variants_swaps_and_undo() {
|
|
let d = dir("variants");
|
|
let lib_path = path_str(&d.join("chips.figcraft"));
|
|
let mut lib = Session::new();
|
|
let mut variants = Vec::new();
|
|
for (i, color) in ["#ff0000", "#00ff00"].iter().enumerate() {
|
|
let f = idv(&ex(&mut lib, "frame.create", json!({"x": i as f64 * 100.0, "y": 0, "width": 60, "height": 20, "name": "Chip"})));
|
|
ex(&mut lib, "node.set", json!({"id": f, "props": {"fill": color}}));
|
|
variants.push(idv(&ex(&mut lib, "component.create", json!({"ids": [f]}))));
|
|
}
|
|
ex(&mut lib, "component.combineVariants", json!({"ids": variants}));
|
|
for name in ["Icon A", "Icon B"] {
|
|
let f = idv(&ex(&mut lib, "frame.create", json!({"x": 0, "y": 100, "width": 16, "height": 16, "name": name})));
|
|
ex(&mut lib, "component.create", json!({"ids": [f]}));
|
|
}
|
|
let lid = publish_to(&mut lib, &lib_path);
|
|
|
|
let mut s = Session::new();
|
|
ex(&mut s, "library.add", json!({"path": lib_path}));
|
|
let before = s.doc().unwrap().clone();
|
|
let chip = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Chip", "x": 0, "y": 0})));
|
|
assert_eq!(fill(&s, chip), "FF0000");
|
|
// Variant properties switch between the library's variants.
|
|
ex(&mut s, "instance.setProperties", json!({"ids": [chip], "properties": {"Property 1": "Variant2"}}));
|
|
assert_eq!(fill(&s, chip), "00FF00");
|
|
let NodeKind::Instance { component, .. } = node(&s, chip).kind else { panic!("not an instance") };
|
|
assert!(s.doc().unwrap().is_remote(component));
|
|
// Swapping to another library component (imported first).
|
|
let icon = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Icon A", "x": 100, "y": 0})));
|
|
let b = id_of(&ex(&mut s, "library.import", json!({"library": lid, "component": "Icon B"}))["component"]).unwrap();
|
|
ex(&mut s, "instance.swap", json!({"ids": [icon], "component": b}));
|
|
assert!(matches!(node(&s, icon).kind, NodeKind::Instance { component, .. } if component == b));
|
|
healthy(&s);
|
|
// Undo goes back step by step, to before the first copy.
|
|
for _ in 0..5 {
|
|
s.undo().unwrap();
|
|
}
|
|
assert_eq!(s.doc().unwrap().libraries, before.libraries);
|
|
assert_eq!(s.doc().unwrap().node_count(), before.node_count());
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
}
|
|
|
|
#[test]
|
|
fn library_survives_saving_with_versions() {
|
|
let d = dir("versions");
|
|
let path = path_str(&d.join("kit.figcraft"));
|
|
let mut lib = Session::new();
|
|
kit(&mut lib);
|
|
let r = ex(&mut lib, "library.publish", json!({}));
|
|
ex(&mut lib, "file.saveVersion", json!({"name": "Published"}));
|
|
ex(&mut lib, "file.saveAs", json!({"path": path}));
|
|
// Reopened: both the version history and the library are there.
|
|
let mut back = Session::empty();
|
|
ex(&mut back, "file.open", json!({"path": path}));
|
|
assert_eq!(ex(&mut back, "version.list", json!({})).as_array().map(Vec::len), Some(1));
|
|
assert_eq!(back.doc().unwrap().published.as_ref().map(|p| p.manifest.id.clone()), r["id"].as_str().map(str::to_string));
|
|
// Restoring a version keeps what the file published; saved again, other files can use it.
|
|
ex(&mut back, "version.restore", json!({"index": 0}));
|
|
assert!(back.doc().unwrap().published.is_some());
|
|
ex(&mut back, "file.save", json!({}));
|
|
let mut user = Session::new();
|
|
let added = ex(&mut user, "library.add", json!({"path": path}));
|
|
assert_eq!(added["id"], r["id"]);
|
|
ex(&mut user, "library.place", json!({"library": added["id"], "component": "Card", "x": 0, "y": 0}));
|
|
healthy(&user);
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_stamp_from_the_moment_it_was_read_does_not_prove_it_unchanged() {
|
|
use std::time::{Duration, SystemTime};
|
|
let read_at = SystemTime::now();
|
|
let entry = |modified: SystemTime| Entry {
|
|
stamp: Some(Stamp { len: 10, modified: Some(modified) }),
|
|
read_at: Some(read_at),
|
|
result: Err("unused".into()),
|
|
};
|
|
// Written in the same coarse tick as the read: a same-length rewrite would keep this stamp.
|
|
let fresh = entry(read_at);
|
|
assert!(!fresh.still_valid(&Stamp { len: 10, modified: Some(read_at) }));
|
|
// Written well before the read: an unchanged stamp means an unchanged file.
|
|
let old = read_at - Duration::from_secs(10);
|
|
assert!(entry(old).still_valid(&Stamp { len: 10, modified: Some(old) }));
|
|
assert!(!entry(old).still_valid(&Stamp { len: 11, modified: Some(old) }));
|
|
}
|