417 lines
12 KiB
TypeScript
417 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
computeCharKey,
|
|
computeTallyId,
|
|
type KillCharacterRecord,
|
|
type KillStatsSnapshot,
|
|
type KillTallyRecord,
|
|
} from '../../../plugins/kill-stats/kill-stats-model.ts'
|
|
import {
|
|
BOSS_SU_BOARD_ROW_COUNT,
|
|
buildKillStatsViewModel,
|
|
getBossSuperUniqueBoardCatalog,
|
|
resolveCharacterClassName,
|
|
resolveLocalizedMonsterName,
|
|
} from '../../../plugins/kill-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])
|
|
})
|
|
})
|