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.
92 lines
5.7 KiB
TypeScript
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 的主题独立管理');
|
|
});
|