diablo2-web/tests/hotkeys.test.ts

354 lines
12 KiB
TypeScript

/**
* Tests for SkillHotkeysHud, speedbars, keybindings, and Attack slot binding (Issue #151).
*/
import { describe, expect, it, beforeEach } from 'vitest'
import {
SkillHotkeysHud,
LEFT_SKILL_BOUNDS,
RIGHT_SKILL_BOUNDS,
DEFAULT_SORCERESS_SKILLS,
resolveSkillIcon,
getAvailableSkillsForSide,
} from '../src/ui/hotkeys.ts'
describe('SkillHotkeysHud & Dual Slot Mechanics', () => {
let hud: SkillHotkeysHud
beforeEach(() => {
hud = new SkillHotkeysHud()
})
it('initializes with expected default skills and state', () => {
expect(hud.leftSkillId).toBe(47) // Fire Ball
expect(hud.rightSkillId).toBe(64) // Frozen Orb
expect(hud.selectionOpen).toBeNull()
expect(hud.availableSkills.length).toBeGreaterThanOrEqual(10)
// Skill 0 is available
const attack = hud.availableSkills.find((s) => s.skillId === 0)
expect(attack).toBeDefined()
expect(attack!.leftUsable).toBe(true)
expect(attack!.rightUsable).toBe(true)
})
it('opens and closes left selection speedbar on click', () => {
// Click center of left skill icon
const leftX = LEFT_SKILL_BOUNDS.x + 24
const leftY = LEFT_SKILL_BOUNDS.y + 24
const handled = hud.handleClick(leftX, leftY)
expect(handled).toBe(true)
expect(hud.selectionOpen).toBe('left')
// Click again to close
hud.handleClick(leftX, leftY)
expect(hud.selectionOpen).toBeNull()
})
it('opens and closes right selection speedbar on click', () => {
// Click center of right skill icon
const rightX = RIGHT_SKILL_BOUNDS.x + 24
const rightY = RIGHT_SKILL_BOUNDS.y + 24
const handled = hud.handleClick(rightX, rightY)
expect(handled).toBe(true)
expect(hud.selectionOpen).toBe('right')
// Click outside to close
hud.handleClick(100, 100)
expect(hud.selectionOpen).toBeNull()
})
it('allows selecting Normal Attack from left speedbar palette', () => {
hud.openSpeedbar('left')
expect(hud.selectionOpen).toBe('left')
// Find Attack in speedbar layout
const speedbarSkills = hud.getSpeedbarSkills('left')
const attackEntry = speedbarSkills.find((e) => e.skill.skillId === 0)
expect(attackEntry).toBeDefined()
// Click on Attack icon in speedbar
const clicked = hud.handleClick(attackEntry!.x + 10, attackEntry!.y + 10)
expect(clicked).toBe(true)
expect(hud.leftSkillId).toBe(0)
expect(hud.selectionOpen).toBeNull() // closed after selection
})
it('allows selecting Normal Attack from right speedbar palette', () => {
hud.openSpeedbar('right')
expect(hud.selectionOpen).toBe('right')
const speedbarSkills = hud.getSpeedbarSkills('right')
const attackEntry = speedbarSkills.find((e) => e.skill.skillId === 0)
expect(attackEntry).toBeDefined()
const clicked = hud.handleClick(attackEntry!.x + 10, attackEntry!.y + 10)
expect(clicked).toBe(true)
expect(hud.rightSkillId).toBe(0)
expect(hud.selectionOpen).toBeNull()
})
it('supports dual slot binding with Attack on both slots', () => {
hud.assignSkill('left', 0)
hud.assignSkill('right', 0)
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(0)
// Verify resolveSkillIcon works for both slots
const leftIcon = resolveSkillIcon(hud.leftSkillId)
const rightIcon = resolveSkillIcon(hud.rightSkillId)
expect(leftIcon.skillId).toBe(0)
expect(rightIcon.skillId).toBe(0)
expect(leftIcon.name).toBe('Attack')
expect(rightIcon.name).toBe('Attack')
expect(leftIcon.iconPath).toBe('/skills/icon_0.png')
expect(rightIcon.iconPath).toBe('/skills/icon_0.png')
})
it('manages F1-F8 hotkey bindings and key presses', () => {
// Bind F1 to Attack (0)
hud.setHotkey('F1', 0)
expect(hud.getHotkey('F1')).toBe(0)
// Bind F2 to Frozen Orb (64)
hud.setHotkey('F2', 64)
expect(hud.getHotkey('F2')).toBe(64)
// Pressing F1 assigns right skill to 0
const handledF1 = hud.handleKeyDown('F1')
expect(handledF1).toBe(true)
expect(hud.rightSkillId).toBe(0)
// Pressing F2 assigns right skill to 64
const handledF2 = hud.handleKeyDown('F2')
expect(handledF2).toBe(true)
expect(hud.rightSkillId).toBe(64)
})
it('handles mouse hover over skill buttons and speedbar icons', () => {
hud.handleMouseMove(LEFT_SKILL_BOUNDS.x + 10, LEFT_SKILL_BOUNDS.y + 10)
expect(hud.hoverSkillId).toBe(hud.leftSkillId)
hud.handleMouseMove(RIGHT_SKILL_BOUNDS.x + 10, RIGHT_SKILL_BOUNDS.y + 10)
expect(hud.hoverSkillId).toBe(hud.rightSkillId)
hud.openSpeedbar('left')
const speedbarSkills = hud.getSpeedbarSkills('left')
const attackEntry = speedbarSkills.find((e) => e.skill.skillId === 0)
expect(attackEntry).toBeDefined()
hud.handleMouseMove(attackEntry!.x + 10, attackEntry!.y + 10)
expect(hud.hoverSpeedbarSkillId).toBe(0)
})
it('includes Throw and Unsummon as default universal skills with correct usability (Issue #376)', () => {
const throwSkill = hud.availableSkills.find((s) => s.skillId === 2)
const unsummonSkill = hud.availableSkills.find((s) => s.skillId === 3)
expect(throwSkill).toBeDefined()
expect(throwSkill!.name).toBe('Throw')
expect(throwSkill!.nameZh).toBe('投掷')
expect(throwSkill!.leftUsable).toBe(true)
expect(throwSkill!.rightUsable).toBe(true)
expect(unsummonSkill).toBeDefined()
expect(unsummonSkill!.name).toBe('Unsummon')
expect(unsummonSkill!.nameZh).toBe('取消召唤')
expect(unsummonSkill!.leftUsable).toBe(false)
expect(unsummonSkill!.rightUsable).toBe(true)
})
it('resolves authentic D2 icons: sword with weapon, fist unarmed, throw and unsummon (Issue #376)', () => {
// Attack with weapon: sword (frame 2)
const swordIcon = resolveSkillIcon(0, true)
expect(swordIcon.iconPath).toBe('/skills/icon_0.png')
expect(swordIcon.frameIndex).toBe(2)
expect(swordIcon.atlasRect.x).toBe(96)
// Attack without weapon: fist (frame 0)
const fistIcon = resolveSkillIcon(0, false)
expect(fistIcon.iconPath).toBe('/skills/icon_0_fist.png')
expect(fistIcon.frameIndex).toBe(0)
expect(fistIcon.atlasRect.x).toBe(0)
// Throw (skill 2): frame 6
const throwIcon = resolveSkillIcon(2)
expect(throwIcon.iconPath).toBe('/skills/icon_2.png')
expect(throwIcon.frameIndex).toBe(6)
expect(throwIcon.atlasRect.x).toBe(288)
// Unsummon (skill 3): frame 4
const unsummonIcon = resolveSkillIcon(3)
expect(unsummonIcon.iconPath).toBe('/skills/icon_3.png')
expect(unsummonIcon.frameIndex).toBe(4)
expect(unsummonIcon.atlasRect.x).toBe(192)
})
it('renders sword when weapon equipped and fist when unarmed (Issue #376)', () => {
const drawImageCalls: any[] = []
const ctx = {
fillRect: () => {},
strokeRect: () => {},
drawImage: (...args: any[]) => {
drawImageCalls.push(args)
},
} as unknown as CanvasRenderingContext2D
const mockAtlas = {
complete: true,
naturalWidth: 1152,
naturalHeight: 48,
} as unknown as HTMLImageElement
const mockFont = { drawText: () => {} } as any
hud.leftSkillId = 0
// 1. Armed attack: should draw sword (sx = 96, frame 2)
drawImageCalls.length = 0
hud.draw(ctx, mockAtlas, mockFont, true)
const armedDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 96)
expect(armedDraw).toBeDefined()
// 2. Unarmed attack: should draw fist (sx = 0, frame 0)
drawImageCalls.length = 0
hud.draw(ctx, mockAtlas, mockFont, false)
const unarmedDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 0)
expect(unarmedDraw).toBeDefined()
// 3. Throw (skill 2): should draw throw (sx = 288, frame 6)
hud.leftSkillId = 2
drawImageCalls.length = 0
hud.draw(ctx, mockAtlas, mockFont, true)
const throwDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 288)
expect(throwDraw).toBeDefined()
// 4. Unsummon (skill 3): should draw unsummon (sx = 192, frame 4)
hud.rightSkillId = 3
drawImageCalls.length = 0
hud.draw(ctx, mockAtlas, mockFont, true)
const unsummonDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 192)
expect(unsummonDraw).toBeDefined()
// 5. Left Hand Throw (skill 4): should draw left hand throw (sx = 576, frame 12)
hud.leftSkillId = 4
drawImageCalls.length = 0
hud.draw(ctx, mockAtlas, mockFont, true)
const leftHandThrowDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 576)
expect(leftHandThrowDraw).toBeDefined()
})
it('allows binding Throw to left/right slot and Unsummon to right slot only (Issue #376)', () => {
// Left speedbar should include Throw but NOT Unsummon
const leftSkills = hud.getSpeedbarSkills('left')
expect(leftSkills.some((e) => e.skill.skillId === 2)).toBe(true)
expect(leftSkills.some((e) => e.skill.skillId === 3)).toBe(false)
// Right speedbar should include both Throw and Unsummon
const rightSkills = hud.getSpeedbarSkills('right')
expect(rightSkills.some((e) => e.skill.skillId === 2)).toBe(true)
expect(rightSkills.some((e) => e.skill.skillId === 3)).toBe(true)
// Assign Throw to left
hud.assignSkill('left', 2)
expect(hud.leftSkillId).toBe(2)
// Assign Unsummon to right
hud.assignSkill('right', 3)
expect(hud.rightSkillId).toBe(3)
})
it('includes Glacial Spike (55) in right-click and left-click speedbars and allows assignment (Issue #385)', () => {
// 1. Verify Glacial Spike is in availableSkills
const glacialSpike = hud.availableSkills.find((s) => s.skillId === 55)
expect(glacialSpike).toBeDefined()
expect(glacialSpike!.name).toBe('Glacial Spike')
expect(glacialSpike!.nameZh).toBe('冰尖柱')
expect(glacialSpike!.level).toBe(10)
expect(glacialSpike!.leftUsable).toBe(true)
expect(glacialSpike!.rightUsable).toBe(true)
// 2. Right speedbar contains Glacial Spike
const rightSpeedbar = hud.getSpeedbarSkills('right')
const rightCell = rightSpeedbar.find((c) => c.skill.skillId === 55)
expect(rightCell).toBeDefined()
// Click on Glacial Spike in right speedbar
hud.openSpeedbar('right')
const clickedRight = hud.handleClick(rightCell!.x + 10, rightCell!.y + 10)
expect(clickedRight).toBe(true)
expect(hud.rightSkillId).toBe(55)
expect(hud.selectionOpen).toBeNull()
// 3. Left speedbar contains Glacial Spike
const leftSpeedbar = hud.getSpeedbarSkills('left')
const leftCell = leftSpeedbar.find((c) => c.skill.skillId === 55)
expect(leftCell).toBeDefined()
// Click on Glacial Spike in left speedbar
hud.openSpeedbar('left')
const clickedLeft = hud.handleClick(leftCell!.x + 10, leftCell!.y + 10)
expect(clickedLeft).toBe(true)
expect(hud.leftSkillId).toBe(55)
expect(hud.selectionOpen).toBeNull()
// 4. Icon resolution
const icon = resolveSkillIcon(55)
expect(icon.skillId).toBe(55)
expect(icon.iconPath).toBe('/skills/icon_55.png')
})
it('includes all 26 active Sorceress skills on right-click speedbar and excludes all 4 passives', () => {
const rightSkills = hud.getSpeedbarSkills('right')
// 3 universal (Attack 0, Throw 2, Unsummon 3) + 26 class active = 29 skills
expect(rightSkills.length).toBe(29)
// Verify all 26 active Sorceress skills are present
const activeIds = [
36, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50,
51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 62, 64,
]
for (const id of activeIds) {
expect(rightSkills.some((c) => c.skill.skillId === id)).toBe(true)
}
// Verify the 4 passives are NOT present
const passiveIds = [37, 61, 63, 65]
for (const id of passiveIds) {
expect(rightSkills.some((c) => c.skill.skillId === id)).toBe(false)
}
})
it('allows adding and updating skills dynamically via addOrUpdateSkill', () => {
// Add custom skill
hud.addOrUpdateSkill({
skillId: 48, // Nova
name: 'Nova',
nameZh: '新星',
level: 15,
manaCost: 20,
leftUsable: false,
rightUsable: true,
})
const nova = hud.availableSkills.find((s) => s.skillId === 48)
expect(nova).toBeDefined()
expect(nova!.level).toBe(15)
// Cannot add passive skill
hud.addOrUpdateSkill({
skillId: 37, // Warmth
name: 'Warmth',
nameZh: '暖气',
level: 5,
manaCost: 0,
leftUsable: false,
rightUsable: true,
})
expect(hud.availableSkills.some((s) => s.skillId === 37)).toBe(false)
})
})