AkurAI Build
Menu

Public repository / 2ebaebff3945c75127afb6a9524ac1192993158b

AkurAI-Framework / crates/css/tests/grammar.rs

Latest change 3aae9f4e85241bcb775c31f5c03094becd3da56a - Pure-Rust UI layer: akurai-ui, akurai-crypto, Tailwind-grade akurai-css by Ólafur Búi Ólafsson

//! Grammar coverage: one class in, the exact CSS out (via `build_any`).
//!
//! Each section exercises one utility family, then variants, escaping, the
//! important/negative modifiers, ordering and the flat-corner clamp.

use akurai_css::build_any;

/// Render a single class (or a whitespace-separated list) through `build_any`.
fn css(classes: &str) -> String {
    build_any(&[classes])
}

/// Assert `class` renders as `.class{body}` (plain selector, no escaping).
fn assert_body(class: &str, body: &str) {
    assert_eq!(css(class), format!(".{class}{{{body}}}"), "class `{class}`");
}

/// Assert `class` renders as `.class{body}` followed only by `@property`
/// registrations (setters of composed custom properties register them).
fn assert_rule(class: &str, body: &str) {
    let out = css(class);
    let head = format!(".{class}{{{body}}}");
    assert!(out.starts_with(&head), "class `{class}`: {out}");
    let rest = &out[head.len()..];
    assert!(
        rest.is_empty() || rest.starts_with("@property --tw-"),
        "class `{class}` trailing: {rest}"
    );
}

// ---------------------------------------------------------------- spacing

#[test]
fn spacing_scale_keeps_legacy_px_output() {
    assert_body("p-2", "padding:8px");
    assert_body("p-0", "padding:0px");
    assert_body("px-4", "padding-left:16px;padding-right:16px");
    assert_body("mt-3", "margin-top:12px");
    assert_body("ml-1", "margin-left:4px");
    assert_body("pb-6", "padding-bottom:24px");
}

#[test]
fn spacing_fractional_steps_and_px() {
    assert_eq!(css("p-0.5"), r".p-0\.5{padding:2px}");
    assert_eq!(
        css("py-1.5"),
        r".py-1\.5{padding-top:6px;padding-bottom:6px}"
    );
    assert_eq!(css("m-2.5"), r".m-2\.5{margin:10px}");
    assert_eq!(css("gap-3.5"), r".gap-3\.5{gap:14px}");
    assert_body("p-px", "padding:1px");
    assert_body("px-px", "padding-left:1px;padding-right:1px");
}

#[test]
fn margin_auto() {
    assert_body("mx-auto", "margin-left:auto;margin-right:auto");
    assert_body("m-auto", "margin:auto");
    assert_body("ml-auto", "margin-left:auto");
}

#[test]
fn gap_axes() {
    assert_body("gap-x-2", "column-gap:8px");
    assert_body("gap-y-4", "row-gap:16px");
}

#[test]
fn space_between_children() {
    assert_eq!(
        css("space-x-2"),
        ".space-x-2 > :not([hidden]) ~ :not([hidden]){margin-left:8px}"
    );
    assert_eq!(
        css("space-y-4"),
        ".space-y-4 > :not([hidden]) ~ :not([hidden]){margin-top:16px}"
    );
}

#[test]
fn negative_spacing() {
    assert_body("-mt-2", "margin-top:-8px");
    assert_body("-mx-1", "margin-left:-4px;margin-right:-4px");
    assert_eq!(css("-top-1/2"), r".-top-1\/2{top:-50%}");
}

#[test]
fn negative_rejected_where_meaningless() {
    assert_eq!(css("-p-2"), "");
    assert_eq!(css("-flex"), "");
}

#[test]
fn malformed_spacing_is_ignored() {
    for bad in ["p-", "p-x", "p--1", "p-01", "p-1.0", "p-0.25", "zzz-2"] {
        assert_eq!(css(bad), "", "`{bad}` should be ignored");
    }
}

// ------------------------------------------------------------ inset / pos

#[test]
fn inset_and_offsets() {
    assert_body("inset-0", "inset:0px");
    assert_body("inset-x-0", "left:0px;right:0px");
    assert_body("inset-y-2", "top:8px;bottom:8px");
    assert_body("top-0", "top:0px");
    assert_body("right-4", "right:16px");
    assert_body("bottom-auto", "bottom:auto");
    assert_body("left-full", "left:100%");
    assert_eq!(css("top-1/2"), r".top-1\/2{top:50%}");
}

