Files
figcraft/crates/engine/src/variable_props_tests.rs
T
0d47564737 M3: bind fill and stroke variables directly in layer props (#1000)
node.create and node.set take {"$var": variable id or name} for fill and stroke: an exact id
first, then an unambiguous name or Collection/name. The binding goes through the same code as
variable.bind, is applied after literal values and style links, replaces other bindings of that
paint's colour and clears its style link, all in one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-10-27 12:55:48 +03:00

390 lines
19 KiB
Rust

//! Top-level paint variable props share `variable.bind` behaviour in one transaction (#1000).
use figcraft_color::Color;
use figcraft_doc::NodeId;
use serde_json::{Value, json};
use crate::{Session, params::id_of};
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command}: {e}"))
}
fn setup() -> (Session, String) {
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
let v = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "palette/surface", "type": "color", "value": "#0D99FF80"}));
(s, v["id"].as_str().unwrap().to_string())
}
fn create(s: &mut Session, props: Value) -> NodeId {
id_of(&run(s, "node.create", json!({"type": "rectangle", "width": 10, "height": 10, "props": props}))["id"]).unwrap()
}
fn color(s: &Session, id: NodeId, field: &str) -> Color {
let n = s.doc().unwrap().node(id).unwrap();
let paints = if field == "fill" { &n.fills } else { &n.strokes };
paints[0].solid_color().unwrap()
}
fn assert_bound(s: &Session, id: NodeId, field: &str, variable: &str) {
assert_eq!(s.doc().unwrap().node(id).unwrap().bound.get(field).map(String::as_str), Some(variable));
assert_eq!(color(s, id, field).to_hex(), "0D99FF");
assert!((color(s, id, field).a - 128.0 / 255.0).abs() < 1e-6);
}
#[test]
fn create_and_set_take_variable_ids_names_and_collection_names_for_both_paints() {
let (mut s, vid) = setup();
for field in ["fill", "stroke"] {
for name in [&vid, "palette/surface", "Theme/palette/surface"] {
let id = create(&mut s, json!({field: {"$var": name}}));
assert_bound(&s, id, field, &vid);
let id = create(&mut s, json!({"fill": "none", "stroke": "none"}));
run(&mut s, "node.set", json!({"id": id, "props": {field: {"$var": name}}}));
assert_bound(&s, id, field, &vid);
}
}
}
#[test]
fn ambiguous_and_unknown_variable_names_are_errors_but_ids_still_work() {
let (mut s, vid) = setup();
run(&mut s, "variable.createCollection", json!({"name": "Other"}));
run(&mut s, "variable.create", json!({"collection": "Other", "name": "palette/surface", "type": "color", "value": "#FF0000"}));
let e = s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": "palette/surface"}}})).unwrap_err().to_string();
assert!(e.contains("ambiguous variable `palette/surface`"), "{e}");
assert!(e.contains("Other/palette/surface") && e.contains("Theme/palette/surface"), "{e}");
let id = create(&mut s, json!({"fill": {"$var": vid}}));
assert_bound(&s, id, "fill", &vid);
let id = create(&mut s, json!({"fill": {"$var": "Theme/palette/surface"}}));
assert_bound(&s, id, "fill", &vid);
let e = s.execute("node.set", &json!({"id": id, "props": {"fill": {"$var": "missing/surface"}}})).unwrap_err().to_string();
assert!(e.contains("no such variable `missing/surface`"), "{e}");
// The existing command still uses the first match, not the new strict resolver.
run(&mut s, "variable.bind", json!({"id": id, "field": "fill", "variable": "palette/surface"}));
assert_bound(&s, id, "fill", &vid);
}
#[test]
fn variable_ids_choose_between_variables_with_the_same_name() {
let (mut s, first) = setup();
run(&mut s, "variable.createCollection", json!({"name": "Other"}));
let second = run(&mut s, "variable.create", json!({"collection": "Other", "name": "palette/surface", "type": "color", "value": "#FF0000"}))["id"]
.as_str()
.unwrap()
.to_string();
let id = create(&mut s, json!({"fill": {"$var": second}}));
assert_eq!(s.doc().unwrap().node(id).unwrap().bound.get("fill"), Some(&second));
assert_eq!(color(&s, id, "fill").to_hex(), "FF0000");
// `variable.bind` deliberately keeps its first id-or-name match.
run(&mut s, "variable.bind", json!({"id": id, "field": "fill", "variable": "palette/surface"}));
assert_bound(&s, id, "fill", &first);
run(&mut s, "node.set", json!({"id": id, "props": {"fill": {"$var": second}}}));
assert_eq!(s.doc().unwrap().node(id).unwrap().bound.get("fill"), Some(&second));
assert_eq!(color(&s, id, "fill").to_hex(), "FF0000");
}
#[test]
fn whole_name_matches_and_qualified_matches_share_one_candidate_set() {
let (mut s, _) = setup();
run(&mut s, "variable.createCollection", json!({"name": "Other"}));
run(&mut s, "variable.create", json!({"collection": "Other", "name": "Theme/palette/surface", "type": "color", "value": "#FF0000"}));
let e = s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": "Theme/palette/surface"}}})).unwrap_err().to_string();
assert!(e.contains("ambiguous variable"), "{e}");
assert!(e.contains("Other/Theme/palette/surface") && e.contains("Theme/palette/surface"), "{e}");
// Collection names can contain slashes too; no splitting at the first slash.
run(&mut s, "variable.createCollection", json!({"name": "Brand/Theme"}));
let v = run(&mut s, "variable.create", json!({"collection": "Brand/Theme", "name": "palette/surface", "type": "color", "value": "#0D99FF80"}));
let id = create(&mut s, json!({"fill": {"$var": "Brand/Theme/palette/surface"}}));
assert_bound(&s, id, "fill", v["id"].as_str().unwrap());
}
#[test]
fn wrong_variable_type_rolls_back_create_and_set() {
let (mut s, _) = setup();
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "size", "type": "number", "value": 20}));
let id = create(&mut s, json!({"fill": "#FF0000"}));
for field in ["fill", "stroke"] {
for command in ["node.create", "node.set"] {
let before = s.doc().unwrap().clone();
let selection = s.selection().to_vec();
let depth = s.undo_depth();
let e = s.execute(command, &json!({"type": "rectangle", "id": id, "props": {"name": "must roll back", "fill": {"$var": "palette/surface"}, field: {"$var": "size"}}})).unwrap_err().to_string();
assert!(e.contains(&format!("`{field}` doesn't take a number variable")), "{e}");
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.selection(), selection);
assert_eq!(s.undo_depth(), depth);
}
}
}
#[test]
fn variable_props_follow_modes_and_later_literal_paints_detach() {
let (mut s, vid) = setup();
// Start with both paints present: mode alpha on a newly created paint is an existing,
// separate `variable.bind` bug, not part of variable props.
let id = create(&mut s, json!({"strokes": ["#FFFFFF"], "fill": {"$var": vid}, "stroke": {"$var": vid}}));
run(&mut s, "variable.set", json!({"variable": vid, "mode": "Dark", "value": "#20212440"}));
for mode in ["Dark", "Light", "Dark"] {
run(&mut s, "variable.setMode", json!({"collection": "Theme", "mode": mode}));
for field in ["fill", "stroke"] {
let c = color(&s, id, field);
assert_eq!(c.to_hex(), if mode == "Dark" { "202124" } else { "0D99FF" });
let alpha = if mode == "Dark" { 64.0 } else { 128.0 } / 255.0;
assert!((c.a - alpha).abs() < 1e-6, "{mode}: {c:?}");
}
}
run(&mut s, "node.set", json!({"id": id, "props": {"fill": "#FF0000", "stroke": "#00FF00"}}));
assert!(s.doc().unwrap().node(id).unwrap().bound.is_empty());
run(&mut s, "variable.set", json!({"variable": vid, "mode": "Dark", "value": "#000000"}));
assert_eq!(color(&s, id, "fill").to_hex(), "FF0000");
assert_eq!(color(&s, id, "stroke").to_hex(), "00FF00");
}
#[test]
fn create_with_variables_is_one_undo_entry_and_redo_restores_it() {
let (mut s, vid) = setup();
let before = s.doc().unwrap().clone();
let depth = s.undo_depth();
let id = create(&mut s, json!({"fill": {"$var": vid}, "stroke": {"$var": vid}}));
assert_eq!(s.undo_depth(), (depth.0 + 1, 0));
let after = s.doc().unwrap().clone();
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap(), &before);
assert!(!s.doc().unwrap().contains(id));
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc().unwrap(), &after);
assert_bound(&s, id, "fill", &vid);
assert_bound(&s, id, "stroke", &vid);
}
#[test]
fn paint_variable_props_match_explicit_binding_including_typed_alpha_and_opacity() {
let (mut s, vid) = setup();
for field in ["fill", "stroke"] {
let list = if field == "fill" { "fills" } else { "strokes" };
for opacity in [1.0, 0.25] {
let paints = json!({list: [{"type": "solid", "color": "#FF000080", "opacity": opacity}]});
let explicit = create(&mut s, paints.clone());
run(&mut s, "variable.bind", json!({"id": explicit, "field": field, "variable": vid}));
let mut props = paints.clone();
props[field] = json!({"$var": vid});
let inline = create(&mut s, props);
let set = create(&mut s, paints);
run(&mut s, "node.set", json!({"id": set, "props": {field: {"$var": vid}}}));
for id in [inline, set] {
let doc = s.doc().unwrap();
let (a, b) = (doc.node(id).unwrap(), doc.node(explicit).unwrap());
assert_eq!(a.fills, b.fills);
assert_eq!(a.strokes, b.strokes);
assert_eq!(a.bound, b.bound);
assert!((color(&s, id, field).a - 128.0 / 255.0 * opacity as f32).abs() < 1e-6);
}
// Repeating the same binding does not accumulate alpha.
run(&mut s, "node.set", json!({"id": inline, "props": {field: {"$var": vid}}}));
assert_eq!(color(&s, inline, field), color(&s, explicit, field));
}
}
}
#[test]
fn variable_props_win_over_literal_paint_lists_and_style_links() {
let (mut s, vid) = setup();
let st = run(&mut s, "style.create", json!({"type": "paint", "name": "Red", "value": ["#FF000080"]}))["id"].clone();
for field in ["fill", "stroke"] {
let style_key = if field == "fill" { "fillStyle" } else { "strokeStyle" };
let list = if field == "fill" { "fills" } else { "strokes" };
let props = json!({list: ["#00FF00"], style_key: st, field: {"$var": vid}});
let created = create(&mut s, props.clone());
let set = create(&mut s, json!({}));
run(&mut s, "node.set", json!({"id": set, "props": props}));
for id in [created, set] {
assert_bound(&s, id, field, &vid);
let n = s.doc().unwrap().node(id).unwrap();
assert!(if field == "fill" { n.styles.fill.is_none() } else { n.styles.stroke.is_none() });
}
run(&mut s, "style.update", json!({"style": st, "value": ["#FF00FF"]}));
for id in [created, set] {
assert_bound(&s, id, field, &vid);
}
}
}
#[test]
fn variable_props_unlink_a_preexisting_paint_style_even_when_the_colour_is_unchanged() {
let (mut s, vid) = setup();
run(&mut s, "variable.set", json!({"variable": vid, "value": "#FF0000"}));
let style = run(&mut s, "style.create", json!({"type": "paint", "name": "Red", "value": ["#FF0000"]}))["id"].clone();
for field in ["fill", "stroke"] {
let style_key = if field == "fill" { "fillStyle" } else { "strokeStyle" };
let id = create(&mut s, json!({style_key: style}));
let n = s.doc().unwrap().node(id).unwrap();
assert!(if field == "fill" { n.styles.fill.is_some() } else { n.styles.stroke.is_some() });
run(&mut s, "node.set", json!({"id": id, "props": {field: {"$var": vid}}}));
let n = s.doc().unwrap().node(id).unwrap();
assert_eq!(n.bound.get(field), Some(&vid));
assert!(if field == "fill" { n.styles.fill.is_none() } else { n.styles.stroke.is_none() });
assert_eq!(color(&s, id, field).to_hex(), "FF0000");
}
}
#[test]
fn malformed_and_unsupported_variable_props_are_errors_and_leave_no_edit() {
let (mut s, _) = setup();
let id = create(&mut s, json!({}));
for props in [
json!({"fill": {"$var": null}}),
json!({"fill": {"$var": 5}}),
json!({"stroke": {"$var": []}}),
json!({"fill": {"$var": {}}}),
json!({"fill": {"$var": ""}}),
json!({"fill": {}}),
json!({"fill": {"$var": "palette/surface", "extra": true}}),
json!({"fill": {"$var": "palette/surface", "$ref": "x"}}),
json!({"fill": {"$var": "palette/surface", "$step": 0}}),
] {
for command in ["node.create", "node.set"] {
let before = s.doc().unwrap().clone();
let depth = s.undo_depth();
let e = s.execute(command, &json!({"type": "rectangle", "id": id, "props": props})).unwrap_err().to_string();
assert!(e.contains("$var") || e.contains("must be a colour"), "{e}");
assert_eq!(s.doc().unwrap(), &before, "{props}");
assert_eq!(s.undo_depth(), depth);
}
}
for key in ["width", "fills", "strokes", "opacity", "gap", "path", "pluginData", "fillStyle", "unknown"] {
for command in ["node.create", "node.set"] {
let e = s.execute(command, &json!({"type": "rectangle", "id": id, "props": {key: {"$var": "palette/surface"}}})).unwrap_err().to_string();
assert!(e.contains("only `fill` and `stroke` take variables in props") && e.contains("variable.bind"), "{e}");
}
}
}
#[test]
fn batch_references_resolve_inside_variable_wrappers_and_mixed_keys_roll_back() {
for reference in [json!({"$ref": "surface"}), json!({"$step": 1})] {
let mut s = Session::new();
let commands = json!([
{"command": "variable.createCollection", "params": {"name": "Theme"}},
{"command": "variable.create", "as": "surface", "params": {"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF80"}},
{"command": "node.create", "params": {"type": "rectangle", "props": {"fill": {"$var": reference}}}}
]);
let result = run(&mut s, "command.batch", json!({"commands": commands}));
let id = id_of(&result[2]["id"]).unwrap();
let vid = result[1]["id"].as_str().unwrap();
assert_bound(&s, id, "fill", vid);
assert_eq!(s.undo_depth(), (1, 0));
run(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().variables.variables.is_empty());
assert!(!s.doc().unwrap().contains(id));
let before = s.doc().unwrap().clone();
let mut bad_commands = commands.clone();
bad_commands[2]["params"]["props"]["fill"] = json!({"$var": "surface", "$ref": "surface"});
let e = s.execute("command.batch", &json!({"commands": bad_commands})).unwrap_err().to_string();
assert!(e.contains("not `$var`"), "{e}");
assert_eq!(s.doc().unwrap(), &before);
}
}
#[test]
fn variable_props_replace_first_paint_aliases_but_keep_other_slots_bound() {
let (mut s, vid) = setup();
let old = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "old", "type": "color", "value": "#FF0000"}))["id"]
.as_str()
.unwrap()
.to_string();
for (field, list, aliases) in [
("fill", "fills", vec!["fill", "fills", "fills/0", "fills/0/color", "fills/00/color"]),
("stroke", "strokes", vec!["stroke", "strokes/0/color", "strokes/00/color"]),
] {
for alias in aliases {
let first = format!("{list}/0/color");
let second = format!("{list}/1/color");
let props = json!({list: ["#FFFFFF", "#FFFFFF"], "boundVariables": {alias: old, second.clone(): old}});
let set = create(&mut s, props.clone());
run(&mut s, "node.set", json!({"id": set, "props": {field: {"$var": vid}}}));
let mut props = props;
props[field] = json!({"$var": vid});
let created = create(&mut s, props);
for id in [set, created] {
assert_bound(&s, id, field, &vid);
let n = s.doc().unwrap().node(id).unwrap();
assert_eq!(n.bound.len(), 2, "{alias}");
assert_eq!(n.bound.get(&second), Some(&old));
if alias != field {
assert!(!n.bound.contains_key(alias), "{alias}");
}
assert!(!n.bound.contains_key(&first));
let paints = if field == "fill" { &n.fills } else { &n.strokes };
assert_eq!(paints[1].solid_color().unwrap().to_hex(), "FF0000");
}
}
}
}
#[test]
fn variable_props_bind_a_mixed_selection_and_unsupported_wrappers_leave_no_edit() {
let (mut s, vid) = setup();
let ids: Vec<_> = ["frame", "rectangle", "text"]
.into_iter()
.map(|kind| id_of(&run(&mut s, "node.create", json!({"type": kind, "width": 10, "height": 10}))["id"]).unwrap())
.collect();
run(&mut s, "select.set", json!({"ids": ids}));
run(&mut s, "node.set", json!({"props": {"fill": {"$var": vid}, "stroke": {"$var": vid}}}));
for &id in &ids {
for field in ["fill", "stroke"] {
assert_bound(&s, id, field, &vid);
}
}
let before = s.doc().unwrap().clone();
let selection = s.selection().to_vec();
let depth = s.undo_depth();
let e = s.execute("node.set", &json!({"props": {"name": "must not change", "cornerRadius": {"$var": vid}}})).unwrap_err().to_string();
assert!(e.contains("only `fill` and `stroke` take variables in props"), "{e}");
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.selection(), selection);
assert_eq!(s.undo_depth(), depth);
}
#[test]
fn variable_keys_inside_typed_paint_lists_stay_literal_but_bare_wrappers_error() {
let (mut s, vid) = setup();
let id = create(&mut s, json!({}));
for command in ["node.create", "node.set"] {
// Typed paints ignore extra keys today: `$var` is only a wrapper in top-level props.
let result = run(
&mut s,
command,
json!({"type": "rectangle", "id": id, "props": {
"fills": [{"type": "solid", "color": "#FF0000", "$var": vid}]
}}),
);
let painted = if command == "node.create" { id_of(&result["id"]).unwrap() } else { id };
assert_eq!(color(&s, painted, "fill").to_hex(), "FF0000");
assert!(s.doc().unwrap().node(painted).unwrap().bound.is_empty());
let before = s.doc().unwrap().clone();
let depth = s.undo_depth();
assert!(s.execute(command, &json!({"type": "rectangle", "id": id, "props": {"fills": [{"$var": vid}]}})).is_err());
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.undo_depth(), depth);
}
}
#[test]
fn variable_bind_keeps_its_legacy_type_lookup_when_a_name_collides_with_an_id() {
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "Theme"}));
let number = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "size", "type": "number", "value": 20}))["id"].clone();
let paint = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#FF0000"}))["id"].clone();
run(&mut s, "variable.update", json!({"variable": number, "name": paint}));
let id = create(&mut s, json!({}));
let before = s.doc().unwrap().clone();
let depth = s.undo_depth();
let e = s.execute("variable.bind", &json!({"id": id, "field": "fill", "variable": "surface"})).unwrap_err().to_string();
assert!(e.contains("`fill` doesn't take a number variable"), "{e}");
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.undo_depth(), depth);
}