452 lines
16 KiB
TypeScript
452 lines
16 KiB
TypeScript
/**
|
|
* Empirical Challenger Stress Test Suite for Milestone M6:
|
|
* Normal Attack Icon Resolution, Dual Slot Binding Permutations, and Hotkey Switching.
|
|
*
|
|
* Ground Truth: Diablo II v1.13c Skill System Parity (Issue #151)
|
|
*/
|
|
|
|
import { describe, expect, it, beforeEach } from 'vitest'
|
|
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import {
|
|
DEFAULT_SORCERESS_SKILLS,
|
|
SkillHotkeysHud,
|
|
resolveSkillIcon,
|
|
getAvailableSkillsForSide,
|
|
LEFT_SKILL_BOUNDS,
|
|
RIGHT_SKILL_BOUNDS,
|
|
type HotkeySkillEntry,
|
|
} from '../src/ui/hotkeys.ts'
|
|
|
|
describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindings', () => {
|
|
const rootDir = process.cwd()
|
|
|
|
// =========================================================================
|
|
// 1. Icon Resolution & Asset Existence
|
|
// =========================================================================
|
|
describe('1. Icon Resolution & Disk Asset Integrity', () => {
|
|
it('verifies resolveSkillIcon(0) resolves to authentic Attack sword icon metadata', () => {
|
|
const mapping = resolveSkillIcon(0)
|
|
expect(mapping).toBeDefined()
|
|
expect(mapping.skillId).toBe(0)
|
|
expect(mapping.name).toBe('Attack')
|
|
expect(mapping.iconPath).toBe('/skills/icon_0.png')
|
|
expect(mapping.isGeneric).toBe(true)
|
|
expect(mapping.dc6Path).toBe('data/global/ui/SPELLS/Skillicon.DC6')
|
|
expect(mapping.dc6Source).toBe('data/global/ui/SPELLS/Skillicon.DC6')
|
|
expect(mapping.frameIndex).toBe(2)
|
|
expect(mapping.atlasRect).toEqual({
|
|
x: 96,
|
|
y: 0,
|
|
width: 48,
|
|
height: 48,
|
|
w: 48,
|
|
h: 48,
|
|
})
|
|
})
|
|
|
|
it('verifies resolveSkillIcon handles numeric variations consistently', () => {
|
|
const zeroPos = resolveSkillIcon(+0)
|
|
const zeroNeg = resolveSkillIcon(-0)
|
|
const zeroNum = resolveSkillIcon(Number(0))
|
|
|
|
expect(zeroPos).toEqual(zeroNeg)
|
|
expect(zeroPos).toEqual(zeroNum)
|
|
expect(zeroPos.skillId).toBe(0)
|
|
expect(zeroPos.name).toBe('Attack')
|
|
|
|
// Contrast with non-zero skills
|
|
const skill47 = resolveSkillIcon(47)
|
|
expect(skill47.skillId).toBe(47)
|
|
expect(skill47.isGeneric).toBe(false)
|
|
expect(skill47.iconPath).toBe('/skills/icon_47.png')
|
|
})
|
|
|
|
const targetPngs = [
|
|
{
|
|
path: join(rootDir, 'public', 'skills', 'icon_0.png'),
|
|
name: 'icon_0.png (skills)',
|
|
expectedWidth: 48,
|
|
expectedHeight: 48,
|
|
},
|
|
{
|
|
path: join(rootDir, 'public', 'skills', 'icon_0_active.png'),
|
|
name: 'icon_0_active.png (skills)',
|
|
expectedWidth: 48,
|
|
expectedHeight: 48,
|
|
},
|
|
{
|
|
path: join(rootDir, 'public', 'ui', 'icon_0.png'),
|
|
name: 'icon_0.png (ui)',
|
|
expectedWidth: 48,
|
|
expectedHeight: 48,
|
|
},
|
|
]
|
|
|
|
for (const file of targetPngs) {
|
|
it(`verifies ${file.name} exists on disk and is a valid 48x48 PNG with authentic headers`, () => {
|
|
expect(existsSync(file.path)).toBe(true)
|
|
const stat = statSync(file.path)
|
|
expect(stat.size).toBeGreaterThan(100) // valid png is larger than dummy stub
|
|
expect(stat.size).toBeLessThan(1024 * 1024) // sanity upper bound
|
|
|
|
const buf = readFileSync(file.path)
|
|
|
|
// 1. PNG 8-byte Magic Signature (\x89PNG\r\n\x1a\n)
|
|
const magic = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
for (let i = 0; i < 8; i++) {
|
|
expect(buf[i]).toBe(magic[i])
|
|
}
|
|
|
|
// 2. IHDR Chunk Header
|
|
const ihdrLength = buf.readUInt32BE(8)
|
|
expect(ihdrLength).toBe(13) // standard IHDR length
|
|
const ihdrType = buf.toString('ascii', 12, 16)
|
|
expect(ihdrType).toBe('IHDR')
|
|
|
|
// 3. Exact 48x48 Dimensions
|
|
const width = buf.readUInt32BE(16)
|
|
const height = buf.readUInt32BE(20)
|
|
expect(width).toBe(file.expectedWidth)
|
|
expect(height).toBe(file.expectedHeight)
|
|
|
|
// 4. Bit depth and valid color type
|
|
const bitDepth = buf[24]
|
|
expect([8, 16]).toContain(bitDepth)
|
|
const colorType = buf[25]
|
|
expect([0, 2, 3, 4, 6]).toContain(colorType)
|
|
|
|
// 5. IEND Chunk at file end
|
|
const endChunkType = buf.toString('ascii', buf.length - 8, buf.length - 4)
|
|
expect(endChunkType).toBe('IEND')
|
|
})
|
|
}
|
|
})
|
|
|
|
// =========================================================================
|
|
// 2. Slot Binding Permutations & Invariance
|
|
// =========================================================================
|
|
describe('2. Slot Binding Permutations & Invariance', () => {
|
|
let hud: SkillHotkeysHud
|
|
|
|
beforeEach(() => {
|
|
hud = new SkillHotkeysHud()
|
|
})
|
|
|
|
it('verifies getAvailableSkillsForSide includes Skill 0 (Attack) in both left and right palettes', () => {
|
|
const leftSkills = getAvailableSkillsForSide('left')
|
|
const rightSkills = getAvailableSkillsForSide('right')
|
|
|
|
const leftAttack = leftSkills.find((s) => s.skillId === 0)
|
|
const rightAttack = rightSkills.find((s) => s.skillId === 0)
|
|
|
|
expect(leftAttack).toBeDefined()
|
|
expect(rightAttack).toBeDefined()
|
|
|
|
expect(leftAttack!.name).toBe('Attack')
|
|
expect(leftAttack!.nameZh).toBe('普通攻击')
|
|
expect(leftAttack!.leftUsable).toBe(true)
|
|
expect(leftAttack!.rightUsable).toBe(true)
|
|
|
|
expect(rightAttack!.name).toBe('Attack')
|
|
expect(rightAttack!.nameZh).toBe('普通攻击')
|
|
expect(rightAttack!.leftUsable).toBe(true)
|
|
expect(rightAttack!.rightUsable).toBe(true)
|
|
})
|
|
|
|
it('assigns Attack to left slot and right slot simultaneously (Dual Attack)', () => {
|
|
hud.assignSkill('left', 0)
|
|
hud.assignSkill('right', 0)
|
|
|
|
expect(hud.leftSkillId).toBe(0)
|
|
expect(hud.rightSkillId).toBe(0)
|
|
|
|
// Test setDualSkill convenience method
|
|
hud.assignSkill('left', 47)
|
|
hud.assignSkill('right', 64)
|
|
expect(hud.leftSkillId).toBe(47)
|
|
expect(hud.rightSkillId).toBe(64)
|
|
|
|
hud.setDualSkill(0)
|
|
expect(hud.leftSkillId).toBe(0)
|
|
expect(hud.rightSkillId).toBe(0)
|
|
})
|
|
|
|
it('tests asymmetric permutations: Attack left / Spells right, and Spells left / Attack right', () => {
|
|
const availableLeft = hud.getAvailableSkillsForSide('left').map((s) => s.skillId)
|
|
const availableRight = hud.getAvailableSkillsForSide('right').map((s) => s.skillId)
|
|
|
|
// Permutation A: Attack on Left, cycle every available Right skill
|
|
hud.assignSkill('left', 0)
|
|
for (const rSkill of availableRight) {
|
|
hud.assignSkill('right', rSkill)
|
|
expect(hud.leftSkillId).toBe(0)
|
|
expect(hud.rightSkillId).toBe(rSkill)
|
|
}
|
|
|
|
// Permutation B: Attack on Right, cycle every available Left skill
|
|
hud.assignSkill('right', 0)
|
|
for (const lSkill of availableLeft) {
|
|
hud.assignSkill('left', lSkill)
|
|
expect(hud.leftSkillId).toBe(lSkill)
|
|
expect(hud.rightSkillId).toBe(0)
|
|
}
|
|
})
|
|
|
|
it('rigorously verifies slot state orthogonality across full Cartesian product of skills', () => {
|
|
const leftSkills = hud.getAvailableSkillsForSide('left').map((s) => s.skillId)
|
|
const rightSkills = hud.getAvailableSkillsForSide('right').map((s) => s.skillId)
|
|
|
|
for (const l of leftSkills) {
|
|
for (const r of rightSkills) {
|
|
hud.assignSkill('left', l)
|
|
expect(hud.leftSkillId).toBe(l)
|
|
|
|
hud.assignSkill('right', r)
|
|
// Crucial check: assigning to right slot MUST NOT mutate left slot
|
|
expect(hud.leftSkillId).toBe(l)
|
|
expect(hud.rightSkillId).toBe(r)
|
|
|
|
// Re-assign left slot: assigning left MUST NOT mutate right slot
|
|
hud.assignSkill('left', 0)
|
|
expect(hud.leftSkillId).toBe(0)
|
|
expect(hud.rightSkillId).toBe(r)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('stress tests 1000 randomized slot mutations against an independent reference oracle', () => {
|
|
const leftCandidates = hud.getAvailableSkillsForSide('left').map((s) => s.skillId)
|
|
const rightCandidates = hud.getAvailableSkillsForSide('right').map((s) => s.skillId)
|
|
|
|
let oracleLeft = hud.leftSkillId
|
|
let oracleRight = hud.rightSkillId
|
|
|
|
// Pseudo-random deterministic LCG generator
|
|
let seed = 1337
|
|
function nextRand(): number {
|
|
seed = (seed * 1664525 + 1013904223) >>> 0
|
|
return seed / 4294967296
|
|
}
|
|
|
|
for (let i = 0; i < 1000; i++) {
|
|
const action = Math.floor(nextRand() * 3)
|
|
if (action === 0) {
|
|
// Mutate left
|
|
const idx = Math.floor(nextRand() * leftCandidates.length)
|
|
const chosen = leftCandidates[idx]
|
|
hud.assignSkill('left', chosen)
|
|
oracleLeft = chosen
|
|
} else if (action === 1) {
|
|
// Mutate right
|
|
const idx = Math.floor(nextRand() * rightCandidates.length)
|
|
const chosen = rightCandidates[idx]
|
|
hud.assignSkill('right', chosen)
|
|
oracleRight = chosen
|
|
} else {
|
|
// Dual assign (frequently attack = 0)
|
|
const chosen = nextRand() > 0.5 ? 0 : leftCandidates[Math.floor(nextRand() * leftCandidates.length)]
|
|
hud.setDualSkill(chosen)
|
|
oracleLeft = chosen
|
|
oracleRight = chosen
|
|
}
|
|
|
|
expect(hud.leftSkillId).toBe(oracleLeft)
|
|
expect(hud.rightSkillId).toBe(oracleRight)
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// 3. Hotkey Assignment, Rapid Switching, & Speedbar Selection
|
|
// =========================================================================
|
|
describe('3. Hotkey Assignment & Rapid Switching Stress', () => {
|
|
let hud: SkillHotkeysHud
|
|
|
|
beforeEach(() => {
|
|
hud = new SkillHotkeysHud()
|
|
})
|
|
|
|
it('binds F1 through F8 to Attack (0) and verifies instant key activation', () => {
|
|
const keys = ['F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8']
|
|
|
|
for (const key of keys) {
|
|
// Set F-key to Attack for right slot
|
|
hud.setHotkey(key, 0, 'right')
|
|
expect(hud.getHotkey(key)).toBe(0)
|
|
|
|
// Set right skill to something else first
|
|
hud.assignSkill('right', 64)
|
|
expect(hud.rightSkillId).toBe(64)
|
|
|
|
// Press hotkey
|
|
const handled = hud.handleKeyDown(key)
|
|
expect(handled).toBe(true)
|
|
expect(hud.rightSkillId).toBe(0)
|
|
}
|
|
|
|
// Also test left slot binding
|
|
for (const key of keys) {
|
|
hud.bindHotkey(key, 'left', 0)
|
|
hud.assignSkill('left', 47)
|
|
expect(hud.leftSkillId).toBe(47)
|
|
|
|
const handled = hud.handleKeyDown(key)
|
|
expect(handled).toBe(true)
|
|
expect(hud.leftSkillId).toBe(0)
|
|
}
|
|
})
|
|
|
|
it('verifies slot state invariance during hotkey trigger', () => {
|
|
// Bind F1 to right Attack (0) and F2 to left Attack (0)
|
|
hud.bindHotkey('F1', 'right', 0)
|
|
hud.bindHotkey('F2', 'left', 0)
|
|
|
|
hud.assignSkill('left', 47)
|
|
hud.assignSkill('right', 64)
|
|
|
|
// Triggering F1 (right attack) must not alter left skill
|
|
hud.handleKeyDown('F1')
|
|
expect(hud.rightSkillId).toBe(0)
|
|
expect(hud.leftSkillId).toBe(47)
|
|
|
|
// Triggering F2 (left attack) must not alter right skill
|
|
hud.handleKeyDown('F2')
|
|
expect(hud.leftSkillId).toBe(0)
|
|
expect(hud.rightSkillId).toBe(0)
|
|
})
|
|
|
|
it('asserts leftUsable: true and rightUsable: true remain immutable on Skill 0', () => {
|
|
const attackDef = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0)
|
|
expect(attackDef).toBeDefined()
|
|
expect(attackDef!.leftUsable).toBe(true)
|
|
expect(attackDef!.rightUsable).toBe(true)
|
|
|
|
// Perform a flurry of mutations, speedbar interactions, and bindings
|
|
for (let i = 0; i < 200; i++) {
|
|
hud.openSpeedbar('left')
|
|
hud.handleClick(LEFT_SKILL_BOUNDS.x, LEFT_SKILL_BOUNDS.y)
|
|
hud.openSpeedbar('right')
|
|
hud.handleClick(RIGHT_SKILL_BOUNDS.x, RIGHT_SKILL_BOUNDS.y)
|
|
hud.assignSkill('left', 0)
|
|
hud.assignSkill('right', 0)
|
|
hud.bindHotkey('F1', 'left', 0)
|
|
hud.handleKeyDown('F1')
|
|
}
|
|
|
|
// Check DEFAULT_SORCERESS_SKILLS
|
|
const attackDefAfter = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0)
|
|
expect(attackDefAfter!.leftUsable).toBe(true)
|
|
expect(attackDefAfter!.rightUsable).toBe(true)
|
|
|
|
// Check hud.availableSkills
|
|
const hudAttack = hud.availableSkills.find((s) => s.skillId === 0)
|
|
expect(hudAttack!.leftUsable).toBe(true)
|
|
expect(hudAttack!.rightUsable).toBe(true)
|
|
})
|
|
|
|
it('stress tests rapid hotkey switching with 5000 key events simulating hyper APM', () => {
|
|
// Configure F1..F8 layout
|
|
const config: Array<{ key: string; side: 'left' | 'right'; skillId: number }> = [
|
|
{ key: 'F1', side: 'right', skillId: 0 }, // Attack
|
|
{ key: 'F2', side: 'right', skillId: 64 }, // Frozen Orb
|
|
{ key: 'F3', side: 'right', skillId: 59 }, // Blizzard
|
|
{ key: 'F4', side: 'right', skillId: 54 }, // Teleport
|
|
{ key: 'F5', side: 'left', skillId: 0 }, // Attack
|
|
{ key: 'F6', side: 'left', skillId: 47 }, // Fire Ball
|
|
{ key: 'F7', side: 'left', skillId: 36 }, // Fire Bolt
|
|
{ key: 'F8', side: 'right', skillId: 40 }, // Frozen Armor
|
|
]
|
|
|
|
for (const item of config) {
|
|
hud.bindHotkey(item.key, item.side, item.skillId)
|
|
}
|
|
|
|
let expectedLeft = hud.leftSkillId
|
|
let expectedRight = hud.rightSkillId
|
|
|
|
let seed = 424242
|
|
function randInt(max: number): number {
|
|
seed = (seed * 1103515245 + 12345) & 0x7fffffff
|
|
return seed % max
|
|
}
|
|
|
|
for (let step = 0; step < 5000; step++) {
|
|
const choice = config[randInt(config.length)]
|
|
const res = hud.triggerFunctionKey(choice.key)
|
|
|
|
expect(res).not.toBeNull()
|
|
expect(res!.skillId).toBe(choice.skillId)
|
|
expect(res!.side).toBe(choice.side)
|
|
|
|
if (choice.side === 'left') {
|
|
expectedLeft = choice.skillId
|
|
} else {
|
|
expectedRight = choice.skillId
|
|
}
|
|
|
|
expect(hud.leftSkillId).toBe(expectedLeft)
|
|
expect(hud.rightSkillId).toBe(expectedRight)
|
|
}
|
|
})
|
|
|
|
it('stress tests speedbar popup selection for Normal Attack', () => {
|
|
// 1. Left Speedbar
|
|
hud.openSpeedbar('left')
|
|
expect(hud.selectionOpen).toBe('left')
|
|
|
|
const leftGrid = hud.getSpeedbarSkills('left')
|
|
const leftAttack = leftGrid.find((cell) => cell.skill.skillId === 0)
|
|
expect(leftAttack).toBeDefined()
|
|
|
|
// Click within Attack cell bounds
|
|
const leftClicked = hud.handleClick(leftAttack!.x + 16, leftAttack!.y + 16)
|
|
expect(leftClicked).toBe(true)
|
|
expect(hud.leftSkillId).toBe(0)
|
|
expect(hud.selectionOpen).toBeNull() // closed automatically
|
|
|
|
// 2. Right Speedbar
|
|
hud.openSpeedbar('right')
|
|
expect(hud.selectionOpen).toBe('right')
|
|
|
|
const rightGrid = hud.getSpeedbarSkills('right')
|
|
const rightAttack = rightGrid.find((cell) => cell.skill.skillId === 0)
|
|
expect(rightAttack).toBeDefined()
|
|
|
|
// Click within Attack cell bounds
|
|
const rightClicked = hud.handleClick(rightAttack!.x + 16, rightAttack!.y + 16)
|
|
expect(rightClicked).toBe(true)
|
|
expect(hud.rightSkillId).toBe(0)
|
|
expect(hud.selectionOpen).toBeNull()
|
|
})
|
|
|
|
it('tests speedbar interactive hover and in-palette hotkey rebinding', () => {
|
|
hud.openSpeedbar('right')
|
|
const rightGrid = hud.getSpeedbarSkills('right')
|
|
const attackCell = rightGrid.find((cell) => cell.skill.skillId === 0)!
|
|
|
|
// Move mouse over Attack in speedbar
|
|
hud.handleMouseMove(attackCell.x + 10, attackCell.y + 10)
|
|
expect(hud.hoverSpeedbarSkillId).toBe(0)
|
|
expect(hud.hoveredPopupSkill).toEqual({ side: 'right', skillId: 0 })
|
|
|
|
// Press F5 while hovering -> rebinds F5 to Attack on right
|
|
const boundRes = hud.triggerFunctionKey('F5')
|
|
expect(boundRes).toEqual({ side: 'right', skillId: 0 })
|
|
expect(hud.getHotkey('F5')).toBe(0)
|
|
|
|
// Close speedbar
|
|
hud.handleClick(10, 10)
|
|
expect(hud.selectionOpen).toBeNull()
|
|
|
|
// Reset right skill to Blizzard (59)
|
|
hud.assignSkill('right', 59)
|
|
expect(hud.rightSkillId).toBe(59)
|
|
|
|
// Trigger F5
|
|
hud.handleKeyDown('F5')
|
|
expect(hud.rightSkillId).toBe(0)
|
|
})
|
|
})
|
|
})
|