tests.rs
raw
use std::collections::BTreeSet;
use guiduck_component_core::registry::builtins;
use guiduck_scene::FragmentStore;
use guiduck_scene::geom::{Point, Size};
use guiduck_scene::paint::{Brush, Color};
use taffy::prelude::{length, percent};
use super::*;
use crate::clipboard::NoClipboard;
use crate::event::{PointerButton, PointerInput};
use crate::text::TextContext;
use crate::widget::{Container, WidgetTree};
const THEME: &str = r##"
(theme Test
(tokens
(accent "#4682b4")
(accent-hover "#6495ed")
(accent-active "#483d8b"))
(rule container :background "#ffffff")
(rule (container .button) :background accent :corner-radius 8)
(rule (container .button :hover) :background accent-hover)
(rule (container .button :active) :background accent-active)
(rule text :font-size 14))
"##;
fn theme() -> Theme {
Theme::parse(THEME).expect("valid theme")
}
fn color(hex: u32) -> Brush {
Color::from_rgba8((hex >> 16) as u8, (hex >> 8) as u8, hex as u8, 255).into()
}
#[test]
fn resolution_merges_rules_in_file_order() {
let theme = theme();
let plain = theme.resolve("container", &[], InteractionState::default());
assert_eq!(plain.brush("background"), Some(&color(0xffffff)));
assert_eq!(plain.number("corner-radius"), None);
let classes = vec!["button".to_owned()];
let button = theme.resolve("container", &classes, InteractionState::default());
assert_eq!(button.brush("background"), Some(&color(0x4682b4)));
assert_eq!(button.number("corner-radius"), Some(8.0));
let hovered = theme.resolve(
"container",
&classes,
InteractionState {
hover: true,
active: false,
focus: false,
disabled: false,
},
);
assert_eq!(hovered.brush("background"), Some(&color(0x6495ed)));
assert_eq!(
hovered.number("corner-radius"),
Some(8.0),
"base properties persist under state rules"
);
// Active wins over hover because its rule comes later in the file.
let pressed = theme.resolve(
"container",
&classes,
InteractionState {
hover: true,
active: true,
focus: false,
disabled: false,
},
);
assert_eq!(pressed.brush("background"), Some(&color(0x483d8b)));
}
#[test]
fn rules_are_stamped_with_dirt() {
let theme = theme();
// text rules touch font-size → layout; container rules touch only paint.
assert_eq!(theme.max_dirt_for("text"), crate::Dirt::LAYOUT);
assert_eq!(theme.max_dirt_for("container"), crate::Dirt::PAINT);
}
/// End-to-end: theming a live tree, including hover/active restyling driven
/// by pointer state and needs_frame gating.
#[test]
fn themed_tree_restyles_on_interaction() {
let mut tree = WidgetTree::with_text_context(TextContext::hermetic([]));
let root = tree.insert(
Container::new(),
taffy::Style {
size: taffy::Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: taffy::Rect::length(20.0_f32),
..Default::default()
},
None,
);
let button = tree.insert(
Container::new(),
taffy::Style {
size: taffy::Size {
width: length(100.0_f32),
height: length(40.0_f32),
},
..Default::default()
},
Some(root),
);
tree.set_classes(button, ["button".to_owned()]);
tree.set_theme(Some(theme()));
let mut store = FragmentStore::new();
assert!(tree.needs_frame());
tree.render_frame(Size::new(300.0, 200.0));
assert!(!tree.needs_frame(), "quiescent after initial themed frame");
let base = tree
.widget::<Container>(button)
.unwrap()
.current_background()
.cloned();
assert_eq!(base, Some(color(0x4682b4)));
// Hover on: engine-level restyle without any user handlers.
tree.dispatch_pointer(
PointerInput::Moved(Point::new(50.0, 30.0)),
&mut NoClipboard,
);
assert!(tree.needs_frame(), "hover state change schedules a frame");
store.clear();
tree.render_frame(Size::new(300.0, 200.0));
assert_eq!(
tree.widget::<Container>(button)
.unwrap()
.current_background(),
Some(&color(0x6495ed))
);
// Press: active style.
tree.dispatch_pointer(
PointerInput::Down {
time: std::time::Instant::now(),
pos: Point::new(50.0, 30.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
store.clear();
tree.render_frame(Size::new(300.0, 200.0));
assert_eq!(
tree.widget::<Container>(button)
.unwrap()
.current_background(),
Some(&color(0x483d8b))
);
// Release + move away: back to base.
tree.dispatch_pointer(
PointerInput::Up {
pos: Point::new(50.0, 30.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
tree.dispatch_pointer(PointerInput::Moved(Point::new(5.0, 5.0)), &mut NoClipboard);
store.clear();
tree.render_frame(Size::new(300.0, 200.0));
assert_eq!(
tree.widget::<Container>(button)
.unwrap()
.current_background(),
Some(&color(0x4682b4))
);
assert!(!tree.needs_frame());
// Dropping the app theme leaves the default theme (and the fallback
// under it), which say nothing about a plain container — so hovering
// still schedules a restyle, that restyle resolves nothing, and the tree
// settles in one frame with its pixels unchanged.
tree.set_theme(None);
store.clear();
tree.render_frame(Size::new(300.0, 200.0));
tree.dispatch_pointer(
PointerInput::Moved(Point::new(50.0, 30.0)),
&mut NoClipboard,
);
assert!(tree.needs_frame(), "hover schedules a restyle");
tree.render_frame(Size::new(300.0, 200.0));
assert_eq!(
tree.widget::<Container>(button)
.unwrap()
.current_background(),
Some(&color(0x4682b4)),
"no rule matches a plain container: pixels unchanged"
);
assert!(!tree.needs_frame(), "the restyle settles in one frame");
}
#[test]
fn theme_swap_restyles_everything() {
const DARK: &str = r##"
(theme Dark
(rule container :background "#202020")
(rule (container .button) :background "#334455"))
"##;
let mut tree = WidgetTree::with_text_context(TextContext::hermetic([]));
let root = tree.insert(
Container::new(),
taffy::Style {
size: taffy::Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
},
None,
);
tree.set_theme(Some(theme()));
let mut store = FragmentStore::new();
tree.render_frame(Size::new(100.0, 100.0));
assert_eq!(
tree.widget::<Container>(root).unwrap().current_background(),
Some(&color(0xffffff))
);
tree.set_theme(Some(Theme::parse(DARK).expect("valid")));
assert!(tree.needs_frame());
store.clear();
tree.render_frame(Size::new(100.0, 100.0));
assert_eq!(
tree.widget::<Container>(root).unwrap().current_background(),
Some(&color(0x202020))
);
}
#[test]
fn disabled_state_rules_resolve() {
let theme = Theme::parse(
r##"
(theme Disabled
(rule (container .button) :background "#4682b4")
(rule (container .button :disabled) :background "#c0c0c0"))
"##,
)
.expect("valid theme");
let classes = vec!["button".to_owned()];
let normal = theme.resolve("container", &classes, InteractionState::default());
assert_eq!(normal.brush("background"), Some(&color(0x4682b4)));
let disabled = theme.resolve(
"container",
&classes,
InteractionState {
hover: false,
active: false,
focus: false,
disabled: true,
},
);
assert_eq!(disabled.brush("background"), Some(&color(0xc0c0c0)));
}
#[test]
fn layering_puts_the_app_theme_over_the_base() {
let base = Theme::parse(
r##"(theme Base
(rule container :background "#111111" :corner-radius 4))"##,
)
.expect("valid");
let app = Theme::parse(
r##"(theme App
(rule container :background "#222222"))"##,
)
.expect("valid");
let layered = app.over(&base);
assert_eq!(layered.name, "App");
let computed = layered.resolve("container", &[], InteractionState::default());
// The app's rule wins where both speak; the base fills the rest.
assert_eq!(computed.brush("background"), Some(&color(0x222222)));
assert_eq!(computed.number("corner-radius"), Some(4.0));
}
/// Every interaction state a rule can select on, for exhaustive checks.
const EVERY_STATE: &[InteractionState] = &[
InteractionState {
hover: true,
active: false,
focus: false,
disabled: false,
},
InteractionState {
hover: false,
active: true,
focus: false,
disabled: false,
},
InteractionState {
hover: false,
active: false,
focus: true,
disabled: false,
},
InteractionState {
hover: false,
active: false,
focus: false,
disabled: true,
},
];
fn token_names(theme: &Theme, widget: &str, state: InteractionState) -> BTreeSet<String> {
theme
.resolve(widget, &[], state)
.names()
.map(str::to_owned)
.collect()
}
#[test]
fn the_fallback_theme_type_checks_against_the_registry() {
use guiduck_component_core::registry::{ThemeValueType, builtin};
for rule in &fallback_theme().rules {
let widget = builtin(&rule.widget).expect("a known widget");
for (name, value) in &rule.props {
let expected = widget
.theme_value_type(name)
.unwrap_or_else(|| panic!("`{}` has no themable `{name}`", rule.widget));
let ok = match (expected, value) {
(ThemeValueType::Brush, StyleValue::Brush(_))
| (ThemeValueType::Number, StyleValue::Number(_))
| (ThemeValueType::Str, StyleValue::Str(_))
| (ThemeValueType::Graphic, StyleValue::Graphic(_)) => true,
_ => false,
};
assert!(
ok,
"`{}` `{name}`: theme expects {expected:?}, fallback carries {value:?}",
rule.widget
);
}
}
}
#[test]
fn the_fallback_and_default_themes_cover_the_same_tokens() {
// The two-way invariant the layering rests on. Fallback ⊇ default means a
// default theme that failed to compile still leaves every control fully
// dressed. Default ⊇ fallback means the fallback's deliberately plain
// values never show through in a working build — which is why migrating
// appearance into the default theme stayed byte-identical.
let default_parsed = Theme::parse(DEFAULT_THEME_SOURCE).expect("the default theme compiles");
for widget in builtins() {
let base = InteractionState::default();
assert_eq!(
token_names(fallback_theme(), &widget.name, base),
token_names(&default_parsed, &widget.name, base),
"`{}`: the fallback and default themes must state the same base tokens",
widget.name
);
}
}
/// A widget with paint-only tokens must be dressed by both themes.
///
/// The two-way test above compares the fallback against the default and is
/// satisfied when both are empty — so a newly declared widget whose tokens no
/// theme states passes it, and renders as nothing. This closes exactly that
/// hole; between them, the two themes must state the *same* non-empty set.
///
/// Non-emptiness rather than per-token completeness, because completeness is
/// not decidable from here: a token that is also a settable property takes its
/// value from the instance, and a typography token falls back to the widget's
/// own default. Which tokens *must* be stated is a judgement per token; that
/// none may be stated is not.
#[test]
fn a_widget_with_paint_only_tokens_is_dressed_by_both_themes() {
let default_parsed = Theme::parse(DEFAULT_THEME_SOURCE).expect("the default theme compiles");
let base = InteractionState::default();
for widget in builtins() {
let paint_only = widget
.theme_tokens()
.any(|(name, _)| widget.lookup(name).is_none());
if !paint_only {
continue;
}
for (label, theme) in [("fallback", fallback_theme()), ("default", &default_parsed)] {
assert!(
!token_names(theme, &widget.name, base).is_empty(),
"`{}` has appearance only a theme can give it, and the {label} \
theme says nothing about it",
widget.name
);
}
}
}
#[test]
fn every_fallback_state_rule_has_a_base_to_return_to() {
// The compiler enforces this for parsed themes; the fallback is built in
// code, so it gets the same check here. Without it, leaving a state would
// have no value to restore.
for widget in builtins() {
let base = token_names(fallback_theme(), &widget.name, InteractionState::default());
for state in EVERY_STATE {
let stated = token_names(fallback_theme(), &widget.name, *state);
let uncovered: Vec<_> = stated.difference(&base).collect();
assert!(
uncovered.is_empty(),
"`{}` in {state:?}: {uncovered:?} have no stateless value",
widget.name
);
}
}
}
#[test]
fn the_fallback_theme_dresses_every_control_by_itself() {
// The point of the fallback: with the default theme out of the picture
// entirely, controls still resolve real appearance rather than nothing.
let checkbox = fallback_theme().resolve("checkbox", &[], InteractionState::default());
assert!(checkbox.brush("box-fill").is_some());
assert!(checkbox.graphic("check-mark").is_some());
let focused = fallback_theme().resolve(
"button",
&[],
InteractionState {
focus: true,
..Default::default()
},
);
// A focus ring the user can actually see is the difference between
// "degraded" and "unusable" for keyboard operation.
assert!(!crate::widget::is_transparent(
focused.brush("focus-ring-color").expect("a ring color")
));
}
#[test]
fn a_default_theme_that_will_not_compile_falls_back_instead_of_panicking() {
// The recovery path `default_theme()` takes. A control must still resolve
// a usable appearance: reporting the problem and running plainly beats
// refusing to start.
let recovered = compile_default(r##"(theme Default (rule button :background "not-a-color"))"##);
let button = recovered.resolve("button", &[], InteractionState::default());
assert!(button.brush("background").is_some());
assert!(button.number("focus-ring-width").is_some());
let checkbox = recovered.resolve("checkbox", &[], InteractionState::default());
assert!(checkbox.graphic("check-mark").is_some());
}
#[test]
fn the_default_theme_layers_over_the_fallback() {
// Every token the fallback states, the shipped default theme restates —
// so what a control actually resolves is the default theme's value, and
// the fallback is invisible in a working build.
let resolved = default_theme().resolve("checkbox", &[], InteractionState::default());
let fallback = fallback_theme().resolve("checkbox", &[], InteractionState::default());
assert_eq!(
resolved.number("box-corner-radius"),
Some(4.0),
"the default theme's radius"
);
assert_eq!(
fallback.number("box-corner-radius"),
Some(0.0),
"the fallback's plainer one"
);
assert_eq!(default_theme().name, "Default");
}