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");
}