// ----------------------------------------------------------------- sizing

#[test]
fn width_height_size() {
    assert_body("w-4", "width:16px");
    assert_body("h-10", "height:40px");
    assert_body("size-6", "width:24px;height:24px");
    assert_body("w-full", "width:100%");
    assert_body("w-screen", "width:100vw");
    assert_body("h-screen", "height:100vh");
    assert_body("h-dvh", "height:100dvh");
    assert_body("min-h-screen", "min-height:100vh");
    assert_body("w-auto", "width:auto");
    assert_body("w-min", "width:min-content");
    assert_body("w-max", "width:max-content");
    assert_body("w-fit", "width:fit-content");
    assert_body("w-px", "width:1px");
    assert_eq!(css("w-0.5"), r".w-0\.5{width:2px}");
}

#[test]
fn fractions() {
    assert_eq!(css("w-1/2"), r".w-1\/2{width:50%}");
    assert_eq!(css("w-1/3"), r".w-1\/3{width:33.333333%}");
    assert_eq!(css("w-2/3"), r".w-2\/3{width:66.666667%}");
    assert_eq!(css("w-3/4"), r".w-3\/4{width:75%}");
    assert_eq!(css("w-4/5"), r".w-4\/5{width:80%}");
    assert_eq!(css("w-5/6"), r".w-5\/6{width:83.333333%}");
    assert_eq!(css("w-1/0"), "");
}

#[test]
fn min_max_sizes() {
    assert_body("min-w-0", "min-width:0px");
    assert_body("min-w-full", "min-width:100%");
    assert_body("max-w-md", "max-width:28rem");
    assert_body("max-w-7xl", "max-width:80rem");
    assert_body("max-w-none", "max-width:none");
    assert_body("max-w-prose", "max-width:65ch");
    assert_body("max-w-screen-lg", "max-width:1024px");
    assert_body("max-w-full", "max-width:100%");
    assert_body("max-h-screen", "max-height:100vh");
    assert_body("max-h-64", "max-height:256px");
}

#[test]
fn arbitrary_sizes() {
    assert_eq!(css("w-[220px]"), r".w-\[220px\]{width:220px}");
    assert_eq!(
        css("h-[calc(100vh-4rem)]"),
        r".h-\[calc\(100vh-4rem\)\]{height:calc(100vh-4rem)}"
    );
    assert_eq!(
        css("max-w-[min(90vw,_40rem)]"),
        r".max-w-\[min\(90vw\,_40rem\)\]{max-width:min(90vw, 40rem)}"
    );
    assert_eq!(css("p-[3px]"), r".p-\[3px\]{padding:3px}");
}

#[test]
fn arbitrary_values_reject_css_injection() {
    assert_eq!(css("w-[1px;color:red]"), "");
    assert_eq!(css("w-[1px}]"), "");
    assert_eq!(css("w-[]"), "");
}

// ----------------------------------------------------------------- layout

#[test]
fn display_keywords() {
    for (class, body) in [
        ("block", "display:block"),
        ("inline-block", "display:inline-block"),
        ("inline", "display:inline"),
        ("flex", "display:flex"),
        ("inline-flex", "display:inline-flex"),
        ("grid", "display:grid"),
        ("inline-grid", "display:inline-grid"),
        ("hidden", "display:none"),
        ("contents", "display:contents"),
        ("table", "display:table"),
    ] {
        assert_body(class, body);
    }
}

#[test]
fn sr_only() {
    assert_body(
        "sr-only",
        "position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;\
         clip:rect(0,0,0,0);white-space:nowrap;border-width:0",
    );
    assert!(css("not-sr-only").starts_with(".not-sr-only{position:static;"));
}

#[test]
fn position_z_overflow_misc() {
    assert_body("relative", "position:relative");
    assert_body("sticky", "position:sticky");
    assert_body("z-10", "z-index:10");
    assert_body("z-auto", "z-index:auto");
    assert_body("-z-10", "z-index:-10");
    assert_eq!(css("z-[999]"), r".z-\[999\]{z-index:999}");
    assert_body("overflow-hidden", "overflow:hidden");
    assert_body("overflow-x-auto", "overflow-x:auto");
    assert_body("overflow-y-hidden", "overflow-y:hidden");
    assert_body("invisible", "visibility:hidden");
    assert_body("visible", "visibility:visible");
    assert_body("isolate", "isolation:isolate");
    assert_body("box-border", "box-sizing:border-box");
    assert_body("aspect-square", "aspect-ratio:1 / 1");
    assert_body("aspect-video", "aspect-ratio:16 / 9");
    assert_body("object-cover", "object-fit:cover");
    assert_body("object-contain", "object-fit:contain");
}

