tests.rs raw

use super::*;

/// A rule's properties as (name, value) pairs — spans are for diagnostics,
/// not for these assertions.
fn props(rule: &RuleIr) -> Vec<(String, Literal)> {
    rule.props
        .iter()
        .map(|p| (p.name.clone(), p.value.clone()))
        .collect()
}

const GOOD: &str = r##"
(theme Light
  (tokens
    (color-accent "#4682b4")
    (color-accent-hover "#6495ed")
    (radius-m 8)
    (fg color-accent))   ; token referencing an earlier token

  (rule text :color "#111111" :font-size 14)
  (rule (container .button)
        :background color-accent :corner-radius radius-m)
  (rule (container .button :hover) :background color-accent-hover)
  (rule (container .button :active) :background "#483d8b"))
"##;

#[test]
fn compiles_tokens_and_rules() {
    let theme = compile_theme(GOOD).expect("valid theme");
    assert_eq!(theme.name, "Light");
    assert_eq!(theme.rules.len(), 4);

    let button = &theme.rules[1];
    assert_eq!(button.widget, "container");
    assert_eq!(button.classes, ["button"]);
    assert_eq!(button.state, None);
    assert_eq!(
        props(button),
        vec![
            (
                "background".to_owned(),
                Literal::Color(0x46, 0x82, 0xb4, 255)
            ),
            ("corner-radius".to_owned(), Literal::Int(8)),
        ]
    );

    let hover = &theme.rules[2];
    assert_eq!(hover.state, Some(StateSel::Hover));
    assert_eq!(
        props(hover),
        vec![(
            "background".to_owned(),
            Literal::Color(0x64, 0x95, 0xed, 255)
        )]
    );
}

#[test]
fn token_forward_reference_is_an_error() {
    let source = r##"
(theme Broken
  (tokens (a b) (b "#fff"))
  (rule text :color a))
"##;
    let errors = compile_theme(source).unwrap_err();
    assert!(errors[0].message.contains("not a defined token"));
}

#[test]
fn state_rule_without_base_is_an_error() {
    let source = r##"
(theme Broken
  (rule (container .button :hover) :background "#123456"))
"##;
    let errors = compile_theme(source).unwrap_err();
    assert!(errors[0].message.contains("no stateless rule covers it"));
}

#[test]
fn base_coverage_accepts_broader_base() {
    // Base on `container` (no class) covers the classed state rule.
    let source = r##"
(theme Ok
  (rule container :background "#ffffff")
  (rule (container .button :hover) :background "#123456"))
"##;
    assert!(compile_theme(source).is_ok());
}

#[test]
fn layout_props_are_not_themable() {
    let source = r##"
(theme Broken
  (rule (container .x) :padding 4))
"##;
    let errors = compile_theme(source).unwrap_err();
    assert!(errors[0].message.contains("not themable"));
}

