82 lines
4.5 KiB
TypeScript
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\)/);
|
|
});
|