#[test]
fn tables() {
    assert_body("border-collapse", "border-collapse:collapse");
    assert_body("table-fixed", "table-layout:fixed");
    assert_body("table-auto", "table-layout:auto");
}

// -------------------------------------------------------------- flex/grid

#[test]
fn flex_keywords() {
    assert_body("flex-row", "flex-direction:row");
    assert_body("flex-col", "flex-direction:column");
    assert_body("flex-col-reverse", "flex-direction:column-reverse");
    assert_body("flex-wrap", "flex-wrap:wrap");
    assert_body("flex-nowrap", "flex-wrap:nowrap");
    assert_body("flex-1", "flex:1 1 0%");
    assert_body("flex-auto", "flex:1 1 auto");
    assert_body("flex-none", "flex:none");
    assert_body("flex-initial", "flex:0 1 auto");
    assert_body("grow", "flex-grow:1");
    assert_body("grow-0", "flex-grow:0");
    assert_body("shrink-0", "flex-shrink:0");
    assert_body("basis-0", "flex-basis:0px");
    assert_eq!(css("basis-1/2"), r".basis-1\/2{flex-basis:50%}");
    assert_body("order-2", "order:2");
    assert_body("order-last", "order:9999");
    assert_body("-order-1", "order:-1");
}

#[test]
fn alignment_keywords() {
    assert_body("items-center", "align-items:center");
    assert_body("items-baseline", "align-items:baseline");
    assert_body("justify-between", "justify-content:space-between");
    assert_body("justify-evenly", "justify-content:space-evenly");
    assert_body("content-center", "align-content:center");
    assert_body("self-end", "align-self:flex-end");
    assert_body("place-items-center", "place-items:center");
    assert_body("place-content-center", "place-content:center");
    assert_body("justify-items-center", "justify-items:center");
}

#[test]
fn grid_templates_and_placement() {
    assert_body(
        "grid-cols-3",
        "grid-template-columns:repeat(3,minmax(0,1fr))",
    );
    assert_body(
        "grid-cols-12",
        "grid-template-columns:repeat(12,minmax(0,1fr))",
    );
    assert_body("grid-cols-none", "grid-template-columns:none");
    assert_eq!(
        css("grid-cols-[1fr_2fr]"),
        r".grid-cols-\[1fr_2fr\]{grid-template-columns:1fr 2fr}"
    );
    assert_body("grid-rows-2", "grid-template-rows:repeat(2,minmax(0,1fr))");
    assert_body("col-span-2", "grid-column:span 2 / span 2");
    assert_body("col-span-full", "grid-column:1 / -1");
    assert_body("col-start-2", "grid-column-start:2");
    assert_body("row-span-3", "grid-row:span 3 / span 3");
    assert_body("grid-flow-col", "grid-auto-flow:column");
    assert_body("grid-flow-dense", "grid-auto-flow:dense");
    assert_body("auto-cols-fr", "grid-auto-columns:minmax(0,1fr)");
    assert_body("auto-rows-min", "grid-auto-rows:min-content");
    assert_eq!(css("grid-cols-0"), "");
}

// ------------------------------------------------------------- typography

#[test]
fn text_sizes_are_font_size_only() {
    assert_body("text-sm", "font-size:0.875rem");
    assert_body("text-lg", "font-size:1.125rem");
    assert_body("text-xs", "font-size:0.75rem");
    assert_body("text-base", "font-size:1rem");
    assert_body("text-2xl", "font-size:1.5rem");
    assert_body("text-6xl", "font-size:3.75rem");
    assert_eq!(css("text-[13px]"), r".text-\[13px\]{font-size:13px}");
    assert_eq!(
        css("text-sm/6"),
        r".text-sm\/6{font-size:0.875rem;line-height:1.5rem}"
    );
}

#[test]
fn font_weight_and_family() {
    assert_body("font-thin", "font-weight:100");
    assert_body("font-medium", "font-weight:500");
    assert_body("font-bold", "font-weight:700");
    assert_body("font-black", "font-weight:900");
    assert_body(
        "font-mono",
        "font-family:var(--font-mono,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace)",
    );
    assert_body(
        "font-sans",
        "font-family:var(--font-sans,ui-sans-serif,system-ui,sans-serif)",
    );
    assert_body("italic", "font-style:italic");
    assert_body("not-italic", "font-style:normal");
}

