130 lines
6.8 KiB
TypeScript
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();
|
|
}
|
|
});
|