Files
kjol/go/tw/tailwind_test.go

276 lines
8.5 KiB
Go

package tw
import (
"strings"
"testing"
)
func twTestCompile(t *testing.T, candidates ...string) string {
t.Helper()
css, _, err := twCompile(`@import "tailwindcss";`, ".", candidates)
if err != nil {
t.Fatalf("twCompile error: %v", err)
}
return css
}
// Edge cases that the modifier/fraction rewrite fixes.
func TestEngineEdgeCases(t *testing.T) {
cases := []struct {
candidate string
contains string
}{
// Improper fraction read as a fraction (not an opacity modifier).
{"aspect-16/9", "aspect-ratio: 16/9"},
// Proper fraction.
{"w-1/2", "width: calc(1 / 2 * 100%)"},
// Arbitrary opacity modifier decoded (not "[0.5]%").
{"bg-white/[0.5]", "color-mix(in oklab, var(--color-white) 50%, transparent)"},
// Bare spacing.
{"m-4", "margin: calc(var(--spacing) * 4)"},
{"p-4", "padding: calc(var(--spacing) * 4)"},
// text-{size}/{leading}: font-size AND line-height (the dropped-modifier fix).
{"text-sm/6", "line-height: calc(var(--spacing) * 6)"},
// Theme color via @theme namespace.
{"bg-red-500", "background-color: var(--color-red-500)"},
}
for _, c := range cases {
css := twTestCompile(t, c.candidate)
if !strings.Contains(css, c.contains) {
t.Errorf("compile(%q): expected to contain %q\n---\n%s", c.candidate, c.contains, css)
}
}
}
func TestSegmentTopLevel(t *testing.T) {
cases := []struct {
in string
sep string
want []string
}{
{"a:b:c", ":", []string{"a", "b", "c"}},
{"var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0)", ",",
[]string{"var(--a, 0 0 1px rgb(0, 0, 0))", " 0 0 1px rgb(0, 0, 0)"}},
{"display:grid", ":", []string{"display", "grid"}},
{"[display:grid]", ":", []string{"[display:grid]"}},
{"red-500/50", "/", []string{"red-500", "50"}},
{"calc(1/2)/3", "/", []string{"calc(1/2)", "3"}},
}
for _, c := range cases {
got := segment(c.in, c.sep)
if len(got) != len(c.want) {
t.Errorf("segment(%q,%q) = %v, want %v", c.in, c.sep, got, c.want)
continue
}
for i := range got {
if got[i] != c.want[i] {
t.Errorf("segment(%q,%q)[%d] = %q, want %q", c.in, c.sep, i, got[i], c.want[i])
}
}
}
}
func TestEscapeIdentifier(t *testing.T) {
cases := []struct{ in, want string }{
{"flex", "flex"},
{"hover:bg-red-500", `hover\:bg-red-500`},
{"w-1/2", `w-1\/2`},
{"bg-[#fff]", `bg-\[\#fff\]`},
{"2xl", `\32 xl`},
}
for _, c := range cases {
if got := escape(c.in); got != c.want {
t.Errorf("escape(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestDecodeArbitraryValue(t *testing.T) {
cases := []struct{ in, want string }{
{"100%_!important", "100% !important"},
{"calc(100dvh_-_5rem)", "calc(100dvh - 5rem)"},
{`url(/a_b.png)`, "url(/a_b.png)"},
{`var(--my_var)`, "var(--my_var)"},
{"calc(var(--spacing)*4_+_env(safe-area-inset-bottom,0px))",
"calc(var(--spacing) * 4 + env(safe-area-inset-bottom,0px))"},
}
for _, c := range cases {
if got := decodeArbitraryValue(c.in); got != c.want {
t.Errorf("decodeArbitraryValue(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestInferDataType(t *testing.T) {
cases := []struct {
value string
types []string
want string
}{
{"#ff0000", []string{dtColor, dtLength}, dtColor},
{"10rem", []string{dtColor, dtLength}, dtLength},
{"50%", []string{dtLength, dtPercentage}, dtPercentage},
{"16/9", []string{dtRatio, dtColor}, dtRatio},
{"var(--x)", []string{dtColor, dtLength}, ""},
{"calc(1px+2px)", []string{dtLength}, dtLength},
{"red", []string{dtColor}, dtColor},
}
for _, c := range cases {
if got := inferDataType(c.value, c.types); got != c.want {
t.Errorf("inferDataType(%q,%v) = %q, want %q", c.value, c.types, got, c.want)
}
}
}
func TestNumericPredicates(t *testing.T) {
if !isValidSpacingMultiplier("0.5") {
t.Error("0.5 should be a valid spacing multiplier")
}
if isValidSpacingMultiplier("0.3") {
t.Error("0.3 should NOT be a valid spacing multiplier")
}
if !isPositiveInteger("3") || isPositiveInteger("3.0") || isPositiveInteger("03") {
t.Error("isPositiveInteger canonical-form check failed")
}
}
// Apostrophes inside JS comments (e.g. "don't", "button's") must not desync the
// quote-based class scanner. Before comments were skipped, a stray apostrophe
// flipped quote parity and swallowed every class literal until the next quote —
// silently dropping singly-used utilities like the lineup card's switch and
// drag styles.
func TestExtractCandidatesSkipsComments(t *testing.T) {
src := "// we don't need reactivity here\n" +
"const cur = locked ? \"cursor-not-allowed\" : \"cursor-move\";\n" +
"/* the button's thumb: it's offset */\n" +
"const thumb = on ? \"translate-x-3\" : \"\";\n" +
"const tpl = `<input class=\"sr-only\"/>`;\n"
got := map[string]bool{}
for _, c := range extractCandidates(src) {
got[c] = true
}
for _, want := range []string{"cursor-not-allowed", "cursor-move", "translate-x-3", "sr-only"} {
if !got[want] {
t.Errorf("candidate %q was not extracted past comment apostrophes", want)
}
}
}
// scan returns the set of candidates extracted from src.
func scan(src string) map[string]bool {
got := map[string]bool{}
for _, c := range extractCandidates(src) {
got[c] = true
}
return got
}
// Quotes inside a regex literal must not be read as a string (which would
// desync the scanner and drop following class literals), and a division `/`
// must not be mistaken for a regex (which would swallow the code after it).
func TestExtractCandidatesRegexLiterals(t *testing.T) {
cases := []struct {
name string
src string
want []string
}{
{
"apostrophe in regex",
`const r = /it's/; const c = "cursor-move";`,
[]string{"cursor-move"},
},
{
"regex after return keyword",
`function f(){ return /a"b/; } const c = "p-4";`,
[]string{"p-4"},
},
{
"char class containing slash and quote",
`const r = /[/']/g; const c = "block";`,
[]string{"block"},
},
{
"division is not a regex (string between divisions survives)",
`const w = a / 2; cls = "text-lg"; const h = b / 4; cls2 = "m-2";`,
[]string{"text-lg", "m-2"},
},
{
"regex inside a template interpolation, class after it",
"const t = html`<a class=${x.replace(/'/g, \"\")}>${y ? \"p-5\" : \"p-6\"}</a>`;",
[]string{"p-5", "p-6"},
},
{
"regex with braces in template interpolation keeps ${} balanced",
"const t = html`<a class=${s.match(/[{}]/) ? \"flex\" : \"hidden\"}></a>`;",
[]string{"flex", "hidden"},
},
}
for _, tc := range cases {
got := scan(tc.src)
for _, w := range tc.want {
if !got[w] {
t.Errorf("%s: candidate %q not extracted (src=%q)", tc.name, w, tc.src)
}
}
}
}
// The preflight is written against Tailwind's compile-time CSS functions, e.g.
// `font-family: --theme(--default-font-family, …)`. If those are not resolved, they
// reach the browser verbatim; the browser cannot parse `--theme(…)` and DROPS THE
// WHOLE DECLARATION. The symptom is subtle and global: every page silently loses its
// font-family and falls back to the browser default, and no @theme override of
// --font-sans can ever take effect. That shipped for a while, so pin it.
func TestPreflightResolvesThemeFunctions(t *testing.T) {
css, _, err := twCompile(`@import "tailwindcss";`, ".", nil)
if err != nil {
t.Fatal(err)
}
flat := strings.Join(strings.Fields(css), " ")
if strings.Contains(css, "--theme(") {
t.Error("the compiled CSS still contains an unresolved --theme(…); the browser will drop those declarations")
}
if !strings.Contains(flat, "font-family: var(--default-font-family)") {
t.Error("preflight did not resolve html's font-family to a var()")
}
}
// And the whole point of resolving it: an app's @theme override of --font-sans must
// actually reach the page.
func TestThemeFontOverrideReachesHTML(t *testing.T) {
css, _, err := twCompile(`
@import "tailwindcss";
@font-face {
font-family: "Lora";
src: url("/fonts/lora.woff2") format("woff2");
}
@theme {
--font-sans: "Lora", serif;
}
`, ".", nil)
if err != nil {
t.Fatal(err)
}
// The chain the browser walks: html -> --default-font-family -> --font-sans.
flat := strings.Join(strings.Fields(css), " ")
for _, want := range []string{
"font-family: var(--default-font-family)",
"--default-font-family: var(--font-sans)",
`--font-sans: "Lora", serif`,
} {
if !strings.Contains(flat, want) {
t.Errorf("missing %q — the font override does not reach the page", want)
}
}
// A vendored face must survive the build; dropping it would leave the family
// declared but never loaded.
if !strings.Contains(css, "@font-face") || !strings.Contains(css, "lora.woff2") {
t.Error("@font-face was dropped from the output")
}
}