#[test]
fn tracking_leading_alignment() {
    assert_body("tracking-tight", "letter-spacing:-0.025em");
    assert_body("tracking-widest", "letter-spacing:0.1em");
    assert_body("leading-none", "line-height:1");
    assert_body("leading-relaxed", "line-height:1.625");
    assert_body("leading-6", "line-height:1.5rem");
    assert_body("text-left", "text-align:left");
    assert_body("text-center", "text-align:center");
    assert_body("text-justify", "text-align:justify");
}

#[test]
fn text_transform_decoration_wrapping() {
    assert_body("uppercase", "text-transform:uppercase");
    assert_body("normal-case", "text-transform:none");
    assert_body("underline", "text-decoration-line:underline");
    assert_body("line-through", "text-decoration-line:line-through");
    assert_body("no-underline", "text-decoration-line:none");
    assert_body("underline-offset-4", "text-underline-offset:4px");
    assert_body(
        "truncate",
        "overflow:hidden;text-overflow:ellipsis;white-space:nowrap",
    );
    assert_body("text-ellipsis", "text-overflow:ellipsis");
    assert_body("whitespace-nowrap", "white-space:nowrap");
    assert_body("whitespace-pre-wrap", "white-space:pre-wrap");
    assert_body("break-words", "overflow-wrap:break-word");
    assert_body("break-all", "word-break:break-all");
    assert_body("tabular-nums", "font-variant-numeric:tabular-nums");
    assert_body(
        "antialiased",
        "-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale",
    );
    assert_body(
        "line-clamp-2",
        "overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2",
    );
    assert_body("align-middle", "vertical-align:middle");
    assert_body("list-none", "list-style-type:none");
    assert_body("list-disc", "list-style-type:disc");
}

// ----------------------------------------------------------------- colors

#[test]
fn color_tokens_cover_theme() {
    for token in [
        "bg", "bg-2", "panel", "panel-2", "border", "border-2", "fg", "muted", "dim", "accent",
        "accent-2", "ok", "warn", "info", "danger",
    ] {
        assert_body(&format!("text-{token}"), &format!("color:var(--{token})"));
        assert_body(
            &format!("bg-{token}"),
            &format!("background-color:var(--{token})"),
        );
    }
}

#[test]
fn color_properties() {
    assert_body("bg-bg", "background-color:var(--bg)");
    assert_body("border-border", "border-color:var(--border)");
    assert_body("outline-accent", "outline-color:var(--accent)");
    assert_body("fill-current", "fill:currentColor");
    assert_body("stroke-fg", "stroke:var(--fg)");
    assert_body("accent-accent", "accent-color:var(--accent)");
    assert_body("caret-accent", "caret-color:var(--accent)");
    assert_rule("ring-accent", "--tw-ring-color:var(--accent)");
    assert_rule("ring-offset-bg", "--tw-ring-offset-color:var(--bg)");
    assert_body("bg-transparent", "background-color:transparent");
    assert_body("text-white", "color:#fff");
    assert_body("text-black", "color:#000");
    assert_body("text-inherit", "color:inherit");
    assert_eq!(
        css("divide-border"),
        ".divide-border > :not([hidden]) ~ :not([hidden]){border-color:var(--border)}"
    );
    assert_eq!(
        css("placeholder-muted"),
        ".placeholder-muted::placeholder{color:var(--muted)}"
    );
}

#[test]
fn color_opacity_modifier() {
    assert_eq!(
        css("bg-accent/90"),
        r".bg-accent\/90{background-color:color-mix(in srgb, var(--accent) 90%, transparent)}"
    );
    assert_eq!(
        css("text-current/50"),
        r".text-current\/50{color:color-mix(in srgb, currentColor 50%, transparent)}"
    );
    assert_eq!(css("bg-accent/101"), "");
    assert_eq!(css("bg-accent/x"), "");
}

#[test]
fn arbitrary_colors() {
    assert_eq!(
        css("bg-[#123456]"),
        r".bg-\[\#123456\]{background-color:#123456}"
    );
    assert_eq!(
        css("text-[var(--x)]"),
        r".text-\[var\(--x\)\]{color:var(--x)}"
    );
    assert_eq!(
        css("bg-[color:var(--member)]"),
        r".bg-\[color\:var\(--member\)\]{background-color:var(--member)}"
    );
    assert_eq!(
        css("bg-[#123456]/50"),
        r".bg-\[\#123456\]\/50{background-color:color-mix(in srgb, #123456 50%, transparent)}"
    );
}

