Files
Jyotisha/frontend/tests/theme-preference-contract.test.ts
T
jesse-uxandGrok (agent loop) 1c428d7b6e
Independent Staging Quality Gate / validate (push) Failing after 19m8s
Independent Staging Quality Gate / publish (push) Skipped
跟上已经换过的界面检查,测试环境才能接着发布(BUG-1439)。
Co-Authored-By: Grok (agent loop)
2026-10-25 15:27:14 +08:00

82 lines
4.5 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
THEME_STORAGE_KEY,
isThemePreference,
themePreferenceBootScript,
} from "../src/lib/theme-preference.ts";
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
const menu = readFileSync(new URL("../src/components/theme-preference-menu.tsx", import.meta.url), "utf8");
const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
const lib = readFileSync(new URL("../src/lib/theme-preference.ts", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("only the three preferences are accepted", () => {
for (const value of ["system", "light", "dark"]) assert.equal(isThemePreference(value), true);
for (const value of ["", "Dark", "auto", null, undefined, 0, {}]) {
assert.equal(isThemePreference(value), false);
}
});
test("the boot script runs before paint and only ever pins light or dark", () => {
// "system" is the absence of data-theme — that is what the CSS media query
// reads. Writing data-theme="system" would match neither dark block.
assert.match(layout, /<script dangerouslySetInnerHTML=\{\{ __html: themePreferenceBootScript \}\} \/>/);
// A plain <script>, never next/script: any strategy defers it past first
// paint and the flash comes straight back.
const injection = layout.slice(layout.indexOf("<script dangerouslySetInnerHTML"));
assert.doesNotMatch(injection.slice(0, 200), /strategy=/);
assert.ok(
layout.indexOf("themePreferenceBootScript") < layout.indexOf("<head>") + 400,
"the boot script must sit at the top of <head>",
);
assert.match(themePreferenceBootScript, new RegExp(`localStorage\\.getItem\\("${THEME_STORAGE_KEY}"\\)`));
assert.match(themePreferenceBootScript, /t==="dark"\|\|t==="light"/);
assert.doesNotMatch(themePreferenceBootScript, /"system"/);
// Blocked storage must not throw before the app renders.
assert.match(themePreferenceBootScript, /^try\{/);
assert.match(themePreferenceBootScript, /catch\(e\)\{\}$/);
});
test("the boot script and the runtime share one storage key", () => {
// Two literals would drift and silently lose the user's choice.
assert.equal((lib.match(/"jyotisha-theme"/g) ?? []).length, 1);
assert.match(lib, /const THEME_STORAGE_KEY = "jyotisha-theme"/);
});
test("system clears the attribute rather than writing a third value", () => {
assert.match(lib, /if \(preference === "system"\) delete root\.dataset\.theme;/);
assert.match(lib, /localStorage\.removeItem\(THEME_STORAGE_KEY\)/);
});
test("stored preference is read as external state, not effect-synced React state", () => {
// Reading it into state inside an effect both trips the cascading-render rule
// and misses changes made in another tab.
assert.match(menu, /useSyncExternalStore\(/);
assert.match(menu, /\(\) => "system" as ThemePreference/);
assert.doesNotMatch(menu, /useState|useEffect/);
// Same-tab writes need an explicit notify: `storage` only fires elsewhere.
assert.match(lib, /window\.addEventListener\("storage", onChange\)/);
assert.match(lib, /for \(const listener of listeners\) listener\(\);/);
});
test("the control lives in the account menu and keeps menu semantics", () => {
const radioItem = readFileSync(new URL("../src/components/ui/dropdown-menu.tsx", import.meta.url), "utf8");
// 原值:Menu.Separator、Menu.RadioGroup、Menu.RadioItem、Menu.RadioItemIndicator,勾选样式是 .theme-menu-check。
// 新值:DropdownMenuSeparator、DropdownMenuRadioGroup、DropdownMenuRadioItem;勾选标记在组件库里,颜色仍是主题色。
// 原因:K1 账户菜单改用组件库。三项外观、点了不立刻关上,都还在。
assert.match(sidebar, /<ThemePreferenceMenu \/>/);
assert.match(sidebar, /<DropdownMenuSeparator className="account-menu-separator" \/>\s*<ThemePreferenceMenu \/>/);
assert.match(menu, /DropdownMenuRadioGroup/);
assert.match(menu, /DropdownMenuRadioItem/);
assert.match(radioItem, /RadioItemIndicator/);
// Picking a theme should not slam the menu shut before the change is visible.
assert.match(menu, /closeOnClick=\{false\}/);
assert.match(menu, /外观/);
for (const label of ["浅色", "深色", "跟随系统"]) assert.match(menu, new RegExp(label));
assert.match(globalStyles, /\.theme-menu \[data-slot="dropdown-menu-radio-item-indicator"\] \{[^}]*color: var\(--color-action\)/);
});