Files
Jyotisha/frontend/tests/theme-preference-contract.test.ts
T
Jesse_Chen 187ef6ae24
Independent Staging Quality Gate / validate (push) Successful in 10m4s
Independent Staging Quality Gate / publish (push) Successful in 24m59s
feat(frontend): put a light/dark/system control in the account menu
Appearance is a radio group inside the avatar menu rather than a loose
button, so arrow keys reach it and the current choice is announced.
Picking one keeps the menu open, so the change is visible where it was
made.

"跟随系统" removes data-theme instead of writing a third value — the
media query has nothing to match otherwise. A synchronous script at the
top of <head> re-applies a pinned choice before the first paint; going
through next/script with any strategy would defer it and bring the flash
straight back, so the test asserts a plain script tag. Blocked storage
degrades to following the OS instead of throwing.

The stored value is browser state, so it is read through
useSyncExternalStore rather than synced into React state in an effect,
which also trips the cascading-render lint rule. localStorage only fires
`storage` in other tabs, so a same-tab write notifies its own listeners
and every open tab stays in step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
2026-08-29 12:12:06 +00:00

78 lines
3.9 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", () => {
assert.match(sidebar, /<ThemePreferenceMenu \/>/);
assert.match(sidebar, /<Menu\.Separator className="account-menu-separator" \/>\s*<ThemePreferenceMenu \/>/);
assert.match(menu, /Menu\.RadioGroup/);
assert.match(menu, /Menu\.RadioItem/);
assert.match(menu, /Menu\.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-check \{[^}]*color: var\(--color-action\)/);
});