Files
Jyotisha/frontend/tests/report-buttons-scope-20260928.test.ts
T
jesse-uxandGrok (agent loop) 10ecb091a8 报告和合盘的中英切换能看出选中了哪个(BUG-1445)。
选中项改认页签自己的标记。边框和文字用主题色,背景保持中性。整篇和自选重新描上主题色边框。

检查:language-switch-selected 1 通过;report-buttons-scope 与 class-name-definition-contract 共 9 通过。两轮都是 0 失败,退出码 0。

Co-Authored-By: Grok (agent loop)
2026-10-26 23:07:08 +08:00

154 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"\]/);
// 原值:范围单选卡与语言分段 doesNotMatch(/--color-action-soft|--color-action\b/) 且 match(/--color-ink|--color-canvas/)
// 新值:四条选中规则不得用 --color-action-soft / --color-action-fill;描边、圆点、勾选框和语言分段用 --color-action-strong(语言文字用 --color-action);底色只许 transparent / --color-canvas*(勾选框 18px 小方块的 background 可以是 --color-action-strong)
// 原因:issue #90 推翻 DESIGN「Selected states use canvas-muted + ink」。BUG-1078 的主语(不得用 --color-action-soft 深色块)保留。旧正则 --color-action\b 会误伤 --color-action-strong
// 原值:整篇卡片与语言分段的选中规则只认 aria-checked="true"
// 新值:认 data-active 或 aria-selected="true"。勾选框仍认 aria-checked
// 原因:导出范围和语言已经是页签,页签不设 aria-checked,旧规则对不上,选中和未选中看起来一样
const selected = {
mode: rule('.report-export-mode:is([data-active], [aria-selected="true"])'),
radio: rule('.report-export-mode:is([data-active], [aria-selected="true"]) .report-export-radio'),
box: rule('.report-export-row:is([aria-checked="true"], [aria-checked="mixed"]) .report-export-box'),
segment: rule('.report-export-segment button:is([data-active], [aria-selected="true"])'),
};
for (const body of Object.values(selected)) {
assert.doesNotMatch(body, /--color-action-soft|--color-action-fill\b/);
}
assert.match(selected.mode, /border-color: var\(--color-action-strong\);/);
assert.match(selected.mode, /background: transparent;/);
assert.match(selected.radio, /border: 5px solid var\(--color-action-strong\);/);
assert.match(selected.box, /border-color: var\(--color-action-strong\);/);
assert.match(selected.box, /background: var\(--color-action-strong\);/);
assert.doesNotMatch(selected.box, /(?<![\w-])color\s*:/);
assert.match(rule(".report-export-box"), /color: var\(--color-canvas\);/);
assert.match(selected.segment, /background: var\(--color-canvas\);/);
assert.match(selected.segment, /color: var\(--color-action\);/);
assert.match(selected.segment, /font-weight: 600;/);
assert.match(selected.segment, /inset 0 0 0 1px var\(--color-action-strong\)/);
const neutralBackground = /^(?:transparent|var\(--color-canvas(?:-[a-z]+)?\))$/;
for (const [name, body] of [["mode", selected.mode], ["radio", selected.radio], ["segment", selected.segment]] as const) {
for (const value of backgrounds(body)) assert.match(value, neutralBackground, name);
}
for (const value of backgrounds(selected.box)) {
assert.match(value, /^(?:transparent|var\(--color-canvas(?:-[a-z]+)?\)|var\(--color-action-strong\))$/);
}
assert.equal(blockTokens(":root {\n color-scheme: light;").get("color-action-strong"), "#cc785c");
assert.equal(blockTokens(':root:not([data-theme="light"]) {').get("color-action-strong"), "#d78064");
assert.equal(blockTokens(':root[data-theme="dark"] {').get("color-action-strong"), "#d78064");
});
function backgrounds(body: string): string[] {
return [...body.matchAll(/background(?:-color)?\s*:\s*([^;]+);/g)].map((hit) => hit[1].trim());
}