muddle/tests/css.test.ts
2026-10-23 08:53:07 -04:00

263 lines
7.5 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { compileCss } from "../views/css/index.ts";
import { evaluate } from "../views/css/evaluate.ts";
import { defineHooks } from "../views/hooks.ts";
/**
* Tests of the structured css.
*/
describe("stcss", () => {
test("flattens the nested rules", () => {
const css = compileCss(`
.card {
color: red;
&__title {
font-weight: 700;
}
}
`);
expect(css).toContain(".card {");
expect(css).toContain(".card__title {");
expect(css).toContain("font-weight: 700;");
});
test("accepts Pug-like indentation, colonless declarations and blocks", () => {
const css = compileCss(`
$ink #101828
.card
color $ink
padding 1rem
&__title
font-weight 700
&:hover
color #000
`);
expect(css).toContain(".card {");
expect(css).toContain("color: #101828;");
expect(css).toContain("padding: 1rem;");
expect(css).toContain(".card__title {");
expect(css).toContain(".card:hover {");
});
test("accepts Pug-like at-rule blocks", () => {
const css = compileCss(`
.panel
width: 30rem
@media (max-width: 48rem)
width 100%
`);
expect(css).toContain(".panel {");
expect(css).toContain("@media (max-width: 48rem)");
expect(css).toContain("width: 100%;");
});
test("runs application-defined STCSS expansions from a contextual registry", () => {
const hooks = defineHooks((hook) => {
hook.stcss.value("double", ({ inner, resolve }) => `calc(${resolve(inner)} * 2)`);
hook.stcss.declaration(({ selector, declaration }) => declaration.property === "outline-token"
? [`${selector}::before { content: "hook"; }`]
: undefined);
hook.stcss.atRule("paint", ({ selector, params }) => `${selector} { color: ${params}; }`);
});
const css = compileCss(`.card { width: double(12px); outline-token: yes; @paint rebeccapurple; }`, { hooks });
expect(css).toContain("width: calc(12px * 2);");
expect(css).toContain('.card::before { content: "hook"; }');
expect(css).toContain(".card { color: rebeccapurple; }");
});
test("expands calculation hooks and centered position aliases", () => {
const css = compileCss(`
.dialog {
position: fixed-center;
width: 30rem;
margin-left: $offset($center(30rem), $pad);
padding: $pad(2);
}
.page-dialog { position: page-center; transform: scale(.96); }
`);
expect(css).toContain("position: fixed;");
expect(css).toContain("left: 50%;");
expect(css).toContain("top: 50%;");
expect(css).toContain("transform: translate(-50%, -50%);");
expect(css).toContain("margin-left: calc(calc(50% - (30rem / 2)) + var(--muddle-pad, 1rem));");
expect(css).toContain("padding: calc(var(--muddle-pad, 1rem) * 2);");
expect(css).toContain("position: absolute;");
expect(css).toContain("transform: translate(-50%, -50%) scale(.96);");
});
test("registers application calculation hooks", () => {
const hooks = defineHooks((hook) => {
hook.stcss.calculation("third", ({ args, resolve }) => `calc(${resolve(args ?? "100%")} / 3)`);
});
expect(compileCss(".column { width: $third(90%); }", { hooks })).toContain("width: calc(90% / 3);");
});
test("resolves the variables", () => {
const css = compileCss(`
$ink: #101828;
.body {
color: $ink;
}
`);
expect(css).toContain("color: #101828;");
});
test("interpolates an expression", () => {
const css = compileCss(`
$space: 1;
.gutter {
padding: #{$space * 2}rem;
}
`);
expect(css).toContain("padding: 2rem;");
});
test("repeats a block", () => {
const css = compileCss(`
@each $space in 1 2 3 {
.space-#{$space} {
margin: #{$space * 0.5}rem;
}
}
`);
expect(css).toContain(".space-1 {");
expect(css).toContain(".space-2 {");
expect(css).toContain(".space-3 {");
expect(css).toContain("margin: 1.5rem;");
});
test("counts a range", () => {
const css = compileCss(`
@for $i from 1 through 2 {
.col-#{$i} {
width: #{$i * 10}%;
}
}
`);
expect(css).toContain(".col-1 {");
expect(css).toContain(".col-2 {");
});
test("expands a mixin", () => {
const css = compileCss(`
@mixin rounded($size) {
border-radius: $size;
}
.button {
@include rounded(4px);
color: blue;
}
`);
expect(css).toContain("border-radius: 4px;");
expect(css).toContain("color: blue;");
});
test("keeps the css functions untouched", () => {
const css = compileCss(`
.box {
border: 1px solid rgba(16, 24, 40, 0.12);
color: rgb(1, 2, 3);
}
`);
expect(css).toContain("rgba(16, 24, 40, 0.12)");
expect(css).toContain("rgb(1, 2, 3)");
});
test("darkens a colour", () => {
const css = compileCss(`
.button {
color: darken(#2b6cb0, 10);
}
`);
expect(css).toMatch(/color: #[0-9a-f]{6};/);
});
test("evaluates an expression", () => {
expect(evaluate("$a * 2", { scope: { $a: "21" } })).toBe("42");
expect(evaluate("$a + 1", { scope: { $a: "1" } })).toBe("2");
});
test("compiles anchor relations, loop ranges and back-propagated state", () => {
const css = compileCss(`
@each $n in [0..2] {
nav:with(@hello$n:active) li:nth-child(#{$n + 1}) {
left: calc($relate(d"@hello{$n + 1}") + $relate[width](d"@hello{$n + 1}"));
color: @hello$n.computed.backgroundColor;
}
}
nav li:when(nav @hello1:clicked) { opacity: 1; }
`);
expect(css).toContain('nav:has([data-muddle-anchor="hello0"]:active) li:nth-child(1)');
expect(css).toContain("var(--muddle-relate-hello1-x)");
expect(css).toContain("var(--muddle-relate-hello1-width)");
expect(css).toContain('nav:has([data-muddle-anchor="hello1"]:active) li');
});
test("resolves $refer as a relation value", () => {
const css = compileCss(`.tooltip { left: $refer[left](@trigger); width: $refer[width](@trigger); }`);
expect(css).toContain("var(--muddle-relate-trigger-x)");
expect(css).toContain("var(--muddle-relate-trigger-width)");
});
test("reflects dotted anchor groups without enumerating their members", () => {
const css = compileCss(`:reflect(@docs) { scroll-margin-top: 4rem; }`);
expect(css).toContain('[data-muddle-anchor^="docs."]');
});
test("expands concentric border radii", () => {
const css = compileCss(`
.panel {
padding: 1rem;
border: 2px solid #333;
border-radius: concentric 0.75rem;
}
.panel-default {
border-radius: concentric;
}
.blind {
padding: 12px;
border: var(--bw, 1px) solid;
border-radius: concentric var(--outer, 12px);
}
`);
expect(css).toContain(".panel > * {\n\tborder-radius: calc((0.75rem) - (1rem) - (2px));");
expect(css).toContain(".panel-default > * {\n\tborder-radius: inherit;");
expect(css).toContain(".blind > * {\n\tborder-radius: calc((var(--outer, 12px)) - (12px) - (var(--bw, 1px)));");
});
test("expands freeform gradients into radial gradient stacks", () => {
const css = compileCss(`
.paint {
background-image: freeform-gradient(hsl(270 50% 60%) 10% 50%, red 70% 40%, rgb(0 128 0) 40% 80% 30%);
}
`);
expect(css).toContain("radial-gradient(circle farthest-side at 10% 50%, hsl(270 50% 60%), transparent)");
expect(css).toContain("radial-gradient(circle farthest-side at 70% 40%, red, transparent)");
expect(css).toContain("radial-gradient(circle at 40% 80%, rgb(0 128 0), rgb(0 128 0) calc(30% * 0.97), transparent 30%)");
expect(() => compileCss(".x { background-image: freeform-gradient(red 1% 2% 3% 4%); }")).toThrow();
});
});
describe("stcss @measure", () => {
test("records prediction targets and anchors them as relations", () => {
const measured: string[] = [];
const css = compileCss(`
.ribbon {
@measure;
}
@measure @trigger, .card, panel;
`, { measured });
expect(css).toContain("/* muddle-predict:");
expect(measured).toEqual([
".ribbon",
'[data-muddle-anchor="trigger"]',
".card",
"panel",
]);
});
});