Files
figcraft/crates/engine/src/variables_editor_tests.rs
T
BFlatandClaude Opus 5.5 1c6f04bf62 M3: Variables editor with aliases, scopes, and rename/delete for variables, modes and collections (#33)
The Local variables window becomes a table editor: collections in a
sidebar, variables grouped by name with one value column per mode, typed
value cells (colour swatch + hex + alpha, number, string, boolean), alias
chips with a variable picker and detach, double-click rename, context
menus, and an Edit variable window for description and scopes.

New commands: variable.update (name, description, scopes),
variable.duplicate, variable.renameMode, variable.removeMode,
variable.renameCollection, variable.deleteCollection. variable.set
validates aliases (same type, no loops) and stores them by id; addMode
always picks a fresh mode id. Pickers offer only variables scoped to the
property. ui.set {variables} opens the editor on a collection.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:12:08 +07:00

117 lines
7.1 KiB
Rust

//! The Variables editor's commands: renaming, scopes, duplicates, modes, collections and aliases.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap()
}
fn tokens(s: &mut Session) {
ex(s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light", "Dark"]}));
ex(s, "variable.create", json!({"collection": "Tokens", "name": "space/sm", "type": "number", "value": 8}));
ex(s, "variable.create", json!({"collection": "Tokens", "name": "space/md", "type": "number", "value": 16}));
ex(s, "variable.create", json!({"collection": "Tokens", "name": "brand", "type": "color", "value": "#0D99FF"}));
}
#[test]
fn rename_describe_scope_and_duplicate() {
let mut s = Session::new();
tokens(&mut s);
ex(&mut s, "variable.update", json!({"variable": "space/sm", "name": " spacing / small ", "description": "Tight gaps"}));
let v = s.doc().unwrap().variables.get("spacing/small").cloned().unwrap();
assert_eq!(v.description, "Tight gaps");
// Names stay unique in a collection, and can't be blank.
assert!(s.execute("variable.update", &json!({"variable": "spacing/small", "name": "space/md"})).is_err());
assert!(s.execute("variable.update", &json!({"variable": "spacing/small", "name": " / "})).is_err());
// Scopes: a subset limits pickers, all of them (or ALL_SCOPES) is no limit, [] hides it.
ex(&mut s, "variable.update", json!({"variable": "spacing/small", "scopes": ["GAP", "CORNER_RADIUS"]}));
let v = s.doc().unwrap().variables.get("spacing/small").cloned().unwrap();
assert!(v.in_scope("GAP") && !v.in_scope("OPACITY"));
ex(&mut s, "variable.update", json!({"variable": "spacing/small", "scopes": ["ALL_SCOPES"]}));
assert_eq!(s.doc().unwrap().variables.get("spacing/small").unwrap().scopes, None);
ex(&mut s, "variable.update", json!({"variable": "spacing/small", "scopes": []}));
assert!(!s.doc().unwrap().variables.get("spacing/small").unwrap().in_scope("GAP"));
assert!(s.execute("variable.update", &json!({"variable": "brand", "scopes": ["GAP"]})).is_err());
ex(&mut s, "variable.update", json!({"variable": "brand", "scopes": ["ALL_FILLS"]}));
let brand = s.doc().unwrap().variables.get("brand").cloned().unwrap();
assert!(brand.in_scope("TEXT_FILL") && !brand.in_scope("STROKE_COLOR"));
// Duplicates sit after the original with a fresh id and name.
let id = ex(&mut s, "variable.duplicate", json!({"variable": "brand"}))["id"].as_str().unwrap().to_string();
let id2 = ex(&mut s, "variable.duplicate", json!({"variable": "brand"}))["id"].as_str().unwrap().to_string();
let vars = &s.doc().unwrap().variables;
assert_eq!(vars.get(&id).unwrap().name, "brand copy");
assert_eq!(vars.get(&id2).unwrap().name, "brand copy 2");
assert_eq!(vars.get(&id).unwrap().scopes, brand.scopes);
}
#[test]
fn modes_and_collections_rename_and_delete() {
let mut s = Session::new();
tokens(&mut s);
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "width", "variable": "space/md"}));
ex(&mut s, "variable.set", json!({"variable": "space/md", "mode": "Dark", "value": 24}));
ex(&mut s, "variable.setMode", json!({"ids": [f], "collection": "Tokens", "mode": "Dark"}));
assert_eq!(s.doc().unwrap().node(f).unwrap().width, 24.0);
ex(&mut s, "variable.renameMode", json!({"collection": "Tokens", "mode": "Dark", "name": "Night"}));
// Deleting the mode a frame uses puts it back on the default mode.
ex(&mut s, "variable.removeMode", json!({"collection": "Tokens", "mode": "Night"}));
assert_eq!(s.doc().unwrap().node(f).unwrap().width, 16.0);
assert!(s.execute("variable.removeMode", &json!({"collection": "Tokens", "mode": "Light"})).is_err(), "last mode stays");
// A new mode gets a fresh id even after a delete.
let m = ex(&mut s, "variable.addMode", json!({"collection": "Tokens", "name": "Dim"}))["id"].as_str().unwrap().to_string();
let coll = s.doc().unwrap().variables.collection("Tokens").cloned().unwrap();
assert_eq!(coll.modes.iter().filter(|x| x.id == m).count(), 1);
ex(&mut s, "variable.createCollection", json!({"name": "Other"}));
assert!(s.execute("variable.renameCollection", &json!({"collection": "Tokens", "name": "Other"})).is_err());
ex(&mut s, "variable.renameCollection", json!({"collection": "Tokens", "name": "Primitives"}));
// Deleting a collection removes its variables; bound layers keep their values.
let r = ex(&mut s, "variable.deleteCollection", json!({"collection": "Primitives"}));
assert_eq!(r["deletedVariables"], 3);
let doc = s.doc().unwrap();
assert!(doc.variables.get("space/md").is_none());
let n = doc.node(f).unwrap();
assert!(n.bound.is_empty() && n.var_modes.is_empty());
assert_eq!(n.width, 16.0);
// Undo brings it all back.
ex(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().variables.get("space/md").is_some());
}
#[test]
fn aliases_follow_their_target_and_refuse_loops_and_other_types() {
let mut s = Session::new();
tokens(&mut s);
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "width", "variable": "space/md"}));
ex(&mut s, "variable.set", json!({"variable": "space/md", "value": {"alias": "space/sm"}}));
// Stored by id, resolved through the target.
let sm = s.doc().unwrap().variables.get("space/sm").unwrap().id.clone();
let md = s.doc().unwrap().variables.get("space/md").cloned().unwrap();
assert!(md.values.values().any(|v| *v == figcraft_doc::VariableValue::Alias(sm.clone())));
assert_eq!(s.doc().unwrap().node(f).unwrap().width, 8.0);
ex(&mut s, "variable.set", json!({"variable": "space/sm", "value": 12}));
assert_eq!(s.doc().unwrap().node(f).unwrap().width, 12.0);
// sm → md would loop (md already aliases sm); self-aliases too; types must match.
assert!(s.execute("variable.set", &json!({"variable": "space/sm", "value": {"alias": "space/md"}})).is_err());
assert!(s.execute("variable.set", &json!({"variable": "space/sm", "value": {"alias": "space/sm"}})).is_err());
assert!(s.execute("variable.set", &json!({"variable": "space/sm", "value": {"alias": "brand"}})).is_err());
assert!(s.execute("variable.set", &json!({"variable": "space/sm", "value": {"alias": "nope"}})).is_err());
}
#[test]
fn field_scopes_by_property_and_layer() {
let mut s = Session::new();
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"]).unwrap();
let doc = s.doc().unwrap();
let scope = |id, field| crate::cmd::variables::field_scope(doc, id, field);
assert_eq!(scope(f, "fill"), Some("FRAME_FILL"));
assert_eq!(scope(r, "fill"), Some("SHAPE_FILL"));
assert_eq!(scope(f, "paddingHorizontal"), Some("GAP"));
assert_eq!(scope(f, "visible"), None);
}