refactor(variables): split tests to sibling file (codex cap fix)

Codex stop-gate flagged that `document/variables.rs` was 865 lines
after the three rounds of `set_color_hex` correctness fixes — over
the project's 800-line ceiling. Tests moved verbatim to a sibling,
mirroring the `layer_panel_tests` / `property_panel_tests` pattern.

  crates/openpencil-shell-core/src/document/variables.rs       865 → 318
  crates/openpencil-shell-core/src/document/variables_tests.rs (new) 549

`document.rs` registers the sibling under `#[cfg(test)]` between
the existing `mod variables;` and the rest of the file's mod list.

Tests file gets a flat layout — bare `#[test]` functions at file
scope (no inner `mod tests {}` wrapper), `use super::*` removed in
favor of the explicit `use super::{ThemedValue, Variable, ...}`
imports the body actually needs.

No semantic change. 290 shell-core tests pass.
This commit is contained in:
Kayshen-X 2026-05-14 18:49:06 +08:00
parent e2b258cfd7
commit 4d963fd018
3 changed files with 551 additions and 548 deletions

View file

@ -789,6 +789,8 @@ mod mutators;
mod page_mutators;
mod pen;
mod variables;
#[cfg(test)]
mod variables_tests;
mod walkers;
pub use align::AlignAction;
pub use components::{Component, ComponentLibrary};

View file