#[test]
fn unknown_tokens_are_not_colors() {
    assert_eq!(css("text-purple"), "");
    assert_eq!(css("bg-nope"), "");
}

// ------------------------------------------------------------ backgrounds

#[test]
fn gradients() {
    assert_body("bg-none", "background-image:none");
    assert_body(
        "bg-gradient-to-r",
        "background-image:linear-gradient(to right,var(--tw-gradient-stops,\
         var(--tw-gradient-from,transparent),var(--tw-gradient-to,transparent)))",
    );
    assert!(css("bg-gradient-to-br").contains("to bottom right"));
    assert_rule("from-accent", "--tw-gradient-from:var(--accent)");
    assert_rule("to-panel", "--tw-gradient-to:var(--panel)");
    assert_rule(
        "via-ok",
        "--tw-gradient-stops:var(--tw-gradient-from,transparent),var(--ok),\
         var(--tw-gradient-to,transparent)",
    );
}

// ---------------------------------------------------------------- borders

#[test]
fn border_widths_and_styles() {
    assert_body(
        "border",
        "border-style:var(--tw-border-style,solid);border-width:1px",
    );
    assert_body(
        "border-2",
        "border-style:var(--tw-border-style,solid);border-width:2px",
    );
    assert_body("border-0", "border-width:0px");
    assert_body(
        "border-t",
        "border-top-style:var(--tw-border-style,solid);border-top-width:1px",
    );
    assert_body(
        "border-x-2",
        "border-left-style:var(--tw-border-style,solid);border-left-width:2px;\
         border-right-style:var(--tw-border-style,solid);border-right-width:2px",
    );
    assert_rule("border-dashed", "--tw-border-style:dashed");
    assert_rule("border-none", "--tw-border-style:none;border-style:none");
    assert_eq!(
        css("border-[3px]"),
        r".border-\[3px\]{border-style:var(--tw-border-style,solid);border-width:3px}"
    );
}

#[test]
fn divide_children() {
    assert_eq!(
        css("divide-y"),
        ".divide-y > :not([hidden]) ~ :not([hidden]){border-top-style:solid;border-top-width:1px}"
    );
    assert_eq!(
        css("divide-x-2"),
        ".divide-x-2 > :not([hidden]) ~ :not([hidden]){border-left-style:solid;border-left-width:2px}"
    );
}

#[test]
fn radius_is_flat_and_clamped_to_6px() {
    assert_body("rounded-none", "border-radius:0px");
    assert_body("rounded-sm", "border-radius:2px");
    assert_body("rounded", "border-radius:4px");
    assert_body("rounded-md", "border-radius:6px");
    for clamped in [
        "rounded-lg",
        "rounded-xl",
        "rounded-2xl",
        "rounded-3xl",
        "rounded-full",
    ] {
        assert_body(clamped, "border-radius:6px");
    }
    assert_body(
        "rounded-t-md",
        "border-top-left-radius:6px;border-top-right-radius:6px",
    );
    assert_body("rounded-tl-sm", "border-top-left-radius:2px");
    assert_body("rounded-br-full", "border-bottom-right-radius:6px");
    assert_eq!(css("rounded-[3px]"), r".rounded-\[3px\]{border-radius:3px}");
    assert_eq!(
        css("rounded-[20px]"),
        r".rounded-\[20px\]{border-radius:6px}"
    );
    assert_eq!(
        css("rounded-[50%]"),
        r".rounded-\[50\%\]{border-radius:min(50%,6px)}"
    );
}

// ---------------------------------------------------------------- effects

#[test]
fn shadows_compose_with_rings() {
    let composed = "box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),\
                    var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)";
    let shadow = css("shadow-md");
    assert!(shadow.starts_with(
        ".shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,0.1),0 2px 4px -2px rgba(0,0,0,0.1);"
    ));
    assert!(shadow.contains(composed));
    assert!(css("shadow").contains("--tw-shadow:0 1px 3px 0 rgba(0,0,0,0.1)"));
    assert!(css("shadow-none").contains("--tw-shadow:0 0 #0000"));
    // custom properties are registered non-inheriting, once
    assert_eq!(
        css("shadow shadow-sm")
            .matches("@property --tw-shadow{")
            .count(),
        1
    );
}

