Files
figcraft/crates/engine/src/paint_reorder_tests.rs
T
bflatastic f3012ce6e4 M3: styles and variables behaviour suite and fixes
**A styles and variables behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (what a style holds, creating from a selection, applying, editing, detaching, deleting a used style, organising by folders and order, descriptions; variable types, modes and the default mode, aliases, scopes, mode inheritance, bindings by field and type, deleting a bound variable, code syntax, number variables in pixels) is a table-driven case with a one-line derivation; where the documentation is silent the case says so and pins the behaviour.

**Fixes (10 of the suite's 18 tests failed on the old code, counting the missing settings below)**
- **Editing a styled property unlinks that style**: a fill, stroke, effect or layout grid list edited away from its style (through `node.set`, a paint row, an added fill, or a variable bound to it) drops the link, as text chars already did. Before, the layer kept a stale link and the next style edit silently overwrote the user's edit. Moving or renaming a layer keeps its links.
- **Applying a style replaces variables bound to what it sets** (the fill list for a paint style, the typography for a text style); other bindings stay. Before, the variable won again on the next edit.
- **Text styles go on text layers only**: applying one to a mixed selection leaves the other layers alone (they used to get a stray text style link); a selection without text is refused.
- **Deleting a bound variable unbinds the layers using it**: they keep the value they show; variables aliasing it take its values per mode (docs silent on aliases). Before, the binding dangled. `variable.deleteCollection` now does the same for aliases from other collections into it.
- **Bindings are type-checked**: a variable of a type the field doesn't take (a colour on `width`, a number on `visible`) is refused; scopes still only filter pickers. Number variables can fill text content (shown as text).
- **Aliases are checked when a variable is created** (`variable.create {value: {alias}}` took any name, of any type).
- **Style names** are trimmed per `/` folder part; empty names are refused.

**Missing documented settings, added**
- **Organising styles**: `style.move {styles, before?, folder?}` reorders and moves into a folder (renaming the style to match), `style.duplicate` puts "<name> copy" right below the original, `style.renameFolder` renames a folder's styles. The page's **Local styles** list (nothing selected) is now grouped by type and folder with swatches and text sizes: click opens the style editor, right-click offers Edit style / Duplicate / Delete, a row dragged by its grip moves (into the folder it lands in), right-click on a folder renames it.
- **Modes**: Duplicate mode (`variable.addMode {from}`), Set as default and Move column left/right (`variable.moveMode {collection, mode, to}`); layers on Auto follow a new default, layers set to a mode keep theirs.
- **Code syntax** per platform (Web, iOS, Android): `variable.update {codeSyntax}`, three fields in Edit variable, carried by the variables JSON (import and export); generated CSS and the CSS variables export use the Web name (`var(--x)` or `--x`).
- **Number variable scopes**: font weight, paragraph spacing, paragraph indent and text content (and `fontWeight`, `paragraphSpacing`, `paragraphIndent` bind with their scopes).

New strings go through `tr(...)` with `en.toml` / `es.toml` entries, new commands have Spanish labels; the new rows, fields and menus have accessible names. Each edit stays one undo step.
2026-10-12 00:57:43 +07:00

291 lines
16 KiB
Rust

//! Reordering paints, effects and layout grids (`fill.move`, `stroke.move`, `effect.move`,
//! `grid.move`): one undo step each, items keep everything, variable bindings and style links
//! follow; layout grid fields and instances' component properties bound to variables.
use figcraft_color::{Color, Gradient, GradientKind, ImageScaleMode, Paint};
use figcraft_doc::{GridKind, LayoutGrid, Node, NodeId, NodeKind};
use serde_json::{Value, json};
use crate::Session;
fn id(v: Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().node(id).unwrap().clone()
}
fn rect(s: &mut Session) -> NodeId {
id(ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})))
}
fn hexes(paints: &[Paint]) -> Vec<String> {
paints.iter().map(|p| p.solid_color().map(|c| c.to_hex()).unwrap_or_else(|| "-".into())).collect()
}
fn vars(s: &mut Session) {
ex(s, "variable.createCollection", json!({"name": "T", "modes": ["Light", "Dark"]}));
for (name, kind, value) in [("brand", "color", json!("#FF0000")), ("gap", "number", json!(12)), ("cols", "number", json!(6))] {
ex(s, "variable.create", json!({"collection": "T", "name": name, "type": kind, "value": value}));
}
}
#[test]
fn fill_move_reorders_as_one_undo_step_and_keeps_every_paint() {
let mut s = Session::new();
let r = rect(&mut s);
let gradient = Paint::gradient(Gradient::default_for(GradientKind::Radial, Color::from_hex("#00FF00").unwrap()));
let mut image = Paint::image("synthetic-hash", ImageScaleMode::Fit);
image.opacity = 0.4;
let mut red = Paint::solid(Color::from_hex("#FF0000").unwrap());
red.visible = false;
let fills = vec![red.clone(), gradient.clone(), image.clone()];
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": fills}}));
let undo = s.ds().unwrap().history.undo.len();
// Bottom (0) to top (2).
ex(&mut s, "fill.move", json!({"ids": [r], "from": 0, "to": 2}));
assert_eq!(node(&s, r).fills, vec![gradient.clone(), image.clone(), red.clone()], "image, gradient and hidden paint survive whole");
assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1, "one undo step");
ex(&mut s, "fill.move", json!({"ids": [r], "from": 2, "to": 1}));
assert_eq!(node(&s, r).fills, vec![gradient.clone(), red.clone(), image.clone()]);
ex(&mut s, "edit.undo", json!({}));
ex(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, r).fills, fills);
ex(&mut s, "edit.redo", json!({}));
assert_eq!(node(&s, r).fills, vec![gradient, image, red]);
}
#[test]
fn moves_out_of_range_change_nothing_and_junk_is_refused() {
let mut s = Session::new();
let r = rect(&mut s);
ex(&mut s, "fill.add", json!({"ids": [r], "color": "#00FF00"}));
let before = node(&s, r).fills;
let undo = s.ds().unwrap().history.undo.len();
for cmd in ["fill.move", "stroke.move", "effect.move", "grid.move"] {
// Nothing to move: no change.
ex(&mut s, cmd, json!({"ids": [r], "from": 0, "to": 9}));
ex(&mut s, cmd, json!({"ids": [r], "from": 1, "to": 1}));
for bad in [json!({"ids": [r], "from": -1, "to": 0}), json!({"ids": [r], "from": 0.5, "to": 1}), json!({"ids": [r], "from": 0})] {
assert!(s.execute(cmd, &bad).is_err(), "{cmd} {bad}");
}
}
assert_eq!(node(&s, r).fills, before);
assert_eq!(s.ds().unwrap().history.undo.len(), undo, "no-op moves add no undo steps");
// A negative index never removes the first paint or grid.
assert!(s.execute("fill.remove", &json!({"ids": [r], "index": -1})).is_err());
ex(&mut s, "node.set", json!({"ids": [r], "props": {"grids": [LayoutGrid::default()]}}));
assert!(s.execute("grid.remove", &json!({"ids": [r], "index": -1})).is_err());
assert_eq!(node(&s, r).fills, before);
assert_eq!(node(&s, r).grids.len(), 1);
}
#[test]
fn a_bound_paint_colour_follows_its_paint() {
let mut s = Session::new();
let r = rect(&mut s);
vars(&mut s);
ex(&mut s, "fill.set", json!({"ids": [r], "index": 0, "color": "#0000FF"}));
ex(&mut s, "fill.add", json!({"ids": [r], "color": "#00FF00"}));
ex(&mut s, "fill.add", json!({"ids": [r], "color": "#FFFFFF"}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
assert_eq!(hexes(&node(&s, r).fills), ["FF0000", "00FF00", "FFFFFF"]);
// The bound bottom paint goes to the top: the binding goes with it.
ex(&mut s, "fill.move", json!({"ids": [r], "from": 0, "to": 2}));
assert_eq!(node(&s, r).bound.keys().collect::<Vec<_>>(), ["fills/2/color"]);
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
assert_eq!(hexes(&node(&s, r).fills), ["00FF00", "FFFFFF", "123456"], "the neighbours aren't recoloured");
// Modes recolour the moved paint too.
ex(&mut s, "variable.set", json!({"variable": "brand", "mode": "Dark", "value": "#ABCDEF"}));
ex(&mut s, "variable.setMode", json!({"collection": "T", "mode": "Dark", "ids": [r]}));
assert_eq!(hexes(&node(&s, r).fills), ["00FF00", "FFFFFF", "ABCDEF"]);
// Removing a paint below shifts the binding down with its paint.
ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0}));
assert_eq!(node(&s, r).bound.keys().collect::<Vec<_>>(), ["fills/1/color"]);
ex(&mut s, "variable.set", json!({"variable": "brand", "mode": "Dark", "value": "#010203"}));
assert_eq!(hexes(&node(&s, r).fills), ["FFFFFF", "010203"]);
// Undo brings back the binding where it was.
ex(&mut s, "edit.undo", json!({}));
ex(&mut s, "edit.undo", json!({}));
ex(&mut s, "edit.undo", json!({}));
ex(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, r).bound.keys().collect::<Vec<_>>(), ["fills/2/color"]);
// Removing the bound paint drops its binding.
ex(&mut s, "fill.remove", json!({"ids": [r], "index": 2}));
assert!(node(&s, r).bound.is_empty());
}
#[test]
fn strokes_move_with_their_bindings_and_moves_unlink_styles() {
let mut s = Session::new();
let (a, b) = (rect(&mut s), rect(&mut s));
vars(&mut s);
for r in [a, b] {
ex(&mut s, "stroke.add", json!({"ids": [r], "color": "#000000"}));
ex(&mut s, "stroke.add", json!({"ids": [r], "color": "#00FF00"}));
}
ex(&mut s, "variable.bind", json!({"ids": [a, b], "field": "stroke", "variable": "brand"}));
// (Adding a fill to a styled layer would unlink its style: add it first.)
ex(&mut s, "fill.add", json!({"ids": [a, b], "color": "#FFFFFF"}));
ex(&mut s, "style.create", json!({"type": "paint", "name": "Two", "from": a}));
ex(&mut s, "style.apply", json!({"ids": [a, b], "style": "Two"}));
assert!(node(&s, a).styles.fill.is_some());
// One command moves the strokes of every selected layer.
ex(&mut s, "stroke.move", json!({"ids": [a, b], "from": 0, "to": 1}));
for r in [a, b] {
assert_eq!(hexes(&node(&s, r).strokes), ["00FF00", "FF0000"]);
assert_eq!(node(&s, r).bound.keys().collect::<Vec<_>>(), ["strokes/1/color"]);
}
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000FF"}));
assert_eq!(hexes(&node(&s, b).strokes), ["00FF00", "0000FF"]);
// A paint style defined the whole fill list: reordering it unlinks the style.
let fills = node(&s, a).fills.len();
assert!(fills >= 2);
ex(&mut s, "fill.move", json!({"ids": [a], "from": 0, "to": fills - 1}));
assert!(node(&s, a).styles.fill.is_none());
assert!(node(&s, b).styles.fill.is_some(), "only the moved layer unlinks");
}
#[test]
fn bound_paint_keys_resolve_and_scope_by_index() {
use crate::cmd::lists::{BoundList, list_binding};
assert_eq!(list_binding("fill"), Some((BoundList::Fills, 0, "color")));
assert_eq!(list_binding("stroke"), Some((BoundList::Strokes, 0, "color")));
assert_eq!(list_binding("fills/3/color"), Some((BoundList::Fills, 3, "color")));
assert_eq!(list_binding("layoutGrids/1/gutter"), Some((BoundList::Grids, 1, "gutter")));
for junk in
["fills/-1/color", "fills/+1/color", "fills//color", "fills/1/", "effects/x/radius", "grids/0/size", "fills/99999999999999999999999/color"]
{
assert_eq!(list_binding(junk), None, "{junk}");
}
let mut s = Session::new();
let r = rect(&mut s);
let doc = s.doc().unwrap();
use crate::cmd::variables::field_scope;
assert_eq!(field_scope(doc, r, "fills/2/color"), Some("SHAPE_FILL"));
assert_eq!(field_scope(doc, r, "strokes/1/color"), Some("STROKE_COLOR"));
assert_eq!(field_scope(doc, r, "effects/0/color"), Some("EFFECT_COLOR"));
assert_eq!(field_scope(doc, r, "layoutGrids/0/count"), None);
}
#[test]
fn inspect_names_the_variable_of_each_paint() {
let mut s = Session::new();
let r = rect(&mut s);
vars(&mut s);
ex(&mut s, "fill.add", json!({"ids": [r], "color": "#00FF00"}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
ex(&mut s, "fill.move", json!({"ids": [r], "from": 0, "to": 1}));
let props = crate::devinspect::properties(s.doc().unwrap(), r);
let fills = props.iter().find(|p| p.title == "Fills").unwrap();
let tokens: Vec<Option<&str>> = fills.rows.iter().map(|p| p.token.as_deref()).collect();
assert_eq!(tokens, [None, Some("brand")], "the token follows the moved paint");
}
#[test]
fn layout_grid_fields_bind_to_number_variables_and_follow_moves() {
let mut s = Session::new();
let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300})));
vars(&mut s);
ex(&mut s, "grid.add", json!({"ids": [f], "grid": {"kind": "columns", "count": 4, "gutter": 20}}));
ex(&mut s, "grid.add", json!({"ids": [f], "grid": {"kind": "rows", "count": 3}}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "layoutGrids/0/gutter", "variable": "gap"}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "layoutGrids/0/count", "variable": "cols"}));
let g = node(&s, f).grids[0].clone();
assert_eq!((g.kind, g.count, g.gutter), (GridKind::Columns, 6, 12.0));
// Values and modes flow in.
ex(&mut s, "variable.set", json!({"variable": "gap", "mode": "Dark", "value": 32}));
ex(&mut s, "variable.setMode", json!({"collection": "T", "mode": "Dark", "ids": [f]}));
assert_eq!(node(&s, f).grids[0].gutter, 32.0);
// Junk numbers are kept in range.
ex(&mut s, "variable.set", json!({"variable": "cols", "mode": "Dark", "value": -5}));
assert_eq!(node(&s, f).grids[0].count, 1);
ex(&mut s, "variable.set", json!({"variable": "cols", "mode": "Dark", "value": 1e12}));
assert_eq!(node(&s, f).grids[0].count, 1000);
ex(&mut s, "variable.set", json!({"variable": "cols", "mode": "Dark", "value": 5}));
// Move the bound grid on top: its bindings go with it, the rows grid isn't touched.
ex(&mut s, "grid.move", json!({"ids": [f], "from": 0, "to": 1}));
let keys: Vec<String> = node(&s, f).bound.keys().cloned().collect();
assert_eq!(keys, ["layoutGrids/1/count", "layoutGrids/1/gutter"]);
ex(&mut s, "variable.set", json!({"variable": "gap", "mode": "Dark", "value": 8}));
let gs = node(&s, f).grids;
assert_eq!((gs[0].kind, gs[0].count), (GridKind::Rows, 3));
assert_eq!((gs[1].kind, gs[1].count, gs[1].gutter), (GridKind::Columns, 5, 8.0));
// Removing the grid below shifts them down; removing the bound grid drops them.
ex(&mut s, "grid.remove", json!({"ids": [f], "index": 0}));
assert!(node(&s, f).bound.keys().all(|k| k.starts_with("layoutGrids/0/")));
ex(&mut s, "grid.remove", json!({"ids": [f], "index": 0}));
assert!(node(&s, f).bound.is_empty());
ex(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, f).bound.len(), 2);
}
#[test]
fn instance_properties_bind_to_boolean_and_string_variables() {
let mut s = Session::new();
let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
let title = id(ex(&mut s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f})));
let dot = id(ex(&mut s, "shape.ellipse", json!({"x": 170, "y": 10, "width": 20, "height": 20, "parent": f})));
let c = id(ex(&mut s, "component.create", json!({"ids": [f]})));
ex(&mut s, "component.addProperty", json!({"id": dot, "name": "Show dot", "type": "boolean", "default": true}));
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title}));
let i = id(ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
let copy = |s: &Session, src: NodeId| {
let doc = s.doc().unwrap();
doc.subtree(i).into_iter().find(|x| doc.node(*x).and_then(|n| n.source) == Some(src)).unwrap()
};
let (ititle, idot) = (copy(&s, title), copy(&s, dot));
ex(&mut s, "variable.createCollection", json!({"name": "Flags", "modes": ["Short", "Long"]}));
ex(&mut s, "variable.create", json!({"collection": "Flags", "name": "badge", "type": "boolean", "value": false}));
ex(&mut s, "variable.create", json!({"collection": "Flags", "name": "label", "type": "string", "value": "Hi"}));
ex(&mut s, "variable.set", json!({"variable": "label", "mode": "Long", "value": "Hello there"}));
// Types must fit, and only instances take them.
assert!(s.execute("variable.bind", &json!({"ids": [i], "field": "componentProperties/Show dot", "variable": "label"})).is_err());
assert!(s.execute("variable.bind", &json!({"ids": [i], "field": "componentProperties/Title", "variable": "badge"})).is_err());
assert!(s.execute("variable.bind", &json!({"ids": [i], "field": "componentProperties/Nope", "variable": "badge"})).is_err());
assert!(s.execute("variable.bind", &json!({"ids": [c], "field": "componentProperties/Show dot", "variable": "badge"})).is_err());
ex(&mut s, "variable.bind", json!({"ids": [i], "field": "componentProperties/Show dot", "variable": "badge"}));
ex(&mut s, "variable.bind", json!({"ids": [i], "field": "componentProperties/Title", "variable": "label"}));
assert!(!node(&s, idot).visible, "the bound boolean hides the dot");
assert_eq!(node(&s, ititle).kind.text().unwrap().characters, "Hi");
// Values and modes flow in.
ex(&mut s, "variable.set", json!({"variable": "badge", "value": true}));
assert!(node(&s, idot).visible);
ex(&mut s, "variable.setMode", json!({"collection": "Flags", "mode": "Long", "ids": [i]}));
assert_eq!(node(&s, ititle).kind.text().unwrap().characters, "Hello there");
assert!(node(&s, ititle).overrides.is_empty(), "property values aren't overrides");
// A main edit rebuilds the instance: its bindings stay.
ex(&mut s, "node.set", json!({"id": dot, "props": {"fill": "#00FF00"}}));
assert!(node(&s, i).bound.contains_key("componentProperties/Title"));
ex(&mut s, "variable.set", json!({"variable": "label", "mode": "Long", "value": "After edit"}));
assert_eq!(node(&s, ititle).kind.text().unwrap().characters, "After edit");
// Renaming the property keeps the binding; deleting it drops the binding.
ex(&mut s, "component.editProperty", json!({"id": c, "name": "Title", "newName": "Heading"}));
assert!(node(&s, i).bound.contains_key("componentProperties/Heading"));
ex(&mut s, "variable.set", json!({"variable": "label", "mode": "Long", "value": "Renamed"}));
assert_eq!(node(&s, ititle).kind.text().unwrap().characters, "Renamed");
ex(&mut s, "component.deleteProperty", json!({"id": c, "name": "Heading"}));
assert!(!node(&s, i).bound.keys().any(|k| k.contains("Heading")));
assert!(matches!(node(&s, i).kind, NodeKind::Instance { .. }));
// Unbinding keeps the value (the Long mode's `false`).
assert!(!node(&s, idot).visible);
ex(&mut s, "variable.bind", json!({"ids": [i], "field": "componentProperties/Show dot", "variable": null}));
ex(&mut s, "variable.set", json!({"variable": "badge", "mode": "Long", "value": true}));
assert!(!node(&s, idot).visible);
ex(&mut s, "variable.set", json!({"variable": "badge", "mode": "Long", "value": false}));
// Resetting the instance drops its property bindings with its values; detaching too.
ex(&mut s, "variable.bind", json!({"ids": [i], "field": "componentProperties/Show dot", "variable": "badge"}));
assert!(!node(&s, idot).visible);
ex(&mut s, "component.resetInstance", json!({"ids": [i]}));
assert!(node(&s, i).bound.is_empty());
assert!(node(&s, idot).visible, "back to the default");
ex(&mut s, "variable.bind", json!({"ids": [i], "field": "componentProperties/Show dot", "variable": "badge"}));
ex(&mut s, "component.detach", json!({"ids": [i]}));
assert!(node(&s, i).bound.is_empty());
}