Files
figcraft/crates/engine/src/library_swap_tests.rs
T
BFlat 09600d6da2 M9: team libraries on the server; swap library (#117)
A file's library can be published to figcraft-server and enabled in other files by a share link (view access is enough); placing, styles, variables and library updates with overrides kept work as for local libraries, with a background check while the Assets panel is open. library.swap re-points instances, styles and variables to another library by name (one undo step, unmatched items listed). The server keeps per-file usage reports (library.usage).
2026-10-11 13:05:46 +07:00

117 lines
6.4 KiB
Rust

//! Swapping a library: instances, styles and variables move to the other library's assets of the
//! same names (overrides kept), what has no match stays and is reported, one undo step.
use figcraft_doc::*;
use serde_json::{Value, json};
use crate::Session;
use crate::library::{is_remote_style, library_of};
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()
}
/// A scratch directory, removed when dropped.
struct Scratch(std::path::PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A library file at `path` with "Button" (filled `color`, a "Label" text), a "Brand" paint style,
/// a "Theme" collection with an `accent` colour, and `extra` components; returns its id.
fn library(path: &str, color: &str, extra: &[&str]) -> String {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Button"})));
ex(&mut s, "text.create", json!({"text": "OK", "x": 10, "y": 10, "parent": f, "props": {"name": "Label"}}));
ex(&mut s, "node.set", json!({"id": f, "props": {"fill": color}}));
ex(&mut s, "component.create", json!({"ids": [f]}));
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 100, "width": 10, "height": 10})));
ex(&mut s, "node.set", json!({"id": r, "props": {"fill": color}}));
ex(&mut s, "style.create", json!({"type": "paint", "name": "Brand", "from": r}));
ex(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "accent", "type": "color", "value": color}));
for (i, name) in extra.iter().enumerate() {
let e = idv(&ex(&mut s, "frame.create", json!({"x": 200 + 50 * i, "y": 0, "width": 20, "height": 20, "name": name})));
ex(&mut s, "component.create", json!({"ids": [e]}));
}
let id = ex(&mut s, "library.publish", json!({}))["id"].as_str().unwrap().to_string();
ex(&mut s, "file.saveAs", json!({"path": path}));
id
}
fn main_of(s: &Session, inst: NodeId) -> NodeId {
match s.doc().unwrap().node(inst).unwrap().kind {
NodeKind::Instance { component, .. } => component,
_ => panic!("not an instance"),
}
}
#[test]
fn swap_moves_instances_styles_and_variables_by_name() {
let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-{}", std::process::id())));
std::fs::create_dir_all(&dir.0).unwrap();
let (pa, pb) = (dir.0.join("a.figcraft").to_string_lossy().into_owned(), dir.0.join("b.figcraft").to_string_lossy().into_owned());
let a = library(&pa, "#0000ff", &["Only in A"]);
let b = library(&pb, "#00ff00", &[]);
let mut s = Session::new();
ex(&mut s, "library.add", json!({"path": pa}));
ex(&mut s, "library.add", json!({"path": pb}));
let inst = idv(&ex(&mut s, "library.place", json!({"library": a, "component": "Button", "x": 0, "y": 0})));
let label = s.doc().unwrap().node(inst).unwrap().children[0];
ex(&mut s, "text.set", json!({"id": label, "characters": "Buy"}));
let only = idv(&ex(&mut s, "library.place", json!({"library": a, "component": "Only in A", "x": 300, "y": 0})));
let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 200, "width": 10, "height": 10})));
ex(&mut s, "library.applyStyle", json!({"library": a, "style": "Brand", "ids": [rect]}));
let dot = idv(&ex(&mut s, "shape.rectangle", json!({"x": 50, "y": 200, "width": 10, "height": 10})));
ex(&mut s, "library.bindVariable", json!({"library": a, "variable": "accent", "field": "fill", "ids": [dot]}));
let extended = ex(&mut s, "library.extendCollection", json!({"library": a, "collection": "Theme"}))["id"].clone();
let before = s.doc().unwrap().clone();
let r = ex(&mut s, "library.swap", json!({"from": a, "to": b}));
assert_eq!((r["components"].as_u64(), r["styles"].as_u64(), r["variables"].as_u64()), (Some(1), Some(1), Some(1)), "{r}");
assert_eq!(r["unmatched"], json!(["component Only in A", "extended collection Theme (extended)"]));
assert_eq!(r["removed"], false, "Only in A and the extension still use library A");
let doc = s.doc().unwrap();
assert_eq!(library_of(doc, main_of(&s, inst)), Some(b.as_str()), "the instance shows B's Button");
assert_eq!(library_of(doc, main_of(&s, only)), Some(a.as_str()), "no match: still A's");
let label = doc.node(inst).unwrap().children[0];
assert_eq!(doc.node(label).unwrap().kind.text().map(|t| t.characters.as_str()), Some("Buy"), "the override stays");
let fill = |id: NodeId| doc.node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default();
assert_eq!(fill(inst), "00FF00", "B's Button");
let style = doc.node(rect).unwrap().styles.fill.clone().unwrap();
assert!(is_remote_style(doc, &style) && doc.libraries.style_import(&style).is_some_and(|r| r.library == b));
assert_eq!(fill(rect), "00FF00", "the style's paint follows");
let var = doc.node(dot).unwrap().bound.get("fills/0/color").or_else(|| doc.node(dot).unwrap().bound.values().next()).cloned().unwrap();
assert!(doc.libraries.variable_import(&var).is_some_and(|r| r.library == b), "bound to B's accent");
assert!(doc.libraries.styles.iter().all(|r| r.library == b), "A's unused style copy is gone");
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
// One undo step brings A back.
s.undo().unwrap();
let doc = s.doc().unwrap();
assert_eq!(library_of(doc, main_of(&s, inst)), Some(a.as_str()));
assert_eq!(doc.libraries.as_ref(), before.libraries.as_ref());
// Swapping what has a match for everything removes the old library.
ex(&mut s, "edit.delete", json!({"ids": [only]}));
ex(&mut s, "variable.deleteCollection", json!({"collection": extended}));
let r = ex(&mut s, "library.swap", json!({"from": a, "to": b}));
assert_eq!((r["unmatched"].as_array().map(Vec::len), &r["removed"]), (Some(0), &json!(true)), "{r}");
assert!(s.doc().unwrap().libraries.enabled.iter().all(|l| l.id != a));
// The same library, or one not enabled, is refused.
assert!(s.execute("library.swap", &json!({"from": b, "to": b})).is_err());
assert!(s.execute("library.swap", &json!({"from": a, "to": b})).is_err());
}