#[test]
fn ring_widths() {
    let ring = css("ring-2");
    assert!(ring.contains(
        "--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width,0px)) \
         var(--tw-ring-color,var(--accent))"
    ));
    assert!(ring.contains(
        "box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)"
    ));
    assert!(css("ring").contains("calc(3px + "));
    assert!(css("ring-0").contains("calc(0px + "));
    assert!(css("ring-inset").starts_with(".ring-inset{--tw-ring-inset:inset}"));
    assert!(css("ring-offset-2").starts_with(".ring-offset-2{--tw-ring-offset-width:2px}"));
    assert!(css("ring-inset").contains("@property --tw-ring-inset{"));
}

#[test]
fn opacity_blur() {
    assert_body("opacity-0", "opacity:0");
    assert_body("opacity-50", "opacity:0.5");
    assert_body("opacity-5", "opacity:0.05");
    assert_body("opacity-100", "opacity:1");
    assert_eq!(css("opacity-101"), "");
    assert_body("blur-sm", "filter:blur(4px)");
    assert_body("blur", "filter:blur(8px)");
    assert_body(
        "backdrop-blur-md",
        "-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px)",
    );
}

#[test]
fn transforms() {
    let tx = css("translate-x-4");
    assert!(tx.starts_with(
        ".translate-x-4{--tw-translate-x:16px;translate:var(--tw-translate-x) var(--tw-translate-y,0)}"
    ));
    assert!(tx.contains("@property --tw-translate-x{"));
    assert!(css("-translate-x-1/2").starts_with(
        r".-translate-x-1\/2{--tw-translate-x:-50%;translate:var(--tw-translate-x) var(--tw-translate-y,0)}"
    ));
    assert!(css("-translate-y-full").contains("--tw-translate-y:-100%"));
    assert_body("scale-95", "scale:95%");
    assert_body("rotate-45", "rotate:45deg");
    assert_body("-rotate-90", "rotate:-90deg");
    assert_body(
        "transform-none",
        "transform:none;translate:none;scale:none;rotate:none",
    );
}

// ------------------------------------------------------- interactivity etc

#[test]
fn interactivity() {
    assert_body("cursor-pointer", "cursor:pointer");
    assert_body("cursor-not-allowed", "cursor:not-allowed");
    assert_body("pointer-events-none", "pointer-events:none");
    assert_body("select-none", "user-select:none");
    assert_body("resize-none", "resize:none");
    assert_body("resize-y", "resize:vertical");
    assert_body("resize", "resize:both");
    assert_body("appearance-none", "appearance:none");
    assert_body(
        "outline-none",
        "outline:2px solid transparent;outline-offset:2px",
    );
    assert_body(
        "outline-2",
        "outline-style:var(--tw-outline-style,solid);outline-width:2px",
    );
    assert_body("outline-offset-2", "outline-offset:2px");
    assert_body("scroll-smooth", "scroll-behavior:smooth");
    assert_body(
        "snap-x",
        "scroll-snap-type:x var(--tw-scroll-snap-strictness,proximity)",
    );
    assert_rule("snap-mandatory", "--tw-scroll-snap-strictness:mandatory");
    assert_body("snap-start", "scroll-snap-align:start");
    assert_body("touch-manipulation", "touch-action:manipulation");
    assert_body("will-change-transform", "will-change:transform");
}

#[test]
fn transitions() {
    let t = css("transition-colors");
    assert!(t.starts_with(
        ".transition-colors{transition-property:color,background-color,border-color,\
         text-decoration-color,fill,stroke;"
    ));
    assert!(t.contains("transition-duration:var(--tw-duration,150ms)"));
    assert!(css("transition-all").contains("transition-property:all;"));
    assert_body("transition-none", "transition-property:none");
    assert!(css("duration-200")
        .starts_with(".duration-200{--tw-duration:200ms;transition-duration:200ms}"));
    assert!(css("ease-in-out").starts_with(
        ".ease-in-out{--tw-ease:cubic-bezier(0.4,0,0.2,1);transition-timing-function:cubic-bezier(0.4,0,0.2,1)}"
    ));
    assert_body("delay-75", "transition-delay:75ms");
}

#[test]
fn animations_emit_keyframes_once_at_end() {
    let out = css("animate-spin p-2 animate-spin");
    assert_eq!(
        out,
        ".animate-spin{animation:spin 1s linear infinite}.p-2{padding:8px}\
         @keyframes spin{to{transform:rotate(360deg)}}"
    );
    assert!(css("animate-pulse").contains("@keyframes pulse{50%{opacity:.5}}"));
    assert!(css("animate-ping").contains("@keyframes ping{"));
    assert_body("animate-none", "animation:none");
}

