Files
figcraft/crates/doc/src/proto_expr_mode_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

141 lines
6.6 KiB
Rust

//! Variable mode lookups in prototype expressions: a variable's value in a given mode of its
//! collection (`n-name[Dark]`) and the mode a collection is in (`mode(Theme)`), typed, checked,
//! evaluated, written back and stored as JSON.
use std::collections::BTreeMap;
use crate::proto::*;
use crate::proto_expr_tests::vars;
use crate::{Collection, Mode, Variable, VariableType, VariableValue, Variables};
/// The test variables plus a collection "Color scheme" (modes "High contrast" and "a]b") with a
/// number variable `k` ("k-name").
fn more_vars() -> Variables {
let mut v = vars();
v.collections.push(Collection {
id: "cs".into(),
name: "Color scheme".into(),
modes: vec![Mode { id: "hc".into(), name: "High contrast".into() }, Mode { id: "odd".into(), name: "a]b".into() }],
extends: None,
});
v.variables.push(Variable {
id: "k".into(),
name: "k-name".into(),
collection: "cs".into(),
kind: VariableType::Number,
values: BTreeMap::from([("hc".to_string(), VariableValue::Number(1.0)), ("odd".to_string(), VariableValue::Number(2.0))]),
description: String::new(),
scopes: None,
hidden_from_publishing: false,
extends: None,
overrides: Default::default(),
code_syntax: Default::default(),
});
v
}
fn parse(text: &str) -> Expr {
Expr::parse(text, &more_vars()).unwrap_or_else(|e| panic!("{text}: {e:?}"))
}
fn error(text: &str) -> String {
match Expr::parse(text, &more_vars()) {
Ok(e) => panic!("{text} parsed as {e:?}"),
Err(e) => e.message,
}
}
fn in_mode(var: &str, mode: &str) -> Expr {
Expr::InMode { variable: var.into(), mode: mode.into() }
}
fn mode_of(c: &str) -> Expr {
Expr::Mode { collection: c.into() }
}
#[test]
fn reads_mode_lookups_and_writes_them_back() {
use ExprOp::*;
assert_eq!(parse("n-name[Dark]"), in_mode("n", "dark"));
assert_eq!(parse("n-name [ dark ] + 1"), Expr::op(Add, vec![in_mode("n", "dark"), Expr::Value(VariableValue::Number(1.0))]), "ids, spaces");
assert_eq!(parse("{k-name}[High contrast]"), in_mode("k", "hc"));
assert_eq!(parse(r#"k-name["a]b"]"#), in_mode("k", "odd"), "quoted names may hold brackets");
assert_eq!(parse("mode(Theme)"), mode_of("c"));
assert_eq!(
parse("MODE ( 'Color scheme' ) == \"High contrast\""),
Expr::op(Equals, vec![mode_of("cs"), Expr::Value(VariableValue::String("High contrast".into()))])
);
assert_eq!(parse("mode({Color scheme})"), mode_of("cs"));
assert_eq!(parse("mode(c) != \"Dark\" and n-name[light] < n-name"), parse("(mode(Theme) != 'Dark') and (n-name[Light] < n)"));
let vars = more_vars();
for text in [
"n-name[Dark]",
"k-name[High contrast]",
"k-name[\"a]b\"]",
"mode(Theme) == \"Dark\"",
"mode(Color scheme)",
"(mode(Theme) == \"Light\") or (k-name[High contrast] > 0)",
] {
let e = parse(text);
assert_eq!(e.display(&vars), text, "{e:?}");
assert_eq!(Expr::parse(&e.display(&vars), &vars), Ok(e.clone()), "{text} reads back");
// Stored as JSON and read back.
let json = serde_json::to_value(&e).unwrap();
assert_eq!(serde_json::from_value::<Expr>(json).unwrap(), e);
}
assert_eq!(serde_json::to_value(in_mode("n", "dark")).unwrap(), serde_json::json!({"inMode": {"variable": "n", "mode": "dark"}}));
assert_eq!(serde_json::to_value(mode_of("c")).unwrap(), serde_json::json!({"mode": {"collection": "c"}}));
}
#[test]
fn mode_lookup_errors_say_what_is_wrong() {
assert_eq!(error("n-name[Sepia]"), "Theme has no mode `Sepia`");
assert_eq!(error("n-name[High contrast]"), "Theme has no mode `High contrast`", "another collection's mode");
assert_eq!(error("n-name[Dark"), "Add a closing ]");
assert_eq!(error("n-name[]"), "Name a mode between the brackets");
assert_eq!(error("mode(Nope)"), "No variable collection `Nope`");
assert_eq!(error("mode(Theme"), "Add a closing )");
assert_eq!(error("mode()"), "Name a collection between the brackets");
assert_eq!(error("mode(\"Theme)"), "Close the text with \"");
// `mode` alone isn't a function call (nor a variable here).
assert_eq!(error("mode"), "No variable `mode`");
// Type checks: a lookup has its variable's type, a mode is text.
let vars = more_vars();
assert_eq!(parse("n-name[Dark] + 1").type_of(&vars), Ok(VariableType::Number));
assert_eq!(parse("mode(Theme)").type_of(&vars), Ok(VariableType::String));
assert_eq!(parse("mode(Theme) == 1").check_condition(&vars), Err("Can't compare text with a number".into()));
assert_eq!(parse("mode(Theme) == 'Dark'").check_condition(&vars), Ok(()));
// Stored expressions whose collection, variable or mode is gone.
assert_eq!(mode_of("gone").type_of(&vars), Err("No variable collection `gone`".into()));
assert_eq!(in_mode("gone", "dark").type_of(&vars), Err("No variable `gone`".into()));
assert_eq!(in_mode("n", "gone").type_of(&vars), Err("Theme has no mode `gone`".into()));
assert_eq!(in_mode("n", "gone").display(&vars), "n-name[\"(missing mode)\"]");
assert_eq!(mode_of("gone").display(&vars), "mode(\"(missing collection)\")");
}
#[test]
fn lookups_evaluate_in_their_mode_and_modes_follow_the_layer() {
let vars = more_vars();
let light = BTreeMap::new();
let dark = BTreeMap::from([("c".to_string(), "dark".to_string())]);
let ev = |text: &str, modes: &BTreeMap<String, String>| parse(text).eval(&vars, modes);
// A lookup reads its mode whichever mode is chosen; plain references follow the chosen one.
assert_eq!(ev("n-name[Dark]", &light), Some(VariableValue::Number(7.0)));
assert_eq!(ev("n-name[Light]", &dark), Some(VariableValue::Number(3.0)));
assert_eq!(ev("n-name", &dark), Some(VariableValue::Number(7.0)));
// Aliases resolve in the looked-up mode.
assert_eq!(ev("alias-name[Dark]", &light), Some(VariableValue::Number(7.0)));
// The current mode by name: the default when none is chosen (or the chosen one is gone).
assert_eq!(ev("mode(Theme)", &light), Some(VariableValue::String("Light".into())));
assert_eq!(ev("mode(Theme)", &dark), Some(VariableValue::String("Dark".into())));
assert_eq!(
parse("mode(Theme)").eval(&vars, &BTreeMap::from([("c".to_string(), "gone".to_string())])),
Some(VariableValue::String("Light".into()))
);
assert!(parse("mode(Theme) == \"Dark\" and k-name[\"a]b\"] == 2").holds(&vars, &dark));
// Missing parts evaluate to nothing.
assert_eq!(in_mode("n", "gone").eval(&vars, &light), None);
assert_eq!(mode_of("gone").eval(&vars, &light), None);
assert_eq!(in_mode("n", "dark").variables(), ["n"]);
}