Files

183 lines
8.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { existsSync, readdirSync, 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<typeof renderToStaticMarkup>[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");
for (const f of readdirSync(path.resolve(__dirname, "../src/styles"))) {
expect(readFileSync(path.resolve(__dirname, "../src/styles", f), "utf8")).not.toMatch(/(^|[\s,}])button:disabled\s*\{[^}]*opacity/);
}
expect(html(createElement(SparkleButton, { label: "Sign in", disabled: true }))).toContain("disabled");
});
});
describe("legacy.css", () => {
it("is deleted and no longer imported, and no global button margin rule is left", () => {
expect(existsSync(path.resolve(__dirname, "../src/styles/legacy.css"))).toBe(false);
expect(readFileSync(path.resolve(__dirname, "../src/app/layout.tsx"), "utf8")).not.toContain("legacy.css");
expect(globals).not.toMatch(/(^|[\s,}])button\s*\{[^}]*margin/);
});
});
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(/<svg/g)).toHaveLength(2);
expect(out).toMatch(/btn-flip-back" aria-hidden="true">Publish/);
});
it("disabled: native disabled, flips to WRITE FIRST, no icon", () => {
const out = html(createElement(FlipButton, { state: "disabled" }));
expect(out).toContain('disabled=""');
expect(out).toMatch(/btn-flip-front">Post</);
expect(out).toMatch(/btn-flip-back" aria-hidden="true">Write first</);
expect(out).not.toContain("<svg");
});
it("posting: POSTING, busy, no flip panel", () => {
const out = html(createElement(FlipButton, { state: "posting" }));
expect(out).toContain("btn-flip btn-flip-busy");
expect(out).toContain('aria-busy="true"');
expect(out).toMatch(/btn-flip-front">Posting</);
expect(out).not.toContain("btn-flip-back");
});
it("the disabled face is solid graphite and never faded", () => {
const disabled = rule(".btn-flip:disabled");
expect(disabled).toMatch(/opacity:\s*1/);
expect(disabled).toMatch(/background:\s*var\(--tile-3\)/);
expect(disabled).toMatch(/color:\s*var\(--ink-2\)/);
expect(css).not.toMatch(/\.btn-flip[^{]*\{[^}]*opacity:\s*0?\.\d/);
});
it("sets text color explicitly for every state, never by inheritance", () => {
expect(rule(".btn-flip")).toMatch(/color:\s*var\(--bg\)/);
expect(rule(".btn-flip .btn-flip-front")).toMatch(/color:\s*var\(--bg\)/);
expect(rule(".btn-flip .btn-flip-back")).toMatch(/color:\s*var\(--on-accent\)/);
expect(rule(".btn-flip:disabled .btn-flip-front")).toMatch(/color:\s*var\(--ink-2\)/);
expect(rule(".btn-flip:disabled .btn-flip-back")).toMatch(/color:\s*var\(--ink\)/);
expect(rule(".btn-flip.btn-flip-busy:disabled .btn-flip-front")).toMatch(/color:\s*var\(--bg\)/);
// Face and text never share a token in any state.
expect(rule(".btn-flip")).toMatch(/background:\s*var\(--ink\)/);
expect(rule(".btn-flip.btn-flip-busy:disabled")).toMatch(/background:\s*var\(--ink\)/);
});
});
describe("the other styles", () => {
it("GrowButton renders the crimson circle and the label", () => {
const out = html(createElement(GrowButton, { label: "3 new posts" }));
expect(out).toContain('class="btn-grow"');
expect(out).toContain("btn-grow-circle");
expect(out).toMatch(/btn-grow-text">3 new posts</);
});
it("SweepButton stays filled and pressed when selected", () => {
expect(html(createElement(SweepButton, { label: "Business", selected: true }))).toContain('class="btn-sweep btn-sweep-on" aria-pressed="true"');
expect(html(createElement(SweepButton, { label: "Media", selected: false }))).toContain('aria-pressed="false"');
expect(html(createElement(SweepButton, { label: "Cancel" }))).not.toContain("aria-pressed");
});
it("DepthButton renders label, sublabel and icon", () => {
const out = html(createElement(DepthButton, { label: "Trending", sublabel: "Hot now", icon: createElement(SendIcon) }));
expect(out).toContain("btn-depth-icon");
expect(out).toMatch(/btn-depth-label">Trending</);
expect(out).toMatch(/btn-depth-sub">Hot now</);
});
it("SparkleButton: sign in has the fold, hexagon and ten sparks; fab has a plus and a name", () => {
const signin = html(createElement(SparkleButton, { label: "Sign in", type: "submit" }));
expect(signin).toContain("btn-spark-fold");
expect(signin).toContain("btn-spark-hex");
expect(signin.match(/<i><\/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/);
}
});
});