#[test]
fn arbitrary_properties() {
    assert_eq!(
        css("[mask-type:luminance]"),
        r".\[mask-type\:luminance\]{mask-type:luminance}"
    );
    assert_eq!(
        css("[grid-area:1_/_2]"),
        r".\[grid-area\:1_\/_2\]{grid-area:1 / 2}"
    );
    assert_eq!(css("[nope]"), "");
    assert_eq!(css("[Bad:x]"), "");
}

// --------------------------------------------------------------- variants

#[test]
fn pseudo_class_variants() {
    assert_eq!(
        css("hover:bg-accent/90"),
        r".hover\:bg-accent\/90:hover{background-color:color-mix(in srgb, var(--accent) 90%, transparent)}"
    );
    assert_eq!(
        css("focus-visible:ring-2"),
        css("ring-2").replacen(".ring-2{", r".focus-visible\:ring-2:focus-visible{", 1)
    );
    assert_eq!(
        css("disabled:opacity-50"),
        r".disabled\:opacity-50:disabled{opacity:0.5}"
    );
    assert_eq!(
        css("first:mt-0"),
        r".first\:mt-0:first-child{margin-top:0px}"
    );
    assert_eq!(
        css("odd:bg-panel"),
        r".odd\:bg-panel:nth-child(odd){background-color:var(--panel)}"
    );
    assert_eq!(
        css("focus-within:border-accent"),
        r".focus-within\:border-accent:focus-within{border-color:var(--accent)}"
    );
    assert_eq!(
        css("placeholder:text-dim"),
        r".placeholder\:text-dim::placeholder{color:var(--dim)}"
    );
}

#[test]
fn chained_variants() {
    assert_eq!(
        css("hover:focus:underline"),
        r".hover\:focus\:underline:hover:focus{text-decoration-line:underline}"
    );
}

#[test]
fn group_variant() {
    assert_eq!(
        css("group-hover:text-fg"),
        r".group:hover .group-hover\:text-fg{color:var(--fg)}"
    );
}

#[test]
fn aria_and_data_variants() {
    assert_eq!(
        css("aria-selected:bg-panel-2"),
        r#".aria-selected\:bg-panel-2[aria-selected="true"]{background-color:var(--panel-2)}"#
    );
    assert_eq!(
        css("aria-current:font-bold"),
        r#".aria-current\:font-bold[aria-current]:not([aria-current="false"]){font-weight:700}"#
    );
    assert_eq!(
        css("data-[state=open]:bg-panel"),
        r#".data-\[state\=open\]\:bg-panel[data-state="open"]{background-color:var(--panel)}"#
    );
    assert_eq!(
        css("data-[active]:text-accent"),
        r".data-\[active\]\:text-accent[data-active]{color:var(--accent)}"
    );
}

#[test]
fn responsive_variants_ascend() {
    let out = css("lg:flex md:hidden sm:block 2xl:grid");
    assert_eq!(
        out,
        concat!(
            r"@media (min-width:640px){.sm\:block{display:block}}",
            r"@media (min-width:768px){.md\:hidden{display:none}}",
            r"@media (min-width:1024px){.lg\:flex{display:flex}}",
            r"@media (min-width:1536px){.\32 xl\:grid{display:grid}}",
        )
    );
}

#[test]
fn media_feature_variants() {
    assert_eq!(
        css("dark:bg-panel"),
        r"@media (prefers-color-scheme:dark){.dark\:bg-panel{background-color:var(--panel)}}"
    );
    assert_eq!(
        css("motion-reduce:transition-none"),
        r"@media (prefers-reduced-motion:reduce){.motion-reduce\:transition-none{transition-property:none}}"
    );
    assert_eq!(
        css("motion-safe:animate-none"),
        r"@media (prefers-reduced-motion:no-preference){.motion-safe\:animate-none{animation:none}}"
    );
    assert_eq!(
        css("print:hidden"),
        r"@media print{.print\:hidden{display:none}}"
    );
    assert_eq!(
        css("md:dark:hover:bg-accent"),
        r"@media (min-width:768px) and (prefers-color-scheme:dark){.md\:dark\:hover\:bg-accent:hover{background-color:var(--accent)}}"
    );
}