@ -315,551 +315,3 @@ fn parse_hex_color(s: &str) -> Option<crate::Color> {
a: a as f32 / 255.0,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn axis(name: &str, value: &str) -> BTreeMap<String, String> {
let mut m = BTreeMap::new();
m.insert(name.to_string(), value.to_string());
m
}
#[test]
fn scalar_variable_resolves_regardless_of_theme() {
let v = Variable {
name: "color-1".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("#ff0000".into())),
};
let theme = axis("mode", "dark");
match v.resolve(&theme).unwrap() {
VariableScalar::Str(s) => assert_eq!(s, "#ff0000"),
_ => panic!(),
}
}
#[test]
fn themed_variable_picks_active_axis() {
let v = Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#ffffff".into()),
theme: Some(axis("mode", "light")),
},
ThemedValue {
value: VariableScalar::Str("#000000".into()),
theme: Some(axis("mode", "dark")),
},
]),
};
let dark = axis("mode", "dark");
let light = axis("mode", "light");
match v.resolve(&dark).unwrap() {
VariableScalar::Str(s) => assert_eq!(s, "#000000"),
_ => panic!(),
}
match v.resolve(&light).unwrap() {
VariableScalar::Str(s) => assert_eq!(s, "#ffffff"),
_ => panic!(),
}
}
#[test]
fn themed_variable_falls_back_to_default_when_no_match() {
let v = Variable {
name: "accent".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#888888".into()),
theme: None,
},
ThemedValue {
value: VariableScalar::Str("#000000".into()),
theme: Some(axis("mode", "dark")),
},
]),
};
let light = axis("mode", "light");
match v.resolve(&light).unwrap() {
VariableScalar::Str(s) => assert_eq!(s, "#888888"),
_ => panic!(),
}
}
#[test]
fn resolve_color_parses_rrggbb_hex() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "accent".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("#ff8040".into())),
});
let c = tbl.resolve_color("accent").unwrap();
assert!((c.r - 1.0).abs() < 0.01);
assert!((c.g - (128.0 / 255.0)).abs() < 0.01);
assert!((c.b - (64.0 / 255.0)).abs() < 0.01);
assert_eq!(c.a, 1.0);
}
#[test]
fn resolve_color_picks_themed_active_value() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#ffffff".into()),
theme: Some(axis("mode", "light")),
},
ThemedValue {
value: VariableScalar::Str("#000000".into()),
theme: Some(axis("mode", "dark")),
},
]),
});
tbl.active_theme = axis("mode", "dark");
let c = tbl.resolve_color("bg").unwrap();
assert_eq!(c.r, 0.0);
assert_eq!(c.g, 0.0);
assert_eq!(c.b, 0.0);
}
#[test]
fn resolve_color_rejects_non_color_variables() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "spacing".into(),
kind: VariableKind::Number,
value: VariableValue::Scalar(VariableScalar::Num(12.0)),
});
assert!(tbl.resolve_color("spacing").is_none());
}
#[test]
fn fill_for_resolves_registered_node_ref_to_themed_color() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "accent".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#ff0000".into()),
theme: Some(axis("mode", "light")),
},
ThemedValue {
value: VariableScalar::Str("#00ff00".into()),
theme: Some(axis("mode", "dark")),
},
]),
});
let node = crate::document::NodeId::new(42);
tbl.set_fill_ref(node, "accent");
tbl.active_theme = axis("mode", "dark");
let c = tbl.fill_for(node).unwrap();
assert!((c.g - 1.0).abs() < 0.01, "dark mode → green; got {c:?}");
// Switch theme; same ref resolves differently.
tbl.active_theme = axis("mode", "light");
let c2 = tbl.fill_for(node).unwrap();
assert!((c2.r - 1.0).abs() < 0.01, "light mode → red; got {c2:?}");
}
#[test]
fn stroke_color_for_resolves_registered_ref() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "border".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("#0000ff".into())),
});
let node = crate::document::NodeId::new(7);
tbl.set_stroke_ref(node, "border");
let c = tbl.stroke_color_for(node).unwrap();
assert!((c.b - 1.0).abs() < 0.01);
}
#[test]
fn set_active_theme_round_trips_through_axis_picker() {
let mut tbl = super::VariableTable::default();
tbl.set_active_theme("mode", "dark");
assert_eq!(tbl.active_theme.get("mode"), Some(&"dark".to_string()));
tbl.set_active_theme("density", "comfortable");
assert_eq!(tbl.active_theme.len(), 2);
tbl.clear_active_axis("mode");
assert!(!tbl.active_theme.contains_key("mode"));
assert_eq!(tbl.active_theme.len(), 1);
}
#[test]
fn fill_for_returns_none_when_no_ref_registered() {
let tbl = super::VariableTable::default();
assert!(tbl.fill_for(crate::document::NodeId::new(99)).is_none());
}
#[test]
fn resolve_color_rejects_invalid_hex() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "broken".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("not-hex".into())),
});
assert!(tbl.resolve_color("broken").is_none());
}
#[test]
fn themed_variable_returns_none_when_empty_and_no_default() {
let v = Variable {
name: "x".into(),
kind: VariableKind::Number,
value: VariableValue::Themed(vec![]),
};
let theme = BTreeMap::new();
assert!(v.resolve(&theme).is_none());
}
#[test]
fn set_color_hex_writes_scalar_variable() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "color-1".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("#ff0000".into())),
});
assert!(tbl.set_color_hex("color-1", "#00ff00"));
assert_eq!(
tbl.resolve_color("color-1"),
Some(crate::Color {
r: 0.0,
g: 1.0,
b: 0.0,
a: 1.0,
})
);
}
#[test]
fn set_color_hex_rejects_malformed_input() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "color-1".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("#ff0000".into())),
});
assert!(!tbl.set_color_hex("color-1", "not-hex"));
// Scalar must be unchanged after a rejected write.
assert_eq!(
tbl.resolve_color("color-1"),
Some(crate::Color {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
})
);
}
#[test]
fn set_color_hex_returns_false_for_unknown_variable() {
let mut tbl = super::VariableTable::default();
assert!(!tbl.set_color_hex("does-not-exist", "#ffffff"));
}
#[test]
fn set_color_hex_returns_false_for_wrong_kind() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "spacing".into(),
kind: VariableKind::Number,
value: VariableValue::Scalar(VariableScalar::Num(16.0)),
});
// Number-kind variable: write rejected.
assert!(!tbl.set_color_hex("spacing", "#ffffff"));
}
#[test]
fn set_color_hex_changes_resolved_value_under_subset_active_theme() {
// Codex stop-gate regression: when the themed entry's theme
// is a SUBSET of the active theme (e.g. entry only specifies
// `mode: dark`; active is `mode: dark, density: compact`),
// `resolve` still picks that entry — so a write must update
// that entry in place, NOT push a new entry at the end that
// `resolve` will never reach.
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![ThemedValue {
value: VariableScalar::Str("#111111".into()),
theme: Some({
let mut m = BTreeMap::new();
m.insert("mode".into(), "dark".into());
m
}),
}]),
});
tbl.set_active_theme("mode", "dark");
tbl.set_active_theme("density", "compact");
// Sanity: resolves through the subset-matching entry.
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x11 as f32 / 255.0,
g: 0x11 as f32 / 255.0,
b: 0x11 as f32 / 255.0,
a: 1.0,
})
);
// Write under the wider active theme. The original entry
// (only mode=dark) is the resolved one — the write MUST go
// through it, not append a new entry that resolve never
// reaches.
assert!(tbl.set_color_hex("bg", "#22ff44"));
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x22 as f32 / 255.0,
g: 1.0,
b: 0x44 as f32 / 255.0,
a: 1.0,
}),
"themed write must update the entry resolve picks"
);
}
#[test]
fn set_color_hex_under_active_theme_does_not_clobber_default() {
// Codex stop-gate (round 2): when active_theme is non-empty
// and no subset-matching themed entry exists, the write
// must scope to the active theme (push a new entry), NOT
// mutate the `theme: None` default. Mutating the default
// would silently change the value under EVERY OTHER theme
// axis the user hasn't explicitly authored, which is
// never what an editor user clicking a colour swatch in
// dark mode means.
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#ffffff".into()),
theme: Some({
let mut m = BTreeMap::new();
m.insert("mode".into(), "light".into());
m
}),
},
ThemedValue {
value: VariableScalar::Str("#888888".into()),
theme: None,
},
]),
});
tbl.set_active_theme("mode", "dark"); // matches neither
// Pre-write: resolves to the default (#888) under dark.
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x88 as f32 / 255.0,
g: 0x88 as f32 / 255.0,
b: 0x88 as f32 / 255.0,
a: 1.0,
})
);
// Write under active=dark must scope to dark only.
assert!(tbl.set_color_hex("bg", "#000000"));
// Under dark — new value reads back.
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
}),
);
// The default entry must be untouched. Flipping to a
// never-authored axis value (e.g. mode=sepia) should still
// resolve to the original default #888888, NOT the new dark
// value.
tbl.set_active_theme("mode", "sepia");
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x88 as f32 / 255.0,
g: 0x88 as f32 / 255.0,
b: 0x88 as f32 / 255.0,
a: 1.0,
}),
"default entry must NOT have been clobbered by the dark write"
);
// Light entry also untouched.
tbl.set_active_theme("mode", "light");
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
}),
"light entry must NOT have been clobbered"
);
}
#[test]
fn set_color_hex_does_not_shadow_existing_entries_on_other_axes() {
// Codex stop-gate (round 3): when the write pushes a new
// themed entry, it must NOT shadow pre-existing entries
// whose theme references a different axis. The pathological
// case is an existing entry keyed to `{density:compact}`
// and a write under `{mode:dark}`. A naive front-insert
// would mean active=`{mode:dark, density:compact}` resolves
// to the new entry instead of the compact entry, silently
// changing the resolved colour for the combined axis.
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![ThemedValue {
value: VariableScalar::Str("#11ccaa".into()),
theme: Some({
let mut m = BTreeMap::new();
m.insert("density".into(), "compact".into());
m
}),
}]),
});
// Active theme = mode:dark only (no compact axis set, so the
// existing entry doesn't subset-match — the write must push
// a new entry).
tbl.set_active_theme("mode", "dark");
assert!(tbl.set_color_hex("bg", "#000000"));
// Under mode:dark — the new entry is reachable.
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
})
);
// Now flip active to the combined axes — mode:dark +
// density:compact. Under that resolve walk:
// - The pre-existing {density:compact} entry must still
// win (it's earlier in the vec AND its theme is a
// subset of the active combo).
// - The new {mode:dark} entry, end-pushed, is also a
// subset of the active combo, but it's later. First
// subset match wins.
// Pre-fix (front-insert) this would have read #000000
// instead — that was the codex BLOCK.
tbl.set_active_theme("density", "compact");
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x11 as f32 / 255.0,
g: 0xcc as f32 / 255.0,
b: 0xaa as f32 / 255.0,
a: 1.0,
}),
"compact entry must not be shadowed by the end-pushed dark entry"
);
}
#[test]
fn set_color_hex_empty_active_theme_targets_default_entry() {
// The flip side: when active_theme IS empty (no axis
// selected), writes target the default `theme: None`
// entry — that's what resolve falls back to in this state.
// Editing with no theme selected is the explicit "edit the
// universal value" path.
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![ThemedValue {
value: VariableScalar::Str("#888888".into()),
theme: None,
}]),
});
// No active theme set.
assert!(tbl.set_color_hex("bg", "#aabbcc"));
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0xaa as f32 / 255.0,
g: 0xbb as f32 / 255.0,
b: 0xcc as f32 / 255.0,
a: 1.0,
})
);
}
#[test]
fn set_color_hex_writes_themed_entry_matching_active_axis() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#ffffff".into()),
theme: Some({
let mut m = BTreeMap::new();
m.insert("mode".into(), "light".into());
m
}),
},
ThemedValue {
value: VariableScalar::Str("#000000".into()),
theme: Some({
let mut m = BTreeMap::new();
m.insert("mode".into(), "dark".into());
m
}),
},
]),
});
tbl.set_active_theme("mode", "dark");
// Resolves to dark first.
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
})
);
// Write under the active theme → updates only the dark entry.
assert!(tbl.set_color_hex("bg", "#3344ff"));
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x33 as f32 / 255.0,
g: 0x44 as f32 / 255.0,
b: 1.0,
a: 1.0,
})
);
// Flip to light — original light entry untouched.
tbl.set_active_theme("mode", "light");
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
})
);
}
}

