mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 19:36:46 -06:00
A .fig import now links the layers of an instance to the layers of the component they came from by the ids the file stores, instead of leaving it to the engine to guess from names and places. Renamed slots and layers, siblings that share a name and swapped mains keep their source link and the override they carry. Components that live only on the file's hidden internal pages (cached copies of library components) are kept in the hidden library store when a visible layer uses them, so their instances stay instances. The read-only guard for library copies now compares the copies themselves rather than the dirty set, which an import leaves full until the first edit. Rebasing an instance (after detaching an outer instance or restoring a main) follows the source chain inside the right enclosing instance, so contextual links survive it. The last change of a GUID wins and a removed change deletes it. Ordinary file JSON never reads the private provenance. Importing such a file into an open document, or pasting its layers into another one, copies the cached components along (import) or leaves them behind as a missing main (paste), so no link points at a layer of the target.
448 lines
22 KiB
Rust
448 lines
22 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);
|
|
}
|