354 lines
12 KiB
TypeScript
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)
|
|
})
|
|
})
|