diablo2-web/tests/challenger-m6-attack-bindin...

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)
})
})
})