Files
Jyotisha/frontend/tests/theme-preference-contract.test.ts
T
jesse-uxandGrok (agent loop) b2a561b34c 被放宽的抽屉、外观和设置测试改回点按检查(U10)。
Co-Authored-By: Grok (agent loop)
2026-10-27 00:51:11 +08:00

130 lines
6.8 KiB
TypeScript

import "./install-test-document";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import test from "node:test";
import { ThemePreferenceMenu } from "../src/components/theme-preference-menu.tsx";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "../src/components/ui/dropdown-menu.tsx";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
import {
THEME_STORAGE_KEY,
isThemePreference,
themePreferenceBootScript,
} from "../src/lib/theme-preference.ts";
Object.assign(globalThis, { React });
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", async () => {
// 原值:勾选断言读共享下拉菜单源码里的 RadioItemIndicator,样式类是 .theme-menu-check。
// 新值:打开外观菜单后,只有当前选中的「跟随系统」渲染出勾,浅色和深色没有勾。
// 原因:K1 改用组件库。勾要出现在外观菜单自己的输出里,不能靠共享文件里有这个零件来证明。
assert.match(sidebar, /<ThemePreferenceMenu \/>/);
assert.match(sidebar, /<DropdownMenuSeparator className="account-menu-separator" \/>\s*<ThemePreferenceMenu \/>/);
assert.match(menu, /DropdownMenuRadioGroup/);
assert.match(menu, /DropdownMenuRadioItem/);
// 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\)/);
const h = createClientLifecycleHarness();
const memory = new Map<string, string>();
Object.assign(globalThis, {
localStorage: {
getItem: (key: string) => memory.get(key) ?? null,
setItem: (key: string, value: string) => { memory.set(key, value); },
removeItem: (key: string) => { memory.delete(key); },
},
});
type Node = ReturnType<typeof h.elements>[number];
const nodes = () => {
const walk = (node: Node): Node[] => [node, ...node.childNodes.flatMap((child) => walk(child))];
return [...walk(h.container), ...[...h.document.body.childNodes].flatMap((child) => walk(child))];
};
const radios = () => nodes().filter((node) => node.getAttribute("role") === "menuitemradio");
try {
await h.render(React.createElement(DropdownMenu, { open: true },
React.createElement(DropdownMenuTrigger, null, "账户"),
React.createElement(DropdownMenuContent, null, React.createElement(ThemePreferenceMenu)),
));
for (let attempt = 0; attempt < 30 && radios().length < 3; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 20));
await h.idle();
}
const byLabel = (label: string) => radios().find((node) => node.text.includes(label));
const checks = (node: Node | undefined) => {
const walk = (current: Node): Node[] => [current, ...current.childNodes.flatMap((child) => walk(child))];
const slot = node ? walk(node).find((child) => child.getAttribute("data-slot") === "dropdown-menu-radio-item-indicator") : undefined;
return slot ? walk(slot).filter((child) => child.tagName === "SVG").length : 0;
};
assert.equal(byLabel("跟随系统")?.getAttribute("aria-checked"), "true");
assert.equal(checks(byLabel("跟随系统")), 1);
assert.equal(byLabel("浅色")?.getAttribute("aria-checked"), "false");
assert.equal(checks(byLabel("浅色")), 0);
assert.equal(byLabel("深色")?.getAttribute("aria-checked"), "false");
assert.equal(checks(byLabel("深色")), 0);
assert.deepEqual(h.errors, []);
} finally {
Reflect.deleteProperty(globalThis, "localStorage");
await h.close();
}
});