diablo2-web/tests/plugins/registry.test.ts

232 lines
7.0 KiB
TypeScript

import { readdirSync, statSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { BUILTIN_PLUGINS } from '../../plugins/index.ts'
import {
createKillStatsPlugin,
killStatsPlugin,
} from '../../plugins/kill-stats/index.ts'
import {
validatePluginRegistry,
type PluginActionSpec,
type PluginSessionView,
type PluginSettingSpec,
} from '../../src/client/plugin-host/api.ts'
import { PluginConfigStore } from '../../src/client/plugin-host/plugin-config-store.ts'
import {
PluginHost,
type PluginHostToolbarPort,
} from '../../src/client/plugin-host/plugin-host.ts'
import {
OnlineSession,
type OnlineSessionLobbySnapshot,
type OnlineSessionPhase,
} from '../../src/client/session/online-session.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import type { ServerEvent } from '../../src/netproto/index.ts'
import { MemoryKillStatsRepository } from './kill-stats/memory-repository.ts'
class MemoryStorage implements Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
private readonly map = new Map<string, string>()
getItem(key: string): string | null {
return this.map.get(key) ?? null
}
setItem(key: string, value: string): void {
this.map.set(key, value)
}
removeItem(key: string): void {
this.map.delete(key)
}
}
describe('BUILTIN_PLUGINS Registry & killStatsPlugin Lifecycle', () => {
it('validates BUILTIN_PLUGINS and ensures every plugins/<id>/ directory is registered', () => {
expect(() => validatePluginRegistry(BUILTIN_PLUGINS)).not.toThrow()
const pluginsDir = resolve(process.cwd(), 'plugins')
const dirIds = readdirSync(pluginsDir)
.filter((name) => statSync(resolve(pluginsDir, name)).isDirectory())
.sort()
const registeredIds = BUILTIN_PLUGINS.map((p) => p.id).sort()
expect(registeredIds).toEqual(dirIds)
expect(BUILTIN_PLUGINS).toContain(killStatsPlugin)
})
it('manages #d2-toolbar-plugin-kill-stats-open shortcut, reports mid-game warn/recording/ready statuses, and flushes pending kills on deactivate', async () => {
const repository = new MemoryKillStatsRepository({
initialReplicaId: '11111111-1111-4111-8111-111111111111',
})
const plugin = createKillStatsPlugin({
repository,
})
const baseSnapshot = new OnlineSession().getSnapshot()
let phase: OnlineSessionPhase = 'disconnected'
const eventListeners = new Set<(ev: ServerEvent) => void>()
const stateListeners = new Set<(snapshot: OnlineSessionLobbySnapshot) => void>()
const world = new ClientWorld()
world.difficulty = 2
world.hardcore = true
world.expansion = true
world.ladder = true
world.self.unitId = 1
world.self.charClass = 1
world.self.level = 90
world.self.name = 'SorcHero'
const getSnapshot = (): OnlineSessionLobbySnapshot => ({
...baseSnapshot,
phase,
username: 'DeckardCain',
selectedRealm: {
id: 1,
title: 'D2CS',
description: 'Realm',
},
realms: [],
characters: [
{
name: 'SorcHero',
charClass: 1,
level: 90,
act: 1,
difficulty: 2,
hardcore: true,
expansion: true,
ladder: true,
dead: false,
expiresAt: 0,
equipmentGraphics: new Uint8Array(0),
rawStatString: new Uint8Array(0),
},
],
selectedChar: 'SorcHero',
})
const session: PluginSessionView = {
get phase() {
return phase
},
loginHost: 'www.laiseek.xyz',
getSnapshot,
world,
onServerEvent(cb) {
eventListeners.add(cb)
return () => eventListeners.delete(cb)
},
onStateChange(cb) {
stateListeners.add(cb)
return () => stateListeners.delete(cb)
},
}
const toolbarLinks = new Map<string, string>()
const toolbarPort: PluginHostToolbarPort = {
setPluginsButtonState() {},
addContribution(spec) {
if (spec.kind === 'link') {
toolbarLinks.set(spec.domId, spec.href)
}
return () => {
toolbarLinks.delete(spec.domId)
}
},
}
const configStore = new PluginConfigStore([plugin], {
storage: new MemoryStorage(),
})
const host = new PluginHost({
plugins: [plugin],
configStore,
session,
toolbar: toolbarPort,
autoStart: false,
})
await host.start()
// 1. Default enabled + showToolbarShortcut = true -> link registered, status Ready (not in game)
expect(toolbarLinks.get('d2-toolbar-plugin-kill-stats-open')).toBe(
'plugins/kill-stats/index.html',
)
expect(host.list()[0]?.status).toEqual({
level: 'ok',
text: {
zh: '就绪(未在游戏中)',
en: 'Ready (not in game)',
},
})
// 2. Toggle showToolbarShortcut off and back on
host.setSetting('kill-stats', 'showToolbarShortcut', false)
expect(toolbarLinks.has('d2-toolbar-plugin-kill-stats-open')).toBe(false)
host.setSetting('kill-stats', 'showToolbarShortcut', true)
expect(toolbarLinks.has('d2-toolbar-plugin-kill-stats-open')).toBe(true)
// 3. Enter game before GameFlags (0x01) arrives -> status is 'warn' (Waiting for next game)
phase = 'ingame'
for (const cb of stateListeners) cb(getSnapshot())
expect(host.list()[0]?.status.level).toBe('warn')
expect(host.list()[0]?.status.text.zh).toContain('等待下一局')
// 4. Emit GameFlags (0x01) -> status transitions to 'ok' (Recording / 记录中)
for (const cb of eventListeners) {
cb({
type: 'GameFlags',
difficulty: 2,
hardcore: true,
expansion: true,
ladder: true,
})
}
expect(host.list()[0]?.status).toEqual({
level: 'ok',
text: {
zh: '记录中',
en: 'Recording',
},
})
// 5. Record a kill (unflushed in 2s batch buffer), then disable plugin -> deactivate() flushes immediately!
for (const cb of eventListeners) {
cb({
type: 'UnitAssign',
unit: {
unitType: 1,
id: 500,
classId: 156, // Andariel
x: 5000,
y: 5000,
lifePct: 128,
mode: 1,
},
})
cb({
type: 'UnitMode',
unitType: 1,
id: 500,
mode: 0,
})
}
await host.setEnabled('kill-stats', false)
expect(toolbarLinks.has('d2-toolbar-plugin-kill-stats-open')).toBe(false)
const snap = await repository.loadAll()
expect(snap.characters).toHaveLength(1)
expect(snap.tallies.map((t) => `${t.kind}:${t.subject}=${t.count}`).sort()).toEqual([
'boss:156=1',
'monster:156=1',
])
// Verify setting and action declarations
const settingSpecs: readonly PluginSettingSpec[] = plugin.settings ?? []
const actionSpecs: readonly PluginActionSpec[] = plugin.actions ?? []
expect(settingSpecs.map((s) => s.key)).toEqual(['showToolbarShortcut'])
expect(actionSpecs.map((a) => a.id)).toEqual(['open-page'])
await host.destroy()
})
})