diablo2-web/tests/client/plugin-host/plugin-menu.test.ts

512 lines
16 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { D2WebPlugin } from '../../../src/client/plugin-host/api.ts'
import {
PLUGIN_CONFIG_STORAGE_KEY,
PluginConfigStore,
} from '../../../src/client/plugin-host/plugin-config-store.ts'
import {
PluginHost,
type PluginHostToolbarPort,
} from '../../../src/client/plugin-host/plugin-host.ts'
import { PluginMenu } from '../../../src/client/plugin-host/plugin-menu.ts'
import { OnlineSession } from '../../../src/client/session/online-session.ts'
import { isModalDomOverlayOpen } from '../../../src/client/ui/dom-overlay.ts'
class MemoryStorage implements Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
readonly data = new Map<string, string>()
getItem(key: string): string | null {
return this.data.get(key) ?? null
}
setItem(key: string, value: string): void {
this.data.set(key, value)
}
removeItem(key: string): void {
this.data.delete(key)
}
}
type FakeListener = (event: FakeEvent) => void
interface FakeEvent {
type: string
key?: string
target?: FakeElement | null
defaultPrevented: boolean
immediatePropagationStopped: boolean
preventDefault(): void
stopImmediatePropagation(): void
}
function makeFakeEvent(type: string, init: { key?: string; target?: FakeElement | null } = {}): FakeEvent {
const ev: FakeEvent = {
type,
...(init.key !== undefined ? { key: init.key } : {}),
target: init.target ?? null,
defaultPrevented: false,
immediatePropagationStopped: false,
preventDefault() {
ev.defaultPrevented = true
},
stopImmediatePropagation() {
ev.immediatePropagationStopped = true
},
}
return ev
}
class FakeElement {
readonly tagName: string
readonly ownerDocument: FakeDocument
readonly style: Record<string, string> = {}
readonly dataset: Record<string, string> = {}
id = ''
className = ''
type = ''
checked = false
value = ''
href = ''
target = ''
rel = ''
parentNode: FakeElement | null = null
readonly children: FakeElement[] = []
private readonly attrs = new Map<string, string>()
private readonly listeners = new Map<string, FakeListener[]>()
private text = ''
private isHidden = false
constructor(tagName: string, ownerDocument: FakeDocument) {
this.tagName = tagName.toLowerCase()
this.ownerDocument = ownerDocument
}
get hidden(): boolean {
return this.isHidden
}
set hidden(val: boolean) {
this.isHidden = Boolean(val)
if (this.isHidden) {
this.attrs.set('hidden', '')
} else {
this.attrs.delete('hidden')
}
}
get textContent(): string {
if (this.children.length === 0) return this.text
return this.text + this.children.map((c) => c.textContent).join('')
}
set textContent(val: string | null) {
this.children.length = 0
this.text = val ?? ''
}
get innerHTML(): string {
return this.textContent
}
set innerHTML(val: string) {
this.children.length = 0
this.text = val
}
setAttribute(name: string, value: string): void {
if (name === 'id') this.id = value
if (name === 'hidden') this.isHidden = true
this.attrs.set(name, value)
}
getAttribute(name: string): string | null {
if (name === 'id' && this.id) return this.id
if (name === 'hidden') return this.isHidden ? '' : null
return this.attrs.get(name) ?? null
}
appendChild(child: FakeElement): FakeElement {
if (child.parentNode) {
child.parentNode.removeChild(child)
}
child.parentNode = this
this.children.push(child)
return child
}
removeChild(child: FakeElement): FakeElement {
const idx = this.children.indexOf(child)
if (idx >= 0) {
this.children.splice(idx, 1)
child.parentNode = null
}
return child
}
addEventListener(type: string, listener: FakeListener): void {
const list = this.listeners.get(type) ?? []
list.push(listener)
this.listeners.set(type, list)
}
removeEventListener(type: string, listener: FakeListener): void {
const list = this.listeners.get(type)
if (!list) return
const idx = list.indexOf(listener)
if (idx >= 0) list.splice(idx, 1)
}
dispatchEvent(event: FakeEvent): void {
if (!event.target) event.target = this
const list = [...(this.listeners.get(event.type) ?? [])]
for (const fn of list) {
fn(event)
if (event.immediatePropagationStopped) break
}
}
click(): void {
this.dispatchEvent(makeFakeEvent('click', { target: this }))
}
focus(): void {
this.ownerDocument.activeElement = this
}
matchesSelector(selector: string): boolean {
if (selector === '[data-d2-modal-overlay]:not([hidden])') {
return this.attrs.has('data-d2-modal-overlay') && !this.isHidden
}
if (selector.startsWith('#')) {
return this.id === selector.slice(1)
}
const attrMatch = /^\[([a-z0-9-]+)="([^"]+)"\]$/i.exec(selector)
if (attrMatch) {
return this.getAttribute(attrMatch[1]!) === attrMatch[2]!
}
return false
}
querySelector(selector: string): FakeElement | null {
for (const child of this.children) {
if (child.matchesSelector(selector)) return child
const found = child.querySelector(selector)
if (found) return found
}
return null
}
}
class FakeDocument {
readonly head: FakeElement
readonly body: FakeElement
activeElement: FakeElement | null = null
constructor() {
this.head = new FakeElement('head', this)
this.body = new FakeElement('body', this)
}
createElement(tagName: string): FakeElement {
return new FakeElement(tagName, this)
}
getElementById(id: string): FakeElement | null {
return this.head.querySelector(`#${id}`) ?? this.body.querySelector(`#${id}`)
}
querySelector(selector: string): FakeElement | null {
return this.head.querySelector(selector) ?? this.body.querySelector(selector)
}
}
class FakeWindow {
private readonly captureListeners = new Map<string, FakeListener[]>()
private readonly bubbleListeners = new Map<string, FakeListener[]>()
addEventListener(type: string, listener: FakeListener, capture?: boolean): void {
const map = capture ? this.captureListeners : this.bubbleListeners
const list = map.get(type) ?? []
list.push(listener)
map.set(type, list)
}
removeEventListener(type: string, listener: FakeListener, capture?: boolean): void {
const map = capture ? this.captureListeners : this.bubbleListeners
const list = map.get(type)
if (!list) return
const idx = list.indexOf(listener)
if (idx >= 0) list.splice(idx, 1)
}
dispatchKey(type: 'keydown' | 'keyup', key: string): FakeEvent {
const ev = makeFakeEvent(type, { key })
for (const fn of [...(this.captureListeners.get(type) ?? [])]) {
fn(ev)
if (ev.immediatePropagationStopped) return ev
}
for (const fn of [...(this.bubbleListeners.get(type) ?? [])]) {
fn(ev)
if (ev.immediatePropagationStopped) return ev
}
return ev
}
}
function makeMenuPlugin(overrides: Partial<D2WebPlugin> & { onAction?: () => void } = {}): D2WebPlugin {
return {
id: overrides.id ?? 'kill-stats',
name: overrides.name ?? { zh: '击杀统计', en: 'Kill Stats' },
description: overrides.description ?? {
zh: '记录角色击杀数据',
en: 'Record 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: 'theme',
label: { zh: '主题', en: 'Theme' },
default: 'gold',
options: [
{ value: 'gold', label: { zh: '暗金', en: 'Gold' } },
{ value: 'iron', label: { zh: '玄铁', en: 'Iron' } },
],
},
],
actions: overrides.actions ?? [
{
kind: 'link',
id: 'open-page',
label: { zh: '打开统计页', en: 'Open Stats Page' },
href: 'plugins/kill-stats/index.html',
},
{
kind: 'button',
id: 'custom-btn',
label: { zh: '执行动作', en: 'Run Action' },
run() {
overrides.onAction?.()
},
},
],
activate:
overrides.activate ??
(() => ({
deactivate() {},
})),
}
}
describe('PluginMenu (DOM Modal Overlay, Keyboard Isolation & Interactive Controls)', () => {
let prevDocument: unknown
let prevWindow: unknown
let fakeDoc: FakeDocument
let fakeWin: FakeWindow
beforeEach(() => {
prevDocument = (globalThis as { document?: unknown }).document
prevWindow = (globalThis as { window?: unknown }).window
fakeDoc = new FakeDocument()
fakeWin = new FakeWindow()
;(globalThis as unknown as { document: unknown }).document = fakeDoc
;(globalThis as unknown as { window: unknown }).window = fakeWin
})
afterEach(() => {
;(globalThis as unknown as { document: unknown }).document = prevDocument
;(globalThis as unknown as { window: unknown }).window = prevWindow
vi.restoreAllMocks()
})
it('mounts hidden [data-d2-modal-overlay="plugins"], toggles isModalDomOverlayOpen, intercepts Escape in capture phase, and returns focus to #d2-toolbar-plugins-btn', async () => {
const toolbarBtn = fakeDoc.createElement('button')
toolbarBtn.id = 'd2-toolbar-plugins-btn'
fakeDoc.body.appendChild(toolbarBtn)
const storage = new MemoryStorage()
const plugin = makeMenuPlugin()
const configStore = new PluginConfigStore([plugin], { storage })
const buttonStates: { ready: boolean; hasWarning: boolean; open?: boolean | undefined }[] = []
const toolbarPort: PluginHostToolbarPort = {
setPluginsButtonState(state) {
buttonStates.push(state)
},
addContribution() {
return () => {}
},
}
const host = new PluginHost({
plugins: [plugin],
configStore,
session: new OnlineSession(),
toolbar: toolbarPort,
autoStart: false,
})
await host.start()
const menu = new PluginMenu({ host, toolbar: toolbarPort })
expect(menu.isOpen()).toBe(false)
expect(isModalDomOverlayOpen(fakeDoc as unknown as Document)).toBe(false)
// Register a bubble listener on window to verify capture-phase key interception when modal is open
const leakedKeys: string[] = []
fakeWin.addEventListener('keydown', (e) => leakedKeys.push(`down:${e.key}`), false)
fakeWin.addEventListener('keyup', (e) => leakedKeys.push(`up:${e.key}`), false)
// Open modal
menu.open()
expect(menu.isOpen()).toBe(true)
expect(isModalDomOverlayOpen(fakeDoc as unknown as Document)).toBe(true)
expect(fakeDoc.activeElement?.id).toBe('d2-plugin-menu-close')
expect(buttonStates.at(-1)?.open).toBe(true)
// Pressing a regular key while open is stopped in capture phase and never reaches bubble listeners
const spaceEv = fakeWin.dispatchKey('keydown', ' ')
const spaceUpEv = fakeWin.dispatchKey('keyup', ' ')
expect(spaceEv.immediatePropagationStopped).toBe(true)
expect(spaceUpEv.immediatePropagationStopped).toBe(true)
expect(leakedKeys).toEqual([])
// Pressing Escape closes the modal, prevents default, stops propagation, and restores focus to #d2-toolbar-plugins-btn
const escEv = fakeWin.dispatchKey('keydown', 'Escape')
expect(escEv.defaultPrevented).toBe(true)
expect(escEv.immediatePropagationStopped).toBe(true)
expect(menu.isOpen()).toBe(false)
expect(isModalDomOverlayOpen(fakeDoc as unknown as Document)).toBe(false)
expect(fakeDoc.activeElement).toBe(toolbarBtn)
expect(buttonStates.at(-1)?.open).toBe(false)
// When modal is closed, keys reach bubble listeners normally
fakeWin.dispatchKey('keydown', 'i')
expect(leakedKeys).toEqual(['down:i'])
menu.destroy()
await host.destroy()
})
it('renders plugin cards, boolean/select settings, link/button actions, and updates PluginHost on user interaction', async () => {
let actionCount = 0
const plugin = makeMenuPlugin({
onAction: () => {
actionCount++
},
})
const storage = new MemoryStorage()
const configStore = new PluginConfigStore([plugin], { storage })
const host = new PluginHost({
plugins: [plugin],
configStore,
session: new OnlineSession(),
autoStart: false,
})
await host.start()
const menu = new PluginMenu({ host })
menu.open()
// Verify link action renders with target="_blank" and relative href
const linkEl = fakeDoc.querySelector('[data-plugin-action="kill-stats:open-page"]')
expect(linkEl).not.toBeNull()
expect(linkEl?.tagName).toBe('a')
expect(linkEl?.href).toBe('plugins/kill-stats/index.html')
expect(linkEl?.target).toBe('_blank')
// Verify button action invokes host.runAction
const actionBtn = fakeDoc.querySelector('[data-plugin-action="kill-stats:custom-btn"]')
expect(actionBtn).not.toBeNull()
actionBtn!.click()
expect(actionCount).toBe(1)
// Toggle boolean setting
const boolCheckbox = fakeDoc.querySelector(
'[data-plugin-setting="kill-stats:showToolbarShortcut"]',
)
expect(boolCheckbox).not.toBeNull()
expect(boolCheckbox?.checked).toBe(true)
boolCheckbox!.checked = false
boolCheckbox!.dispatchEvent(makeFakeEvent('change'))
expect(host.getSetting('kill-stats', 'showToolbarShortcut')).toBe(false)
// Change select setting
const selectEl = fakeDoc.querySelector('[data-plugin-setting="kill-stats:theme"]')
expect(selectEl).not.toBeNull()
expect(selectEl?.value).toBe('gold')
selectEl!.value = 'iron'
selectEl!.dispatchEvent(makeFakeEvent('change'))
expect(host.getSetting('kill-stats', 'theme')).toBe('iron')
// Disable plugin via toggle checkbox -> controls row is hidden when disabled
const toggleCheckbox = fakeDoc.querySelector('[data-plugin-toggle="kill-stats"]')
expect(toggleCheckbox).not.toBeNull()
toggleCheckbox!.checked = false
toggleCheckbox!.dispatchEvent(makeFakeEvent('change'))
await Promise.resolve()
await Promise.resolve()
expect(host.isEnabled('kill-stats')).toBe(false)
expect(
fakeDoc.querySelector('[data-plugin-setting="kill-stats:showToolbarShortcut"]'),
).toBeNull()
menu.destroy()
await host.destroy()
})
it('displays #d2-plugin-menu-alert on corrupt storage or plugin error and clears it when #d2-plugin-menu-reset is clicked', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const storage = new MemoryStorage()
storage.data.set(PLUGIN_CONFIG_STORAGE_KEY, '{corrupt-json')
let shouldFail = true
const plugin = makeMenuPlugin({
id: 'flaky-plugin',
activate() {
if (shouldFail) {
throw new Error('Activation failed once')
}
return { deactivate() {} }
},
})
const configStore = new PluginConfigStore([plugin], { storage })
const host = new PluginHost({
plugins: [plugin],
configStore,
session: new OnlineSession(),
autoStart: false,
})
await host.start()
const menu = new PluginMenu({ host })
menu.open()
const alertEl = fakeDoc.getElementById('d2-plugin-menu-alert')
expect(alertEl).not.toBeNull()
expect(alertEl?.hidden).toBe(false)
expect(alertEl?.getAttribute('role')).toBe('alert')
expect(alertEl?.textContent).toContain('Corrupt JSON')
expect(alertEl?.textContent).toContain('Activation failed once')
// Fix underlying error and click #d2-plugin-menu-reset
shouldFail = false
const resetBtn = fakeDoc.getElementById('d2-plugin-menu-reset')
expect(resetBtn).not.toBeNull()
resetBtn!.click()
await Promise.resolve()
await Promise.resolve()
expect(host.hasErrorOrCorruption()).toBe(false)
expect(alertEl?.hidden).toBe(true)
menu.destroy()
await host.destroy()
errSpy.mockRestore()
})
})