//! Library variables: publishing collections (hidden variables, versions), binding, aliasing and //! explicit modes from a library, read-only copies, updates, components that bind variables, and //! files that bring copies along. use figcraft_doc::*; use serde_json::{Value, json}; use crate::Session; use crate::library::*; use crate::params::id_of; 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 fill(s: &Session, id: NodeId) -> String { s.doc().unwrap().node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default() } fn healthy(s: &Session) { let doc = s.doc().unwrap(); assert!(doc.validate().is_empty(), "{:?}", doc.validate()); } /// A scratch directory for one test. fn dir(name: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("figcraft-libvars-{}-{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() } /// A library file's variables: "Theme" (Light/Dark) with `primary` (blue/white), `radius` (4), /// `accent` aliasing `base` (hidden from publishing), and a private "_Scratch" collection. fn theme(s: &mut Session) { ex(s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})); ex(s, "variable.create", json!({"collection": "Theme", "name": "primary", "type": "color", "values": {"Light": "#0000ff", "Dark": "#ffffff"}})); ex(s, "variable.create", json!({"collection": "Theme", "name": "radius", "type": "number", "value": 4})); ex(s, "variable.create", json!({"collection": "Theme", "name": "base", "type": "color", "value": "#00ff00"})); ex(s, "variable.update", json!({"variable": "base", "hiddenFromPublishing": true})); ex(s, "variable.create", json!({"collection": "Theme", "name": "accent", "type": "color", "value": "#000000"})); ex(s, "variable.set", json!({"variable": "accent", "value": {"alias": "base"}})); ex(s, "variable.createCollection", json!({"name": "_Scratch"})); ex(s, "variable.create", json!({"collection": "_Scratch", "name": "tmp", "type": "number", "value": 1})); } /// 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!({})); ex(s, "file.saveAs", json!({"path": path})); r["id"].as_str().unwrap().to_string() } fn republish(lib: &mut Session) { ex(lib, "library.publish", json!({})); ex(lib, "file.save", json!({})); } /// The local copy of library variable `name`. fn copy_of(s: &Session, name: &str) -> Variable { let doc = s.doc().unwrap(); doc.variables.variables.iter().find(|v| v.name == name && is_remote_variable(doc, &v.id)).cloned().unwrap() } #[test] fn publishing_collections_hides_and_versions_variables() { let mut s = Session::new(); theme(&mut s); let r = ex(&mut s, "library.publish", json!({})); assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(3)), "{r}"); let lib = s.doc().unwrap().published.clone().unwrap(); let m = &lib.manifest; let var = |name: &str| m.variables.iter().find(|v| v.name == name).cloned(); assert!(m.collections.iter().all(|c| c.name != "_Scratch"), "names starting with _ stay private"); assert!(var("primary").is_some_and(|v| v.published && v.version == 1 && v.kind == "color")); // A variable hidden from publishing stays out of the offer, but comes along for the alias. assert!(var("base").is_some_and(|v| !v.published)); assert!(var("tmp").is_none()); assert_eq!(lib.doc.variables.variables.len(), 4); // Saved and read back, the manifest and snapshot are the same. let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap(); assert_eq!(read.manifest, *m); assert_eq!(read.doc.variables, lib.doc.variables); // Nothing changed: refused. A change bumps only the variable it touches. assert!(s.execute("library.publish", &json!({})).unwrap_err().to_string().contains("no changes")); ex(&mut s, "variable.set", json!({"variable": "radius", "value": 8})); assert_eq!(ex(&mut s, "library.publish", json!({}))["version"], 2); let m = s.doc().unwrap().published.clone().unwrap().manifest.clone(); let version = |name: &str| m.variables.iter().find(|v| v.name == name).map(|v| v.version); assert_eq!((version("radius"), version("primary")), (Some(2), Some(1))); assert_eq!(m.collections.first().map(|c| c.version), Some(1), "the collection itself didn't change"); // A new mode changes the collection. ex(&mut s, "variable.addMode", json!({"collection": "Theme", "name": "Dim"})); ex(&mut s, "library.publish", json!({})); let m = s.doc().unwrap().published.clone().unwrap().manifest.clone(); assert_eq!(m.collections.first().map(|c| c.version), Some(3)); // Hiding a published variable is a change; collections can be included and excluded by name. ex(&mut s, "variable.update", json!({"variable": "radius", "hiddenFromPublishing": true})); let r = ex(&mut s, "library.publish", json!({})); assert_eq!(r["variables"], 2); let r = ex(&mut s, "library.publish", json!({"include": ["_Scratch"]})); assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(1))); ex(&mut s, "library.publish", json!({"include": ["Theme", "_Scratch"]})); let r = ex(&mut s, "library.publish", json!({"exclude": ["_Scratch"]})); assert_eq!(r["collections"], 1, "the default follows the last publish"); let list = ex(&mut s, "library.list", json!({"assets": true})); assert_eq!(list["publishable"]["collections"].as_array().map(Vec::len), Some(2), "{list}"); // Variables alone can be published; nothing at all can't. let mut empty = Session::new(); assert!(empty.execute("library.publish", &json!({})).is_err()); } #[test] fn bind_alias_mode_read_only_and_updates() { let d = dir("flow"); let lib_path = path_str(&d.join("theme.figcraft")); let mut lib = Session::new(); theme(&mut lib); let lid = publish_to(&mut lib, &lib_path); let mut s = Session::new(); let added = ex(&mut s, "library.add", json!({"path": lib_path})); assert_eq!(added["variables"].as_array().map(Vec::len), Some(3), "{added}"); assert_eq!(added["collections"][0]["modes"].as_array().map(Vec::len), Some(2)); let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 200}))); let inner = idv(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20, "parent": frame}))); let outer = idv(&ex(&mut s, "shape.rectangle", json!({"x": 300, "y": 0, "width": 20, "height": 20}))); // Binding copies the variable in (linked, read-only) and the layers take its value. let r = ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "primary", "field": "fill", "ids": [inner, outer]})); let local = r["variable"].as_str().unwrap().to_string(); assert_eq!((fill(&s, inner), fill(&s, outer)), ("0000FF".into(), "0000FF".into())); ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "radius", "field": "cornerRadius", "ids": [outer]})); // Undo and redo (as commands, like the menus) take a copy back and bring it again. ex(&mut s, "edit.undo", json!({})); assert_eq!(s.doc().unwrap().variables.variables.len(), 1); ex(&mut s, "edit.redo", json!({})); assert_eq!(s.doc().unwrap().variables.variables.len(), 2); { let doc = s.doc().unwrap(); assert!(is_remote_variable(doc, &local)); assert_eq!(doc.variables.collections.len(), 1, "one copy of the collection for both"); assert!(local_collections(doc).next().is_none(), "library copies aren't local collections"); assert!(publishable(doc).collections.is_empty()); } // Binding again reuses the copy; hidden or unknown variables can't be bound. ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "primary", "field": "fill", "ids": [outer]})); assert_eq!(s.doc().unwrap().variables.variables.len(), 2); for v in ["base", "tmp", "nope"] { assert!(s.execute("library.bindVariable", &json!({"library": lid, "variable": v, "field": "fill", "ids": [outer]})).is_err(), "{v}"); } // Explicit mode: the frame shows the library collection's Dark mode. let r = ex(&mut s, "library.setVariableMode", json!({"library": lid, "collection": "Theme", "mode": "Dark", "ids": [frame]})); assert_eq!(r["collection"].as_str(), Some(s.doc().unwrap().variables.collections[0].id.as_str())); assert_eq!((fill(&s, inner), fill(&s, outer)), ("FFFFFF".into(), "0000FF".into())); assert!(s.execute("library.setVariableMode", &json!({"library": lid, "collection": "Theme", "mode": "Nope"})).is_err()); assert!(s.execute("library.setVariableMode", &json!({"library": lid, "collection": "_Scratch", "mode": "Mode 1"})).is_err()); // A local variable aliases a library one (copied in with the hidden variable it aliases). ex(&mut s, "variable.createCollection", json!({"name": "App"})); ex(&mut s, "variable.create", json!({"collection": "App", "name": "brand", "type": "color", "value": "#123456"})); ex(&mut s, "variable.create", json!({"collection": "App", "name": "gap", "type": "number", "value": 2})); ex(&mut s, "library.aliasVariable", json!({"library": lid, "alias": "accent", "variable": "brand"})); let modes = Default::default(); assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FF00")); assert!(s.execute("library.aliasVariable", &json!({"library": lid, "alias": "primary", "variable": "gap"})).is_err(), "types differ"); assert!(s.execute("library.aliasVariable", &json!({"library": lid, "alias": "primary", "variable": local})).is_err(), "copies are read-only"); // The copies are read-only. let coll = copy_of(&s, "primary").collection; for (cmd, p) in [ ("variable.set", json!({"variable": local, "value": "#ff0000"})), ("variable.update", json!({"variable": local, "name": "mine"})), ("variable.delete", json!({"variable": local})), ("variable.duplicate", json!({"variable": local})), ("variable.create", json!({"collection": coll, "name": "extra", "type": "number", "value": 1})), ("variable.addMode", json!({"collection": coll, "name": "Mine"})), ("variable.renameMode", json!({"collection": coll, "mode": "Dark", "name": "Night"})), ("variable.renameCollection", json!({"collection": coll, "name": "Mine"})), ("variable.deleteCollection", json!({"collection": coll})), ] { let e = s.execute(cmd, &p).unwrap_err(); assert!(e.to_string().contains("can't be edited"), "{cmd}: {e}"); } // Local edits still work. ex(&mut s, "variable.set", json!({"variable": "gap", "value": 3})); healthy(&s); // The library changes its variables and publishes again. ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#ff0000"})); ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Dark", "value": "#222222"})); ex(&mut lib, "variable.set", json!({"variable": "base", "value": "#00ffff"})); ex(&mut lib, "variable.renameMode", json!({"collection": "Theme", "mode": "Dark", "name": "Night"})); republish(&mut lib); let u = ex(&mut s, "library.updates", json!({})); assert_eq!(u["count"], 3, "primary, base and the collection: {u}"); assert_eq!(u["libraries"][0]["variables"].as_array().map(Vec::len), Some(2)); let r = ex(&mut s, "library.acceptUpdates", json!({})); assert_eq!((r["variables"].as_u64(), r["collections"].as_u64()), (Some(2), Some(1)), "{r}"); // Same copies (same ids), new values: the bound layers and the alias follow. assert_eq!(copy_of(&s, "primary").id, local); assert_eq!((fill(&s, inner), fill(&s, outer)), ("222222".into(), "FF0000".into())); assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FFFF")); assert_eq!(s.doc().unwrap().variables.collections[0].modes[1].name, "Night"); assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 0); // Undo takes the update back. ex(&mut s, "edit.undo", json!({})); assert_eq!(fill(&s, outer), "0000FF"); ex(&mut s, "edit.redo", json!({})); healthy(&s); // Saved and reopened, the copies, records and bindings stay; the file renders without the library. let user_path = path_str(&d.join("app.figcraft")); ex(&mut s, "file.saveAs", json!({"path": user_path})); std::fs::remove_file(&lib_path).unwrap(); let mut t = Session::empty(); ex(&mut t, "file.open", json!({"path": user_path})); assert_eq!(t.doc().unwrap().libraries, s.doc().unwrap().libraries); assert_eq!(fill(&t, outer), "FF0000"); assert!(t.execute("library.bindVariable", &json!({"library": lid, "variable": "primary", "field": "fill", "ids": [outer]})).is_err()); // Removing the library keeps the copies in use and drops the rest. ex(&mut s, "variable.bind", json!({"ids": [outer], "field": "cornerRadius", "variable": null})); ex(&mut s, "library.remove", json!({"id": lid})); let doc = s.doc().unwrap(); let names: Vec<&str> = doc.variables.variables.iter().filter(|v| is_remote_variable(doc, &v.id)).map(|v| v.name.as_str()).collect(); assert_eq!(names.len(), 3, "primary (bound), accent and base (aliased): {names:?}"); assert!(!names.contains(&"radius")); assert_eq!(doc.libraries.collections.len(), 1); healthy(&s); let _ = std::fs::remove_dir_all(&d); } #[test] fn library_components_keep_their_variable_bindings() { let d = dir("components"); let lib_path = path_str(&d.join("kit.figcraft")); let mut lib = Session::new(); theme(&mut lib); let f = idv(&ex(&mut lib, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Chip"}))); ex(&mut lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"})); ex(&mut lib, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "tmp"})); ex(&mut lib, "variable.setMode", json!({"ids": [f], "collection": "Theme", "mode": "Dark"})); let chip = idv(&ex(&mut lib, "component.create", json!({"ids": [f]}))); let lid = publish_to(&mut lib, &lib_path); // The private collection's variable comes along (not offered) for the component. let m = lib.doc().unwrap().published.clone().unwrap().manifest.clone(); assert!(m.variables.iter().any(|v| v.name == "tmp" && !v.published)); let mut s = Session::new(); ex(&mut s, "library.add", json!({"path": lib_path})); let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Chip", "x": 0, "y": 0}))); assert_eq!(fill(&s, inst), "FFFFFF", "the component's own mode comes along"); { let doc = s.doc().unwrap(); let node = doc.node(inst).unwrap(); let bound = node.bound.get("fill").unwrap(); assert!(is_remote_variable(doc, bound), "bound to the copy, not to a variable of the file"); assert!(node.var_modes.keys().all(|c| is_remote_collection(doc, c))); assert_eq!(doc.libraries.variables.len(), 2); } // A value of a mode the component doesn't show: only the variable changes. ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#444444"})); republish(&mut lib); let version = |lib: &Session| lib.doc().unwrap().published.as_ref().and_then(|p| p.manifest.component(chip)).map(|a| a.version); assert_eq!(version(&lib), Some(1), "the component didn't change"); assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 1); ex(&mut s, "library.acceptUpdates", json!({})); assert_eq!(fill(&s, inst), "FFFFFF"); let light = copy_of(&s, "primary").values.values().next().cloned(); assert!(matches!(light, Some(VariableValue::Color(c)) if c.to_hex() == "444444")); // The value it shows: the component (its resolved fill) and the variable update, the instance follows. ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Dark", "value": "#333333"})); republish(&mut lib); assert_eq!(version(&lib), Some(3)); assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 2); ex(&mut s, "library.acceptUpdates", json!({})); assert_eq!(fill(&s, inst), "333333"); healthy(&s); // Another file importing these pages brings the copies and their records along. let user_path = path_str(&d.join("user.figcraft")); ex(&mut s, "file.saveAs", json!({"path": user_path})); let mut t = Session::new(); ex(&mut t, "file.import", json!({"path": user_path})); let doc = t.doc().unwrap(); assert_eq!(doc.libraries.variables.len(), 2); assert_eq!(doc.libraries.collections.len(), 2); assert!(local_collections(doc).next().is_none()); healthy(&t); let _ = std::fs::remove_dir_all(&d); } #[test] fn library_variable_commands_fail_cleanly() { let mut s = Session::new(); for cmd in ["library.importVariable", "library.bindVariable", "library.aliasVariable", "library.setVariableMode"] { for p in [json!({}), json!({"library": 1}), json!({"library": "x", "variable": [], "alias": null, "collection": {}, "mode": 3})] { assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}"); } } assert!(s.doc().unwrap().libraries.is_empty()); } #[test] fn library_style_bindings_ignore_unrelated_local_variables() { let d = dir("style-bound"); let path = path_str(&d.join("lib.figcraft")); let mut lib = Session::new(); ex(&mut lib, "variable.createCollection", json!({"name": "Tokens"})); let var = ex(&mut lib, "variable.create", json!({"collection": "Tokens", "name": "Brand", "type": "color", "value": "#0000ff"}))["id"].clone(); let style = ex(&mut lib, "style.create", json!({"type": "paint", "name": "Brand", "value": "#0000ff"}))["id"].clone(); ex(&mut lib, "style.bind", json!({"style": style, "field": "fill", "variable": var})); let lid = publish_to(&mut lib, &path); let mut s = Session::new(); ex(&mut s, "variable.createCollection", json!({"name": "Local"})); let local = ex(&mut s, "variable.create", json!({"collection": "Local", "name": "Unrelated", "type": "color", "value": "#ff0000"}))["id"].clone(); assert_eq!(local, var, "the ids must collide for this test"); let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 40, "height": 40}))); ex(&mut s, "library.add", json!({"path": path})); ex(&mut s, "library.applyStyle", json!({"library": lid, "style": "Brand", "id": r})); assert_eq!(fill(&s, r), "0000FF"); healthy(&s); // An updated style follows the library's variable, not the local one. ex(&mut lib, "variable.set", json!({"variable": "Brand", "value": "#00ffff"})); republish(&mut lib); ex(&mut s, "library.acceptUpdates", json!({})); assert_eq!(fill(&s, r), "00FFFF"); healthy(&s); let _ = std::fs::remove_dir_all(&d); } /// A library "Modal" holding a Button instance whose icon copy is on mode Dark: (the Button main, /// the icon copy in Modal). fn nested_mode_kit(lib: &mut Session) -> (NodeId, NodeId) { theme(lib); let f = idv(&ex(lib, "frame.create", json!({"x": 0, "y": 0, "width": 20, "height": 20, "name": "Icon"}))); ex(lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"})); let icon = idv(&ex(lib, "component.create", json!({"ids": [f]}))); let b = idv(&ex(lib, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"}))); ex(lib, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": b})); let button = idv(&ex(lib, "component.create", json!({"ids": [b]}))); let m = idv(&ex(lib, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"}))); let close = idv(&ex(lib, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": m}))); let nested = lib.doc().unwrap().node(close).unwrap().children[0]; ex(lib, "variable.setMode", json!({"ids": [nested], "collection": "Theme", "mode": "Dark"})); ex(lib, "component.create", json!({"ids": [m]})); (button, nested) } /// The icon inside the Button inside library Modal instance `inst`. fn modal_icon(s: &Session, inst: NodeId) -> NodeId { let doc = s.doc().unwrap(); doc.node(doc.node(inst).unwrap().children[0]).unwrap().children[0] } #[test] fn modes_set_inside_nested_instances_come_along() { let d = dir("nested-modes"); let lib_path = path_str(&d.join("kit.figcraft")); let mut lib = Session::new(); let (button, _) = nested_mode_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 inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0}))); let copy = |s: &Session| modal_icon(s, inst); assert_eq!(fill(&s, copy(&s)), "FFFFFF", "the Dark mode set on the nested icon comes along"); // An update that rebuilds the Button copies keeps it too. ex(&mut lib, "node.set", json!({"ids": [button], "layout": "horizontal"})); republish(&mut lib); ex(&mut s, "library.acceptUpdates", json!({})); assert_eq!(fill(&s, copy(&s)), "FFFFFF"); healthy(&s); let _ = std::fs::remove_dir_all(&d); } #[test] fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_nested_instances() { let d = dir("nested-modes-old"); let lib_path = path_str(&d.join("kit.figcraft")); let mut lib = Session::new(); let (_, nested) = nested_mode_kit(&mut lib); // Published before `modes` was an override group: the copy has its mode but no marker. lib.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes"); let lid = publish_to(&mut lib, &lib_path); let mut s = Session::new(); ex(&mut s, "library.add", json!({"path": lib_path})); let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0}))); assert_eq!(fill(&s, modal_icon(&s, inst)), "FFFFFF", "the Dark mode comes along"); healthy(&s); let _ = std::fs::remove_dir_all(&d); }