import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import path from "node:path"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { DepthButton, FlipButton, GrowButton, IconButton, SendIcon, SparkleButton, SweepButton, flipLabels, } from "@/components/Buttons"; import { ButtonGallery } from "@/components/ButtonGallery"; const html = (el: Parameters[0]) => renderToStaticMarkup(el); const css = readFileSync(path.resolve(__dirname, "../src/styles/buttons.css"), "utf8"); const globals = readFileSync(path.resolve(__dirname, "../src/app/globals.css"), "utf8"); /** Body of the first rule whose selector list matches exactly. */ function rule(selector: string): string { const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const m = css.match(new RegExp(`(?:^|\\n|,)\\s*${esc}\\s*\\{([^}]*)\\}`)); if (!m) throw new Error(`no rule for ${selector}`); return m[1]; } describe("SparkleButton disabled", () => { it("keeps full opacity and a solid readable face", () => { const body = rule(".btn-spark:disabled"); expect(body).toMatch(/opacity:\s*1\b/); expect(body).toContain("var(--tile-3)"); expect(body).toContain("var(--ink-2)"); expect(body).toContain("not-allowed"); expect(readFileSync(path.resolve(__dirname, "../src/styles/legacy.css"), "utf8")).not.toMatch(/button:disabled\s*\{[^}]*opacity/); expect(html(createElement(SparkleButton, { label: "Sign in", disabled: true }))).toContain("disabled"); }); }); describe("FlipButton states", () => { it("ready: POST plus send icon, flips to PUBLISH with the icon", () => { expect(flipLabels("ready")).toEqual({ front: "Post", back: "Publish", icon: true }); const out = html(createElement(FlipButton, { state: "ready" })); expect(out).toContain('class="btn-flip"'); expect(out).not.toContain("disabled"); expect(out.match(/<\/i>/g)).toHaveLength(10); expect(signin).toContain('type="submit"'); const fab = html(createElement(SparkleButton, { variant: "fab", label: "New post" })); expect(fab).toContain('aria-label="New post"'); expect(fab).toContain("btn-spark btn-spark-fab"); expect(fab).not.toContain("btn-spark-fold"); expect(fab).toContain("btn-spark-plus"); }); it("IconButton is the glossy crimson button with an accessible name", () => { const out = html(createElement(IconButton, { label: "Send comment", icon: createElement(SendIcon), shape: "round", size: "sm" })); expect(out).toContain('class="btn-gloss btn-gloss-round btn-gloss-sm"'); expect(out).toContain('aria-label="Send comment"'); expect(rule(".btn-gloss:disabled")).toMatch(/opacity:\s*1/); }); }); describe("button CSS rules", () => { it("uses only .btn-* class selectors", () => { const selectors = css.replace(/\/\*[\s\S]*?\*\//g, "").match(/\.[a-zA-Z][\w-]*/g) ?? []; expect(selectors.filter((s) => !s.startsWith(".btn-"))).toEqual([]); }); it("does not include the rejected liquid waves or 3D drop buttons", () => { expect(css).not.toMatch(/wave|wv/i); }); it("stops hover and press motion under prefers-reduced-motion", () => { const block = css.slice(css.indexOf("@media (prefers-reduced-motion: reduce)")); expect(block.length).toBeGreaterThan(0); for (const cls of ["btn-flip", "btn-grow", "btn-sweep", "btn-depth", "btn-spark", "btn-gloss"]) { expect(block).toContain(`.${cls}`); } expect(block).toMatch(/transition:\s*none !important/); expect(block).toMatch(/animation:\s*none !important/); expect(block).toMatch(/transform:\s*none !important/); }); it("every theme defines the button tokens", () => { for (const token of ["--shadow-btn", "--shadow-float", "--neu-d", "--neu-l"]) { expect(globals.match(new RegExp(`${token}:`, "g"))).toHaveLength(2); } expect(globals).toContain("--on-accent:"); }); }); describe("ButtonGallery story", () => { it("shows every style in both themes", () => { for (const theme of ["dark", "light"] as const) { const out = html(createElement(ButtonGallery, { theme })); expect(out).toContain(`data-theme="${theme}"`); for (const cls of ["btn-flip", "btn-flip-busy", "btn-grow", "btn-sweep-on", "btn-depth", "btn-spark-fold", "btn-spark-fab", "btn-gloss"]) { expect(out).toContain(cls); } expect(out).toMatch(/Write first/); expect(out).toMatch(/Posting/); } }); });