diablo2-web/tests/client/kill-stats-view.test.ts

417 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
computeCharKey,
computeTallyId,
type KillCharacterRecord,
type KillStatsSnapshot,
type KillTallyRecord,
} from '../../src/client/stats/kill-stats-model.ts'
import {
BOSS_SU_BOARD_ROW_COUNT,
buildKillStatsViewModel,
getBossSuperUniqueBoardCatalog,
resolveCharacterClassName,
resolveLocalizedMonsterName,
} from '../../src/client/stats/kill-stats-view.ts'
const REPLICA_A = '11111111-1111-4111-8111-111111111111'
const REPLICA_B = '22222222-2222-4222-8222-222222222222'
function makeChar(overrides: Partial<KillCharacterRecord> & {
host: string
realm: string
account: string
charName: string
}): KillCharacterRecord {
const charKey = computeCharKey(
overrides.host,
overrides.realm,
overrides.account,
overrides.charName,
)
return {
charKey,
host: overrides.host,
realm: overrides.realm,
account: overrides.account,
charName: overrides.charName,
charClass: overrides.charClass ?? 1,
level: overrides.level ?? 85,
hardcore: overrides.hardcore ?? true,
expansion: overrides.expansion ?? true,
ladder: overrides.ladder ?? true,
firstSeenAt: overrides.firstSeenAt ?? 1000,
lastSeenAt: overrides.lastSeenAt ?? 2000,
}
}
function makeTally(params: {
replicaId?: string
charKey: string
kind: 'monster' | 'boss' | 'superunique'
difficulty: 0 | 1 | 2
subject: number
count: number
soloCount: number
firstAt?: number
lastAt?: number
}): KillTallyRecord {
const replicaId = params.replicaId ?? REPLICA_A
return {
id: computeTallyId(
replicaId,
params.charKey,
params.kind,
params.difficulty,
params.subject,
),
replicaId,
charKey: params.charKey,
kind: params.kind,
difficulty: params.difficulty,
subject: params.subject,
count: params.count,
soloCount: params.soloCount,
firstAt: params.firstAt ?? 1000,
lastAt: params.lastAt ?? 2000,
}
}
describe('kill-stats-view (Pure View Model & 86-Row Boss/SU Board)', () => {
it('derives the exact 86-row Boss & SuperUnique catalog (20 standalone bosses + 66 SuperUniques with 4 merged overlaps)', () => {
const catalog = getBossSuperUniqueBoardCatalog()
expect(catalog).toHaveLength(BOSS_SU_BOARD_ROW_COUNT)
const bossRows = catalog.filter((r) => r.rowKind === 'boss')
const suRows = catalog.filter((r) => r.rowKind === 'superunique')
expect(bossRows).toHaveLength(20)
expect(suRows).toHaveLength(66)
// Verify the 4 overlap boss hcIdx values are NOT present as standalone boss rows
const standaloneBossSubjects = new Set(bossRows.map((r) => r.subject))
expect(standaloneBossSubjects.has(229)).toBe(false) // radament -> SU10
expect(standaloneBossSubjects.has(250)).toBe(false) // summoner -> SU18
expect(standaloneBossSubjects.has(365)).toBe(false) // griswold -> SU5
expect(standaloneBossSubjects.has(526)).toBe(false) // nihlathakboss -> SU60
// Verify they are merged into SU5, SU10, SU18, SU60
const mergedSu = suRows
.filter((r) => r.mergedBossHcIdx !== null)
.map((r) => ({ suIdx: r.subject, id: r.canonicalId, mergedBossHcIdx: r.mergedBossHcIdx }))
expect(mergedSu).toEqual([
{ suIdx: 5, id: 'Griswold', mergedBossHcIdx: 365 },
{ suIdx: 10, id: 'Radament', mergedBossHcIdx: 229 },
{ suIdx: 18, id: 'The Summoner', mergedBossHcIdx: 250 },
{ suIdx: 60, id: 'Nihlathak Boss', mergedBossHcIdx: 526 },
])
})
it('disambiguates the 12 duplicate-name Boss rows with canonicalId subtitles in both zh and en', () => {
const emptySnapshot: KillStatsSnapshot = {
replicaId: REPLICA_A,
characters: [],
tallies: [],
}
const expectedDupCanonicalIds = [
'baalclone',
'baalcrab',
'diablo',
'diabloclone',
'duriel',
'izual',
'mephisto',
'uberbaal',
'uberdiablo',
'uberduriel',
'uberizual',
'ubermephisto',
]
for (const lang of ['zh', 'en'] as const) {
const vm = buildKillStatsViewModel(emptySnapshot, { lang, showUnkilled: true })
expect(vm.boardRows).toHaveLength(86)
const withSubtitle = vm.boardRows
.filter((r) => r.subtitle !== null)
.map((r) => r.subtitle!)
.sort()
expect(withSubtitle).toEqual(expectedDupCanonicalIds)
// Unique names like Andariel and Pindleskin have null subtitle
const andariel = vm.boardRows.find((r) => r.canonicalId === 'andariel')
const pindleskin = vm.boardRows.find((r) => r.canonicalId === 'Pindleskin')
expect(andariel?.subtitle).toBeNull()
expect(pindleskin?.subtitle).toBeNull()
}
})
it('merges tallies for the 4 overlapping Boss/SuperUnique rows (SU5/365, SU10/229, SU18/250, SU60/526) without double-counting summary totals', () => {
const char = makeChar({
host: 'www.laiseek.xyz',
realm: 'D2CS',
account: 'acct1',
charName: 'SorcA',
})
const snapshot: KillStatsSnapshot = {
replicaId: REPLICA_A,
characters: [char],
tallies: [
// Radament killed once as boss/229 in Normal, and once as superunique/10 in Hell
makeTally({
charKey: char.charKey,
kind: 'monster',
difficulty: 0,
subject: 229,
count: 1,
soloCount: 1,
lastAt: 1500,
}),
makeTally({
charKey: char.charKey,
kind: 'boss',
difficulty: 0,
subject: 229,
count: 1,
soloCount: 1,
lastAt: 1500,
}),
makeTally({
charKey: char.charKey,
kind: 'monster',
difficulty: 2,
subject: 229,
count: 2,
soloCount: 1,
lastAt: 3500,
}),
makeTally({
charKey: char.charKey,
kind: 'superunique',
difficulty: 2,
subject: 10,
count: 2,
soloCount: 1,
lastAt: 3500,
}),
],
}
const vm = buildKillStatsViewModel(snapshot, { showUnkilled: false })
// Summary cards sum ONLY kind === 'monster'
expect(vm.summary).toEqual({
normal: 1,
nightmare: 0,
hell: 2,
total: 3,
})
// Board has only SU10 (Radament) with Normal=1, Hell=2, Total=3, lastAt=3500
expect(vm.boardRows).toHaveLength(1)
expect(vm.boardRows[0]).toMatchObject({
key: 'superunique:10',
canonicalId: 'Radament',
normal: 1,
nightmare: 0,
hell: 2,
total: 3,
lastAt: 3500,
})
})
it('supports All / Account / Character scopes, cross-replica summation, and soloOnly filtering', () => {
const char1 = makeChar({
host: 'www.laiseek.xyz',
realm: 'D2CS',
account: 'Alpha',
charName: 'SorcOne',
charClass: 1,
level: 90,
})
const char2 = makeChar({
host: 'www.laiseek.xyz',
realm: 'D2CS',
account: 'Alpha',
charName: 'PalTwo',
charClass: 3,
level: 82,
})
const char3 = makeChar({
host: 'www.laiseek.xyz',
realm: 'D2CS',
account: 'Beta',
charName: 'BarbThree',
charClass: 4,
level: 75,
})
const snapshot: KillStatsSnapshot = {
replicaId: REPLICA_A,
characters: [char1, char2, char3],
tallies: [
// char1 on replica A & replica B: Andariel (156) in Hell
makeTally({
replicaId: REPLICA_A,
charKey: char1.charKey,
kind: 'monster',
difficulty: 2,
subject: 156,
count: 3,
soloCount: 2,
}),
makeTally({
replicaId: REPLICA_B,
charKey: char1.charKey,
kind: 'monster',
difficulty: 2,
subject: 156,
count: 4,
soloCount: 4,
}),
// char2 on replica A: Fallen (19) in Normal (party only: soloCount = 0)
makeTally({
replicaId: REPLICA_A,
charKey: char2.charKey,
kind: 'monster',
difficulty: 0,
subject: 19,
count: 10,
soloCount: 0,
}),
// char3 on replica A: Baal (544) in Nightmare
makeTally({
replicaId: REPLICA_A,
charKey: char3.charKey,
kind: 'monster',
difficulty: 1,
subject: 544,
count: 5,
soloCount: 5,
}),
],
}
// 1. All scope, soloOnly = false
const allVm = buildKillStatsViewModel(snapshot, { scope: { kind: 'all' }, soloOnly: false })
expect(allVm.totalAllAccountsKills).toBe(3 + 4 + 10 + 5)
expect(allVm.summary).toEqual({
normal: 10,
nightmare: 5,
hell: 7,
total: 22,
})
expect(allVm.accounts).toHaveLength(2)
expect(allVm.accounts[0]?.account).toBe('Alpha')
expect(allVm.accounts[0]?.totalKills).toBe(17)
expect(allVm.accounts[1]?.account).toBe('Beta')
expect(allVm.accounts[1]?.totalKills).toBe(5)
// 2. Account scope ('Alpha'), soloOnly = false
const acctVm = buildKillStatsViewModel(snapshot, {
scope: { kind: 'account', host: 'www.laiseek.xyz', realm: 'D2CS', account: 'Alpha' },
soloOnly: false,
})
expect(acctVm.summary).toEqual({
normal: 10,
nightmare: 0,
hell: 7,
total: 17,
})
// 3. Account scope ('Alpha'), soloOnly = true -> char2's party-only Fallen kills drop to 0
const acctSoloVm = buildKillStatsViewModel(snapshot, {
scope: { kind: 'account', host: 'www.laiseek.xyz', realm: 'D2CS', account: 'Alpha' },
soloOnly: true,
})
expect(acctSoloVm.summary).toEqual({
normal: 0,
nightmare: 0,
hell: 6,
total: 6,
})
// Fallen (19) had soloCount = 0 so it should not appear in monsterRows when soloOnly = true
expect(acctSoloVm.monsterRows.map((r) => r.hcIdx)).toEqual([156])
// 4. Character scope (char1)
const charVm = buildKillStatsViewModel(snapshot, {
scope: { kind: 'character', charKey: char1.charKey },
soloOnly: false,
})
expect(charVm.selectedCharacter?.charName).toBe('SorcOne')
expect(charVm.summary).toEqual({
normal: 0,
nightmare: 0,
hell: 7,
total: 7,
})
})
it('resolves bilingual names (trimming Act 5 space-padded CHI entries) and supports sorting and search on the monster table', () => {
// hcIdx 440 = reanimatedhorde5 (NameStr = DefiledWarrior, which is space-padded in CHI .tbl)
const zh440 = resolveLocalizedMonsterName('DefiledWarrior', 'reanimatedhorde5', 'zh')
const en440 = resolveLocalizedMonsterName('DefiledWarrior', 'reanimatedhorde5', 'en')
expect(zh440.trim().length).toBeGreaterThan(0)
expect(en440).toBe('Defiled Warrior')
expect(resolveCharacterClassName(1, 'zh')).toBe('法師')
expect(resolveCharacterClassName(1, 'en')).toBe('Sorceress')
const char = makeChar({
host: 'www.laiseek.xyz',
realm: 'D2CS',
account: 'acct',
charName: 'Hero',
})
const snapshot: KillStatsSnapshot = {
replicaId: REPLICA_A,
characters: [char],
tallies: [
makeTally({
charKey: char.charKey,
kind: 'monster',
difficulty: 0,
subject: 19, // fallen1
count: 25,
soloCount: 25,
}),
makeTally({
charKey: char.charKey,
kind: 'monster',
difficulty: 2,
subject: 156, // andariel
count: 5,
soloCount: 5,
}),
makeTally({
charKey: char.charKey,
kind: 'monster',
difficulty: 1,
subject: 544, // baalcrab
count: 12,
soloCount: 12,
}),
],
}
// Sort by total desc (default)
const byTotalDesc = buildKillStatsViewModel(snapshot)
expect(byTotalDesc.monsterRows.map((r) => r.hcIdx)).toEqual([19, 544, 156])
// Sort by hcIdx asc
const byHcIdxAsc = buildKillStatsViewModel(snapshot, {
monsterSort: { column: 'hcIdx', direction: 'asc' },
})
expect(byHcIdxAsc.monsterRows.map((r) => r.hcIdx)).toEqual([19, 156, 544])
// Search by Chinese name ('安達利爾'), English name ('andariel'), or canonical ID ('fallen1')
const searchZh = buildKillStatsViewModel(snapshot, { monsterSearch: '安達利爾' })
expect(searchZh.monsterRows.map((r) => r.hcIdx)).toEqual([156])
const searchEn = buildKillStatsViewModel(snapshot, { monsterSearch: 'ANDARIEL' })
expect(searchEn.monsterRows.map((r) => r.hcIdx)).toEqual([156])
const searchId = buildKillStatsViewModel(snapshot, { monsterSearch: 'fallen1' })
expect(searchId.monsterRows.map((r) => r.hcIdx)).toEqual([19])
})
})