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.
This commit is contained in:
+86
-27
@@ -1,32 +1,91 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
const hooks = vi.hoisted(() => ({slots: [] as any[], cursor: 0, effects: [] as (() => void)[]}));
|
||||
vi.mock('react', async importOriginal => ({
|
||||
...await importOriginal<typeof import('react')>(),
|
||||
useRef(value: unknown) { const i = hooks.cursor++; return hooks.slots[i] ??= {current:value}; },
|
||||
useState(value: unknown) { const i = hooks.cursor++; if (!(i in hooks.slots)) hooks.slots[i] = value; return [hooks.slots[i], (v: unknown) => { hooks.slots[i] = v; }]; },
|
||||
useEffect(fn: () => (() => void) | undefined, deps: unknown[]) {
|
||||
const i = hooks.cursor++, previous = hooks.slots[i];
|
||||
if (!previous || deps.some((v, n) => v !== previous.deps[n])) hooks.effects.push(() => { previous?.cleanup?.(); hooks.slots[i] = {deps, cleanup:fn()}; });
|
||||
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 { useNightMode } from './nightMode';
|
||||
|
||||
const media = {matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn()};
|
||||
const render = () => { hooks.cursor = 0; const result = useNightMode(); hooks.effects.splice(0).forEach(effect => effect()); return result; };
|
||||
beforeEach(() => { hooks.slots = []; hooks.cursor = 0; hooks.effects = []; media.matches = false; vi.clearAllMocks(); vi.stubGlobal('window', {matchMedia: vi.fn(() => media)}); });
|
||||
afterEach(() => { hooks.slots.forEach(s => s?.cleanup?.()); vi.unstubAllGlobals(); });
|
||||
|
||||
it('reflects the current system color scheme', () => {
|
||||
media.matches = true;
|
||||
expect(render()).toBe(true);
|
||||
expect(media.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
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();
|
||||
});
|
||||
|
||||
it('updates when the system scheme changes and unsubscribes on cleanup', () => {
|
||||
expect(render()).toBe(false);
|
||||
const listener = media.addEventListener.mock.calls[0][1] as (event: MediaQueryListEvent) => void;
|
||||
listener({matches: true} as MediaQueryListEvent);
|
||||
expect(render()).toBe(true);
|
||||
hooks.slots.forEach(s => s?.cleanup?.());
|
||||
expect(media.removeEventListener).toHaveBeenCalledWith('change', listener);
|
||||
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 的主题独立管理');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user