Files
figcraft/crates/engine/src/variables_colour_tests.rs
T
Christopher Linnett 8d5bad2afc M3: CSS export never names a calculation it leaves out either (#1280)
The CSS export left out a colour calculation that resolves to nothing but still wrote an alias to
it as var(--name); where no block defines that property, whatever reads the alias is invalid.
CSS (and Tailwind) and Penpot now share one rule, Variables::flattener: an alias that leads to a
calculation some mode of its collection leaves out goes out as the value it shows in the mode,
or is left out when it shows nothing.
2026-10-30 16:59:19 +08:00

511 lines
29 KiB
Rust

//! Colour calculations: `{alpha: …}` (a referenced colour with its alpha replaced) and `{mix: …}`
//! (a blend of two colour operands) as per-mode variable values — set and create shapes, lazy
//! propagation to bound layers and mode switches, composition over aliases and other
//! calculations, set-time refusal of cycles and bad operands (leaving the variable untouched),
//! deletion flattening, undo/redo exactness, save/load round trip, exports and junk input.
use serde_json::{Value, json};
use crate::Session;
use crate::components::tests::{ex, idv};
use crate::error::EngineError;
use crate::paint_tests::fill;
use crate::plugins_tests::rect;
use crate::variables_behaviour_tests::vid;
use crate::widgets_tests::err;
use figcraft_color::Color;
/// A "C" collection (modes M) with `ink` #FF0000 and `edge` #00FF00 (the error-case set's seeds).
fn seeds() -> Session {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "C", "modes": ["M"]}));
ex(&mut s, "variable.create", json!({"collection": "C", "name": "ink", "type": "color", "value": "#FF0000"}));
ex(&mut s, "variable.create", json!({"collection": "C", "name": "edge", "type": "color", "value": "#00FF00"}));
ex(&mut s, "variable.create", json!({"collection": "C", "name": "size", "type": "number", "value": 5}));
s
}
#[test]
fn create_accepts_alpha_and_mix_and_they_propagate_to_bound_layers() {
let mut s = seeds();
let hover = vid(ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "hover", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
));
let box_id = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [box_id], "field": "fill", "variable": "hover"}));
assert_eq!(fill(&s, box_id).to_rgba8(), [255, 0, 0, 128], "alpha replaces the source's alpha");
ex(&mut s, "variable.set", json!({"variable": "ink", "value": "#00AA00"}));
assert_eq!(fill(&s, box_id).to_rgba8(), [0, 170, 0, 128], "the bound layer follows the source, no re-set needed");
// A mix of two variables, one literal, and the t edges.
ex(&mut s, "variable.set", json!({"variable": hover, "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": 0.0}}}));
assert_eq!(fill(&s, box_id).to_rgba8(), [0, 170, 0, 255], "t=0 gives from exactly");
ex(
&mut s,
"variable.set",
json!({"variable": hover, "value": {"mix": {"from": {"literal": "#000000"}, "to": {"literal": "#FFFFFF"}, "t": 0.4}}}),
);
assert_eq!(fill(&s, box_id).to_rgba8(), [102, 102, 102, 255], "literal operands blend (0.4 -> 0x66)");
// Compose: a mix reading the calculation, and alpha over an alias.
ex(&mut s, "variable.create", json!({"collection": "C", "name": "mirror", "type": "color", "value": {"alias": "ink"}}));
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "faded-mirror", "type": "color", "value": {"alpha": {"variable": "mirror", "opacity": 0.5}}}),
);
let b2 = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b2], "field": "fill", "variable": "faded-mirror"}));
assert_eq!(fill(&s, b2).to_rgba8(), [0, 170, 0, 128], "alpha over an alias reads the aliased value");
let toast = vid(ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "toast", "type": "color", "value": {"alpha": {"variable": "hover", "opacity": 0.25}}}),
));
let b3 = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b3], "field": "fill", "variable": toast}));
assert_eq!(fill(&s, b3).to_rgba8(), [102, 102, 102, 64], "a calculation may read a calculation");
}
#[test]
fn per_mode_operands_follow_each_collection_chosen_mode() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Core", "modes": ["Light", "Dark"]}));
ex(&mut s, "variable.createCollection", json!({"name": "Extra", "modes": ["Light", "Dark"]}));
ex(&mut s, "variable.create", json!({"collection": "Core", "name": "p", "type": "color", "values": {"Light": "#FF0000", "Dark": "#0000FF"}}));
ex(&mut s, "variable.create", json!({"collection": "Extra", "name": "s", "type": "color", "values": {"Light": "#000000", "Dark": "#FFFFFF"}}));
ex(
&mut s,
"variable.create",
json!({"collection": "Core", "name": "panel", "type": "color",
"values": {"Light": {"mix": {"from": {"variable": "p"}, "to": {"variable": "s"}, "t": 0.5}},
"Dark": {"mix": {"from": {"variable": "p"}, "to": {"variable": "s"}, "t": 0.5}}}}),
);
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "panel"}));
assert_eq!(fill(&s, b).to_rgba8(), [128, 0, 0, 255], "Light mixes Light values of BOTH collections");
ex(&mut s, "variable.setMode", json!({"ids": [b], "collection": "Core", "mode": "Dark"}));
assert_eq!(fill(&s, b).to_rgba8(), [0, 0, 128, 255], "a Core switch moves only the Core operand (Extra stays Light)");
ex(&mut s, "variable.setMode", json!({"ids": [b], "collection": "Extra", "mode": "Dark"}));
assert_eq!(fill(&s, b).to_rgba8(), [128, 128, 255, 255], "the Extra switch then moves its operand, independently");
}
#[test]
fn bad_shapes_and_operands_are_refused_without_touching_the_variable() {
let mut s = seeds();
let before = json!(s.doc().unwrap().variables);
let want_t = "`t` must be a number from 0 to 1";
let want_opacity = "`opacity` must be a number from 0 to 1";
let want_operand = "mix operands are {variable: …} or {literal: colour}";
let cases: Vec<(&str, Value, &str)> = vec![
(
"self alpha",
json!({"variable": "ink", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
"the colour calculation would refer back to itself",
),
(
"unknown operand",
json!({"variable": "ink", "value": {"alpha": {"variable": "ghost", "opacity": 0.5}}}),
"no variable `ghost` to calculate from",
),
(
"number operand",
json!({"variable": "ink", "value": {"alpha": {"variable": "size", "opacity": 0.5}}}),
"colour calculations take colour variables: `size` is a number one",
),
(
"number mix operand",
json!({"variable": "ink", "value": {"mix": {"from": {"variable": "size"}, "to": {"variable": "edge"}, "t": 0.5}}}),
"colour calculations take colour variables: `size` is a number one",
),
(
"calculation on a number variable",
json!({"variable": "size", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
"colour calculations only fit colour variables",
),
("t too big", json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": 1.5}}}), want_t),
("t missing", json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}}}}), want_t),
(
"t as text",
json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": "half"}}}),
want_t,
),
("opacity above 1", json!({"variable": "ink", "value": {"alpha": {"variable": "edge", "opacity": 2}}}), want_opacity),
("opacity missing", json!({"variable": "ink", "value": {"alpha": {"variable": "edge"}}}), want_opacity),
("alpha on a literal", json!({"variable": "ink", "value": {"alpha": {"literal": "#FF0000", "opacity": 0.5}}}), "alpha: {variable, opacity}"),
("mix with a bare string", json!({"variable": "ink", "value": {"mix": {"from": "ink", "to": {"variable": "edge"}, "t": 0.5}}}), want_operand),
(
"mix operand with two keys",
json!({"variable": "ink", "value": {"mix": {"from": {"variable": "ink", "literal": "#000000"}, "to": {"variable": "edge"}, "t": 0.5}}}),
want_operand,
),
("unknown key in alpha", json!({"variable": "ink", "value": {"alpha": {"variable": "edge", "opactiy": 0.5}}}), "alpha: {variable, opacity}"),
// A bad parameter is named even beside an operand that loops: it is checked first.
(
"bad t beside a loop",
json!({"variable": "ink", "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": 2}}}),
want_t,
),
("bad opacity beside a loop", json!({"variable": "ink", "value": {"alpha": {"variable": "ink", "opacity": -1}}}), want_opacity),
];
for (label, p, want) in cases {
let msg = err(&mut s, "variable.set", p.clone());
assert!(msg.contains(want), "{label}: {msg}");
}
// A second key beside the operation is refused, not dropped.
let alpha = json!({"variable": "edge", "opacity": 0.5});
let mix = json!({"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": 0.5});
for (label, value) in [
("alpha and mix", json!({"alpha": alpha.clone(), "mix": mix.clone()})),
("alias beside alpha", json!({"alias": "edge", "alpha": alpha})),
("unknown key beside mix", json!({"mix": mix, "space": "oklch"})),
] {
let msg = err(&mut s, "variable.set", json!({"variable": "ink", "value": value}));
assert!(msg.contains("on its own"), "{label}: {msg}");
let msg = err(&mut s, "variable.create", json!({"collection": "C", "name": "extra", "type": "color", "value": value}));
assert!(msg.contains("on its own"), "{label} on create: {msg}");
}
assert_eq!(json!(s.doc().unwrap().variables), before, "the refusals left every variable as it was");
// A cycle reached through binary operands too: ink mixes edge; then edge must not read ink.
ex(&mut s, "variable.set", json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": 0.5}}}));
let msg = err(&mut s, "variable.set", json!({"variable": "edge", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}));
assert!(msg.contains("the colour calculation would refer back to itself"), "cycle named as such: {msg}");
let msg = err(
&mut s,
"variable.set",
json!({"variable": "edge", "value": {"mix": {"from": {"variable": "ink"}, "to": {"literal": "#FFFFFF"}, "t": 0.5}}}),
);
assert!(msg.contains("the colour calculation would refer back to itself"), "mix-cycle named as such: {msg}");
// None of the refusals touched the variables: ink still holds its last accepted value, and a
// fresh calculation on it works.
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "ink"}));
assert_eq!(fill(&s, b).to_rgba8(), [128, 255, 128, 255], "ink keeps the accepted mix (edge + white at 0.5)");
ex(&mut s, "variable.set", json!({"variable": "ink", "value": {"alpha": {"variable": "edge", "opacity": 0.5}}}));
assert_eq!(fill(&s, b).to_rgba8(), [0, 255, 0, 128], "and calculations still set afterwards");
}
#[test]
fn undo_and_redo_are_exact_through_calculations() {
let mut s = seeds();
let hover = vid(ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "hover", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
));
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": hover}));
let first = fill(&s, b).to_rgba8();
ex(&mut s, "variable.set", json!({"variable": "hover", "value": {"alpha": {"variable": "edge", "opacity": 0.25}}}));
let second = fill(&s, b).to_rgba8();
assert_ne!(first, second);
ex(&mut s, "edit.undo", json!({}));
assert_eq!(fill(&s, b).to_rgba8(), first, "undo restores the stored alpha calculation exactly");
ex(&mut s, "edit.redo", json!({}));
assert_eq!(fill(&s, b).to_rgba8(), second, "redo replays the new one exactly");
}
#[test]
fn deleting_a_source_flattens_its_calculations() {
let mut s = seeds();
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "fade", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
);
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "fade"}));
let before = json!(s.doc().unwrap().variables);
ex(&mut s, "variable.delete", json!({"variable": "ink"}));
assert_eq!(fill(&s, b).to_rgba8(), [255, 0, 0, 128], "the bound layer keeps the colour it showed");
let fade: Vec<_> = s.doc().unwrap().variables.get("fade").unwrap().values.values().cloned().collect();
assert_eq!(fade, [figcraft_doc::VariableValue::Color(Color::new(1.0, 0.0, 0.0, 0.5))], "the calculation became the colour it showed");
ex(&mut s, "edit.undo", json!({}));
assert_eq!(json!(s.doc().unwrap().variables), before, "undo restores every variable exactly");
}
/// Deleting a collection at once flattens every reader outside it — an alias of a calculation, a
/// calculation of two deleted variables — and deleting a calculation flattens a calculation that
/// reads it; undo restores the variables exactly each time.
#[test]
fn deleting_sources_together_flattens_every_reader() {
let mut s = seeds();
let calc = |name: &str, value: Value| json!({"collection": "C", "name": name, "type": "color", "value": value});
ex(&mut s, "variable.create", calc("fade", json!({"alpha": {"variable": "ink", "opacity": 0.5}})));
ex(&mut s, "variable.create", calc("wash", json!({"mix": {"from": {"variable": "fade"}, "to": {"literal": "#FFFFFF"}, "t": 0.5}})));
ex(&mut s, "variable.createCollection", json!({"name": "App"}));
ex(&mut s, "variable.create", json!({"collection": "App", "name": "echo", "type": "color", "value": {"alias": "wash"}}));
let tint = json!({"mix": {"from": {"variable": "edge"}, "to": {"variable": "ink"}, "t": 0.5}});
ex(&mut s, "variable.create", json!({"collection": "App", "name": "tint", "type": "color", "value": tint}));
let stored = |s: &Session, name: &str| s.doc().unwrap().variables.get(name).unwrap().values.values().next().cloned().unwrap();
let plain = |s: &Session, name: &str| match stored(s, name) {
figcraft_doc::VariableValue::Color(c) => c.to_rgba8(),
other => panic!("{name} is not a plain colour: {other:?}"),
};
let before = json!(s.doc().unwrap().variables);
ex(&mut s, "variable.deleteCollection", json!({"collection": "C"}));
assert_eq!(plain(&s, "echo"), [255, 128, 128, 191], "the alias of a calculation keeps what it showed");
assert_eq!(plain(&s, "tint"), [128, 128, 0, 255], "a calculation of two deleted variables keeps its colour");
ex(&mut s, "edit.undo", json!({}));
assert_eq!(json!(s.doc().unwrap().variables), before, "undo restores the collection and its readers exactly");
ex(&mut s, "variable.delete", json!({"variable": "fade"}));
assert_eq!(plain(&s, "wash"), [255, 128, 128, 191], "a calculation of the deleted calculation keeps its colour");
assert!(matches!(stored(&s, "echo"), figcraft_doc::VariableValue::Alias(_)), "an alias of a surviving variable stays an alias");
ex(&mut s, "edit.undo", json!({}));
assert_eq!(json!(s.doc().unwrap().variables), before);
}
/// One resolve reads at most `Variables::RESOLVE_LIMIT` variables: a mix of two alias chains that
/// would read more is refused when it is set rather than left showing nothing.
#[test]
fn a_calculation_reading_too_many_variables_is_refused() {
let mut s = seeds();
for side in ["a", "b"] {
ex(&mut s, "variable.create", json!({"collection": "C", "name": format!("{side}0"), "type": "color", "value": {"alias": "ink"}}));
for i in 1..16 {
let value = json!({"alias": format!("{side}{}", i - 1)});
ex(&mut s, "variable.create", json!({"collection": "C", "name": format!("{side}{i}"), "type": "color", "value": value}));
}
}
// Reading `a{k}` and `b{k}` takes 2k + 4 variables: itself, both chains and the `ink` they share.
let mix = |k: usize| json!({"mix": {"from": {"variable": format!("a{k}")}, "to": {"variable": format!("b{k}")}, "t": 0.5}});
ex(&mut s, "variable.create", json!({"collection": "C", "name": "fits", "type": "color", "value": mix(14)}));
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "fits"}));
assert_eq!(fill(&s, b).to_rgba8(), [255, 0, 0, 255], "32 variables still resolve");
let too_many = "the colour calculation reads more than 32 variables";
let msg = err(&mut s, "variable.create", json!({"collection": "C", "name": "over", "type": "color", "value": mix(15)}));
assert!(msg.contains(too_many), "create: {msg}");
let msg = err(&mut s, "variable.set", json!({"variable": "fits", "value": mix(15)}));
assert!(msg.contains(too_many), "set: {msg}");
}
/// Junk anywhere inside a calculation is refused cleanly: the variables exist, so it reaches the
/// calculation parsers (the command-wide sweep starts from a document without variables).
#[test]
fn calculation_parsers_survive_junk() {
let junk = [
json!(null),
json!(1e308),
json!(-0.0),
json!("NaN"),
json!("\u{0}"),
json!([]),
json!({}),
json!([1e308, -1e308, "x"]),
json!({"variable": [1]}),
json!({"variable": "ink", "literal": "#000"}),
json!({"literal": {"r": 1e308, "g": "x"}}),
json!({"mix": {"from": {"literal": {"mix": 1}}}}),
json!({"alpha": {"variable": "ink", "opacity": 0.5}}),
];
for j in &junk {
for value in [
json!({"alpha": j}),
json!({"mix": j}),
json!({"mix": {"from": j, "to": j, "t": j}}),
json!({"alpha": {"variable": j, "opacity": j}}),
json!({"alpha": {"variable": "ink", "opacity": j}}),
json!({"mix": {"from": {"variable": "ink"}, "to": {"literal": j}, "t": 0.5}}),
] {
for (cmd, p) in [
("variable.set", json!({"variable": "edge", "value": value})),
("variable.set", json!({"variable": "size", "value": value})),
("variable.create", json!({"collection": "C", "name": "junk", "type": "color", "values": {"M": value}})),
] {
let mut s = seeds();
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, &p) {
panic!("{cmd} panicked on {p}: {msg}");
}
}
}
}
}
#[test]
fn calculations_survive_save_and_load() {
let mut s = seeds();
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "mixv", "type": "color", "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": 0.4}}}),
);
let dir = std::env::temp_dir().join(format!("figcraft-colour-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("d.figcraft");
ex(&mut s, "file.save", json!({"path": path.to_string_lossy()}));
let mut t = Session::new();
ex(&mut t, "file.open", json!({"path": path.to_string_lossy()}));
let _ = std::fs::remove_dir_all(&dir);
let list = ex(&mut t, "variable.list", json!({}));
let mixv = list["variables"].as_array().unwrap().iter().find(|v| v["name"] == "mixv").unwrap();
assert_eq!(mixv["values"].as_object().unwrap().values().next().unwrap()["type"], "colorMix", "still a calculation after a round trip");
// And it still propagates in the reloaded document.
let b = idv(&ex(&mut t, "shape.rectangle", json!({"x": 0, "y": 0, "width": 4, "height": 4})));
ex(&mut t, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "mixv"}));
assert_eq!(fill(&t, b).to_rgba8(), [153, 102, 0, 255], "ink + edge at 0.4: r=255*(1-0.4), g=255*0.4");
}
#[test]
fn rest_and_css_exports_write_calculations_as_their_colours() {
let mut s = seeds();
// The exported REST variable `name` of `collections`, and the CSS text for them.
let json_variable = |s: &mut Session, collections: Value, name: &str| -> Value {
let text = ex(s, "variable.export", json!({"format": "json", "collections": collections}));
let doc: Value = serde_json::from_str(text["text"].as_str().unwrap()).unwrap();
let meta = doc.get("meta").unwrap_or(&doc);
meta["variables"].as_object().unwrap().values().find(|v| v["name"] == name).unwrap().clone()
};
let css = |s: &mut Session, collections: Value| {
ex(s, "variable.export", json!({"format": "css", "collections": collections}))["text"].as_str().unwrap().to_string()
};
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "fade", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
);
let fade = json_variable(&mut s, json!(["C"]), "fade");
let value = fade["valuesByMode"].as_object().unwrap().values().next().unwrap();
assert_eq!(value["r"], 1.0);
assert_eq!(value["a"], 0.5);
assert!(value.get("type").is_none(), "no calculation shape leaks into the REST JSON: {value}");
let text = css(&mut s, json!(["C"]));
assert!(text.contains("rgba(255, 0, 0, 0.5"), "the CSS export writes the colour it resolves to: {text}");
// Each mode is written as the colour it shows in that mode.
ex(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
ex(
&mut s,
"variable.create",
json!({"collection": "Theme", "name": "brand", "type": "color", "values": {"Light": "#FF0000", "Dark": "#0000FF"}}),
);
let veil = json!({"alpha": {"variable": "brand", "opacity": 0.5}});
ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "veil", "type": "color", "value": veil}));
let veil = json_variable(&mut s, json!(["Theme"]), "veil");
let mut per_mode: Vec<(f64, f64, f64)> = veil["valuesByMode"]
.as_object()
.unwrap()
.values()
.map(|v| (v["r"].as_f64().unwrap(), v["b"].as_f64().unwrap(), v["a"].as_f64().unwrap()))
.collect();
per_mode.sort_by(|x, y| x.partial_cmp(y).unwrap());
assert_eq!(per_mode, [(0.0, 1.0, 0.5), (1.0, 0.0, 0.5)], "Light and Dark each written as their own colour");
let text = css(&mut s, json!(["Theme"]));
assert!(text.contains("rgba(255, 0, 0, 0.5") && text.contains("rgba(0, 0, 255, 0.5"), "both modes in CSS: {text}");
}
/// A hand-edited file can hold a calculation that resolves to nothing (one reading a variable
/// that isn't there). The CSS export leaves it out, so no property may name it: an alias to it
/// goes out as the colour it shows, or is left out when that is nothing.
#[test]
fn css_never_names_a_calculation_it_leaves_out() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "T", "modes": ["Light", "Dark"]}));
let ink = json!({"collection": "T", "name": "ink", "type": "color", "values": {"Light": "#FF0000", "Dark": "#0000FF"}});
ex(&mut s, "variable.create", ink);
for name in ["lost", "half"] {
ex(&mut s, "variable.create", json!({"collection": "T", "name": name, "type": "color", "value": "#000000"}));
}
for (name, to) in [("lost-too", "lost"), ("half-too", "half"), ("ink-too", "ink")] {
ex(&mut s, "variable.create", json!({"collection": "T", "name": name, "type": "color", "value": {"alias": to}}));
}
// As a hand-edited file could hold them: `lost` reads a variable that isn't there in both
// modes, `half` only in Dark.
let doc = s.doc_mut().unwrap();
let ink = doc.variables.get("ink").unwrap().id.clone();
let modes: Vec<String> = doc.variables.collection("T").unwrap().modes.iter().map(|m| m.id.clone()).collect();
let gone = figcraft_doc::VariableValue::ColorAlpha { variable: "gone".into(), opacity: 0.5 };
let half = figcraft_doc::VariableValue::ColorAlpha { variable: ink, opacity: 0.5 };
for v in &mut doc.variables.variables {
match v.name.as_str() {
"lost" => v.values = modes.iter().map(|m| (m.clone(), gone.clone())).collect(),
"half" => v.values = [(modes[0].clone(), half.clone()), (modes[1].clone(), gone.clone())].into_iter().collect(),
_ => {}
}
}
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap().to_string();
let (light, dark) = css.split_once("[data-").unwrap();
assert!(!css.contains("var(--lost)") && !css.contains("var(--half)"), "no var() of a property a block leaves out: {css}");
assert!(!css.contains("--lost-too"), "an alias to what shows nothing is left out: {css}");
assert!(light.contains("--half-too: rgba(255, 0, 0, 0.5") && !dark.contains("--half-too"), "the colour it shows, where it shows one: {css}");
assert!(css.contains("--ink-too: var(--ink);"), "an alias that always resolves stays a reference: {css}");
}
/// A calculation is a variable's value, not a literal an expression may hold: nothing would check
/// what it reads. Every way an expression arrives refuses one.
#[test]
fn prototype_expressions_refuse_a_calculation_literal() {
let mut s = seeds();
let btn = rect(&mut s, 0.0);
let dest = idv(&ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "proto.interaction.add", json!({"id": btn, "dest": dest}));
let calc = json!({"value": {"type": "colorAlpha", "value": {"variable": "ink", "opacity": 9}}});
let msg = err(&mut s, "proto.evaluate", json!({"expr": calc}));
assert!(msg.contains("Not a value"), "evaluate: {msg}");
let set = json!({"type": "setVariable", "variable": "edge", "value": null, "expr": calc});
let msg = err(&mut s, "proto.action.add", json!({"id": btn, "index": 0, "action": set}));
assert!(msg.contains("Not a value"), "set variable action: {msg}");
let added = ex(
&mut s,
"proto.action.add",
json!({"id": btn, "index": 0, "action": {"type": "conditional", "blocks": [{"expression": "size > 1", "actions": []}]}}),
);
let same = json!({"op": {"op": "equals", "args": [{"value": {"type": "alias", "value": "ink"}}, calc]}});
let msg = err(&mut s, "proto.conditional.setCondition", json!({"id": btn, "index": 0, "path": added["path"], "block": 0, "condition": same}));
assert!(msg.contains("Not a value"), "condition: {msg}");
}
#[test]
fn rest_import_skips_a_hand_written_calculation_with_a_warning() {
// The variables JSON has no shape for calculations; a hand-written one must not import or corrupt.
let mut s = seeds();
let text = json!({
"variableCollections": {"COL:X": {"id": "COL:X", "name": "Imp", "modes": [{"modeId": "M:1", "name": "M"}], "defaultModeId": "M:1", "variableIds": ["V:9"]}},
"variables": {"V:9": {"id": "V:9", "name": "ghost", "variableCollectionId": "COL:X", "resolvedType": "COLOR",
"valuesByMode": {"M:1": {"type": "colorAlpha", "value": {"variable": "V:1", "opacity": 0.5}}}}}
});
let out = ex(&mut s, "variable.import", json!({"variables": text}));
assert!(out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("composed colours")), "skip warning present: {out}");
let list = ex(&mut s, "variable.list", json!({}));
assert!(list["variables"].as_array().unwrap().iter().all(|v| v["name"] != "ghost"), "the calculation was skipped");
}
#[test]
fn profile_conversion_reaches_the_colours_stored_in_a_calculation() {
// A mix of identical literals shows that colour; after a converting profile change the
// mix-bound layer must match a layer bound to the plain colour (the literal converted too).
let mut s = seeds();
ex(&mut s, "variable.create", json!({"collection": "C", "name": "p", "type": "color", "value": "#804020"}));
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "dl", "type": "color", "value": {"mix": {"from": {"literal": "#804020"}, "to": {"literal": "#804020"}, "t": 0.5}}}),
);
let a = rect(&mut s, 0.0);
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [a], "field": "fill", "variable": "p"}));
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "dl"}));
ex(&mut s, "file.colorProfile", json!({"profile": "displayP3"}));
let (fa, fb) = (fill(&s, a), fill(&s, b));
assert_ne!(fa.to_rgba8(), [128, 64, 32, 255], "the profile change converted the plain colour");
assert_eq!(fa.to_rgba8(), fb.to_rgba8(), "the colour stored inside the calculation converted the same way");
}
#[test]
fn negative_zero_bounds_are_stored_as_plain_zero() {
let mut s = seeds();
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "n0", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": -0.0}}}),
);
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "m0", "type": "color", "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": -0.0}}}),
);
let list = serde_json::to_string(&ex(&mut s, "variable.list", json!({}))).unwrap();
assert!(!list.contains("-0"), "no negative zeros persisted: {list}");
}