Files
figcraft/crates/engine/src/variables_behaviour_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

368 lines
21 KiB
Rust

//! Variables behaviour suite, written from the public documentation of variables in design
//! tools: types and their default values, collections and modes (new and duplicated modes, the
//! default mode), aliases (same type, chains, no cycles), scopes (pickers only), mode inheritance
//! down the tree, bindings by field and type, deleting bound variables, code syntax and the
//! variables JSON. Each case gives the rule's source in one line; "docs silent" marks behaviour the
//! documentation doesn't describe (pinned as it is).
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{LineHeight, Node, NodeId, VariableType, VariableValue};
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn vid(v: Value) -> String {
v["id"].as_str().unwrap().to_string()
}
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn node(s: &Session, n: NodeId) -> Node {
s.doc().unwrap().node(n).unwrap().clone()
}
fn fill(s: &Session, n: NodeId) -> String {
node(s, n).fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
}
fn frame(s: &mut Session, parent: Option<NodeId>) -> NodeId {
id(run(s, "frame.create", json!({"parent": parent, "x": 0, "y": 0, "width": 100, "height": 100})))
}
fn rect(s: &mut Session, parent: Option<NodeId>) -> NodeId {
id(run(s, "shape.rectangle", json!({"parent": parent, "x": 0, "y": 0, "width": 10, "height": 10})))
}
/// A "Theme" collection with Light and Dark modes and a colour variable `bg` (white / black).
fn theme(s: &mut Session) {
run(s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
run(s, "variable.create", json!({"collection": "Theme", "name": "bg", "type": "color", "values": {"Light": "#FFFFFF", "Dark": "#000000"}}));
}
#[test]
fn types_and_their_starting_values() {
// Documented: four types (colour, number, string, boolean). Docs silent on a new variable's
// value; pinned: black, 0, empty, true. Every mode of the collection gets a value.
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "C", "modes": ["A", "B"]}));
let cases: [(&str, VariableType, VariableValue); 4] = [
("color", VariableType::Color, VariableValue::Color(figcraft_color::Color::BLACK)),
("number", VariableType::Number, VariableValue::Number(0.0)),
("string", VariableType::String, VariableValue::String(String::new())),
("boolean", VariableType::Boolean, VariableValue::Boolean(true)),
];
for (ty, kind, want) in cases {
let v = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": ty, "type": ty})));
let var = s.doc().unwrap().variables.get(&v).unwrap().clone();
assert_eq!(var.kind, kind, "{ty}");
assert_eq!(var.values.len(), 2, "{ty}: a value per mode");
assert!(var.values.values().all(|x| *x == want), "{ty}: {:?}", var.values);
}
assert!(s.execute("variable.create", &json!({"collection": "C", "name": "x", "type": "date"})).is_err());
assert!(s.execute("variable.create", &json!({"collection": "C", "name": "y", "type": "number", "value": "wide"})).is_err());
}
#[test]
fn modes_new_duplicated_reordered_and_the_default() {
// Documented: a new mode starts with the default mode's values and a duplicated one with its
// source's; the default is the left-most mode (set as default = move it there); layers on
// Auto follow a new default, layers set to a mode keep theirs.
let mut s = Session::new();
theme(&mut s);
let v = |s: &Session, mode: &str| {
let doc = s.doc().unwrap();
let c = doc.variables.collection("Theme").unwrap();
let m = c.find_mode(mode).unwrap().id.clone();
doc.variables.get("bg").unwrap().values.get(&m).cloned()
};
run(&mut s, "variable.addMode", json!({"collection": "Theme", "name": "Dim"}));
assert_eq!(v(&s, "Dim"), v(&s, "Light"), "new mode: the first column's values");
run(&mut s, "variable.addMode", json!({"collection": "Theme", "name": "Dark 2", "from": "Dark"}));
assert_eq!(v(&s, "Dark 2"), v(&s, "Dark"), "duplicated mode: that mode's values");
assert!(s.execute("variable.addMode", &json!({"collection": "Theme", "name": "X", "from": "Nope"})).is_err());
let auto = rect(&mut s, None);
let pinned_frame = frame(&mut s, None);
let pinned = rect(&mut s, Some(pinned_frame));
for r in [auto, pinned] {
run(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "bg"}));
}
run(&mut s, "variable.setMode", json!({"ids": [pinned_frame], "collection": "Theme", "mode": "Light"}));
assert_eq!((fill(&s, auto), fill(&s, pinned)), ("FFFFFF".into(), "FFFFFF".into()));
// Dark becomes the default (moved to the first column).
run(&mut s, "variable.moveMode", json!({"collection": "Theme", "mode": "Dark", "to": 0}));
let order: Vec<String> = s.doc().unwrap().variables.collection("Theme").unwrap().modes.iter().map(|m| m.name.clone()).collect();
assert_eq!(order, ["Dark", "Light", "Dim", "Dark 2"]);
assert_eq!(fill(&s, auto), "000000", "Auto follows the new default");
assert_eq!(fill(&s, pinned), "FFFFFF", "an explicit mode stays");
// Moving right past the end clamps; one undo step per move.
run(&mut s, "variable.moveMode", json!({"collection": "Theme", "mode": "Dark", "to": 99}));
assert_eq!(s.doc().unwrap().variables.collection("Theme").unwrap().modes.last().unwrap().name, "Dark");
assert_eq!(fill(&s, auto), "FFFFFF");
s.undo().unwrap();
assert_eq!(fill(&s, auto), "000000");
assert!(s.execute("variable.moveMode", &json!({"collection": "Theme", "mode": "Nope", "to": 0})).is_err());
// Deleting a mode a frame is set to: it goes back to Auto (docs silent; pinned).
run(&mut s, "variable.removeMode", json!({"collection": "Theme", "mode": "Light"}));
assert!(node(&s, pinned_frame).var_modes.is_empty());
assert_eq!(fill(&s, pinned), "000000");
}
#[test]
fn aliases_same_type_chains_and_no_cycles() {
// Documented: an alias points at a variable of the same type. Docs silent on loops; pinned:
// refused (a value must resolve), also when creating with an alias.
let mut s = Session::new();
theme(&mut s);
run(&mut s, "variable.createCollection", json!({"name": "Brand", "modes": ["Base"]}));
run(&mut s, "variable.create", json!({"collection": "Brand", "name": "blue", "type": "color", "value": "#0000FF"}));
run(&mut s, "variable.create", json!({"collection": "Brand", "name": "n", "type": "number", "value": 4}));
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "accent", "type": "color", "value": {"alias": "blue"}}));
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "link", "type": "color", "value": {"alias": "accent"}}));
let r = rect(&mut s, None);
run(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "link"}));
assert_eq!(fill(&s, r), "0000FF", "a chain link → accent → blue resolves");
run(&mut s, "variable.set", json!({"variable": "blue", "value": "#00FF00"}));
assert_eq!(fill(&s, r), "00FF00", "the chain follows the end of it");
let refused: [(&str, Value, &str); 5] = [
("variable.set", json!({"variable": "blue", "value": {"alias": "link"}}), "a loop through the chain"),
("variable.set", json!({"variable": "accent", "value": {"alias": "accent"}}), "itself"),
("variable.set", json!({"variable": "accent", "value": {"alias": "n"}}), "another type"),
("variable.create", json!({"collection": "Theme", "name": "x", "type": "color", "value": {"alias": "n"}}), "create: another type"),
("variable.create", json!({"collection": "Theme", "name": "y", "type": "color", "value": {"alias": "missing"}}), "create: no such variable"),
];
for (cmd, p, what) in refused {
assert!(s.execute(cmd, &p).is_err(), "{what}");
}
assert!(s.doc().unwrap().variables.get("x").is_none() && s.doc().unwrap().variables.get("y").is_none());
}
#[test]
fn scopes_limit_pickers_only() {
// Documented: scoping only changes which variables pickers offer; the scope lists per type
// (number: gap and padding, corner radius, font weight/size/line height/letter spacing/
// paragraph spacing and indent, opacity, effects, stroke, text content, width and height;
// colour: frame/shape/text fill, stroke, effects; string: font family, style, text content).
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "C"}));
run(&mut s, "variable.create", json!({"collection": "C", "name": "radius", "type": "number", "value": 8}));
run(&mut s, "variable.update", json!({"variable": "radius", "scopes": ["CORNER_RADIUS"]}));
let r = rect(&mut s, None);
let doc = s.doc().unwrap();
let var = doc.variables.get("radius").unwrap();
let picker = |field: &str| crate::cmd::variables::field_scope(doc, r, field).is_none_or(|sc| var.in_scope(sc));
assert!(picker("cornerRadius"));
assert!(!picker("width"), "out of scope: not offered for width");
// Binding out of scope still works (scopes are about pickers).
run(&mut s, "variable.bind", json!({"ids": [r], "field": "width", "variable": "radius"}));
assert_eq!(node(&s, r).width, 8.0);
let number = figcraft_doc::scopes_for(VariableType::Number).iter().map(|(k, _)| *k).collect::<Vec<_>>();
for want in [
"GAP",
"CORNER_RADIUS",
"FONT_WEIGHT",
"FONT_SIZE",
"LINE_HEIGHT",
"LETTER_SPACING",
"PARAGRAPH_SPACING",
"PARAGRAPH_INDENT",
"OPACITY",
"EFFECT_FLOAT",
"STROKE_FLOAT",
"TEXT_CONTENT",
"WIDTH_HEIGHT",
] {
assert!(number.contains(&want), "number scope {want}");
}
let doc = s.doc().unwrap();
let t = figcraft_doc::NodeId(0);
let fields: [(&str, &str); 3] =
[("fontWeight", "FONT_WEIGHT"), ("paragraphSpacing", "PARAGRAPH_SPACING"), ("paragraphIndent", "PARAGRAPH_INDENT")];
for (field, scope) in fields {
assert_eq!(crate::cmd::variables::field_scope(doc, t, field), Some(scope), "{field}");
}
// Unknown scope ids are refused.
assert!(s.execute("variable.update", &json!({"variable": "radius", "scopes": ["TEXT_FILL"]})).is_err());
}
#[test]
fn modes_inherit_down_the_tree() {
// Documented: layers are on Auto by default and take the nearest container's mode (frames,
// groups, sections, components, pages); with none set, the collection's default mode.
let mut s = Session::new();
theme(&mut s);
let page = s.page().unwrap();
let outer = frame(&mut s, None);
let inner = frame(&mut s, Some(outer));
let r = rect(&mut s, Some(inner));
let free = rect(&mut s, None);
for x in [r, free] {
run(&mut s, "variable.bind", json!({"ids": [x], "field": "fill", "variable": "bg"}));
}
fn set(s: &mut Session, ids: Value, mode: Value) {
run(s, "variable.setMode", json!({"ids": ids, "collection": "Theme", "mode": mode}));
}
// A step: what it does to (page, outer, inner).
type Step = fn(&mut Session, NodeId, NodeId, NodeId);
// (step, expected nested, expected loose)
let steps: [(&str, Step, &str, &str); 5] = [
("nothing set: default mode", |_, _, _, _| {}, "FFFFFF", "FFFFFF"),
("page Dark: everything on it", |s, p, _, _| set(s, json!([p]), json!("Dark")), "000000", "000000"),
("outer Light: its subtree", |s, _, o, _| set(s, json!([o]), json!("Light")), "FFFFFF", "000000"),
("inner Dark: the nearest wins", |s, _, _, i| set(s, json!([i]), json!("Dark")), "000000", "000000"),
("inner back to Auto: outer's again", |s, _, _, i| set(s, json!([i]), Value::Null), "FFFFFF", "000000"),
];
for (what, step, nested, loose) in steps {
step(&mut s, page, outer, inner);
assert_eq!((fill(&s, r).as_str(), fill(&s, free).as_str()), (nested, loose), "{what}");
}
// Moving a layer into another container: it takes that one's mode.
run(&mut s, "node.reparent", json!({"ids": [free], "parent": outer}));
assert_eq!(fill(&s, free), "FFFFFF");
}
#[test]
fn bindings_by_field_and_type() {
// Documented: which properties each type applies to (number: sizes, gaps, padding, radius,
// opacity, font properties, effects, strokes, text content; colour: fills, strokes, effects;
// string: text content, font family/style; boolean: visibility). Opacity numbers are percent
// (as the field shows); other numbers are pixels. A variable of a type the field doesn't take
// is refused (docs: a variable applies only to properties of its type).
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "C"}));
for (name, ty, value) in
[("n", "number", json!(12)), ("c", "color", json!("#336699")), ("t", "string", json!("Hi")), ("b", "boolean", json!(false))]
{
run(&mut s, "variable.create", json!({"collection": "C", "name": name, "type": ty, "value": value}));
}
let f = frame(&mut s, None);
run(&mut s, "layout.add", json!({"ids": [f]}));
let r = rect(&mut s, None);
run(&mut s, "node.set", json!({"id": r, "props": {"strokes": ["#000000"], "strokeWeight": 1}}));
let t = id(run(&mut s, "text.create", json!({"text": "Hello", "x": 0, "y": 200})));
type Check = fn(&Node) -> bool;
let ok: [(NodeId, &str, &str, Check); 14] = [
(r, "width", "n", |n| n.width == 12.0),
(r, "height", "n", |n| n.height == 12.0),
(r, "cornerRadius", "n", |n| n.corners.uniform() == Some(12.0)),
(r, "opacity", "n", |n| (n.opacity - 0.12).abs() < 1e-5),
(r, "strokeWeight", "n", |n| n.stroke.weight == 12.0),
(f, "gap", "n", |n| n.layout.as_ref().is_some_and(|l| l.gap == 12.0)),
(f, "paddingTop", "n", |n| n.layout.as_ref().is_some_and(|l| l.padding[0] == 12.0)),
(t, "fontSize", "n", |n| n.kind.text().is_some_and(|x| x.style.font_size == 12.0)),
(t, "lineHeight", "n", |n| n.kind.text().is_some_and(|x| x.style.line_height == LineHeight::Pixels(12.0))),
(t, "paragraphSpacing", "n", |n| n.kind.text().is_some_and(|x| x.paragraph_spacing == 12.0)),
(r, "fill", "c", |n| n.fills.first().and_then(|p| p.solid_color()).is_some_and(|c| c.to_hex() == "336699")),
(r, "strokes/0/color", "c", |n| n.strokes.first().and_then(|p| p.solid_color()).is_some_and(|c| c.to_hex() == "336699")),
(t, "characters", "t", |n| n.kind.text().is_some_and(|x| x.characters == "Hi")),
(t, "visible", "b", |n| !n.visible),
];
for (layer, field, var, check) in ok {
run(&mut s, "variable.bind", json!({"ids": [layer], "field": field, "variable": var}));
assert!(check(&node(&s, layer)), "{field} ← {var}");
}
// Number variables in text content show as text (documented: number → text content).
let t2 = id(run(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 300})));
run(&mut s, "variable.bind", json!({"ids": [t2], "field": "characters", "variable": "n"}));
assert_eq!(node(&s, t2).kind.text().map(|x| x.characters.clone()).as_deref(), Some("12"));
let refused: [(NodeId, &str, &str); 6] =
[(r, "width", "c"), (r, "fill", "n"), (r, "opacity", "t"), (t, "visible", "n"), (t, "characters", "c"), (t, "fontFamily", "n")];
for (layer, field, var) in refused {
let before = node(&s, layer).bound.get(field).cloned();
assert!(s.execute("variable.bind", &json!({"ids": [layer], "field": field, "variable": var})).is_err(), "{field} ← {var} refused");
assert_eq!(node(&s, layer).bound.get(field).cloned(), before, "{field}: binding unchanged");
}
}
#[test]
fn deleting_a_bound_variable_keeps_the_last_value() {
// Documented: deleting a variable unbinds what used it, which keeps showing its last value.
// Docs silent on variables aliasing it; pinned: they take its values too.
let mut s = Session::new();
theme(&mut s);
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": {"alias": "bg"}}));
let f = frame(&mut s, None);
let r = rect(&mut s, Some(f));
let r2 = rect(&mut s, Some(f));
run(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "bg"}));
run(&mut s, "variable.bind", json!({"ids": [r2], "field": "fill", "variable": "surface"}));
run(&mut s, "variable.setMode", json!({"ids": [f], "collection": "Theme", "mode": "Dark"}));
run(&mut s, "variable.delete", json!({"variable": "bg"}));
assert_eq!(fill(&s, r), "000000", "the last value stays");
assert!(node(&s, r).bound.is_empty(), "no longer connected");
let doc = s.doc().unwrap();
let surface = doc.variables.get("surface").unwrap();
let (light, dark) = (&doc.variables.collection("Theme").unwrap().modes[0].id, &doc.variables.collection("Theme").unwrap().modes[1].id);
assert_eq!(surface.values.get(light).map(VariableValue::display).as_deref(), Some("FFFFFF"), "alias → its value per mode");
assert_eq!(surface.values.get(dark).map(VariableValue::display).as_deref(), Some("000000"));
assert_eq!(fill(&s, r2), "000000");
// Undo brings the variable and the binding back.
s.undo().unwrap();
assert!(node(&s, r).bound.contains_key("fill"));
// Deleting the collection: the same for layers, and aliases from other collections into it.
run(&mut s, "variable.createCollection", json!({"name": "Other"}));
run(&mut s, "variable.create", json!({"collection": "Other", "name": "o", "type": "color", "value": {"alias": "bg"}}));
run(&mut s, "variable.deleteCollection", json!({"collection": "Theme"}));
assert_eq!(fill(&s, r), "000000");
assert!(node(&s, r).bound.is_empty());
let o = s.doc().unwrap().variables.get("o").unwrap().clone();
assert!(matches!(o.values.values().next(), Some(VariableValue::Color(c)) if c.to_hex() == "FFFFFF"), "{:?}", o.values);
}
#[test]
fn code_syntax_per_platform() {
// Documented: a variable can have a code name per platform (Web, iOS, Android) for handoff
// code; a Web one can be written as a CSS `var()` reference.
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "Spacing"}));
run(&mut s, "variable.create", json!({"collection": "Spacing", "name": "space/xs", "type": "number", "value": 4}));
run(
&mut s,
"variable.update",
json!({"variable": "space/xs", "codeSyntax": {"WEB": "var(--space-tight)", "iOS": "Spacing.tight", "ANDROID": "spacing_xs"}}),
);
let var = s.doc().unwrap().variables.get("space/xs").unwrap().clone();
assert_eq!(var.code_syntax.get("WEB").map(String::as_str), Some("var(--space-tight)"));
assert_eq!(var.code_syntax.len(), 3);
// Unknown platforms are refused; an empty name clears one.
assert!(s.execute("variable.update", &json!({"variable": "space/xs", "codeSyntax": {"Windows": "x"}})).is_err());
run(&mut s, "variable.update", json!({"variable": "space/xs", "codeSyntax": {"ANDROID": ""}}));
assert_eq!(s.doc().unwrap().variables.get("space/xs").unwrap().code_syntax.len(), 2);
// Generated CSS uses the Web name.
let f = frame(&mut s, None);
run(&mut s, "layout.add", json!({"ids": [f]}));
run(&mut s, "variable.bind", json!({"ids": [f], "field": "gap", "variable": "space/xs"}));
let css = crate::codegen::code_for(s.doc().unwrap(), f, crate::codegen::CodeLang::Css);
assert!(css.contains("var(--space-tight, 4px)"), "{css}");
// The variables JSON carries it both ways.
let out = run(&mut s, "variable.export", json!({"format": "json"}));
let text = out["text"].as_str().map(str::to_string).unwrap_or_else(|| out.to_string());
assert!(text.contains("Spacing.tight"), "{text}");
let mut s2 = Session::new();
run(&mut s2, "variable.import", json!({"variables": text}));
let back = s2.doc().unwrap().variables.get("space/xs").unwrap().code_syntax.clone();
assert_eq!(back, var.code_syntax.into_iter().filter(|(k, _)| k != "ANDROID").collect());
}
#[test]
fn number_variables_are_pixels_and_edits_are_one_undo_step() {
// Documented: number variables hold plain numbers; sizes, gaps and type sizes take them as
// pixels. Each edit in the editor is one undo step.
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "C"}));
run(&mut s, "variable.create", json!({"collection": "C", "name": "lh", "type": "number", "value": 20}));
let t = id(run(&mut s, "text.create", json!({"text": "Hello", "x": 0, "y": 0})));
run(&mut s, "variable.bind", json!({"ids": [t], "field": "lineHeight", "variable": "lh"}));
let lh = |s: &Session| node(s, t).kind.text().unwrap().style.line_height;
assert_eq!(lh(&s), LineHeight::Pixels(20.0));
run(&mut s, "variable.set", json!({"variable": "lh", "value": 28}));
assert_eq!(lh(&s), LineHeight::Pixels(28.0));
s.undo().unwrap();
assert_eq!(lh(&s), LineHeight::Pixels(20.0), "the value and the bound layer go back together");
}