mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
Fixes #1513 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
387 lines
19 KiB
Rust
387 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, ¶ms).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_type_checks_the_found_variable_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!({}));
|
|
run(&mut s, "variable.bind", json!({"id": id, "field": "fill", "variable": "surface"}));
|
|
assert_eq!(s.doc().unwrap().node(id).unwrap().bound.get("fill").map(String::as_str), paint.as_str());
|
|
assert_eq!(color(&s, id, "fill").to_hex(), "FF0000");
|
|
}
|