Files
Jyotisha/frontend/tests/report-buttons-scope-20260928.test.ts
T

122 lines
7.1 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
// BUG-1079 (TASK-site-button-contrast-20260928). This file began as the BUG-1078
// report-only contract; the report scope is gone and one site-wide definition
// replaced it, so the file now pins that definition. Every assertion that
// changed is listed with 原值 / 新值 / 原因 in
// docs/tasks/PROGRESS-site-button-contrast-20260928.md.
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const cssNoComments = css.replace(/\/\*[\s\S]*?\*\//g, " ");
const buttonSource = readFileSync(new URL("../src/components/ui/button.tsx", import.meta.url), "utf8");
const calendarSource = readFileSync(new URL("../src/components/ui/calendar.tsx", import.meta.url), "utf8");
const pickerSource = readFileSync(new URL("../src/components/birth-date-picker.tsx", import.meta.url), "utf8");
const generateSource = readFileSync(new URL("../src/components/personal-report/generate-personal-report-button.tsx", import.meta.url), "utf8");
function blockTokens(opener: string): Map<string, string> {
const start = css.indexOf(opener);
assert.notEqual(start, -1, `missing block: ${opener}`);
const body = css.slice(start, css.indexOf("\n}", start));
return new Map([...body.matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)].map((hit) => [hit[1], hit[2].trim()]));
}
function luminance(hex: string): number {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a: string, b: string): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
function rule(selector: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const hit = cssNoComments.match(new RegExp(`(?:^|[}\\n])\\s*${escaped}\\s*\\{([^}]*)\\}`));
assert.ok(hit, `missing rule: ${selector}`);
return hit[1];
}
test("the unlayered element rules no longer override button utilities", () => {
// Root cause of BUG-1078 / BUG-1079: unlayered `button { color: inherit; }` beat
// every layered Tailwind text utility.
assert.doesNotMatch(cssNoComments, /(^|[}\n])\s*button\s*\{\s*color\s*:/);
assert.doesNotMatch(cssNoComments, /(^|[}\n])\s*button:disabled\s*\{/);
// Raw <button>s keep the old fade at the old specificity; <Button> is left to its utilities.
assert.match(cssNoComments, /button:disabled:where\(:not\(\[data-slot="button"\]\)\) \{ cursor: default; opacity: \.45; \}/);
});
test("one solid-button token pair per theme, readable at 4.5:1 or better", () => {
const light = blockTokens(":root {\n color-scheme: light;");
const preferred = blockTokens(':root:not([data-theme="light"]) {');
const pinned = blockTokens(':root[data-theme="dark"] {');
assert.equal(light.get("color-action-fill"), "#a9583e");
assert.equal(light.get("color-on-action-fill"), "#fbfaf7");
assert.equal(light.get("color-action-fill-hover"), "#8f4630");
assert.equal(light.get("color-action-fill-disabled"), "var(--color-canvas-strong)");
assert.equal(light.get("color-on-action-fill-disabled"), "var(--color-ink-tertiary)");
assert.ok(contrast("#fbfaf7", "#a9583e") >= 4.5);
assert.ok(contrast("#fbfaf7", "#8f4630") >= 4.5);
for (const dark of [preferred, pinned]) {
assert.equal(dark.get("color-action-fill"), "#8f4a33");
assert.equal(dark.get("color-on-action-fill"), "#f2f0ea");
assert.equal(dark.get("color-action-fill-hover"), "color-mix(in srgb, #8f4a33 94%, #f2f0ea)");
}
assert.ok(contrast("#f2f0ea", "#8f4a33") >= 4.5);
// D6: --color-primary keeps its mapping; text-primary icons still use it.
assert.match(css, /--color-primary: var\(--color-action\);\n --color-primary-foreground: var\(--color-on-dark\);/);
// D2: the brand coral stays for non-button fills.
assert.equal(light.get("color-action-strong"), "#cc785c");
});
test("the shared Button, .button-primary, the composer controls and the calendar read the same fill", () => {
assert.match(buttonSource, /default: "bg-action-fill text-on-action-fill hover:bg-action-fill-hover disabled:bg-action-fill-disabled disabled:text-on-action-fill-disabled disabled:opacity-100"/);
// Non-solid variants keep the old 45% disabled fade (red line 3).
assert.match(buttonSource, /disabled:pointer-events-none disabled:opacity-45/);
assert.match(buttonSource, /outline: "border-border bg-background hover:bg-muted hover:text-foreground"/);
assert.match(buttonSource, /data-slot="button"/);
assert.match(rule(".button-primary"), /background: var\(--color-action-fill\); color: var\(--color-on-action-fill\);/);
assert.match(rule(".composer .composer-row-end > button"), /color: var\(--color-on-action-fill\);[^}]*background: var\(--color-action-fill\);/);
assert.match(rule(".composer .composer-stop"), /background: var\(--color-action-fill\);/);
for (const state of ["selected-single", "range-start", "range-end"]) {
assert.match(calendarSource, new RegExp(`data-\\[${state}=true\\]:bg-action-fill data-\\[${state}=true\\]:text-on-action-fill`));
}
assert.doesNotMatch(calendarSource, /bg-primary|text-primary-foreground/);
// The `!` rescue for the selected day compensated for the root cause and is gone.
assert.doesNotMatch(pickerSource, /text-primary-foreground!/);
});
test("solid disabled buttons are grey at full opacity, never a faded clay", () => {
for (const selector of [".button-primary:disabled", ".composer .composer-row-end > button:disabled"]) {
const body = rule(selector);
assert.match(body, /opacity: 1;/);
assert.match(body, /background: var\(--color-action-fill-disabled\);/);
assert.match(body, /color: var\(--color-on-action-fill-disabled\);/);
}
});
test(".danger-primary keeps its own label colour instead of taking the new fill ink", () => {
const body = rule(".danger-primary");
assert.match(body, /background: var\(--color-danger\);/);
assert.match(body, /color: var\(--color-on-dark\);/);
assert.ok(contrast("#fbfaf7", "#9a2f2f") >= 4.5, "light 7.13");
assert.ok(contrast("#241f1c", "#e08573") >= 4.5, "dark 6.04");
});
test("the BUG-1078 report-only override is gone; the selected export preset stays neutral", () => {
assert.doesNotMatch(css, /--report-button-fill|--report-button-ink/);
assert.doesNotMatch(cssNoComments, /report-center-generate/);
assert.doesNotMatch(generateSource, /report-center-generate/);
assert.doesNotMatch(cssNoComments, /\[class~="bg-primary"\]/);
// 原值:`.report-export-presets [aria-pressed="true"]` 用中性底 + ink,不用 action-soft
// 新值:套餐已删(2026-09-30 导出面板重设计);同一约束落到新的选中态——范围单选卡与语言分段
// 原因:BUG-1078 的主语(导出面板选中态不得用 --color-action-soft 深色块)不变
for (const selector of ['.report-export-mode[aria-checked="true"]', '.report-export-segment button[aria-checked="true"]']) {
const selected = rule(selector);
assert.doesNotMatch(selected, /--color-action-soft|--color-action\b/);
assert.match(selected, /--color-ink|--color-canvas/);
}
});