#[test]
fn rules_in_same_breakpoint_share_one_block() {
    assert_eq!(
        css("md:flex md:p-2"),
        r"@media (min-width:768px){.md\:flex{display:flex}.md\:p-2{padding:8px}}"
    );
}

#[test]
fn unknown_variant_drops_class() {
    assert_eq!(css("wat:flex"), "");
    assert_eq!(css("hover:"), "");
    assert_eq!(css("hover:nope"), "");
}

// ------------------------------------------------------------- modifiers

#[test]
fn important_modifier() {
    assert_eq!(css("!p-0"), r".\!p-0{padding:0px!important}");
    assert_eq!(
        css("!px-2"),
        r".\!px-2{padding-left:8px!important;padding-right:8px!important}"
    );
    assert_eq!(
        css("hover:!bg-ok"),
        r".hover\:\!bg-ok:hover{background-color:var(--ok)!important}"
    );
}

// --------------------------------------------------------------- ordering

#[test]
fn base_then_variants_then_media() {
    let out = css("md:p-4 hover:bg-accent p-2 dark:text-fg");
    assert_eq!(
        out,
        concat!(
            ".p-2{padding:8px}",
            r".hover\:bg-accent:hover{background-color:var(--accent)}",
            r"@media (prefers-color-scheme:dark){.dark\:text-fg{color:var(--fg)}}",
            r"@media (min-width:768px){.md\:p-4{padding:16px}}",
        )
    );
}

#[test]
fn state_variants_follow_tailwind_precedence() {
    // `disabled:` must win over `hover:` regardless of source order.
    let out = css("disabled:opacity-50 hover:opacity-80");
    let hover = out.find(r".hover\:").unwrap();
    let disabled = out.find(r".disabled\:").unwrap();
    assert!(hover < disabled, "{out}");
}

#[test]
fn longhands_follow_shorthands_in_build_any() {
    // `px-2` seen first, but must come after `p-4` so it wins on one element.
    let out = css("px-2 p-4 mt-1 m-0 rounded-t-sm rounded");
    assert_eq!(
        out,
        concat!(
            ".p-4{padding:16px}",
            ".m-0{margin:0px}",
            ".rounded{border-radius:4px}",
            ".px-2{padding-left:8px;padding-right:8px}",
            ".rounded-t-sm{border-top-left-radius:2px;border-top-right-radius:2px}",
            ".mt-1{margin-top:4px}",
        )
    );
}

#[test]
fn output_is_deterministic() {
    let a = css("md:flex hover:bg-ok p-2 animate-spin ring-2 shadow");
    let b = css("md:flex hover:bg-ok p-2 animate-spin ring-2 shadow");
    assert_eq!(a, b);
}

// ------------------------------------------------------------ build_any

#[test]
fn build_any_reads_rust_source() {
    let rust = r#"
        fn button(label: &str) -> Node {
            el("button")
                .class("inline-flex items-center gap-2 rounded-md bg-accent px-4 py-2")
                .class(if active { "data-[state=open]:bg-panel" } else { "w-[220px]" })
                .child(text(label))
        }
    "#;
    let out = build_any(&[rust]);
    assert!(out.starts_with(".inline-flex{display:inline-flex}.items-center{align-items:center}"));
    assert!(out.contains(".gap-2{gap:8px}"));
    assert!(out.contains(".rounded-md{border-radius:6px}"));
    assert!(out.contains(r".w-\[220px\]{width:220px}"));
    assert!(out.contains(r#"[data-state="open"]{background-color:var(--panel)}"#));
    // identifiers like `fn`, `button`, `label` are not utilities
    assert!(!out.contains(".fn"));
}

#[test]
fn build_any_dedupes_across_sources() {
    assert_eq!(
        build_any(&["flex p-2", "'p-2' \"flex\" m-1"]),
        ".flex{display:flex}.p-2{padding:8px}.m-1{margin:4px}"
    );
}

#[test]
fn every_token_of_a_typical_button_is_recognised() {
    let classes = "inline-flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-sm \
                   font-medium text-bg hover:bg-accent/90 focus-visible:ring-2 \
                   disabled:opacity-50 focus-visible:ring-offset-2 ring-offset-bg \
                   transition-colors disabled:pointer-events-none";
    for class in classes.split_whitespace() {
        assert!(
            !akurai_css::generate::rule(class)
                .unwrap_or_default()
                .is_empty(),
            "`{class}` not recognised"
        );
    }
}