mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 02:47:02 -06:00
Slots: a frame of a main component becomes a slot property (component.makeSlot / removeSlot, preferred components, type "slot" in component.properties). Instances show the main's default content until the slot is edited; from then on its layers are the instance's own (override group "slot"): free to add, remove and reorder, kept across main updates, variant switches, copy/paste and detach, and instances placed there stand alone. instance.slotInsert fills a slot, instance.resetSlot brings the default content back. Structure protection allows edits inside slots only; drops from the Assets panel, canvas drags, drawing and paste land in slots. UI: slot icon in the layers panel, dashed outlines and labels on the canvas, slot property rows with a fill menu in the properties panel, Make/Remove slot on frames inside a main. Nested overrides: overrides inside a nested instance survive the main moving it into another frame (layers matched by source alone when unique); a nested instance copied out of an instance is re-linked to its own main with its look kept (values set by component properties aren't overrides); plain layers copied out of instances stop mirroring them; canvas drags can no longer pull layers out of an instance or reorder its auto layout children. Tests for swaps, variant switches, main restructuring, detach and copy/paste. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
625 lines
35 KiB
Rust
625 lines
35 KiB
Rust
//! Component / instance behaviour tests (overrides, structure, nesting, variants, properties).
|
||
|
||
use super::*;
|
||
use crate::{EngineError, Session, params::id_of};
|
||
use serde_json::{Value, json};
|
||
|
||
pub(crate) fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||
match s.execute(cmd, &p) {
|
||
Ok(v) => v,
|
||
Err(e) => panic!("{cmd} {p}: {e}"),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn idv(v: &Value) -> NodeId {
|
||
id_of(&v["id"]).unwrap()
|
||
}
|
||
|
||
pub(crate) fn node(s: &Session, id: NodeId) -> Node {
|
||
s.doc().unwrap().node(id).unwrap().clone()
|
||
}
|
||
|
||
pub(crate) fn kids(s: &Session, id: NodeId) -> Vec<NodeId> {
|
||
node(s, id).children
|
||
}
|
||
|
||
pub(crate) 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()
|
||
}
|
||
|
||
pub(crate) fn chars(s: &Session, id: NodeId) -> String {
|
||
node(s, id).kind.text().map(|t| t.characters.clone()).unwrap_or_default()
|
||
}
|
||
|
||
pub(crate) 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));
|
||
}
|
||
|
||
/// The instance layer mirroring main layer `src`.
|
||
pub(crate) fn copy_of(s: &Session, inst: NodeId, src: NodeId) -> NodeId {
|
||
let doc = s.doc().unwrap();
|
||
doc.subtree(inst).into_iter().find(|i| doc.node(*i).and_then(|n| n.source) == Some(src)).unwrap()
|
||
}
|
||
|
||
/// Card: 200×100 component with "Bg" rectangle, "Title" text and "Dot" ellipse.
|
||
pub(crate) fn card(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
|
||
let bg =
|
||
idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": f, "props": {"name": "Bg", "fill": "#eeeeee"}})));
|
||
let title = idv(&ex(s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}})));
|
||
let dot =
|
||
idv(&ex(s, "shape.ellipse", json!({"x": 170, "y": 10, "width": 20, "height": 20, "parent": f, "props": {"name": "Dot", "fill": "#ff0000"}})));
|
||
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
|
||
(c, bg, title, dot)
|
||
}
|
||
|
||
#[test]
|
||
fn overrides_survive_main_edits() {
|
||
let mut s = Session::new();
|
||
let (c, bg, title, dot) = card(&mut s);
|
||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
|
||
healthy(&s);
|
||
let (ibg, ititle, idot) = (copy_of(&s, i, bg), copy_of(&s, i, title), copy_of(&s, i, dot));
|
||
// Overrides on the instance.
|
||
ex(&mut s, "node.set", json!({"id": ibg, "props": {"fill": "#00ff00", "stroke": "#0000ff", "strokeWeight": 2, "name": "Background"}}));
|
||
ex(&mut s, "node.set", json!({"id": ibg, "props": {"effects": [{"type": "dropShadow"}]}}));
|
||
ex(&mut s, "text.set", json!({"id": ititle, "characters": "Overridden"}));
|
||
ex(&mut s, "node.set", json!({"id": idot, "props": {"visible": false, "width": 30}}));
|
||
let ov = node(&s, ibg).overrides;
|
||
for g in ["fills", "strokes", "effects", "name"] {
|
||
assert!(ov.contains(g), "{g} in {ov:?}");
|
||
}
|
||
// Main edits: overridden groups stay, the rest follows.
|
||
ex(&mut s, "node.set", json!({"id": bg, "props": {"fill": "#123456", "cornerRadius": 8, "name": "Bg2", "strokeWeight": 5}}));
|
||
ex(&mut s, "text.set", json!({"id": title, "characters": "Main text"}));
|
||
ex(&mut s, "node.set", json!({"id": title, "props": {"fontSize": 30}}));
|
||
ex(&mut s, "node.set", json!({"id": dot, "props": {"fill": "#00ffff", "width": 10, "visible": true}}));
|
||
ex(&mut s, "node.set", json!({"id": c, "props": {"fill": "#fafafa"}}));
|
||
healthy(&s);
|
||
assert_eq!(copy_of(&s, i, bg), ibg, "ids are stable across syncs");
|
||
let n = node(&s, ibg);
|
||
assert_eq!(fill(&s, ibg), "00FF00");
|
||
assert_eq!(n.name, "Background");
|
||
assert_eq!(n.corners.radii, [8.0; 4], "non-overridden corners follow");
|
||
assert_eq!(n.stroke.weight, 2.0, "overridden stroke keeps its weight");
|
||
assert_eq!(n.effects.len(), 1);
|
||
assert_eq!(chars(&s, ititle), "Overridden");
|
||
assert_eq!(node(&s, ititle).kind.text().unwrap().style.font_size, 30.0, "text style follows the main");
|
||
let d = node(&s, idot);
|
||
assert!(!d.visible);
|
||
assert_eq!(d.width, 30.0, "size override kept");
|
||
assert_eq!(fill(&s, idot), "00FFFF");
|
||
assert_eq!(fill(&s, i), "FAFAFA");
|
||
// Per-group reset.
|
||
ex(&mut s, "component.resetOverride", json!({"id": ibg, "group": "fills"}));
|
||
assert_eq!(fill(&s, ibg), "123456");
|
||
assert_eq!(node(&s, ibg).name, "Background", "other overrides stay");
|
||
assert!(s.execute("component.resetOverride", &json!({"id": ibg, "group": "bogus"})).is_err());
|
||
// Reset all.
|
||
ex(&mut s, "component.resetInstance", json!({"ids": [i]}));
|
||
assert_eq!(node(&s, ibg).name, "Bg2");
|
||
assert_eq!(chars(&s, ititle), "Main text");
|
||
assert!(node(&s, idot).visible);
|
||
assert_eq!(node(&s, idot).width, 10.0);
|
||
assert!(s.doc().unwrap().subtree(i).iter().all(|d| node(&s, *d).overrides.is_empty()));
|
||
healthy(&s);
|
||
}
|
||
|
||
#[test]
|
||
fn main_structure_edits_propagate() {
|
||
let mut s = Session::new();
|
||
let (c, bg, title, dot) = card(&mut s);
|
||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
|
||
let idot = copy_of(&s, i, dot);
|
||
ex(&mut s, "node.set", json!({"id": idot, "props": {"fill": "#000000"}}));
|
||
// Add a layer.
|
||
let star = idv(&ex(&mut s, "shape.star", json!({"x": 100, "y": 50, "width": 20, "height": 20, "parent": c})));
|
||
healthy(&s);
|
||
assert_eq!(kids(&s, i).len(), 4);
|
||
// Reorder: dot to the bottom.
|
||
ex(&mut s, "node.reorder", json!({"id": dot, "index": 0}));
|
||
healthy(&s);
|
||
assert_eq!(kids(&s, i)[0], idot);
|
||
assert_eq!(fill(&s, idot), "000000");
|
||
// Remove layers.
|
||
ex(&mut s, "edit.delete", json!({"ids": [title, star]}));
|
||
healthy(&s);
|
||
assert_eq!(kids(&s, i).len(), 2);
|
||
assert_eq!(fill(&s, idot), "000000", "overrides on surviving layers stay");
|
||
assert_eq!(copy_of(&s, i, bg), kids(&s, i)[1]);
|
||
// Undo restores the layer in the instance too.
|
||
s.undo().unwrap();
|
||
assert_eq!(kids(&s, i).len(), 4);
|
||
healthy(&s);
|
||
}
|
||
|
||
#[test]
|
||
fn instances_refuse_structure_edits() {
|
||
let mut s = Session::new();
|
||
let (c, bg, _title, dot) = card(&mut s);
|
||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
|
||
let (ibg, idot) = (copy_of(&s, i, bg), copy_of(&s, i, dot));
|
||
let before = s.doc().unwrap().clone();
|
||
let undo = s.ds().unwrap().history.undo.len();
|
||
let refused = [
|
||
("edit.delete", json!({"ids": [ibg]})),
|
||
("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": i})),
|
||
("node.reorder", json!({"id": idot, "index": 0})),
|
||
("object.group", json!({"ids": [ibg, idot]})),
|
||
("node.reparent", json!({"ids": [idot], "parent": s.page().unwrap()})),
|
||
("edit.duplicate", json!({"ids": [idot]})),
|
||
("component.create", json!({"ids": [idot]})),
|
||
];
|
||
for (cmd, p) in refused {
|
||
let r = s.execute(cmd, &p);
|
||
assert!(matches!(r, Err(EngineError::Disabled(..)) | Err(EngineError::BadParams { .. })), "{cmd} should be refused: {r:?}");
|
||
assert_eq!(*s.doc().unwrap(), before, "{cmd} rolled back");
|
||
}
|
||
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
|
||
// Deleting the whole instance is fine.
|
||
ex(&mut s, "edit.delete", json!({"ids": [i]}));
|
||
healthy(&s);
|
||
}
|
||
|
||
/// Icon component (one "Shape" rectangle) and a Button component holding an Icon instance.
|
||
pub(crate) fn button(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 300, "width": 24, "height": 24, "name": "Icon"})));
|
||
let shape = idv(&ex(
|
||
s,
|
||
"shape.rectangle",
|
||
json!({"x": 2, "y": 2, "width": 20, "height": 20, "parent": f, "props": {"name": "Shape", "fill": "#111111"}}),
|
||
));
|
||
let icon = idv(&ex(s, "component.create", json!({"ids": [f]})));
|
||
let b = idv(&ex(s, "frame.create", json!({"x": 0, "y": 400, "width": 120, "height": 40, "name": "Button"})));
|
||
let nested = idv(&ex(s, "instance.create", json!({"component": icon, "x": 8, "y": 8, "parent": b})));
|
||
ex(s, "text.create", json!({"text": "Go", "x": 40, "y": 10, "parent": b, "props": {"name": "Label"}}));
|
||
let button = idv(&ex(s, "component.create", json!({"ids": [b]})));
|
||
(icon, shape, button, nested)
|
||
}
|
||
|
||
#[test]
|
||
fn nested_instances_and_swaps() {
|
||
let mut s = Session::new();
|
||
let (icon, shape, button, nested) = button(&mut s);
|
||
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
|
||
healthy(&s);
|
||
// The button instance's icon copy mirrors the main's nested instance.
|
||
let inested = copy_of(&s, i, nested);
|
||
let nested_shape = copy_of(&s, nested, shape);
|
||
let ishape = copy_of(&s, i, nested_shape);
|
||
assert!(matches!(node(&s, inested).kind, NodeKind::Instance { component, .. } if component == icon));
|
||
// Override inside the nested instance, then edit the Icon main.
|
||
ex(&mut s, "node.set", json!({"id": ishape, "props": {"fill": "#ff00ff"}}));
|
||
ex(&mut s, "node.set", json!({"id": shape, "props": {"fill": "#222222", "cornerRadius": 6}}));
|
||
healthy(&s);
|
||
assert_eq!(fill(&s, nested_shape), "222222", "main's nested instance follows the icon");
|
||
assert_eq!(fill(&s, ishape), "FF00FF", "override inside the nested copy kept");
|
||
assert_eq!(node(&s, ishape).corners.radii, [6.0; 4], "icon edit reaches the button instance");
|
||
// Main-level override on the nested instance reaches instances.
|
||
ex(&mut s, "node.set", json!({"id": nested_shape, "props": {"strokeWeight": 3, "stroke": "#00ff00"}}));
|
||
assert!(node(&s, nested_shape).overrides.contains("strokes"));
|
||
assert_eq!(node(&s, ishape).stroke.weight, 3.0);
|
||
// Swap the nested instance through an instance-swap property.
|
||
let f2 = idv(&ex(&mut s, "frame.create", json!({"x": 100, "y": 300, "width": 24, "height": 24, "name": "Star icon"})));
|
||
ex(&mut s, "shape.star", json!({"x": 0, "y": 0, "width": 24, "height": 24, "parent": f2, "props": {"name": "Glyph"}}));
|
||
ex(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 4, "height": 4, "parent": f2, "props": {"name": "Spark"}}));
|
||
let icon2 = idv(&ex(&mut s, "component.create", json!({"ids": [f2]})));
|
||
ex(&mut s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": icon, "layer": nested}));
|
||
healthy(&s);
|
||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": icon2}}));
|
||
healthy(&s);
|
||
let inested = copy_of(&s, i, nested);
|
||
assert!(matches!(node(&s, inested).kind, NodeKind::Instance { component, .. } if component == icon2));
|
||
assert_eq!(kids(&s, inested).len(), 2, "swapped nested instance shows the new main's layers");
|
||
// Main edits keep the swap; the swapped icon follows its own main.
|
||
ex(&mut s, "node.set", json!({"id": button, "props": {"fill": "#0000ff"}}));
|
||
ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 4, "height": 4, "parent": icon2}));
|
||
healthy(&s);
|
||
let inested = copy_of(&s, i, nested);
|
||
assert!(matches!(node(&s, inested).kind, NodeKind::Instance { component, .. } if component == icon2));
|
||
assert_eq!(kids(&s, inested).len(), 3);
|
||
// Swapping directly (instance.swap on the nested copy) is an override that survives main edits.
|
||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": icon}}));
|
||
let inested = copy_of(&s, i, nested);
|
||
ex(&mut s, "instance.swap", json!({"ids": [inested], "component": icon2}));
|
||
assert!(node(&s, inested).overrides.contains("component"));
|
||
ex(&mut s, "node.set", json!({"id": button, "props": {"fill": "#00ff00"}}));
|
||
healthy(&s);
|
||
assert!(matches!(node(&s, inested).kind, NodeKind::Instance { component, .. } if component == icon2));
|
||
ex(&mut s, "component.resetOverride", json!({"id": inested, "group": "component"}));
|
||
assert!(matches!(node(&s, inested).kind, NodeKind::Instance { component, .. } if component == icon));
|
||
healthy(&s);
|
||
// A nested instance can't be swapped into a component that contains it.
|
||
assert!(s.execute("instance.swap", &json!({"ids": [nested], "component": button})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn variant_switch_keeps_compatible_overrides() {
|
||
let mut s = Session::new();
|
||
let mut variants = Vec::new();
|
||
for (k, (state, color)) in [("Default", "#ff0000"), ("Hover", "#0000ff")].iter().enumerate() {
|
||
let f =
|
||
idv(&ex(&mut s, "frame.create", json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": format!("State={state}")})));
|
||
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"name": "Bg", "fill": color}}));
|
||
ex(&mut s, "text.create", json!({"text": *state, "x": 10, "y": 10, "parent": f, "props": {"name": "Label"}}));
|
||
variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
|
||
}
|
||
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()})));
|
||
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200})));
|
||
assert_eq!(node(&s, i).name, node(&s, set).name, "instances of variants are named after the set");
|
||
let label = kids(&s, i)[1];
|
||
ex(&mut s, "text.set", json!({"id": label, "characters": "Buy"}));
|
||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}}));
|
||
healthy(&s);
|
||
assert!(matches!(node(&s, i).kind, NodeKind::Instance { component, .. } if component == variants[1]));
|
||
let k = kids(&s, i);
|
||
assert_eq!(fill(&s, k[0]), "0000FF");
|
||
assert_eq!(chars(&s, k[1]), "Buy", "text override survives the variant switch");
|
||
assert!(s.execute("instance.setProperties", &json!({"ids": [i], "properties": {"State": "Pressed"}})).is_err());
|
||
assert!(s.execute("instance.setProperties", &json!({"ids": [i], "properties": {"Nope": 1}})).is_err());
|
||
// Adding a variant value names it Prop=Value and grows the set.
|
||
let h0 = node(&s, set).height;
|
||
let v3 = idv(&ex(&mut s, "component.addVariant", json!({"id": set, "property": "State", "value": "Pressed"})));
|
||
assert_eq!(node(&s, v3).name, "State=Pressed");
|
||
assert!(node(&s, set).height > h0);
|
||
assert!(s.execute("component.addVariant", &json!({"id": set, "property": "State", "value": "Pressed"})).is_err());
|
||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Pressed"}}));
|
||
assert!(matches!(node(&s, i).kind, NodeKind::Instance { component, .. } if component == v3));
|
||
// A second variant property, renamed, then a value renamed.
|
||
ex(&mut s, "component.addProperty", json!({"id": set, "name": "Size", "type": "variant", "default": "Large"}));
|
||
assert_eq!(node(&s, variants[0]).name, "State=Default, Size=Large");
|
||
ex(&mut s, "component.editProperty", json!({"id": set, "name": "Size", "newName": "Scale"}));
|
||
ex(&mut s, "component.editProperty", json!({"id": set, "name": "State", "value": "Hover", "newValue": "Over"}));
|
||
assert_eq!(node(&s, variants[1]).name, "State=Over, Scale=Large");
|
||
ex(&mut s, "component.deleteProperty", json!({"id": set, "name": "Scale"}));
|
||
assert_eq!(node(&s, variants[1]).name, "State=Over");
|
||
healthy(&s);
|
||
}
|
||
|
||
#[test]
|
||
fn detach_keeps_the_look() {
|
||
let mut s = Session::new();
|
||
let (icon, shape, button, nested) = button(&mut s);
|
||
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
|
||
let inested = copy_of(&s, i, nested);
|
||
let ishape = kids(&s, inested)[0];
|
||
let ilabel = kids(&s, i)[1];
|
||
ex(&mut s, "node.set", json!({"id": ishape, "props": {"fill": "#abcdef"}}));
|
||
ex(&mut s, "text.set", json!({"id": ilabel, "characters": "Detached"}));
|
||
ex(&mut s, "node.set", json!({"id": i, "props": {"fill": "#fedcba"}}));
|
||
// A nested instance alone can't be detached.
|
||
assert!(s.execute("component.detach", &json!({"ids": [inested]})).is_err());
|
||
ex(&mut s, "component.detach", json!({"ids": [i]}));
|
||
healthy(&s);
|
||
assert_eq!(node(&s, i).kind.type_name(), "FRAME");
|
||
assert_eq!(fill(&s, i), "FEDCBA");
|
||
assert_eq!(chars(&s, ilabel), "Detached");
|
||
assert!(node(&s, ilabel).source.is_none());
|
||
// The nested instance stays an instance of Icon, re-linked to it with its override kept.
|
||
assert!(matches!(node(&s, inested).kind, NodeKind::Instance { component, .. } if component == icon));
|
||
assert_eq!(node(&s, ishape).source, Some(shape));
|
||
assert_eq!(fill(&s, ishape), "ABCDEF");
|
||
ex(&mut s, "node.set", json!({"id": shape, "props": {"cornerRadius": 4, "fill": "#000000"}}));
|
||
assert_eq!(fill(&s, ishape), "ABCDEF");
|
||
assert_eq!(node(&s, ishape).corners.radii, [4.0; 4]);
|
||
// Detached layers are ordinary layers now.
|
||
ex(&mut s, "edit.delete", json!({"ids": [ilabel]}));
|
||
healthy(&s);
|
||
}
|
||
|
||
#[test]
|
||
fn deleted_main_keeps_instances_and_restores() {
|
||
let mut s = Session::new();
|
||
let (c, bg, title, _dot) = card(&mut s);
|
||
let a = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
|
||
let b = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 600, "y": 0})));
|
||
let atitle = copy_of(&s, a, title);
|
||
ex(&mut s, "text.set", json!({"id": atitle, "characters": "Instance A"}));
|
||
assert!(s.execute("component.restoreMain", &json!({"id": a})).is_err(), "main still exists");
|
||
ex(&mut s, "edit.delete", json!({"ids": [c]}));
|
||
healthy(&s);
|
||
assert_eq!(kids(&s, a).len(), 3, "instances keep working without their main");
|
||
assert!(s.execute("component.goToMain", &json!({"id": a})).is_err());
|
||
// Editing an orphan instance still works.
|
||
let bbg = copy_of(&s, b, bg);
|
||
ex(&mut s, "node.set", json!({"id": bbg, "props": {"fill": "#333333"}}));
|
||
// Restore from B: the main comes back with its old id in B's place.
|
||
let bx = s.doc().unwrap().world_bounds(b).unwrap();
|
||
let r = idv(&ex(&mut s, "component.restoreMain", json!({"id": b})));
|
||
assert_eq!(r, c);
|
||
assert!(!s.doc().unwrap().contains(b));
|
||
assert_eq!(s.doc().unwrap().world_bounds(c).unwrap(), bx);
|
||
assert!(matches!(node(&s, c).kind, NodeKind::Component { .. }));
|
||
healthy(&s);
|
||
// A re-links: main edits reach it, its override stays.
|
||
assert_eq!(node(&s, atitle).source, Some(title));
|
||
ex(&mut s, "node.set", json!({"id": bg, "props": {"cornerRadius": 9}}));
|
||
assert_eq!(node(&s, copy_of(&s, a, bg)).corners.radii, [9.0; 4]);
|
||
assert_eq!(fill(&s, copy_of(&s, a, bg)), "333333", "the restored main has B's look");
|
||
assert_eq!(chars(&s, atitle), "Instance A");
|
||
healthy(&s);
|
||
}
|
||
|
||
#[test]
|
||
fn resized_instances_follow_constraints_and_auto_layout() {
|
||
let mut s = Session::new();
|
||
let (c, bg, _title, dot) = card(&mut s);
|
||
ex(&mut s, "node.set", json!({"id": dot, "props": {"constraints": {"horizontal": "right"}}}));
|
||
ex(&mut s, "node.set", json!({"id": bg, "props": {"constraints": {"horizontal": "leftRight", "vertical": "topBottom"}}}));
|
||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
|
||
ex(&mut s, "node.set", json!({"id": i, "props": {"width": 300, "height": 150}}));
|
||
let (ibg, idot) = (copy_of(&s, i, bg), copy_of(&s, i, dot));
|
||
assert_eq!(node(&s, idot).transform.as_coeffs()[4], 270.0);
|
||
assert_eq!((node(&s, ibg).width, node(&s, ibg).height), (300.0, 150.0));
|
||
assert!(node(&s, idot).overrides.is_empty(), "constraint moves aren't overrides");
|
||
assert!(node(&s, i).overrides.contains("size"));
|
||
// A main edit keeps the instance's size and the constraint layout.
|
||
ex(&mut s, "node.set", json!({"id": dot, "props": {"fill": "#00ff00"}}));
|
||
assert_eq!(node(&s, i).width, 300.0);
|
||
assert_eq!(node(&s, idot).transform.as_coeffs()[4], 270.0);
|
||
assert_eq!(node(&s, ibg).width, 300.0);
|
||
// Main resized: instances without a size override follow it.
|
||
let j = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 700, "y": 0})));
|
||
ex(&mut s, "node.set", json!({"id": c, "props": {"width": 220}}));
|
||
assert_eq!(node(&s, j).width, 220.0);
|
||
assert_eq!(node(&s, i).width, 300.0);
|
||
// Auto layout: a longer text override makes a hugging instance grow.
|
||
let row = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 50, "height": 30, "name": "Chip"})));
|
||
let t = idv(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 0, "parent": row})));
|
||
ex(&mut s, "layout.add", json!({"ids": [row]}));
|
||
let chip = idv(&ex(&mut s, "component.create", json!({"ids": [row]})));
|
||
let ci = idv(&ex(&mut s, "instance.create", json!({"component": chip, "x": 300, "y": 600})));
|
||
let w0 = node(&s, ci).width;
|
||
let it = copy_of(&s, ci, t);
|
||
let tw0 = node(&s, it).width;
|
||
ex(&mut s, "text.set", json!({"id": it, "characters": "A much longer chip label"}));
|
||
// Without a measurable font (CI has no craft-fonts) the text keeps its size; only check the
|
||
// hug when the text actually grew.
|
||
if node(&s, it).width > tw0 {
|
||
assert!(node(&s, ci).width > w0, "auto layout instance hugs its overridden text");
|
||
}
|
||
healthy(&s);
|
||
}
|
||
|
||
#[test]
|
||
fn component_properties_apply_to_instances() {
|
||
let mut s = Session::new();
|
||
let (c, _bg, title, dot) = card(&mut s);
|
||
// Boolean bound to the dot's visibility (created from the selected layer).
|
||
ex(&mut s, "component.addProperty", json!({"id": dot, "name": "Show dot", "type": "boolean", "default": true}));
|
||
assert_eq!(node(&s, dot).prop_refs.get("visible").map(String::as_str), Some("Show dot"));
|
||
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title}));
|
||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
|
||
let (ititle, idot) = (copy_of(&s, i, title), copy_of(&s, i, dot));
|
||
assert_eq!(chars(&s, ititle), "Hello", "text property defaults to the layer's text");
|
||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false, "Title": "From prop"}}));
|
||
assert!(!node(&s, idot).visible);
|
||
assert_eq!(chars(&s, ititle), "From prop");
|
||
assert!(node(&s, idot).overrides.is_empty(), "property values aren't overrides");
|
||
// Main edits keep the property values.
|
||
ex(&mut s, "node.set", json!({"id": dot, "props": {"fill": "#00ff00"}}));
|
||
assert!(!node(&s, idot).visible);
|
||
assert_eq!(chars(&s, ititle), "From prop");
|
||
// Rename the property: bindings and instance values follow.
|
||
ex(&mut s, "component.editProperty", json!({"id": c, "name": "Title", "newName": "Heading", "default": "Default heading"}));
|
||
assert_eq!(node(&s, title).prop_refs.get("characters").map(String::as_str), Some("Heading"));
|
||
assert!(matches!(&node(&s, i).kind, NodeKind::Instance { props, .. } if props.get("Heading") == Some(&json!("From prop"))));
|
||
assert_eq!(chars(&s, ititle), "From prop");
|
||
let j = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 600, "y": 0})));
|
||
assert_eq!(chars(&s, copy_of(&s, j, title)), "Default heading");
|
||
// Wrong types and names are refused.
|
||
assert!(s.execute("instance.setProperties", &json!({"ids": [i], "properties": {"Show dot": "maybe"}})).is_err());
|
||
assert!(s.execute("component.bindProperty", &json!({"layer": dot, "property": "Heading"})).is_err());
|
||
assert!(s.execute("component.bindProperty", &json!({"layer": ititle, "property": "Heading"})).is_err());
|
||
assert!(s.execute("component.addProperty", &json!({"id": c, "name": "Heading", "type": "text"})).is_err());
|
||
// Unbind and delete.
|
||
ex(&mut s, "component.bindProperty", json!({"layer": dot, "property": null, "field": "visible"}));
|
||
assert!(node(&s, copy_of(&s, i, dot)).visible);
|
||
ex(&mut s, "component.deleteProperty", json!({"id": c, "name": "Heading"}));
|
||
assert!(node(&s, title).prop_refs.is_empty());
|
||
assert!(matches!(&node(&s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading")));
|
||
let desc = ex(&mut s, "component.properties", json!({"id": i}));
|
||
assert_eq!(desc["properties"].as_array().unwrap().len(), 1);
|
||
// A variant property on a lone component makes it a component set.
|
||
let set = idv(&ex(&mut s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"})));
|
||
assert!(matches!(node(&s, set).kind, NodeKind::ComponentSet { .. }));
|
||
assert_eq!(node(&s, c).name, "Theme=Light");
|
||
healthy(&s);
|
||
}
|
||
|
||
#[test]
|
||
fn push_changes_to_main() {
|
||
let mut s = Session::new();
|
||
let (c, bg, _title, _dot) = card(&mut s);
|
||
let a = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
|
||
let b = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 600, "y": 0})));
|
||
let abg = copy_of(&s, a, bg);
|
||
ex(&mut s, "node.set", json!({"id": abg, "props": {"fill": "#445566"}}));
|
||
ex(&mut s, "component.pushChanges", json!({"id": a}));
|
||
assert_eq!(fill(&s, bg), "445566");
|
||
assert_eq!(fill(&s, copy_of(&s, b, bg)), "445566");
|
||
assert!(node(&s, copy_of(&s, a, bg)).overrides.is_empty());
|
||
healthy(&s);
|
||
}
|
||
|
||
/// Random edits on mains and instances keep the document valid and instances mirroring mains.
|
||
#[test]
|
||
fn random_edit_sequences_keep_instances_consistent() {
|
||
struct Rng(u64);
|
||
impl Rng {
|
||
fn next(&mut self) -> u64 {
|
||
self.0 ^= self.0 << 13;
|
||
self.0 ^= self.0 >> 7;
|
||
self.0 ^= self.0 << 17;
|
||
self.0
|
||
}
|
||
fn pick<T: Copy>(&mut self, v: &[T]) -> Option<T> {
|
||
if v.is_empty() { None } else { v.get((self.next() % v.len() as u64) as usize).copied() }
|
||
}
|
||
}
|
||
for seed in 1..=std::env::var("FC_SEEDS").ok().and_then(|v| v.parse().ok()).unwrap_or(24u64) {
|
||
let mut r = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1);
|
||
let mut s = Session::new();
|
||
let (icon, _shape, button, _nested) = button(&mut s);
|
||
let (card_c, ..) = card(&mut s);
|
||
let comps = [icon, button, card_c];
|
||
for c in comps {
|
||
for k in 0..2 {
|
||
ex(&mut s, "instance.create", json!({"component": c, "x": 500 + k * 200, "y": c.0 * 10}));
|
||
}
|
||
}
|
||
for step in 0..60 {
|
||
let doc = s.doc().unwrap().clone();
|
||
let main_layers: Vec<NodeId> = comps.iter().flat_map(|c| doc.subtree(*c)).filter(|i| doc.contains(*i)).collect();
|
||
let inst_layers: Vec<NodeId> =
|
||
doc.nodes().filter(|n| n.source.is_some() && owning_instance(&doc, n.id).is_some()).map(|n| n.id).collect();
|
||
let roots: Vec<NodeId> =
|
||
doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. }) && !inside_instance(&doc, n.id)).map(|n| n.id).collect();
|
||
let containers: Vec<NodeId> = main_layers
|
||
.iter()
|
||
.copied()
|
||
.filter(|i| {
|
||
doc.node(*i).is_some_and(|n| n.kind.is_container())
|
||
&& !inside_instance(&doc, *i)
|
||
&& !matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Instance { .. }))
|
||
})
|
||
.collect();
|
||
let color = format!("#{:06x}", r.next() % 0xFF_FFFF);
|
||
let (cmd, p) = match r.next() % 18 {
|
||
14 => ("text.set", json!({"id": r.pick(&inst_layers), "characters": format!("T{}", r.next() % 9)})),
|
||
15 => ("component.detach", json!({"ids": [r.pick(&roots)]})),
|
||
16 => ("component.pushChanges", json!({"id": r.pick(&roots)})),
|
||
17 => ("node.set", json!({"id": r.pick(&inst_layers), "props": {"name": format!("I{}", r.next() % 3)}})),
|
||
0 => (
|
||
"shape.rectangle",
|
||
json!({"x": (r.next() % 50) as f64, "y": 5, "width": 8, "height": 8, "parent": r.pick(&containers), "props": {"name": format!("R{step}")}}),
|
||
),
|
||
1 => ("edit.delete", json!({"ids": [r.pick(&main_layers).filter(|i| !comps.contains(i))]})),
|
||
2 => ("node.reorder", json!({"id": r.pick(&main_layers), "index": r.next() % 3})),
|
||
3 => ("node.set", json!({"id": r.pick(&main_layers), "props": {"fill": color}})),
|
||
4 => ("node.set", json!({"id": r.pick(&main_layers), "props": {"name": format!("N{}", r.next() % 4)}})),
|
||
5 => ("node.set", json!({"id": r.pick(&inst_layers), "props": {"fill": color}})),
|
||
6 => ("node.set", json!({"id": r.pick(&inst_layers), "props": {"visible": r.next().is_multiple_of(2)}})),
|
||
7 => ("node.set", json!({"id": r.pick(&inst_layers), "props": {"width": 5 + r.next() % 40}})),
|
||
8 => ("node.set", json!({"id": r.pick(&roots), "props": {"width": 20 + r.next() % 200, "height": 20 + r.next() % 100}})),
|
||
9 => ("edit.delete", json!({"ids": [r.pick(&inst_layers)]})),
|
||
10 => ("component.resetInstance", json!({"ids": [r.pick(&roots)]})),
|
||
11 => ("component.resetOverride", json!({"id": r.pick(&inst_layers)})),
|
||
12 => (
|
||
"instance.swap",
|
||
json!({"ids": [r.pick(&inst_layers).filter(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Instance { .. })))], "component": r.pick(&[icon, card_c])}),
|
||
),
|
||
_ => ("node.set", json!({"id": r.pick(&main_layers), "props": {"width": 5 + r.next() % 120}})),
|
||
};
|
||
if std::env::var("FC_DUMP").ok().as_deref() == Some(&format!("{seed}:{step}")) {
|
||
for n in s.doc().unwrap().nodes() {
|
||
eprintln!(
|
||
"{} {:?} {} parent={:?} src={:?} kids={:?} ov={:?}",
|
||
n.id,
|
||
n.name,
|
||
match &n.kind {
|
||
NodeKind::Instance { component, .. } => format!("INST({component})"),
|
||
k => k.type_name().into(),
|
||
},
|
||
n.parent,
|
||
n.source,
|
||
n.children,
|
||
n.overrides
|
||
);
|
||
}
|
||
}
|
||
let res = s.execute(cmd, &p);
|
||
if let Err(EngineError::Internal { cmd, msg }) = &res {
|
||
panic!("seed {seed} step {step}: {cmd} panicked: {msg}");
|
||
}
|
||
let doc = s.doc().unwrap();
|
||
assert!(doc.validate().is_empty(), "seed {seed} step {step} {cmd} {p}: {:?}", doc.validate());
|
||
assert!(mirror_issues(doc).is_empty(), "seed {seed} step {step} {cmd} {p} -> {res:?}: {:?}", mirror_issues(doc));
|
||
}
|
||
// Undo everything: still valid at every step.
|
||
while s.undo().unwrap() {
|
||
assert!(s.doc().unwrap().validate().is_empty());
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn place_instances_into_frames_and_auto_layout_slots() {
|
||
let mut s = Session::new();
|
||
let c = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Chip"})));
|
||
let c = idv(&ex(&mut s, "component.create", json!({"ids": [c]})));
|
||
// A component made from one shape is named after it.
|
||
let star = idv(&ex(&mut s, "shape.star", json!({"x": 0, "y": 200, "width": 24, "height": 24, "name": "Icon / Star"})));
|
||
let sc = idv(&ex(&mut s, "component.create", json!({"ids": [star]})));
|
||
assert_eq!(node(&s, sc).name, "Icon / Star");
|
||
// On empty canvas: on the page, centred on the point.
|
||
let a = idv(&ex(&mut s, "instance.place", json!({"component": c, "x": 500, "y": 500})));
|
||
let b = s.doc().unwrap().world_bounds(a).unwrap();
|
||
assert_eq!((b.x0, b.y0), (480.0, 490.0));
|
||
assert_eq!(node(&s, a).parent, s.ds().unwrap().doc.first_page());
|
||
// Into a plain frame under the point (frame-relative position).
|
||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 1000, "y": 0, "width": 300, "height": 300})));
|
||
let r = ex(&mut s, "instance.place", json!({"component": c, "x": 1100, "y": 100}));
|
||
let i = idv(&r);
|
||
assert_eq!(node(&s, i).parent, Some(f));
|
||
assert_eq!(s.doc().unwrap().world_bounds(i).unwrap().x0, 1080.0);
|
||
// Into a horizontal auto layout row between its two children.
|
||
let row = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 300, "height": 40})));
|
||
let k1 = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": row})));
|
||
let k2 = idv(&ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 0, "width": 50, "height": 20, "parent": row})));
|
||
ex(&mut s, "layout.add", json!({"ids": [row]}));
|
||
let b1 = s.doc().unwrap().world_bounds(k1).unwrap();
|
||
let b2 = s.doc().unwrap().world_bounds(k2).unwrap();
|
||
let mid = (b1.x1 + b2.x0) / 2.0;
|
||
let j = idv(&ex(&mut s, "instance.place", json!({"component": c, "x": mid, "y": b1.center().y})));
|
||
assert_eq!(kids(&s, row), vec![k1, j, k2], "inserted between");
|
||
// One undo step.
|
||
s.undo().unwrap();
|
||
assert_eq!(kids(&s, row), vec![k1, k2]);
|
||
// Never inside its own main component: lands on the page instead.
|
||
let own = s.doc().unwrap().world_bounds(c).unwrap().center();
|
||
let k = idv(&ex(&mut s, "instance.place", json!({"component": c, "x": own.x, "y": own.y})));
|
||
assert_eq!(node(&s, k).parent, s.ds().unwrap().doc.first_page());
|
||
// Junk.
|
||
for j in [json!({}), json!({"component": c}), json!({"component": 99999, "x": 0, "y": 0}), json!({"component": c, "x": "NaN", "y": 1e308})] {
|
||
assert!(s.execute("instance.place", &j).is_err(), "{j}");
|
||
}
|
||
healthy(&s);
|
||
}
|
||
|
||
#[test]
|
||
fn exposed_nested_instances_show_their_properties_on_instances() {
|
||
let mut s = Session::new();
|
||
let (icon, shape, button, nested) = button(&mut s);
|
||
ex(&mut s, "component.addProperty", json!({"id": icon, "name": "Filled", "type": "boolean", "default": true, "layer": shape}));
|
||
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
|
||
let exposed = |s: &mut Session| ex(s, "instance.exposed", json!({"id": i}))["instances"].as_array().unwrap().len();
|
||
assert_eq!(exposed(&mut s), 0);
|
||
ex(&mut s, "component.exposeInstance", json!({"id": nested}));
|
||
assert!(node(&s, nested).exposed);
|
||
healthy(&s);
|
||
let inested = copy_of(&s, i, nested);
|
||
let list = ex(&mut s, "instance.exposed", json!({"id": i}));
|
||
assert_eq!(list["instances"][0]["id"], json!(inested.0));
|
||
// The nested copy's property is set from the outer instance and survives main edits.
|
||
ex(&mut s, "instance.setProperties", json!({"ids": [inested], "properties": {"Filled": false}}));
|
||
let ishape = copy_of(&s, inested, copy_of(&s, nested, shape));
|
||
assert!(!node(&s, ishape).visible);
|
||
ex(&mut s, "node.set", json!({"id": button, "props": {"fill": "#00ff00"}}));
|
||
let inested = copy_of(&s, i, nested);
|
||
let ishape = copy_of(&s, inested, copy_of(&s, nested, shape));
|
||
assert!(!node(&s, ishape).visible, "kept across main edits");
|
||
healthy(&s);
|
||
// Un-exposing hides it again; one undo step each.
|
||
ex(&mut s, "component.exposeInstance", json!({"id": nested, "exposed": false}));
|
||
assert_eq!(exposed(&mut s), 0);
|
||
s.undo().unwrap();
|
||
assert_eq!(exposed(&mut s), 1);
|
||
// Only nested instances inside a main can be exposed.
|
||
assert!(s.execute("component.exposeInstance", &json!({"id": i})).is_err());
|
||
assert!(s.execute("component.exposeInstance", &json!({"id": inested})).is_err());
|
||
assert!(s.execute("component.exposeInstance", &json!({"id": shape})).is_err());
|
||
}
|