/** * 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() }) 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) }) })