245 lines
7.4 KiB
TypeScript
245 lines
7.4 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, action, and exact localized label declarations
|
|
const settingSpecs: readonly PluginSettingSpec[] = plugin.settings ?? []
|
|
const actionSpecs: readonly PluginActionSpec[] = plugin.actions ?? []
|
|
expect(plugin.name).toEqual({
|
|
zh: '击杀统计',
|
|
en: 'Kill Stats',
|
|
})
|
|
expect(settingSpecs.map((s) => s.key)).toEqual(['showToolbarShortcut'])
|
|
expect(settingSpecs[0]?.label).toEqual({
|
|
zh: '在工具栏显示快捷入口',
|
|
en: 'Show shortcut in toolbar',
|
|
})
|
|
expect(actionSpecs.map((a) => a.id)).toEqual(['open-page'])
|
|
expect(actionSpecs[0]?.label).toEqual({
|
|
zh: '打开统计页',
|
|
en: 'Open Stats Page',
|
|
})
|
|
|
|
await host.destroy()
|
|
})
|
|
})
|
|
|