#[test]
fn unknown_state_and_widget() {
    let errors =
        compile_theme(r##"(theme B (rule (container :visited) :background "#fff"))"##).unwrap_err();
    assert!(errors[0].message.contains("unknown state"));

    // Deliberately not a plausible future widget: this test previously used
    // `slider`, and started failing the day one existed.
    let errors =
        compile_theme(r##"(theme B (rule (nonesuch .x) :background "#fff"))"##).unwrap_err();
    assert!(errors[0].message.contains("unknown widget"));
}

#[test]
fn path_valued_property_compiles() {
    use crate::ir::{Literal, PathCmd};
    let theme = compile_theme(
        r##"(theme T
  (rule checkbox
    :check-mark (path (move 0.2 0.5) (line 0.4 0.7) (line 0.8 0.3))))"##,
    )
    .expect("valid");
    let prop = &theme.rules[0].props[0];
    assert_eq!(prop.name, "check-mark");
    assert_eq!(
        &prop.value,
        &Literal::Path(vec![
            PathCmd::Move(0.2, 0.5),
            PathCmd::Line(0.4, 0.7),
            PathCmd::Line(0.8, 0.3),
        ])
    );
}

#[test]
fn svg_path_property_compiles() {
    let theme =
        compile_theme(r##"(theme T (rule checkbox :check-mark (svg-path "M0.2 0.5 L0.8 0.5")))"##)
            .expect("valid");
    assert!(matches!(
        theme.rules[0].props[0].value,
        crate::ir::Literal::Path(_)
    ));
}

#[test]
fn theme_value_type_is_checked() {
    // A color where a graphic is expected…
    let errors = compile_theme(r##"(theme T (rule checkbox :check-mark "#ffffff"))"##).unwrap_err();
    assert!(
        errors[0].message.contains("expects a graphic"),
        "got: {}",
        errors[0].message
    );

    // …and a graphic where a color is expected.
    let errors =
        compile_theme(r##"(theme T (rule button :background (path (move 0 0))))"##).unwrap_err();
    assert!(
        errors[0].message.contains("expects a color"),
        "got: {}",
        errors[0].message
    );
}

#[test]
fn a_graphic_token_takes_either_a_path_or_an_asset() {
    // The point of the Graphic type: one token, either representation, with
    // no vocabulary in the theme language to tell them apart.
    for value in [
        r##"(path (move 0.2 0.5) (line 0.8 0.5))"##,
        r##"(svg-path "M0.2 0.5 L0.8 0.5")"##,
        r##"(asset "icons/check.png")"##,
    ] {
        let source = format!("(theme T (rule checkbox :check-mark {value}))");
        let theme = compile_theme(&source).unwrap_or_else(|e| panic!("{value}: {e:?}"));
        assert!(matches!(
            theme.rules[0].props[0].value,
            Literal::Path(_) | Literal::Asset(_)
        ));
    }
}

#[test]
fn unknown_themable_property_is_pointed() {
    let errors =
        compile_theme(r##"(theme T (rule button :check-mark (path (move 0 0))))"##).unwrap_err();
    // A button has no check mark.
    assert!(errors[0].message.contains("has no themable property"));
}

#[test]
fn default_theme_compiles() {
    // The shipped default theme must always parse (guiduck-core relies on it
    // at every tree construction).
    compile_theme(include_str!("../../../guiduck-core/src/default.gdt"))
        .expect("the default theme compiles");
}

/// A theme styles a `.gdw`-declared widget exactly as it styles a builtin:
/// its selector resolves, its declared tokens type-check, and everything the
/// builtins get — classes, states, base coverage — applies unchanged.
#[test]
fn a_rule_can_style_a_manifest_declared_widget() {
    let mut registry = Registry::default();
    for widget in crate::manifest::compile_manifest(
        r##"
(widget markdown
  (type "::m::View")
  (token link-color Brush)
  (token gutter Number))
"##,
    )
    .expect("valid manifest")
    {
        registry.insert(widget).expect("fresh name");
    }

    let source = r##"
(theme T
  (rule (markdown .doc) :link-color "#4682b4" :gutter 12)
  (rule (markdown .doc :hover) :link-color "#6495ed"))
"##;
    let theme = compile_theme_with(source, &registry).expect("valid theme");
    assert_eq!(theme.rules[0].widget, "markdown");
    assert_eq!(theme.rules[0].classes, ["doc"]);
    assert_eq!(
        props(&theme.rules[0]),
        vec![
            ("link-color".to_owned(), Literal::Color(70, 130, 180, 255)),
            ("gutter".to_owned(), Literal::Int(12)),
        ]
    );

    // A token it never declared is not themable on it...
    let errors = compile_theme_with(
        r##"(theme T (rule markdown :background "#fff"))"##,
        &registry,
    )
    .unwrap_err();
    assert!(
        errors[0]
            .message
            .contains("`markdown` has no themable property `:background`"),
        "{errors:?}"
    );

    // ...and a declared one still has to be given the right type.
    let errors =
        compile_theme_with(r##"(theme T (rule markdown :link-color 12))"##, &registry).unwrap_err();
    assert!(
        errors[0].message.contains("`:link-color` expects a color"),
        "{errors:?}"
    );

    // Without the manifest, the name is simply not a widget.
    let errors = compile_theme(r##"(theme T (rule markdown :link-color "#fff"))"##).unwrap_err();
    assert!(
        errors[0].message.contains("unknown widget `markdown`"),
        "{errors:?}"
    );
}