mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 10:56:48 -06:00
**A components and variants behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule of instances (what an instance overrides and what it can't, how overrides persist when the main changes, reset), variants (which overrides survive a switch, the default variant, property order, duplicate combinations), swaps (instance menu vs Assets panel), exposed nested instances, detach / reset all / push changes / go to main, resizing and auto layout instances is a table row with the values the rule implies and a one-line derivation; where the documentation is silent the row says so and pins today's behaviour. Nested instance overrides stay covered by `nested_override_tests`, slots by `components_tests`, accepting library updates with overrides kept by `library_tests` / `components_scale_tests` (#91). **Fixes (9 of the 10 new tests fail on the old code)** - **Overrides that were silently lost**: auto layout **padding**, **gap** and **alignment** (three override groups, so a padding override still follows the main's gap), **layout grids**, **export settings** and **prototype interactions** on layers inside instances, and every **text property** (resizing, paragraph spacing and indent, lists, max lines, OpenType features) were reset by the next edit of the main. They are override groups now: kept when the main changes that property, carried by variant switches and swaps, pushed to the main, reset per group, and listed in the Overrides section. REST imports map the matching `overriddenFields`. An instance's own export settings and interactions stay its own, like its place (as before). - **What an instance can't override is refused** (rolled back with a message, one place for every path: commands, MCP, gestures): moving, rotating, flipping, nudging or aligning a layer inside an instance (auto layout items included: their place is the layout's), resizing it (text boxes keep their main's bounds; text whose box follows its content still grows), changing its constraints or resizing (hug / fill / min / max / absolute), and an instance's auto layout direction or other auto layout settings (only padding, gap and alignment are its own). Before, these became `position` / `size` overrides or were lost on the next main edit. Resizing the instance itself still works and its layers follow the main's constraints. Existing `size` / `position` overrides in files are still read and kept. - **Variant switches** keep an override only where the layer's name path matches *and* both variants had the same value for that property; text layers keep all their changes (by name and hierarchy). Before, every same-named override survived, even where the new variant differs (a hover background overridden back to the default one). The instance's own overrides follow the same rule. Undo brings the dropped overrides back. - **Alt-drop swaps from the Assets panel** (`instance.swapAt`, `library.swapAt`) keep only text layers' overrides; the instance menu's swap (`instance.swap`) still keeps every override on same-named layers. - **Default variant = the top-left one** (was the first in layer order): new instances of a set, the set's Assets thumbnail, generated code, `component.properties` defaults and the panel's default dropdown. Picking another default moves that variant to the top-left corner (one undo step). - **Hidden exposed instances hide their properties** (`instance.exposed`). - **Push changes from a nested layer** goes to the outer instance's main, never into the nested instance's own main; it reuses the override copy code, so every group above is pushed too. **Added** - **Variant property order**: `component.editProperty {name, index}` moves a variant property; every variant's name follows. - **Duplicate combinations**: `component.properties` reports `conflicts` (variants sharing one combination of values), and the component panel warns until each has a unique one. - **Design panel**: Position, Rotation, Constraints, Dimensions / resizing and grid cell fields are greyed out (with a tooltip) for layers inside an instance; the auto layout direction, wrap, strokes / canvas stacking and add / remove auto layout are greyed out or hidden for instances. New strings go through `tr(...)` with `en.toml` / `es.toml` entries; disabled controls are announced as disabled.
724 lines
36 KiB
Rust
724 lines
36 KiB
Rust
//! Components and variants behaviour suite: each documented rule of instances, overrides, variants,
|
||
//! swaps, detach / reset / push and resizing is a table row with a one-line derivation. Where the
|
||
//! documentation is silent the row says so and pins today's behaviour. Nested instance overrides
|
||
//! are covered in `nested_override_tests`, slots in `components_tests`, accepting library updates
|
||
//! with overrides kept in `library_tests` and `components_scale_tests`.
|
||
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::Session;
|
||
use crate::components::tests::{chars, copy_of, ex, fill, healthy, idv, kids, node};
|
||
use figcraft_doc::*;
|
||
|
||
/// The card: a 200×100 component with a "Bg" rectangle, a "Title" text and a "Row" auto layout
|
||
/// frame (padding 4, gap 2) holding a "Chip" rectangle; and one instance of it.
|
||
struct Kit {
|
||
card: NodeId,
|
||
bg: NodeId,
|
||
title: NodeId,
|
||
row: NodeId,
|
||
chip: NodeId,
|
||
inst: NodeId,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
enum Layer {
|
||
Root,
|
||
Bg,
|
||
Title,
|
||
Row,
|
||
Chip,
|
||
}
|
||
|
||
impl Kit {
|
||
fn main(&self, l: Layer) -> NodeId {
|
||
match l {
|
||
Layer::Root => self.card,
|
||
Layer::Bg => self.bg,
|
||
Layer::Title => self.title,
|
||
Layer::Row => self.row,
|
||
Layer::Chip => self.chip,
|
||
}
|
||
}
|
||
|
||
/// The instance's copy of main layer `l`.
|
||
fn copy(&self, s: &Session, l: Layer) -> NodeId {
|
||
match l {
|
||
Layer::Root => self.inst,
|
||
_ => copy_of(s, self.inst, self.main(l)),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn kit(s: &mut Session) -> Kit {
|
||
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 row = idv(&ex(s, "frame.create", json!({"x": 10, "y": 50, "width": 120, "height": 30, "parent": f, "name": "Row"})));
|
||
ex(s, "node.set", json!({"id": row, "props": {"layout": "horizontal", "padding": 4, "gap": 2}}));
|
||
let chip = idv(&ex(
|
||
s,
|
||
"shape.rectangle",
|
||
json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": row, "props": {"name": "Chip", "fill": "#333333"}}),
|
||
));
|
||
let card = idv(&ex(s, "component.create", json!({"ids": [f]})));
|
||
let inst = idv(&ex(s, "instance.create", json!({"component": card, "x": 300, "y": 0})));
|
||
Kit { card, bg, title, row, chip, inst }
|
||
}
|
||
|
||
fn undo_len(s: &Session) -> usize {
|
||
s.ds().unwrap().history.undo.len()
|
||
}
|
||
|
||
fn component_of(s: &Session, id: NodeId) -> NodeId {
|
||
match node(s, id).kind {
|
||
NodeKind::Instance { component, .. } => component,
|
||
_ => panic!("{id} is not an instance"),
|
||
}
|
||
}
|
||
|
||
fn text(n: &Node) -> &TextData {
|
||
n.kind.text().unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn overridable_properties_stay_through_main_edits_and_reset() {
|
||
// (what, layer, group, override on the instance, the same property edited on the main, read,
|
||
// derivation)
|
||
type Row = (&'static str, Layer, &'static str, Value, Option<Value>, fn(&Node) -> String, &'static str);
|
||
let grid = serde_json::to_value(LayoutGrid::default()).unwrap();
|
||
let export = serde_json::to_value(ExportSetting::next_after(None)).unwrap();
|
||
let click = json!({"trigger": {"type": "onClick"}, "actions": [{"type": "back"}]});
|
||
let rows: Vec<Row> = vec![
|
||
(
|
||
"fill",
|
||
Layer::Bg,
|
||
"fills",
|
||
json!({"fill": "#00ff00"}),
|
||
Some(json!({"fill": "#123456"})),
|
||
|n| format!("{:?}", n.fills),
|
||
"\"change the fill … of any layers\"",
|
||
),
|
||
(
|
||
"fill of the instance itself",
|
||
Layer::Root,
|
||
"fills",
|
||
json!({"fill": "#abcdef"}),
|
||
Some(json!({"fill": "#fedcba"})),
|
||
|n| format!("{:?}", n.fills),
|
||
"the instance is a layer too",
|
||
),
|
||
(
|
||
"stroke",
|
||
Layer::Bg,
|
||
"strokes",
|
||
json!({"stroke": "#0000ff", "strokeWeight": 3}),
|
||
Some(json!({"strokeWeight": 7})),
|
||
|n| format!("{:?} {:?}", n.strokes, n.stroke),
|
||
"\"change the … stroke of any layers\"",
|
||
),
|
||
(
|
||
"effects",
|
||
Layer::Bg,
|
||
"effects",
|
||
json!({"effects": [{"type": "dropShadow"}]}),
|
||
Some(json!({"effects": [{"type": "dropShadow"}, {"type": "dropShadow"}]})),
|
||
|n| n.effects.len().to_string(),
|
||
"\"add, edit, or remove any shadow or blur effects\"",
|
||
),
|
||
("visibility", Layer::Bg, "visible", json!({"visible": false}), None, |n| n.visible.to_string(), "hidden layers stand in for removed ones"),
|
||
("layer name", Layer::Bg, "name", json!({"name": "Back"}), Some(json!({"name": "Bg2"})), |n| n.name.clone(), "\"change the layer name\""),
|
||
(
|
||
"text content",
|
||
Layer::Title,
|
||
"characters",
|
||
json!({"characters": "Override"}),
|
||
Some(json!({"characters": "Main"})),
|
||
|n| text(n).characters.clone(),
|
||
"text content is overridable",
|
||
),
|
||
(
|
||
"font size",
|
||
Layer::Title,
|
||
"textStyle",
|
||
json!({"fontSize": 30}),
|
||
Some(json!({"fontSize": 18})),
|
||
|n| text(n).style.font_size.to_string(),
|
||
"\"change text properties, including font, weight, size …\"",
|
||
),
|
||
(
|
||
"text resizing",
|
||
Layer::Title,
|
||
"textStyle",
|
||
json!({"textAutoResize": "height"}),
|
||
Some(json!({"textAutoResize": "none"})),
|
||
|n| format!("{:?}", text(n).auto_resize),
|
||
"\"… letter spacing, and resizing\"",
|
||
),
|
||
(
|
||
"paragraph spacing",
|
||
Layer::Title,
|
||
"textStyle",
|
||
json!({"paragraphSpacing": 8}),
|
||
Some(json!({"paragraphSpacing": 2})),
|
||
|n| text(n).paragraph_spacing.to_string(),
|
||
"a text property like the others",
|
||
),
|
||
(
|
||
"padding",
|
||
Layer::Row,
|
||
"padding",
|
||
json!({"padding": 9}),
|
||
Some(json!({"padding": 1})),
|
||
|n| format!("{:?}", n.layout.as_ref().map(|l| l.padding)),
|
||
"\"adjust vertical and horizontal padding\" on instances",
|
||
),
|
||
(
|
||
"gap",
|
||
Layer::Row,
|
||
"gap",
|
||
json!({"gap": 12}),
|
||
Some(json!({"gap": 6})),
|
||
|n| format!("{:?}", n.layout.as_ref().map(|l| l.gap)),
|
||
"\"adjust gap between\" on instances",
|
||
),
|
||
(
|
||
"layout grids",
|
||
Layer::Row,
|
||
"grids",
|
||
json!({"grids": [grid.clone()]}),
|
||
Some(json!({"grids": [grid.clone(), grid.clone()]})),
|
||
|n| n.grids.len().to_string(),
|
||
"\"add, edit, or remove layout guides\"",
|
||
),
|
||
(
|
||
"layout grids of the instance",
|
||
Layer::Root,
|
||
"grids",
|
||
json!({"grids": [grid.clone()]}),
|
||
Some(json!({"grids": [grid.clone(), grid.clone()]})),
|
||
|n| n.grids.len().to_string(),
|
||
"\"add, edit, or remove layout guides\"",
|
||
),
|
||
(
|
||
"export settings",
|
||
Layer::Bg,
|
||
"exports",
|
||
json!({"exports": [export.clone()]}),
|
||
Some(json!({"exports": [export.clone(), export.clone()]})),
|
||
|n| n.exports.len().to_string(),
|
||
"\"add, edit, or remove export settings\"",
|
||
),
|
||
(
|
||
"prototype interactions",
|
||
Layer::Bg,
|
||
"interactions",
|
||
json!({"interactions": [click.clone()]}),
|
||
Some(json!({"interactions": [click.clone(), click.clone()]})),
|
||
|n| n.interactions.len().to_string(),
|
||
"interactions on an instance win over the inherited ones",
|
||
),
|
||
(
|
||
"opacity",
|
||
Layer::Bg,
|
||
"opacity",
|
||
json!({"opacity": 0.5}),
|
||
Some(json!({"opacity": 0.8})),
|
||
|n| n.opacity.to_string(),
|
||
"documentation silent (pinned)",
|
||
),
|
||
(
|
||
"corner radius",
|
||
Layer::Bg,
|
||
"corners",
|
||
json!({"cornerRadius": 6}),
|
||
Some(json!({"cornerRadius": 2})),
|
||
|n| format!("{:?}", n.corners.radii),
|
||
"documentation silent (pinned)",
|
||
),
|
||
];
|
||
for (what, layer, group, over, main_edit, read, why) in rows {
|
||
let mut s = Session::new();
|
||
let k = kit(&mut s);
|
||
let (main, copy) = (k.main(layer), k.copy(&s, layer));
|
||
let steps = undo_len(&s);
|
||
ex(&mut s, "node.set", json!({"id": copy, "props": over}));
|
||
assert_eq!(undo_len(&s), steps + 1, "{what}: one undo step");
|
||
assert!(node(&s, copy).overrides.contains(group), "{what}: a `{group}` override ({why}); has {:?}", node(&s, copy).overrides);
|
||
let value = read(&node(&s, copy));
|
||
assert_ne!(value, read(&node(&s, main)), "{what}: the override shows");
|
||
if let Some(m) = main_edit {
|
||
ex(&mut s, "node.set", json!({"id": main, "props": m}));
|
||
assert_eq!(read(&node(&s, copy)), value, "{what}: kept when the main changes the same property ({why})");
|
||
}
|
||
// A structure edit of the main rebuilds every instance.
|
||
ex(&mut s, "shape.rectangle", json!({"x": 150, "y": 60, "width": 10, "height": 10, "parent": k.card}));
|
||
healthy(&s);
|
||
assert_eq!(read(&node(&s, copy)), value, "{what}: kept through a rebuild");
|
||
ex(&mut s, "component.resetOverride", json!({"id": copy, "group": group}));
|
||
assert_eq!(read(&node(&s, copy)), read(&node(&s, main)), "{what}: reset shows the main's value");
|
||
assert!(!node(&s, copy).overrides.contains(group), "{what}: reset clears the override");
|
||
healthy(&s);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn unrelated_main_edits_still_reach_overridden_layers() {
|
||
// (what, layer, override, main edit of another property, read, expected, derivation)
|
||
type Row = (&'static str, Layer, Value, Value, fn(&Node) -> String, &'static str, &'static str);
|
||
let rows: Vec<Row> = vec![
|
||
(
|
||
"text content override, main font size",
|
||
Layer::Title,
|
||
json!({"characters": "Mine"}),
|
||
json!({"fontSize": 24}),
|
||
|n| format!("{} {}", text(n).characters, text(n).style.font_size),
|
||
"Mine 24",
|
||
"only the overridden property stays; the font follows the main",
|
||
),
|
||
(
|
||
"fill override, main corner radius",
|
||
Layer::Bg,
|
||
json!({"fill": "#00ff00"}),
|
||
json!({"cornerRadius": 5}),
|
||
|n| format!("{} {:?}", n.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default(), n.corners.radii),
|
||
"00FF00 [5.0, 5.0, 5.0, 5.0]",
|
||
"changes to the main reach every property not overridden",
|
||
),
|
||
(
|
||
"padding override, main direction",
|
||
Layer::Row,
|
||
json!({"padding": 9}),
|
||
json!({"layout": "vertical"}),
|
||
|n| format!("{:?}", n.layout.as_ref().map(|l| (l.mode, l.padding))),
|
||
"Some((Vertical, [9.0, 9.0, 9.0, 9.0]))",
|
||
"the direction isn't an instance's to change, so it follows the main",
|
||
),
|
||
];
|
||
for (what, layer, over, main_edit, read, expected, why) in rows {
|
||
let mut s = Session::new();
|
||
let k = kit(&mut s);
|
||
let copy = k.copy(&s, layer);
|
||
ex(&mut s, "node.set", json!({"id": copy, "props": over}));
|
||
ex(&mut s, "node.set", json!({"id": k.main(layer), "props": main_edit}));
|
||
healthy(&s);
|
||
assert_eq!(read(&node(&s, copy)), expected, "{what}: {why}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn what_instances_cannot_override_is_refused() {
|
||
// (what, command, params (`$` stands for the instance's copy of the layer), layer, derivation)
|
||
type Row = (&'static str, &'static str, Value, Layer, &'static str);
|
||
let rows: Vec<Row> = vec![
|
||
("move a layer", "node.set", json!({"id": "$", "props": {"x": 50}}), Layer::Bg, "\"position of any layers within the component\""),
|
||
("nudge a layer", "transform.nudge", json!({"ids": ["$"], "dx": 1, "dy": 0}), Layer::Bg, "position"),
|
||
("rotate a layer", "node.set", json!({"id": "$", "props": {"rotation": 15}}), Layer::Bg, "position and rotation come with the layer's place"),
|
||
("flip a layer", "object.flipH", json!({"ids": ["$"]}), Layer::Bg, "position"),
|
||
(
|
||
"resize a layer",
|
||
"node.set",
|
||
json!({"id": "$", "props": {"width": 50}}),
|
||
Layer::Bg,
|
||
"not among the overridable properties; the instance resizes",
|
||
),
|
||
("resize a text box", "node.set", json!({"id": "$", "props": {"width": 150}}), Layer::Title, "\"the bounds of any text layers\""),
|
||
(
|
||
"change constraints",
|
||
"node.set",
|
||
json!({"id": "$", "props": {"constraints": {"horizontal": "right"}}}),
|
||
Layer::Bg,
|
||
"\"any constraints applied to the layers\"",
|
||
),
|
||
(
|
||
"make a layer fill its auto layout",
|
||
"node.set",
|
||
json!({"id": "$", "props": {"sizingH": "fill"}}),
|
||
Layer::Chip,
|
||
"resizing comes with the layer's size",
|
||
),
|
||
(
|
||
"take a layer out of auto layout",
|
||
"node.set",
|
||
json!({"id": "$", "props": {"absolute": true}}),
|
||
Layer::Chip,
|
||
"position, including items in an auto layout frame",
|
||
),
|
||
(
|
||
"change the auto layout direction",
|
||
"node.set",
|
||
json!({"id": "$", "props": {"layout": "vertical"}}),
|
||
Layer::Row,
|
||
"only padding and gap are adjustable on instances",
|
||
),
|
||
("remove auto layout", "layout.remove", json!({"ids": ["$"]}), Layer::Row, "structure of the instance"),
|
||
("reorder layers", "node.reorder", json!({"id": "$", "index": 3}), Layer::Bg, "\"the order, or z-index, of any layers within the instance\""),
|
||
("delete a layer", "edit.delete", json!({"ids": ["$"]}), Layer::Bg, "layers can only be hidden"),
|
||
(
|
||
"add a layer",
|
||
"shape.rectangle",
|
||
json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": "$"}),
|
||
Layer::Root,
|
||
"\"not possible to make changes to the underlying structure\"",
|
||
),
|
||
];
|
||
for (what, cmd, params, layer, why) in rows {
|
||
let mut s = Session::new();
|
||
let k = kit(&mut s);
|
||
let target = k.copy(&s, layer);
|
||
let p: Value = serde_json::from_str(¶ms.to_string().replace("\"$\"", &target.0.to_string())).unwrap();
|
||
let (before, steps) = (s.doc().unwrap().clone(), undo_len(&s));
|
||
assert!(s.execute(cmd, &p).is_err(), "{what} is refused ({why})");
|
||
assert_eq!(*s.doc().unwrap(), before, "{what}: nothing changed");
|
||
assert_eq!(undo_len(&s), steps, "{what}: no undo step");
|
||
}
|
||
// The same edits on the main, and resizing the instance itself, are fine.
|
||
let mut s = Session::new();
|
||
let k = kit(&mut s);
|
||
ex(&mut s, "node.set", json!({"id": k.bg, "props": {"x": 5, "width": 150, "constraints": {"horizontal": "leftRight"}}}));
|
||
ex(&mut s, "node.set", json!({"id": k.inst, "props": {"width": 260}}));
|
||
let ibg = k.copy(&s, Layer::Bg);
|
||
assert_eq!((node(&s, ibg).transform.as_coeffs()[4], node(&s, ibg).width), (5.0, 210.0), "the main's constraints apply when the instance resizes");
|
||
assert!(node(&s, ibg).overrides.is_empty(), "layout isn't an override");
|
||
// Placing an auto layout item is the layout's job: the x is ignored, not refused.
|
||
let ichip = k.copy(&s, Layer::Chip);
|
||
let x0 = node(&s, ichip).transform.as_coeffs()[4];
|
||
ex(&mut s, "node.set", json!({"id": ichip, "props": {"x": 60}}));
|
||
assert_eq!(node(&s, ichip).transform.as_coeffs()[4], x0, "auto layout puts the item back");
|
||
assert!(!node(&s, ichip).overrides.contains("position"));
|
||
// The instance's own export settings are its own like its place (documentation silent; pinned).
|
||
let export = serde_json::to_value(ExportSetting::next_after(None)).unwrap();
|
||
ex(&mut s, "node.set", json!({"id": k.inst, "props": {"exports": [export]}}));
|
||
ex(&mut s, "shape.rectangle", json!({"x": 150, "y": 60, "width": 10, "height": 10, "parent": k.card}));
|
||
assert_eq!((node(&s, k.inst).exports.len(), node(&s, k.inst).overrides.contains("exports")), (1, false));
|
||
healthy(&s);
|
||
}
|
||
|
||
/// A "Button" set: State=Default (Bg white, Label "Default" dark, Badge, Dot) and State=Hover (Bg
|
||
/// grey, Label "Hover" blue, no Badge, the same Dot), both with a black stroke on Bg and a
|
||
/// "Show dot" boolean bound to Dot. Returns (set, default, hover).
|
||
fn button_set(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
||
let mut variants = Vec::new();
|
||
for (k, (state, bg, label)) in [("Default", "#ffffff", "#222222"), ("Hover", "#eeeeee", "#0000ff")].into_iter().enumerate() {
|
||
let f = idv(&ex(s, "frame.create", json!({"x": k as f64 * 200.0, "y": 0, "width": 120, "height": 40, "name": format!("State={state}")})));
|
||
ex(s, "node.set", json!({"id": f, "props": {"fill": "#fafafa", "cornerRadius": 4 + 4 * k}}));
|
||
let props = json!({"name": "Bg", "fill": bg, "stroke": "#000000", "strokeWeight": 1});
|
||
ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 120, "height": 40, "parent": f, "props": props}));
|
||
ex(s, "text.create", json!({"text": state, "x": 10, "y": 10, "parent": f, "props": {"name": "Label", "fill": label}}));
|
||
if k == 0 {
|
||
ex(s, "shape.ellipse", json!({"x": 90, "y": 4, "width": 8, "height": 8, "parent": f, "props": {"name": "Badge", "fill": "#ff0000"}}));
|
||
}
|
||
ex(s, "shape.ellipse", json!({"x": 104, "y": 4, "width": 8, "height": 8, "parent": f, "props": {"name": "Dot", "fill": "#00aa00"}}));
|
||
variants.push(idv(&ex(s, "component.create", json!({"ids": [f]}))));
|
||
}
|
||
let set = idv(&ex(s, "component.combineVariants", json!({"ids": variants.clone()})));
|
||
ex(s, "component.addProperty", json!({"id": set, "name": "Show dot", "type": "boolean", "default": true}));
|
||
for v in &variants {
|
||
let dot = kids(s, *v).into_iter().find(|c| node(s, *c).name == "Dot").unwrap();
|
||
ex(s, "component.bindProperty", json!({"layer": dot, "property": "Show dot"}));
|
||
}
|
||
(set, variants[0], variants[1])
|
||
}
|
||
|
||
/// The direct layer of instance `inst` mirroring a main layer named `name` (renames don't
|
||
/// matter); `None` when there is none.
|
||
fn layer(s: &Session, inst: NodeId, name: &str) -> Option<NodeId> {
|
||
kids(s, inst).into_iter().find(|c| node(s, *c).source.is_some_and(|src| node(s, src).name == name))
|
||
}
|
||
|
||
#[test]
|
||
fn switching_variants_keeps_overrides_where_the_variants_agreed() {
|
||
let mut s = Session::new();
|
||
let (set, default, hover) = button_set(&mut s);
|
||
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200})));
|
||
assert_eq!(component_of(&s, i), default);
|
||
// (what, layer ("" = the instance), override, kept after the switch, derivation)
|
||
type Row = (&'static str, &'static str, Value, bool, &'static str);
|
||
let rows: Vec<Row> = vec![
|
||
(
|
||
"Bg fill (the variants differ)",
|
||
"Bg",
|
||
json!({"fill": "#ff00ff"}),
|
||
false,
|
||
"kept only if the property \"originally matched between variants\"",
|
||
),
|
||
("Bg stroke (the variants agree)", "Bg", json!({"strokeWeight": 4}), true, "names match and the stroke matched"),
|
||
("Label text (the variants differ)", "Label", json!({"characters": "Buy"}), true, "text layers keep their changes when their names match"),
|
||
("Label colour (the variants differ)", "Label", json!({"fill": "#ff8800"}), true, "\"any changes you've made to text layers\""),
|
||
("Dot renamed and faded (the variants agree)", "Dot", json!({"name": "My dot", "opacity": 0.5}), true, "renaming doesn't break matching"),
|
||
("Badge (only in Default)", "Badge", json!({"fill": "#000000"}), false, "no layer of that name in the new variant"),
|
||
("the instance's fill (the variants agree)", "", json!({"fill": "#123456"}), true, "names match and the fill matched"),
|
||
("the instance's corners (the variants differ)", "", json!({"cornerRadius": 12}), false, "the corner radius didn't match"),
|
||
];
|
||
let target = |s: &Session, name: &str| if name.is_empty() { Some(i) } else { layer(s, i, name) };
|
||
let read = |x: &Node, k: &str| match k {
|
||
"fill" => format!("{:?}", x.fills),
|
||
"strokeWeight" => x.stroke.weight.to_string(),
|
||
"characters" => text(x).characters.clone(),
|
||
"name" => x.name.clone(),
|
||
"opacity" => x.opacity.to_string(),
|
||
"cornerRadius" => format!("{:?}", x.corners.radii),
|
||
other => panic!("no reader for `{other}`"),
|
||
};
|
||
// What each override shows before the switch.
|
||
let mut shown: std::collections::BTreeMap<String, Vec<String>> = Default::default();
|
||
for (what, name, over, _, _) in &rows {
|
||
let id = target(&s, name).unwrap();
|
||
ex(&mut s, "node.set", json!({"id": id, "props": over}));
|
||
assert!(!node(&s, id).overrides.is_empty(), "{what}: an override");
|
||
shown.insert(what.to_string(), over.as_object().unwrap().keys().map(|k| read(&node(&s, id), k)).collect());
|
||
}
|
||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false}}));
|
||
let steps = undo_len(&s);
|
||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}}));
|
||
assert_eq!(undo_len(&s), steps + 1, "one undo step");
|
||
healthy(&s);
|
||
assert_eq!(component_of(&s, i), hover);
|
||
for (what, name, over, kept, why) in &rows {
|
||
let Some(id) = target(&s, name) else {
|
||
assert!(!kept, "{what}: gone ({why})");
|
||
continue;
|
||
};
|
||
let (n, main) = (node(&s, id), node(&s, if id == i { hover } else { node(&s, id).source.unwrap() }));
|
||
for (k, overridden) in over.as_object().unwrap().keys().zip(&shown[&what.to_string()]) {
|
||
let expected = if *kept { overridden.clone() } else { read(&main, k) };
|
||
assert_eq!(read(&n, k), expected, "{what}: {} ({why})", if *kept { "kept" } else { "shows the new variant" });
|
||
}
|
||
}
|
||
let dot = layer(&s, i, "Dot").unwrap();
|
||
assert!(!node(&s, dot).visible, "property values stay across variants (documentation silent; pinned)");
|
||
// Switching back doesn't bring dropped overrides back.
|
||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Default"}}));
|
||
assert_eq!(fill(&s, layer(&s, i, "Bg").unwrap()), "FFFFFF");
|
||
assert_eq!(chars(&s, layer(&s, i, "Label").unwrap()), "Buy");
|
||
s.undo().unwrap();
|
||
s.undo().unwrap();
|
||
assert_eq!(component_of(&s, i), default, "undo restores the variant ...");
|
||
assert_eq!(fill(&s, layer(&s, i, "Bg").unwrap()), "FF00FF", "... and its overrides");
|
||
healthy(&s);
|
||
}
|
||
|
||
#[test]
|
||
fn variant_combinations_order_default_and_conflicts() {
|
||
let mut s = Session::new();
|
||
// Two variants created right to left: the layer order and the top-left corner disagree.
|
||
let a = idv(&ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 80, "height": 40, "name": "Size=Large, State=Default"})));
|
||
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 80, "height": 40, "name": "Size=Small, State=Default"})));
|
||
let c = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 80, "width": 80, "height": 40, "name": "Size=Small, State=Hover"})));
|
||
let mut v = Vec::new();
|
||
for f in [a, b, c] {
|
||
v.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
|
||
}
|
||
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": v.clone()})));
|
||
// Default: "the variant in the top-left corner".
|
||
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 300})));
|
||
assert_eq!(component_of(&s, i), v[1], "the top-left variant is the default");
|
||
let props = ex(&mut s, "component.properties", json!({"id": set}));
|
||
assert_eq!(props["properties"][0]["default"], json!("Small"), "the default value is the top-left variant's");
|
||
// Making Large the default moves its variant to the top-left corner (one undo step).
|
||
let steps = undo_len(&s);
|
||
ex(&mut s, "component.editProperty", json!({"id": set, "name": "Size", "default": "Large"}));
|
||
assert_eq!(undo_len(&s), steps + 1);
|
||
let j = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 400})));
|
||
assert_eq!(component_of(&s, j), v[0], "the new default is the top-left variant");
|
||
// Reordering properties rewrites every variant's name to the new order.
|
||
ex(&mut s, "component.editProperty", json!({"id": set, "name": "State", "index": 0}));
|
||
assert_eq!(node(&s, v[2]).name, "State=Hover, Size=Small");
|
||
let props = ex(&mut s, "component.properties", json!({"id": i}));
|
||
assert_eq!(props["properties"][0]["name"], json!("State"), "the order shows in the properties");
|
||
// A missing combination picks the variant matching the most values (documentation silent; pinned).
|
||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Size": "Large", "State": "Hover"}}));
|
||
assert!(v.contains(&component_of(&s, i)));
|
||
// Two variants with the same values are a conflict, reported until resolved.
|
||
assert_eq!(ex(&mut s, "component.properties", json!({"id": set}))["conflicts"], json!([]));
|
||
ex(&mut s, "component.editProperty", json!({"id": set, "name": "State", "value": "Hover", "newValue": "Default"}));
|
||
let conflicts = ex(&mut s, "component.properties", json!({"id": set}))["conflicts"].clone();
|
||
assert_eq!(conflicts, json!([[v[1].0, v[2].0]]), "duplicate variant combinations are reported as a conflict");
|
||
healthy(&s);
|
||
}
|
||
|
||
/// Components "From" and "To" with layers named alike ("Bg" rectangle, "Title" text) and one only
|
||
/// "From" has ("Mark"); an instance of From with overrides on all three. Returns (from, to, inst).
|
||
fn swap_kit(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
||
let mut comps = Vec::new();
|
||
for (k, name) in ["From", "To"].iter().enumerate() {
|
||
let f = idv(&ex(s, "frame.create", json!({"x": k as f64 * 200.0, "y": 0, "width": 100, "height": 40, "name": name})));
|
||
ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"name": "Bg", "fill": "#eeeeee"}}));
|
||
ex(s, "text.create", json!({"text": name, "x": 4, "y": 4, "parent": f, "props": {"name": "Title"}}));
|
||
if k == 0 {
|
||
ex(s, "shape.ellipse", json!({"x": 80, "y": 4, "width": 8, "height": 8, "parent": f, "props": {"name": "Mark"}}));
|
||
}
|
||
comps.push(idv(&ex(s, "component.create", json!({"ids": [f]}))));
|
||
}
|
||
let i = idv(&ex(s, "instance.create", json!({"component": comps[0], "x": 0, "y": 200})));
|
||
let ks = kids(s, i);
|
||
ex(s, "node.set", json!({"id": ks[0], "props": {"fill": "#ff00ff"}}));
|
||
ex(s, "node.set", json!({"id": ks[1], "props": {"characters": "Mine", "fill": "#00ff00"}}));
|
||
ex(s, "node.set", json!({"id": ks[2], "props": {"opacity": 0.3}}));
|
||
(comps[0], comps[1], i)
|
||
}
|
||
|
||
#[test]
|
||
fn swaps_keep_overrides_by_layer_name() {
|
||
// (how, command, kept on Bg, derivation)
|
||
let rows: [(&str, &str, bool, &str); 2] = [
|
||
("the instance menu", "instance.swap", true, "the layer names of the current instance and the instance you're selecting must match"),
|
||
("the Assets panel", "instance.swapAt", false, "an Assets-panel swap keeps only text overrides"),
|
||
];
|
||
for (how, cmd, bg_kept, why) in rows {
|
||
let mut s = Session::new();
|
||
let (_from, to, i) = swap_kit(&mut s);
|
||
let steps = undo_len(&s);
|
||
let p = if cmd == "instance.swap" { json!({"ids": [i], "component": to}) } else { json!({"component": to, "x": 50, "y": 220}) };
|
||
ex(&mut s, cmd, p);
|
||
assert_eq!(undo_len(&s), steps + 1, "{how}: one undo step");
|
||
healthy(&s);
|
||
assert_eq!(component_of(&s, i), to, "{how}: swapped");
|
||
let (bg, title) = (layer(&s, i, "Bg").unwrap(), layer(&s, i, "Title").unwrap());
|
||
assert_eq!(fill(&s, bg) == "FF00FF", bg_kept, "{how}: Bg fill ({why})");
|
||
assert_eq!((chars(&s, title), fill(&s, title)), ("Mine".to_string(), "00FF00".to_string()), "{how}: text layers keep their changes");
|
||
assert!(layer(&s, i, "Mark").is_none(), "{how}: a layer the new main lacks goes with its overrides");
|
||
}
|
||
// Instance swap properties: preferred values are offered first, any component is allowed.
|
||
let mut s = Session::new();
|
||
let (from, to, _) = swap_kit(&mut s);
|
||
let (icon, shape, button, nested) = crate::components::tests::button(&mut s);
|
||
let _ = (shape, from);
|
||
ex(&mut s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "layer": nested, "preferred": [icon]}));
|
||
let props = ex(&mut s, "component.properties", json!({"id": button}));
|
||
assert_eq!(props["properties"][0]["preferred"], json!([icon.0]));
|
||
let b = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 600, "y": 600})));
|
||
ex(&mut s, "instance.setProperties", json!({"ids": [b], "properties": {"Icon": to}}));
|
||
assert_eq!(component_of(&s, copy_of(&s, b, nested)), to, "a component outside the preferred values can still be picked");
|
||
healthy(&s);
|
||
}
|
||
|
||
#[test]
|
||
fn swapping_to_a_component_of_another_set_takes_its_properties() {
|
||
// Documentation silent beyond name matching (pinned): the instance shows the other set's
|
||
// default variant's layers and offers that set's properties.
|
||
let mut s = Session::new();
|
||
let (set, _default, _hover) = button_set(&mut s);
|
||
let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 60, "height": 20, "name": "Kind=Solid"})));
|
||
ex(&mut s, "text.create", json!({"text": "Tag", "x": 2, "y": 2, "parent": a, "props": {"name": "Label"}}));
|
||
let tag = idv(&ex(&mut s, "component.create", json!({"ids": [a]})));
|
||
let tags = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [tag]})));
|
||
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200})));
|
||
let t = layer(&s, i, "Label").unwrap();
|
||
ex(&mut s, "node.set", json!({"id": t, "props": {"characters": "Kept"}}));
|
||
ex(&mut s, "instance.swap", json!({"ids": [i], "component": tags}));
|
||
healthy(&s);
|
||
assert_eq!(component_of(&s, i), tag);
|
||
assert_eq!(chars(&s, layer(&s, i, "Label").unwrap()), "Kept");
|
||
let props = ex(&mut s, "component.properties", json!({"id": i}));
|
||
assert_eq!(props["set"], json!(tags.0));
|
||
assert_eq!(props["properties"][0]["name"], json!("Kind"));
|
||
}
|
||
|
||
#[test]
|
||
fn hidden_exposed_instances_hide_their_properties() {
|
||
let mut s = Session::new();
|
||
let (_icon, _shape, button, nested) = crate::components::tests::button(&mut s);
|
||
ex(&mut s, "component.exposeInstance", json!({"id": nested}));
|
||
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
|
||
assert_eq!(ex(&mut s, "instance.exposed", json!({"id": i}))["instances"].as_array().unwrap().len(), 1);
|
||
// "Hidden nested instances hide their attached properties."
|
||
let t = copy_of(&s, i, nested);
|
||
ex(&mut s, "node.set", json!({"id": t, "props": {"visible": false}}));
|
||
assert_eq!(ex(&mut s, "instance.exposed", json!({"id": i}))["instances"], json!([]));
|
||
}
|
||
|
||
#[test]
|
||
fn detach_reset_push_and_restore() {
|
||
// Detach: "a regular frame, while keeping the current layers and properties".
|
||
let mut s = Session::new();
|
||
let k = kit(&mut s);
|
||
let row = k.copy(&s, Layer::Row);
|
||
ex(&mut s, "node.set", json!({"id": row, "props": {"padding": 9}}));
|
||
let t = k.copy(&s, Layer::Bg);
|
||
ex(&mut s, "node.set", json!({"id": t, "props": {"fill": "#00ff00"}}));
|
||
let look = figcraft_render::render_node(s.doc().unwrap(), k.inst, 1.0).unwrap();
|
||
let steps = undo_len(&s);
|
||
ex(&mut s, "component.detach", json!({"ids": [k.inst]}));
|
||
assert_eq!(undo_len(&s), steps + 1);
|
||
assert_eq!(node(&s, k.inst).kind.type_name(), "FRAME");
|
||
assert_eq!(figcraft_render::render_node(s.doc().unwrap(), k.inst, 1.0).unwrap().data(), look.data(), "the look is kept");
|
||
assert_eq!(node(&s, row).layout.as_ref().map(|l| l.padding), Some([9.0; 4]));
|
||
healthy(&s);
|
||
|
||
// Reset all: every layer shows its main again, property values go back to their defaults;
|
||
// the instance keeps its size (documentation silent; pinned).
|
||
let mut s = Session::new();
|
||
let k = kit(&mut s);
|
||
let grid = serde_json::to_value(LayoutGrid::default()).unwrap();
|
||
let t = k.copy(&s, Layer::Row);
|
||
ex(&mut s, "node.set", json!({"id": t, "props": {"padding": 9, "grids": [grid]}}));
|
||
let t = k.copy(&s, Layer::Title);
|
||
ex(&mut s, "node.set", json!({"id": t, "props": {"characters": "Mine", "paragraphSpacing": 6}}));
|
||
ex(&mut s, "node.set", json!({"id": k.inst, "props": {"width": 240}}));
|
||
ex(&mut s, "component.resetInstance", json!({"ids": [k.inst]}));
|
||
healthy(&s);
|
||
let (row, title) = (node(&s, k.copy(&s, Layer::Row)), node(&s, k.copy(&s, Layer::Title)));
|
||
assert_eq!((row.layout.as_ref().map(|l| l.padding), row.grids.len()), (Some([4.0; 4]), 0));
|
||
assert_eq!((text(&title).characters.as_str(), text(&title).paragraph_spacing), ("Hello", 0.0));
|
||
assert_eq!(node(&s, k.inst).width, 240.0);
|
||
|
||
// Push changes: the instance's overrides become the main's ("cannot push changes to nested
|
||
// components": from a nested layer they still go to the outer main).
|
||
let mut s = Session::new();
|
||
let (icon, shape, button, nested) = crate::components::tests::button(&mut s);
|
||
let a = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
|
||
let b = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 600, "y": 400})));
|
||
let ishape = copy_of(&s, a, copy_of(&s, nested, shape));
|
||
ex(&mut s, "node.set", json!({"id": ishape, "props": {"fill": "#ff00ff"}}));
|
||
let label = kids(&s, a)[1];
|
||
ex(&mut s, "node.set", json!({"id": label, "props": {"paragraphSpacing": 5}}));
|
||
ex(&mut s, "component.pushChanges", json!({"id": ishape}));
|
||
healthy(&s);
|
||
assert_eq!(fill(&s, copy_of(&s, nested, shape)), "FF00FF", "the outer main's nested instance takes it");
|
||
assert_eq!(fill(&s, shape), "111111", "the nested instance's own main doesn't");
|
||
assert_eq!(fill(&s, copy_of(&s, b, copy_of(&s, nested, shape))), "FF00FF", "other instances follow");
|
||
assert_eq!(text(&node(&s, kids(&s, button)[1])).paragraph_spacing, 5.0, "text properties are pushed");
|
||
assert!(node(&s, ishape).overrides.is_empty() && node(&s, label).overrides.is_empty());
|
||
let _ = icon;
|
||
|
||
// Go to main selects it; restore main brings a deleted main back from an instance
|
||
// (`components_tests::deleted_main_keeps_instances_and_restores`).
|
||
let mut s = Session::new();
|
||
let k = kit(&mut s);
|
||
let t = k.copy(&s, Layer::Title);
|
||
ex(&mut s, "component.goToMain", json!({"id": t}));
|
||
assert_eq!(s.selection(), vec![k.card]);
|
||
}
|
||
|
||
#[test]
|
||
fn auto_layout_instances_follow_their_main() {
|
||
let mut s = Session::new();
|
||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Chip"})));
|
||
for _ in 0..2 {
|
||
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": f}));
|
||
}
|
||
ex(&mut s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 8, "gap": 4, "sizingH": "hug", "sizingV": "hug"}}));
|
||
let chip = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||
let i = idv(&ex(&mut s, "instance.create", json!({"component": chip, "x": 0, "y": 100})));
|
||
assert_eq!((node(&s, i).width, node(&s, i).height), (60.0, 36.0), "8 + 20 + 4 + 20 + 8 by 8 + 20 + 8");
|
||
// Padding on the instance is an override; the main's gap and direction still reach it.
|
||
ex(&mut s, "node.set", json!({"id": i, "props": {"padding": 2}}));
|
||
ex(&mut s, "node.set", json!({"id": chip, "props": {"gap": 10}}));
|
||
assert_eq!(node(&s, i).width, 2.0 + 20.0 + 10.0 + 20.0 + 2.0, "the hug follows the main's gap and the instance's padding");
|
||
ex(&mut s, "node.set", json!({"id": chip, "props": {"layout": "vertical"}}));
|
||
let l = node(&s, i).layout.unwrap();
|
||
assert_eq!((l.mode, l.padding, l.gap), (LayoutMode::Vertical, [2.0; 4], 10.0));
|
||
assert_eq!((node(&s, i).width, node(&s, i).height), (24.0, 2.0 + 20.0 + 10.0 + 20.0 + 2.0));
|
||
// The direction is the main's: refused on the instance.
|
||
assert!(s.execute("node.set", &json!({"id": i, "props": {"layout": "horizontal"}})).is_err());
|
||
assert!(s.execute("layout.remove", &json!({"ids": [i]})).is_err());
|
||
// Min / max width on the instance is its own (documentation silent; pinned).
|
||
ex(&mut s, "node.set", json!({"id": i, "props": {"minWidth": 80}}));
|
||
assert_eq!(node(&s, i).width, 80.0);
|
||
ex(&mut s, "node.set", json!({"id": chip, "props": {"gap": 12}}));
|
||
assert_eq!(node(&s, i).child_layout.min_w, Some(80.0));
|
||
healthy(&s);
|
||
}
|