321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type { D2WebPlugin } from '../../../src/client/plugin-host/api.ts'
|
|
import {
|
|
PLUGIN_CONFIG_STORAGE_KEY,
|
|
PluginConfigStore,
|
|
type PluginConfigChangeEvent,
|
|
} from '../../../src/client/plugin-host/plugin-config-store.ts'
|
|
|
|
class MemoryStorage implements Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
|
|
readonly data = new Map<string, string>()
|
|
setCalls = 0
|
|
removeCalls = 0
|
|
throwOnGet = false
|
|
throwOnSet = false
|
|
|
|
getItem(key: string): string | null {
|
|
if (this.throwOnGet) {
|
|
throw new Error('QuotaExceeded or SecurityError on getItem')
|
|
}
|
|
return this.data.get(key) ?? null
|
|
}
|
|
|
|
setItem(key: string, value: string): void {
|
|
if (this.throwOnSet) {
|
|
throw new Error('QuotaExceeded or SecurityError on setItem')
|
|
}
|
|
this.setCalls++
|
|
this.data.set(key, value)
|
|
}
|
|
|
|
removeItem(key: string): void {
|
|
this.removeCalls++
|
|
this.data.delete(key)
|
|
}
|
|
}
|
|
|
|
function makeTestPlugin(overrides: Partial<D2WebPlugin> = {}): D2WebPlugin {
|
|
return {
|
|
id: overrides.id ?? 'kill-stats',
|
|
name: overrides.name ?? { zh: '击杀统计', en: 'Kill Stats' },
|
|
description: overrides.description ?? {
|
|
zh: '记录并展示角色击杀统计',
|
|
en: 'Record and display character kill statistics',
|
|
},
|
|
defaultEnabled: overrides.defaultEnabled ?? true,
|
|
settings: overrides.settings ?? [
|
|
{
|
|
kind: 'boolean',
|
|
key: 'showToolbarShortcut',
|
|
label: { zh: '在工具栏显示快捷入口', en: 'Show shortcut in toolbar' },
|
|
default: true,
|
|
},
|
|
{
|
|
kind: 'select',
|
|
key: 'density',
|
|
label: { zh: '密度', en: 'Density' },
|
|
default: 'normal',
|
|
options: [
|
|
{ value: 'compact', label: { zh: '紧凑', en: 'Compact' } },
|
|
{ value: 'normal', label: { zh: '标准', en: 'Normal' } },
|
|
],
|
|
},
|
|
],
|
|
actions: overrides.actions ?? [],
|
|
activate:
|
|
overrides.activate ??
|
|
(() => ({
|
|
deactivate() {},
|
|
})),
|
|
}
|
|
}
|
|
|
|
describe('PluginConfigStore (d2web.plugins.v1 Persistence & Corruption Resilience)', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('uses manifest defaults when localStorage is empty without writing to storage', () => {
|
|
const storage = new MemoryStorage()
|
|
const pluginA = makeTestPlugin({ id: 'kill-stats', defaultEnabled: true })
|
|
const pluginB = makeTestPlugin({
|
|
id: 'map-overlay',
|
|
defaultEnabled: false,
|
|
settings: [],
|
|
})
|
|
|
|
const store = new PluginConfigStore([pluginA, pluginB], { storage })
|
|
|
|
expect(store.hasCorruptionError()).toBe(false)
|
|
expect(store.getCorruptionMessage()).toBeNull()
|
|
expect(store.isEnabled('kill-stats')).toBe(true)
|
|
expect(store.isEnabled('map-overlay')).toBe(false)
|
|
expect(store.getSetting('kill-stats', 'showToolbarShortcut')).toBe(true)
|
|
expect(store.getSetting('kill-stats', 'density')).toBe('normal')
|
|
expect(store.getAllSettings('kill-stats')).toEqual({
|
|
showToolbarShortcut: true,
|
|
density: 'normal',
|
|
})
|
|
expect(storage.setCalls).toBe(0)
|
|
store.detachStorageListener()
|
|
})
|
|
|
|
it('persists and round-trips v1 schema across store instances and notifies subscribers', () => {
|
|
const storage = new MemoryStorage()
|
|
const plugin = makeTestPlugin()
|
|
const store1 = new PluginConfigStore([plugin], { storage })
|
|
const events: PluginConfigChangeEvent[] = []
|
|
const unsub = store1.subscribe((e) => events.push(e))
|
|
|
|
store1.setEnabled('kill-stats', false)
|
|
store1.setSetting('kill-stats', 'showToolbarShortcut', false)
|
|
store1.setSetting('kill-stats', 'density', 'compact')
|
|
|
|
expect(events).toEqual([
|
|
{ kind: 'enabled', pluginId: 'kill-stats' },
|
|
{ kind: 'setting', pluginId: 'kill-stats', settingKey: 'showToolbarShortcut' },
|
|
{ kind: 'setting', pluginId: 'kill-stats', settingKey: 'density' },
|
|
])
|
|
|
|
const raw = storage.getItem(PLUGIN_CONFIG_STORAGE_KEY)
|
|
expect(raw).not.toBeNull()
|
|
const parsed = JSON.parse(raw!) as {
|
|
version: number
|
|
plugins: Record<string, { enabled: boolean; settings: Record<string, unknown> }>
|
|
}
|
|
expect(parsed).toEqual({
|
|
version: 1,
|
|
plugins: {
|
|
'kill-stats': {
|
|
enabled: false,
|
|
settings: {
|
|
showToolbarShortcut: false,
|
|
density: 'compact',
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
const store2 = new PluginConfigStore([plugin], { storage })
|
|
expect(store2.hasCorruptionError()).toBe(false)
|
|
expect(store2.isEnabled('kill-stats')).toBe(false)
|
|
expect(store2.getSetting('kill-stats', 'showToolbarShortcut')).toBe(false)
|
|
expect(store2.getSetting('kill-stats', 'density')).toBe('compact')
|
|
|
|
unsub()
|
|
store1.detachStorageListener()
|
|
store2.detachStorageListener()
|
|
})
|
|
|
|
it('flags corrupt JSON via console.error, falls back to defaults in memory, and preserves raw localStorage until explicit user write', () => {
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const storage = new MemoryStorage()
|
|
const corruptRaw = '{not-valid-json'
|
|
storage.data.set(PLUGIN_CONFIG_STORAGE_KEY, corruptRaw)
|
|
|
|
const plugin = makeTestPlugin()
|
|
const store = new PluginConfigStore([plugin], { storage })
|
|
|
|
expect(errSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('[PluginConfigStore] Corrupt JSON in d2web.plugins.v1:'),
|
|
)
|
|
expect(store.hasCorruptionError()).toBe(true)
|
|
expect(store.getCorruptionMessage()).toContain('Corrupt JSON in d2web.plugins.v1:')
|
|
expect(store.isEnabled('kill-stats')).toBe(true)
|
|
expect(store.getSetting('kill-stats', 'showToolbarShortcut')).toBe(true)
|
|
// Raw corrupt entry MUST NOT be overwritten during initialization
|
|
expect(storage.setCalls).toBe(0)
|
|
expect(storage.getItem(PLUGIN_CONFIG_STORAGE_KEY)).toBe(corruptRaw)
|
|
|
|
// Explicit user action clears corruption state and writes valid v1 JSON
|
|
store.setSetting('kill-stats', 'density', 'compact')
|
|
expect(store.hasCorruptionError()).toBe(false)
|
|
expect(store.getCorruptionMessage()).toBeNull()
|
|
expect(storage.setCalls).toBe(1)
|
|
expect(JSON.parse(storage.getItem(PLUGIN_CONFIG_STORAGE_KEY)!).version).toBe(1)
|
|
|
|
store.detachStorageListener()
|
|
})
|
|
|
|
it('rejects unsupported schema version or missing plugins object without overwriting storage', () => {
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const storage = new MemoryStorage()
|
|
const futureRaw = JSON.stringify({ version: 99, plugins: {} })
|
|
storage.data.set(PLUGIN_CONFIG_STORAGE_KEY, futureRaw)
|
|
|
|
const store = new PluginConfigStore([makeTestPlugin()], { storage })
|
|
expect(errSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('[PluginConfigStore] Invalid schema in d2web.plugins.v1'),
|
|
)
|
|
expect(store.hasCorruptionError()).toBe(true)
|
|
expect(store.getCorruptionMessage()).toContain('Invalid schema in d2web.plugins.v1')
|
|
expect(storage.setCalls).toBe(0)
|
|
expect(storage.getItem(PLUGIN_CONFIG_STORAGE_KEY)).toBe(futureRaw)
|
|
|
|
store.detachStorageListener()
|
|
})
|
|
|
|
it('validates per-plugin enabled and setting values, preserves unknown plugin entries, and supports resetAllToDefaults()', () => {
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const storage = new MemoryStorage()
|
|
storage.data.set(
|
|
PLUGIN_CONFIG_STORAGE_KEY,
|
|
JSON.stringify({
|
|
version: 1,
|
|
plugins: {
|
|
'kill-stats': {
|
|
enabled: 'not-a-boolean',
|
|
settings: {
|
|
showToolbarShortcut: false,
|
|
density: 'invalid-option',
|
|
},
|
|
},
|
|
'future-plugin': {
|
|
enabled: false,
|
|
settings: { customKey: 'customVal' },
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
|
|
const store = new PluginConfigStore([makeTestPlugin()], { storage })
|
|
expect(errSpy).toHaveBeenCalled()
|
|
expect(store.hasCorruptionError()).toBe(true)
|
|
// Invalid enabled & density fall back to defaults; valid showToolbarShortcut=false is retained
|
|
expect(store.isEnabled('kill-stats')).toBe(true)
|
|
expect(store.getSetting('kill-stats', 'showToolbarShortcut')).toBe(false)
|
|
expect(store.getSetting('kill-stats', 'density')).toBe('normal')
|
|
expect(storage.setCalls).toBe(0)
|
|
|
|
// resetAllToDefaults clears corruption error, restores manifest defaults, and preserves 'future-plugin'
|
|
store.resetAllToDefaults()
|
|
expect(store.hasCorruptionError()).toBe(false)
|
|
expect(store.getCorruptionMessage()).toBeNull()
|
|
expect(store.isEnabled('kill-stats')).toBe(true)
|
|
expect(store.getSetting('kill-stats', 'showToolbarShortcut')).toBe(true)
|
|
expect(store.getSetting('kill-stats', 'density')).toBe('normal')
|
|
|
|
const persisted = JSON.parse(storage.getItem(PLUGIN_CONFIG_STORAGE_KEY)!) as {
|
|
version: number
|
|
plugins: Record<string, unknown>
|
|
}
|
|
expect(persisted.plugins['future-plugin']).toEqual({
|
|
enabled: false,
|
|
settings: { customKey: 'customVal' },
|
|
})
|
|
|
|
store.detachStorageListener()
|
|
})
|
|
|
|
it('reloads configuration when window dispatches a storage event for d2web.plugins.v1', () => {
|
|
const prevWindow = globalThis.window
|
|
const storageListeners = new Set<(ev: StorageEvent) => void>()
|
|
const fakeWindow = {
|
|
addEventListener(type: string, listener: (ev: StorageEvent) => void) {
|
|
if (type === 'storage') storageListeners.add(listener)
|
|
},
|
|
removeEventListener(type: string, listener: (ev: StorageEvent) => void) {
|
|
if (type === 'storage') storageListeners.delete(listener)
|
|
},
|
|
}
|
|
;(globalThis as unknown as { window: unknown }).window = fakeWindow
|
|
|
|
try {
|
|
const storage = new MemoryStorage()
|
|
const store = new PluginConfigStore([makeTestPlugin()], { storage })
|
|
store.attachStorageListener()
|
|
const events: PluginConfigChangeEvent[] = []
|
|
store.subscribe((e) => events.push(e))
|
|
|
|
expect(store.isEnabled('kill-stats')).toBe(true)
|
|
|
|
storage.data.set(
|
|
PLUGIN_CONFIG_STORAGE_KEY,
|
|
JSON.stringify({
|
|
version: 1,
|
|
plugins: {
|
|
'kill-stats': {
|
|
enabled: false,
|
|
settings: { showToolbarShortcut: false, density: 'compact' },
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
|
|
for (const listener of storageListeners) {
|
|
listener({ key: PLUGIN_CONFIG_STORAGE_KEY } as StorageEvent)
|
|
}
|
|
|
|
expect(store.isEnabled('kill-stats')).toBe(false)
|
|
expect(store.getSetting('kill-stats', 'showToolbarShortcut')).toBe(false)
|
|
expect(store.getSetting('kill-stats', 'density')).toBe('compact')
|
|
expect(events).toEqual([{ kind: 'reload', pluginId: null }])
|
|
|
|
store.detachStorageListener()
|
|
expect(storageListeners.size).toBe(0)
|
|
} finally {
|
|
;(globalThis as unknown as { window: unknown }).window = prevWindow
|
|
}
|
|
})
|
|
|
|
it('handles storage getItem/setItem exceptions gracefully by logging console.error and flagging corruption', () => {
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const storage = new MemoryStorage()
|
|
storage.throwOnGet = true
|
|
|
|
const store = new PluginConfigStore([makeTestPlugin()], { storage })
|
|
expect(store.hasCorruptionError()).toBe(true)
|
|
expect(store.getCorruptionMessage()).toContain('Failed to read d2web.plugins.v1:')
|
|
|
|
storage.throwOnGet = false
|
|
storage.throwOnSet = true
|
|
store.setEnabled('kill-stats', false)
|
|
expect(errSpy).toHaveBeenCalledWith(
|
|
'[PluginConfigStore] Failed to persist plugin configuration:',
|
|
expect.any(Error),
|
|
)
|
|
|
|
store.detachStorageListener()
|
|
})
|
|
})
|