Files
figcraft/crates/engine/src/fig_export_assets_tests.rs
T
bflatastic ac6adc2d54 M7: imported nested instances link to their main's layers
A nested instance opened from a .fig or file JSON document is first linked to its
own component; matching it to the outer main's layer then went by that component's
name instead of the layer's, so nested instances of variants (or under a name of
their own) and their layers stayed unlinked, and a second export lost what they
changed. They go by their own name until they mirror a layer.
2026-10-13 10:05:39 +07:00

209 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `.fig` export through `file.export` and back through `file.open`: variables with their modes,
//! the layers bound to them (also inside instances shown in another mode), shared styles, and
//! instances that are resized, fill an auto layout or were scaled come back as they were. The
//! format's own tests (written fields, damaged files) are in `figcraft_format::fig`.
use figcraft_doc::{Document, NodeKind};
use serde_json::{Value, json};
use crate::Session;
fn id(v: &Value) -> u64 {
v["id"].as_u64().unwrap()
}
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
s.execute(cmd, &params).unwrap_or_else(|e| panic!("{cmd}: {e:?}"))
}
/// A card component (a box that scales with it, a label), instances of it as it is, resized,
/// filling an auto layout frame and scaled; a theme with two modes bound to two frames, one of
/// them dark and holding another instance; a paint style on a rectangle.
fn build(s: &mut Session) {
let card = id(&run(s, "frame.create", json!({"x": 40, "y": 40, "width": 100, "height": 60, "name": "Card"})));
let boxed = json!({"fill": "#3B82F6", "name": "Box", "constraints": {"horizontal": "scale", "vertical": "scale"}});
run(s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40, "parent": card, "props": boxed}));
let label = id(&run(s, "text.create", json!({"text": "Card", "x": 56, "y": 20, "parent": card, "props": {"name": "Label"}})));
run(s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
run(s, "variable.create", json!({"collection": "Theme", "name": "bg", "type": "color", "values": {"Light": "#FFFFFF", "Dark": "#111827"}}));
run(s, "variable.create", json!({"collection": "Theme", "name": "fg", "type": "color", "values": {"Light": "#111827", "Dark": "#F9FAFB"}}));
run(s, "variable.create", json!({"collection": "Theme", "name": "gap", "type": "number", "values": {"Light": 8, "Dark": 16}}));
run(s, "variable.bind", json!({"ids": [card], "field": "fill", "variable": "bg"}));
run(s, "variable.bind", json!({"ids": [label], "field": "fill", "variable": "fg"}));
let comp = id(&run(s, "component.create", json!({"ids": [card]})));
let same = id(&run(s, "instance.create", json!({"component": comp, "x": 200, "y": 40})));
run(s, "node.set", json!({"ids": [same], "name": "Same"}));
let resized = id(&run(s, "instance.create", json!({"component": comp, "x": 360, "y": 40})));
run(s, "node.set", json!({"ids": [resized], "name": "Resized", "width": 200, "height": 120}));
let row = id(&run(s, "frame.create", json!({"x": 40, "y": 220, "width": 400, "height": 100, "name": "Row"})));
run(s, "layout.add", json!({"ids": [row], "direction": "horizontal"}));
run(s, "node.set", json!({"ids": [row], "width": 400, "height": 100, "sizingH": "fixed", "sizingV": "fixed"}));
run(s, "variable.bind", json!({"ids": [row], "field": "gap", "variable": "gap"}));
let filling = id(&run(s, "instance.create", json!({"component": comp, "parent": row})));
run(s, "node.set", json!({"ids": [filling], "name": "Filling", "sizingH": "fill", "sizingV": "fill"}));
let scaled = id(&run(s, "instance.create", json!({"component": comp, "x": 620, "y": 40})));
run(s, "node.set", json!({"ids": [scaled], "name": "Scaled"}));
run(s, "transform.scale", json!({"ids": [scaled], "factor": 1.5}));
let dark = id(&run(s, "frame.create", json!({"x": 40, "y": 360, "width": 300, "height": 120, "name": "Dark"})));
run(s, "variable.bind", json!({"ids": [dark], "field": "fill", "variable": "bg"}));
run(s, "variable.setMode", json!({"ids": [dark], "collection": "Theme", "mode": "Dark"}));
let inside = id(&run(s, "instance.create", json!({"component": comp, "parent": dark, "x": 20, "y": 20})));
run(s, "node.set", json!({"ids": [inside], "name": "In the dark"}));
let swatch =
id(&run(s, "shape.rectangle", json!({"x": 520, "y": 360, "width": 100, "height": 100, "props": {"fill": "#F59E0B", "name": "Swatch"}})));
run(s, "style.create", json!({"type": "paint", "name": "Brand/Accent", "from": swatch}));
run(s, "style.apply", json!({"ids": [swatch], "style": "Brand/Accent"}));
}
/// Every layer, depth-first: name, kind, place, size, first fill colour, what it is bound to and
/// the styles and modes it names (by name: a round trip gives new ids).
fn outline(doc: &Document) -> Vec<String> {
let var = |v: &String| doc.variables.get(v).map_or("?", |x| x.name.as_str()).to_string();
let style = |r: &Option<String>| r.as_ref().and_then(|i| doc.style(i)).map(|x| x.name.clone());
doc.pages
.iter()
.flat_map(|p| doc.subtree(*p))
.filter_map(|i| doc.node(i))
.map(|n| {
let t = n.transform.translation();
let fill = n.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex());
let bound: Vec<(&str, String)> = n.bound.iter().map(|(k, v)| (k.as_str(), var(v))).collect();
let modes: Vec<(String, String)> = n
.var_modes
.iter()
.filter_map(|(c, m)| doc.variables.collection(c).map(|c| (c.name.clone(), c.find_mode(m).map_or("?".into(), |m| m.name.clone()))))
.collect();
let size = n.kind.text().map(|t| t.style.font_size);
format!(
"{} {:?} ({:.1}, {:.1}) {:.1}×{:.1} weight {:.2} font {size:?} fill {fill:?} bound {bound:?} modes {modes:?} style {:?} gap {:?}",
n.kind.type_name(),
n.name,
t.x,
t.y,
n.width,
n.height,
n.stroke.weight,
style(&n.styles.fill),
n.layout.as_ref().map(|l| l.gap),
)
})
.collect()
}
#[test]
fn variables_styles_and_instance_sizes_come_back() {
let mut s = Session::new();
build(&mut s);
let before = outline(s.doc().unwrap());
// What the outline shows: the instance in the dark frame takes the dark values.
assert!(before.iter().any(|l| l.contains("\"In the dark\"") && l.contains("111827")), "{before:#?}");
assert!(before.iter().any(|l| l.contains("\"Resized\"") && l.contains("200.0×120.0")), "{before:#?}");
let path = std::env::temp_dir().join(format!("figcraft-fig-assets-{}.fig", std::process::id()));
let r = run(&mut s, "file.export", json!({"format": "fig", "path": path.to_string_lossy()}));
assert_eq!(r["warnings"], json!([]));
let r = run(&mut s, "file.open", json!({"path": path.to_string_lossy()}));
assert_eq!(r["warnings"], json!([]));
let _ = std::fs::remove_file(&path);
let doc = s.doc().unwrap();
assert_eq!(outline(doc), before);
// Collections, modes, variables and styles themselves.
let theme = doc.variables.collection("Theme").unwrap();
assert_eq!(theme.modes.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Light", "Dark"]);
assert_eq!(doc.variables.variables.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["bg", "fg", "gap"]);
assert_eq!(doc.styles.iter().map(|x| x.name.as_str()).collect::<Vec<_>>(), ["Brand/Accent"]);
// The instances are linked to the main again, layer by layer.
for inst in doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })) {
assert!(inst.children.iter().all(|c| doc.node(*c).is_some_and(|c| c.source.is_some())), "{}", inst.name);
}
// Switching the dark frame back to light recolours what is in it: the bindings are live.
let dark = doc.nodes().find(|n| n.name == "Dark").unwrap().id.0;
run(&mut s, "variable.setMode", json!({"ids": [dark], "collection": "Theme", "mode": "Light"}));
let after = outline(s.doc().unwrap());
assert!(after.iter().any(|l| l.contains("\"In the dark\"") && l.contains("FFFFFF")), "{after:#?}");
}
#[test]
fn every_example_keeps_its_variables_and_styles() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples");
let Ok(files) = std::fs::read_dir(dir) else { return };
for p in files.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "figcraft")) {
let mut s = Session::new();
run(&mut s, "file.open", json!({"path": p.to_string_lossy()}));
let before = outline(s.doc().unwrap());
let counts = |d: &Document| (d.variables.collections.len(), d.variables.variables.len(), d.styles.len());
let had = counts(s.doc().unwrap());
let out = std::env::temp_dir().join(format!("figcraft-fig-assets-{}-{}.fig", std::process::id(), p.file_stem().unwrap().to_string_lossy()));
let r = run(&mut s, "file.export", json!({"format": "fig", "path": out.to_string_lossy()}));
let warnings = r["warnings"].to_string();
assert!(!warnings.contains("variable") && !warnings.contains("style"), "{}: {warnings}", p.display());
run(&mut s, "file.open", json!({"path": out.to_string_lossy()}));
let _ = std::fs::remove_file(&out);
assert_eq!(counts(s.doc().unwrap()), had, "{}", p.display());
// Names, paints, bindings, modes and style links (the format's tests compare the geometry,
// which 32-bit floats round).
let links = |lines: &[String]| -> Vec<String> {
lines.iter().map(|l| format!("{} |{}", l.split(" (").next().unwrap_or(""), l.split(" fill ").nth(1).unwrap_or(""))).collect()
};
let after = outline(s.doc().unwrap());
assert_eq!(after.len(), before.len(), "{}", p.display());
for (a, b) in links(&after).iter().zip(&links(&before)) {
assert_eq!(a, b, "{}", p.display());
}
}
}
/// A component holding an instance of a variant, under a name of its own: opened from a `.fig`
/// file, instances of that component link every layer to the main's, the nested instance and its
/// layers included (a nested instance is first linked to its own component, whose name is not the
/// layer's).
#[test]
fn nested_instances_of_variants_link_to_their_mains_layers() {
let mut s = Session::new();
let variant = |s: &mut Session, y: i64, fill: &str| {
let f = id(&run(s, "frame.create", json!({"x": 0, "y": y, "width": 120, "height": 80, "name": "Scene"})));
run(s, "node.set", json!({"ids": [f], "fill": fill}));
run(s, "shape.ellipse", json!({"x": 70, "y": 10, "width": 30, "height": 30, "parent": f, "props": {"name": "Sun"}}));
id(&run(s, "component.create", json!({"ids": [f]})))
};
let (day, dusk) = (variant(&mut s, 0, "#8FCDF2"), variant(&mut s, 120, "#4A3A8E"));
run(&mut s, "node.set", json!({"ids": [day], "name": "Time=Day"}));
run(&mut s, "node.set", json!({"ids": [dusk], "name": "Time=Dusk"}));
run(&mut s, "component.combineVariants", json!({"ids": [day, dusk]}));
let card = id(&run(&mut s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 140, "name": "Card"})));
let photo = id(&run(&mut s, "instance.create", json!({"component": day, "parent": card, "x": 10, "y": 10})));
run(&mut s, "node.set", json!({"ids": [photo], "name": "Photo", "width": 180, "height": 120}));
let card = id(&run(&mut s, "component.create", json!({"ids": [card]})));
let first = id(&run(&mut s, "instance.create", json!({"component": card, "x": 300, "y": 200})));
let second = id(&run(&mut s, "instance.create", json!({"component": card, "x": 600, "y": 200})));
run(&mut s, "node.set", json!({"ids": [first], "name": "First"}));
run(&mut s, "node.set", json!({"ids": [second], "name": "Second"}));
// The second one shows the other variant.
let nested = s.doc().unwrap().node(figcraft_doc::NodeId(second)).unwrap().children[0].0;
run(&mut s, "instance.swap", json!({"ids": [nested], "component": dusk}));
let before = outline(s.doc().unwrap());
let path = std::env::temp_dir().join(format!("figcraft-fig-nested-{}.fig", std::process::id()));
run(&mut s, "file.export", json!({"format": "fig", "path": path.to_string_lossy()}));
let r = run(&mut s, "file.open", json!({"path": path.to_string_lossy()}));
assert_eq!(r["warnings"], json!([]));
let doc = s.doc().unwrap();
assert_eq!(outline(doc), before);
let mut nested = 0;
for inst in doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })) {
let inside = doc.ancestors(inst.id).iter().any(|a| doc.node(*a).is_some_and(|x| matches!(x.kind, NodeKind::Instance { .. })));
nested += usize::from(inside);
for layer in doc.subtree(inst.id).into_iter().skip(1).filter_map(|i| doc.node(i)) {
assert!(layer.source.is_some(), "{} in {}", layer.name, inst.name);
}
// Inside another instance, a nested instance mirrors the main's layer, not its component.
if let (true, NodeKind::Instance { component, .. }) = (inside, &inst.kind) {
assert_ne!(inst.source, Some(*component), "{}", inst.name);
}
}
assert_eq!(nested, 2);
// Exported and opened once more, nothing moves: the links were what the export needed.
run(&mut s, "file.export", json!({"format": "fig", "path": path.to_string_lossy()}));
run(&mut s, "file.open", json!({"path": path.to_string_lossy()}));
let _ = std::fs::remove_file(&path);
assert_eq!(outline(s.doc().unwrap()), before);
}