View file

@ -0,0 +1,549 @@
//! Tests for `document::variables`. Moved to a sibling file so the
//! types + impl stay under the 800-line cap.
use super::{ThemedValue, Variable, VariableKind, VariableScalar, VariableTable, VariableValue};
use std::collections::BTreeMap;
fn axis(name: &str, value: &str) -> BTreeMap<String, String> {
let mut m = BTreeMap::new();
m.insert(name.to_string(), value.to_string());
m
}
#[test]
fn scalar_variable_resolves_regardless_of_theme() {
let v = Variable {
name: "color-1".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("#ff0000".into())),
};
let theme = axis("mode", "dark");
match v.resolve(&theme).unwrap() {
VariableScalar::Str(s) => assert_eq!(s, "#ff0000"),
_ => panic!(),
}
}
#[test]
fn themed_variable_picks_active_axis() {
let v = Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#ffffff".into()),
theme: Some(axis("mode", "light")),
},
ThemedValue {
value: VariableScalar::Str("#000000".into()),
theme: Some(axis("mode", "dark")),
},
]),
};
let dark = axis("mode", "dark");
let light = axis("mode", "light");
match v.resolve(&dark).unwrap() {
VariableScalar::Str(s) => assert_eq!(s, "#000000"),
_ => panic!(),
}
match v.resolve(&light).unwrap() {
VariableScalar::Str(s) => assert_eq!(s, "#ffffff"),
_ => panic!(),
}
}
#[test]
fn themed_variable_falls_back_to_default_when_no_match() {
let v = Variable {
name: "accent".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#888888".into()),
theme: None,
},
ThemedValue {
value: VariableScalar::Str("#000000".into()),
theme: Some(axis("mode", "dark")),
},
]),
};
let light = axis("mode", "light");
match v.resolve(&light).unwrap() {
VariableScalar::Str(s) => assert_eq!(s, "#888888"),
_ => panic!(),
}
}
#[test]
fn resolve_color_parses_rrggbb_hex() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "accent".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("#ff8040".into())),
});
let c = tbl.resolve_color("accent").unwrap();
assert!((c.r - 1.0).abs() < 0.01);
assert!((c.g - (128.0 / 255.0)).abs() < 0.01);
assert!((c.b - (64.0 / 255.0)).abs() < 0.01);
assert_eq!(c.a, 1.0);
}
#[test]
fn resolve_color_picks_themed_active_value() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#ffffff".into()),
theme: Some(axis("mode", "light")),
},
ThemedValue {
value: VariableScalar::Str("#000000".into()),
theme: Some(axis("mode", "dark")),
},
]),
});
tbl.active_theme = axis("mode", "dark");
let c = tbl.resolve_color("bg").unwrap();
assert_eq!(c.r, 0.0);
assert_eq!(c.g, 0.0);
assert_eq!(c.b, 0.0);
}
#[test]
fn resolve_color_rejects_non_color_variables() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "spacing".into(),
kind: VariableKind::Number,
value: VariableValue::Scalar(VariableScalar::Num(12.0)),
});
assert!(tbl.resolve_color("spacing").is_none());
}
#[test]
fn fill_for_resolves_registered_node_ref_to_themed_color() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "accent".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#ff0000".into()),
theme: Some(axis("mode", "light")),
},
ThemedValue {
value: VariableScalar::Str("#00ff00".into()),
theme: Some(axis("mode", "dark")),
},
]),
});
let node = crate::document::NodeId::new(42);
tbl.set_fill_ref(node, "accent");
tbl.active_theme = axis("mode", "dark");
let c = tbl.fill_for(node).unwrap();
assert!((c.g - 1.0).abs() < 0.01, "dark mode → green; got {c:?}");
// Switch theme; same ref resolves differently.
tbl.active_theme = axis("mode", "light");
let c2 = tbl.fill_for(node).unwrap();
assert!((c2.r - 1.0).abs() < 0.01, "light mode → red; got {c2:?}");
}
#[test]
fn stroke_color_for_resolves_registered_ref() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "border".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("#0000ff".into())),
});
let node = crate::document::NodeId::new(7);
tbl.set_stroke_ref(node, "border");
let c = tbl.stroke_color_for(node).unwrap();
assert!((c.b - 1.0).abs() < 0.01);
}
#[test]
fn set_active_theme_round_trips_through_axis_picker() {
let mut tbl = super::VariableTable::default();
tbl.set_active_theme("mode", "dark");
assert_eq!(tbl.active_theme.get("mode"), Some(&"dark".to_string()));
tbl.set_active_theme("density", "comfortable");
assert_eq!(tbl.active_theme.len(), 2);
tbl.clear_active_axis("mode");
assert!(!tbl.active_theme.contains_key("mode"));
assert_eq!(tbl.active_theme.len(), 1);
}
#[test]
fn fill_for_returns_none_when_no_ref_registered() {
let tbl = super::VariableTable::default();
assert!(tbl.fill_for(crate::document::NodeId::new(99)).is_none());
}
#[test]
fn resolve_color_rejects_invalid_hex() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "broken".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("not-hex".into())),
});
assert!(tbl.resolve_color("broken").is_none());
}
#[test]
fn themed_variable_returns_none_when_empty_and_no_default() {
let v = Variable {
name: "x".into(),
kind: VariableKind::Number,
value: VariableValue::Themed(vec![]),
};
let theme = BTreeMap::new();
assert!(v.resolve(&theme).is_none());
}
#[test]
fn set_color_hex_writes_scalar_variable() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "color-1".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("#ff0000".into())),
});
assert!(tbl.set_color_hex("color-1", "#00ff00"));
assert_eq!(
tbl.resolve_color("color-1"),
Some(crate::Color {
r: 0.0,
g: 1.0,
b: 0.0,
a: 1.0,
})
);
}
#[test]
fn set_color_hex_rejects_malformed_input() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "color-1".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("#ff0000".into())),
});
assert!(!tbl.set_color_hex("color-1", "not-hex"));
// Scalar must be unchanged after a rejected write.
assert_eq!(
tbl.resolve_color("color-1"),
Some(crate::Color {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
})
);
}
#[test]
fn set_color_hex_returns_false_for_unknown_variable() {
let mut tbl = super::VariableTable::default();
assert!(!tbl.set_color_hex("does-not-exist", "#ffffff"));
}
#[test]
fn set_color_hex_returns_false_for_wrong_kind() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "spacing".into(),
kind: VariableKind::Number,
value: VariableValue::Scalar(VariableScalar::Num(16.0)),
});
// Number-kind variable: write rejected.
assert!(!tbl.set_color_hex("spacing", "#ffffff"));
}
#[test]
fn set_color_hex_changes_resolved_value_under_subset_active_theme() {
// Codex stop-gate regression: when the themed entry's theme
// is a SUBSET of the active theme (e.g. entry only specifies
// `mode: dark`; active is `mode: dark, density: compact`),
// `resolve` still picks that entry — so a write must update
// that entry in place, NOT push a new entry at the end that
// `resolve` will never reach.
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![ThemedValue {
value: VariableScalar::Str("#111111".into()),
theme: Some({
let mut m = BTreeMap::new();
m.insert("mode".into(), "dark".into());
m
}),
}]),
});
tbl.set_active_theme("mode", "dark");
tbl.set_active_theme("density", "compact");
// Sanity: resolves through the subset-matching entry.
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x11 as f32 / 255.0,
g: 0x11 as f32 / 255.0,
b: 0x11 as f32 / 255.0,
a: 1.0,
})
);
// Write under the wider active theme. The original entry
// (only mode=dark) is the resolved one — the write MUST go
// through it, not append a new entry that resolve never
// reaches.
assert!(tbl.set_color_hex("bg", "#22ff44"));
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x22 as f32 / 255.0,
g: 1.0,
b: 0x44 as f32 / 255.0,
a: 1.0,
}),
"themed write must update the entry resolve picks"
);
}
#[test]
fn set_color_hex_under_active_theme_does_not_clobber_default() {
// Codex stop-gate (round 2): when active_theme is non-empty
// and no subset-matching themed entry exists, the write
// must scope to the active theme (push a new entry), NOT
// mutate the `theme: None` default. Mutating the default
// would silently change the value under EVERY OTHER theme
// axis the user hasn't explicitly authored, which is
// never what an editor user clicking a colour swatch in
// dark mode means.
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#ffffff".into()),
theme: Some({
let mut m = BTreeMap::new();
m.insert("mode".into(), "light".into());
m
}),
},
ThemedValue {
value: VariableScalar::Str("#888888".into()),
theme: None,
},
]),
});
tbl.set_active_theme("mode", "dark"); // matches neither
// Pre-write: resolves to the default (#888) under dark.
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x88 as f32 / 255.0,
g: 0x88 as f32 / 255.0,
b: 0x88 as f32 / 255.0,
a: 1.0,
})
);
// Write under active=dark must scope to dark only.
assert!(tbl.set_color_hex("bg", "#000000"));
// Under dark — new value reads back.
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
}),
);
// The default entry must be untouched. Flipping to a
// never-authored axis value (e.g. mode=sepia) should still
// resolve to the original default #888888, NOT the new dark
// value.
tbl.set_active_theme("mode", "sepia");
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x88 as f32 / 255.0,
g: 0x88 as f32 / 255.0,
b: 0x88 as f32 / 255.0,
a: 1.0,
}),
"default entry must NOT have been clobbered by the dark write"
);
// Light entry also untouched.
tbl.set_active_theme("mode", "light");
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
}),
"light entry must NOT have been clobbered"
);
}
#[test]
fn set_color_hex_does_not_shadow_existing_entries_on_other_axes() {
// Codex stop-gate (round 3): when the write pushes a new
// themed entry, it must NOT shadow pre-existing entries
// whose theme references a different axis. The pathological
// case is an existing entry keyed to `{density:compact}`
// and a write under `{mode:dark}`. A naive front-insert
// would mean active=`{mode:dark, density:compact}` resolves
// to the new entry instead of the compact entry, silently
// changing the resolved colour for the combined axis.
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![ThemedValue {
value: VariableScalar::Str("#11ccaa".into()),
theme: Some({
let mut m = BTreeMap::new();
m.insert("density".into(), "compact".into());
m
}),
}]),
});
// Active theme = mode:dark only (no compact axis set, so the
// existing entry doesn't subset-match — the write must push
// a new entry).
tbl.set_active_theme("mode", "dark");
assert!(tbl.set_color_hex("bg", "#000000"));
// Under mode:dark — the new entry is reachable.
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
})
);
// Now flip active to the combined axes — mode:dark +
// density:compact. Under that resolve walk:
// - The pre-existing {density:compact} entry must still
// win (it's earlier in the vec AND its theme is a
// subset of the active combo).
// - The new {mode:dark} entry, end-pushed, is also a
// subset of the active combo, but it's later. First
// subset match wins.
// Pre-fix (front-insert) this would have read #000000
// instead — that was the codex BLOCK.
tbl.set_active_theme("density", "compact");
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x11 as f32 / 255.0,
g: 0xcc as f32 / 255.0,
b: 0xaa as f32 / 255.0,
a: 1.0,
}),
"compact entry must not be shadowed by the end-pushed dark entry"
);
}
#[test]
fn set_color_hex_empty_active_theme_targets_default_entry() {
// The flip side: when active_theme IS empty (no axis
// selected), writes target the default `theme: None`
// entry — that's what resolve falls back to in this state.
// Editing with no theme selected is the explicit "edit the
// universal value" path.
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![ThemedValue {
value: VariableScalar::Str("#888888".into()),
theme: None,
}]),
});
// No active theme set.
assert!(tbl.set_color_hex("bg", "#aabbcc"));
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0xaa as f32 / 255.0,
g: 0xbb as f32 / 255.0,
b: 0xcc as f32 / 255.0,
a: 1.0,
})
);
}
#[test]
fn set_color_hex_writes_themed_entry_matching_active_axis() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#ffffff".into()),
theme: Some({
let mut m = BTreeMap::new();
m.insert("mode".into(), "light".into());
m
}),
},
ThemedValue {
value: VariableScalar::Str("#000000".into()),
theme: Some({
let mut m = BTreeMap::new();
m.insert("mode".into(), "dark".into());
m
}),
},
]),
});
tbl.set_active_theme("mode", "dark");
// Resolves to dark first.
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
})
);
// Write under the active theme → updates only the dark entry.
assert!(tbl.set_color_hex("bg", "#3344ff"));
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x33 as f32 / 255.0,
g: 0x44 as f32 / 255.0,
b: 1.0,
a: 1.0,
})
);
// Flip to light — original light entry untouched.
tbl.set_active_theme("mode", "light");
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
})
);
}