Files
dsh-web/web/src/nightMode.test.ts
T
m1saka 602917698a feat: add safe backup management and theme preferences
Add snapshot usage and protected cleanup, preserve unsaved profile edits, avoid holding SQL connections during snapshot deletion, and fix download filenames. Include regression tests and API documentation.
2026-10-17 00:36:21 +08:00

92 lines
5.7 KiB
TypeScript

import { afterEach, beforeEach, expect, it, vi } from 'vitest';
const hooks = vi.hoisted(() => ({cleanups: [] as (() => void)[], notify: vi.fn()}));
vi.mock('react', async original => ({
...await original<typeof import('react')>(),
useSyncExternalStore(subscribe: (fn: () => void) => () => void, snapshot: () => unknown) {
hooks.cleanups.push(subscribe(hooks.notify));
return snapshot();
},
}));
import { initializeTheme, setThemePreference, themeStorageKey, useNightMode, useThemePreference } from './nightMode';
import { ThemeSettings } from './ThemeSettings';
let cleanup: (() => void) | undefined;
let media: EventTarget & {matches: boolean};
let win: EventTarget & {localStorage: {getItem: ReturnType<typeof vi.fn>; setItem: ReturnType<typeof vi.fn>}; matchMedia: ReturnType<typeof vi.fn>};
let dataset: {theme?: string};
let meta: {setAttribute: ReturnType<typeof vi.fn>};
let saved: string | null;
beforeEach(() => {
saved = null;
media = Object.assign(new EventTarget(), {matches: false});
win = Object.assign(new EventTarget(), {localStorage: {getItem: vi.fn(() => saved), setItem: vi.fn((_key, value) => {saved = value;})}, matchMedia: vi.fn(() => media)});
dataset = {}; meta = {setAttribute: vi.fn()};
vi.stubGlobal('window', win);
vi.stubGlobal('document', {documentElement: {dataset}, querySelector: vi.fn(() => meta)});
hooks.notify.mockClear();
});
afterEach(() => {cleanup?.(); hooks.cleanups.splice(0).forEach(fn => fn()); vi.unstubAllGlobals();});
function start() {cleanup = initializeTheme();}
function os(dark: boolean) {media.matches = dark; media.dispatchEvent(new Event('change'));}
function storage(value: string | null, key: string | null = themeStorageKey, area: unknown = win.localStorage) {
win.dispatchEvent(Object.assign(new Event('storage'), {key, newValue: value, storageArea: area}));
}
it('defaults to auto and applies OS changes to DOM, metadata and Monaco', () => {
start(); expect(useThemePreference()).toBe('auto'); expect(useNightMode()).toBe(false);
os(true); expect(useNightMode()).toBe(true); expect(hooks.notify).toHaveBeenCalled();
expect(dataset.theme).toBe('dark');
expect(meta.setAttribute).toHaveBeenLastCalledWith('content', '#101c22');
os(false); expect(dataset.theme).toBe('light');
expect(meta.setAttribute).toHaveBeenLastCalledWith('content', '#152b36');
});
it.each(['light', 'dark'] as const)('persists %s and overrides either OS scheme across initialization', value => {
start(); setThemePreference(value);
for (const system of [true, false]) {os(system); expect(useNightMode()).toBe(value === 'dark');}
expect(win.localStorage.setItem).toHaveBeenCalledWith(themeStorageKey, value);
cleanup!(); start(); expect(useThemePreference()).toBe(value); expect(dataset.theme).toBe(value);
setThemePreference('auto'); os(true); expect(useNightMode()).toBe(true); expect(saved).toBe('auto');
});
it.each([null, '', 'invalid', 'DARK', 'auto'])('treats stored %s as auto', value => {
saved = value; media.matches = true; start();
expect(useThemePreference()).toBe('auto'); expect(dataset.theme).toBe('dark');
});
it('synchronizes tabs including removal, clear and invalid values without writing back', () => {
start(); storage('dark'); expect(useNightMode()).toBe(true);
storage('light'); expect(useThemePreference()).toBe('light'); os(true); expect(useNightMode()).toBe(false);
for (const value of [null, 'bad', 'auto']) {storage(value); expect(useThemePreference()).toBe('auto'); expect(useNightMode()).toBe(true);}
storage('light'); storage(null, null); expect(useThemePreference()).toBe('auto');
storage('light', 'unrelated'); storage('light', themeStorageKey, {});
expect(useThemePreference()).toBe('auto'); expect(win.localStorage.setItem).not.toHaveBeenCalled();
});
it('keeps manual choices usable with blocked storage', () => {
Object.defineProperty(win, 'localStorage', {get() {throw new Error('blocked');}});
start(); expect(useThemePreference()).toBe('auto');
setThemePreference('dark'); os(false); expect(useNightMode()).toBe(true);
setThemePreference('light'); os(true); expect(useNightMode()).toBe(false);
setThemePreference('auto'); expect(useNightMode()).toBe(true);
});
it('keeps a selection when storage writes fail', () => {
start(); win.localStorage.setItem.mockImplementation(() => {throw new Error('quota');});
setThemePreference('dark'); expect(dataset.theme).toBe('dark');
});
it('initializes once and cleans up global listeners and hook subscriptions', () => {
const removeMedia = vi.spyOn(media, 'removeEventListener');
const removeWindow = vi.spyOn(win, 'removeEventListener');
start(); expect(initializeTheme()).toBe(cleanup); expect(win.matchMedia).toHaveBeenCalledOnce();
useNightMode(); hooks.cleanups.splice(0).forEach(fn => fn());
hooks.notify.mockClear(); setThemePreference('light'); expect(hooks.notify).not.toHaveBeenCalled();
cleanup!(); cleanup = undefined;
expect(removeMedia).toHaveBeenCalledWith('change', expect.any(Function));
expect(removeWindow).toHaveBeenCalledWith('storage', expect.any(Function));
os(true); storage('dark'); expect(dataset.theme).toBe('light');
});
it('settings buttons expose one pressed choice and explain DSH independence', () => {
start(); const buttons = () => ThemeSettings().props.children[0].props.children;
expect(buttons().map((b: any) => b.props['aria-pressed'])).toEqual([false, false, true]);
for (const [index, value] of ['light', 'dark', 'auto'].entries()) {
buttons()[index].props.onClick(); expect(useThemePreference()).toBe(value);
expect(buttons().filter((b: any) => b.props['aria-pressed'])).toHaveLength(1);
expect(buttons()[index].props['aria-pressed']).toBe(true);
}
expect(JSON.stringify(ThemeSettings())).toContain('内嵌 DSH 的主题独立管理');
});