diablo2-web/tests/skill-quickbar-tooltip.test.ts

358 lines
14 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest'
import {
SkillHotkeysHud,
LEFT_SKILL_BOUNDS,
RIGHT_SKILL_BOUNDS,
DEFAULT_SORCERESS_SKILLS,
} from '../src/ui/hotkeys.ts'
import { HudManager } from '../src/ui/hud-manager.ts'
import { D2FontRenderer, type D2ColorCode } from '../src/ui/font.ts'
import { SKILLS_BY_ID } from '../src/data/skills-catalog.ts'
describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Tooltips', () => {
let hud: SkillHotkeysHud
const mockFont = {
measureText: (txt: string, font?: string) => {
const charW = font === 'fontexocet10' ? 11 : 8
return txt.length * charW
},
drawText: () => {},
} as unknown as D2FontRenderer
beforeEach(() => {
hud = new SkillHotkeysHud()
})
describe('1. Default Universal Skills (Attack 0, Throw 2, Unsummon 3)', () => {
it('formats authentic Attack tooltip with weapon/barehanded narrative and 0 mana', () => {
const lines = hud.formatTooltipLines(0, 'left', false)
expect(lines.length).toBeGreaterThanOrEqual(3)
// Title (CHI)
expect(lines[0]!.text).toBe('普通攻击')
expect(lines[0]!.color).toBe('gold')
expect(lines[0]!.font).toBe('fontexocet10')
// Title (ENG)
const linesEn = hud.formatTooltipLines(0, 'left', false, 'ENG')
expect(linesEn[0]!.text).toBe('Attack')
// Narrative
const narrativeLine = lines.find(l => l.text.includes('物理攻击'))
expect(narrativeLine).toBeDefined()
expect(narrativeLine!.color).toBe('white')
// Mana
const manaLine = lines.find(l => l.text.includes('法力消耗: 0'))
expect(manaLine).toBeDefined()
expect(manaLine!.color).toBe('white')
})
it('formats authentic Throw tooltip with throw weapon narrative and 0 mana', () => {
const lines = hud.formatTooltipLines(2, 'left', false)
expect(lines[0]!.text).toBe('投掷')
expect(lines[0]!.color).toBe('gold')
expect(hud.formatTooltipLines(2, 'left', false, 'ENG')[0]!.text).toBe('Throw')
const narrativeLine = lines.find(l => l.text.includes('投掷类武器'))
expect(narrativeLine).toBeDefined()
const manaLine = lines.find(l => l.text.includes('法力消耗: 0'))
expect(manaLine).toBeDefined()
})
it('formats authentic Unsummon tooltip with minion dismissal narrative and 0 mana', () => {
const lines = hud.formatTooltipLines(3, 'right', false)
expect(lines[0]!.text).toBe('取消召唤')
expect(lines[0]!.color).toBe('gold')
expect(hud.formatTooltipLines(3, 'right', false, 'ENG')[0]!.text).toBe('Unsummon')
const narrativeLine = lines.find(l => l.text.includes('解散'))
expect(narrativeLine).toBeDefined()
const manaLine = lines.find(l => l.text.includes('法力消耗: 0'))
expect(manaLine).toBeDefined()
})
})
describe('2. Hotkey Binding Prompts & Labels', () => {
it('displays hotkey binding prompt in speedbar popup when unbound', () => {
// Skill 36 (Fire Bolt) not bound initially
const lines = hud.formatTooltipLines(36, 'left', true)
const hotkeyLine = lines.find(l => l.text.includes('按 F1-F8 键设置快捷键'))
expect(hotkeyLine).toBeDefined()
expect(hotkeyLine!.color).toBe('tan')
})
it('displays bound hotkey and rebind prompt in speedbar popup when bound', () => {
// F5 is bound to Fire Ball (47) on left side by default
const lines = hud.formatTooltipLines(47, 'left', true)
const hotkeyLine = lines.find(l => l.text.includes('当前快捷键: F5 (按 F1-F8 键重新绑定)'))
expect(hotkeyLine).toBeDefined()
expect(hotkeyLine!.color).toBe('tan')
})
it('displays bound hotkey on main control bar slot', () => {
// Left slot is Fire Ball (47) with F5
const lines = hud.formatTooltipLines(47, 'left', false)
const hotkeyLine = lines.find(l => l.text.includes('快捷键: F5'))
expect(hotkeyLine).toBeDefined()
expect(hotkeyLine!.color).toBe('tan')
})
})
describe('3. Class Skills & ViewModel Integration', () => {
it('integrates with TooltipViewModel from skill engine displaying level, mana, damage, and synergies', () => {
// Mock tooltip resolver
hud.tooltipResolver = (skillId: number) => {
if (skillId === 47) {
return {
skill: SKILLS_BY_ID[47]!,
hardPoints: 15,
effectiveLevel: 23,
isLearned: true,
canAllocateNow: true,
reqlevel: 12,
levelMet: true,
prereqs: [],
current: {
level: 23,
manaCost: 11,
delaySeconds: 0,
damage: {
hasDamage: true,
typeZh: '火焰伤害',
finalMin: 345,
finalMax: 388,
},
effects: [{ textZh: '爆炸半径: 2.6 码' }],
},
nextLevel: null,
synergiesReceived: [
{
providerId: 36,
providerName: 'Fire Bolt',
providerNameZh: '火弹',
percentPerLevel: 14,
hardPoints: 5,
currentBonusPct: 70,
},
],
masteryInfo: null,
} as any
}
return null
}
const lines = hud.formatTooltipLines(47, 'left', false)
// Title
expect(lines[0]!.text).toBe('火球')
expect(lines[0]!.color).toBe('gold')
expect(hud.formatTooltipLines(47, 'left', false, 'ENG')[0]!.text).toBe('Fire Ball')
// Level with soft bonus
const lvlLine = lines.find(l => l.text.includes('当前技能等级: 23 (基础 15 + 装备 +8)'))
expect(lvlLine).toBeDefined()
expect(lvlLine!.color).toBe('blue')
// Mana cost
const manaLine = lines.find(l => l.text.includes('法力消耗: 11'))
expect(manaLine).toBeDefined()
// Narrative description
const descLine = lines.find(l => l.text.includes('爆炸') || l.text.includes('火球'))
expect(descLine).toBeDefined()
// Damage
const dmgLine = lines.find(l => l.text.includes('火焰伤害: 345 - 388'))
expect(dmgLine).toBeDefined()
expect(dmgLine!.color).toBe('blue')
// Effect
const effLine = lines.find(l => l.text.includes('爆炸半径: 2.6 码'))
expect(effLine).toBeDefined()
// Synergy section header and lines
const synHeader = lines.find(l => l.text.includes('[协同技能加成]'))
expect(synHeader).toBeDefined()
expect(synHeader!.color).toBe('blue')
const synLine = lines.find(l => l.text.includes('火弹: 每个有效技能点增加 14% 伤害 (当前 +70%)'))
expect(synLine).toBeDefined()
expect(synLine!.color).toBe('blue')
})
it('falls back cleanly to catalog data when no TooltipViewModel is supplied', () => {
hud.tooltipResolver = undefined
const lines = hud.formatTooltipLines(47, 'left', false)
expect(lines[0]!.text).toBe('火球')
expect(lines[0]!.color).toBe('gold')
expect(hud.formatTooltipLines(47, 'left', false, 'ENG')[0]!.text).toBe('Fire Ball')
const lvlLine = lines.find(l => l.text.includes('当前技能等级: 8'))
expect(lvlLine).toBeDefined()
const manaLine = lines.find(l => l.text.includes('法力消耗: 8.5'))
expect(manaLine).toBeDefined()
})
})
describe('4. Tooltip Viewport Boundary Clamping & Placement', () => {
it('centers and clamps tooltip over Left Skill Slot without spilling off screen', () => {
const lines = hud.formatTooltipLines(47, 'left', false)
const placement = SkillHotkeysHud.computeTooltipPlacement(LEFT_SKILL_BOUNDS, lines, mockFont)
// Horizontally clamped inside [8, 800 - 8]
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
// Vertically placed strictly above the control bar slot (y <= 551 - 6 - h)
expect(placement.y + placement.h).toBeLessThanOrEqual(551)
expect(placement.y).toBeGreaterThanOrEqual(8)
})
it('centers and clamps tooltip over Right Skill Slot without spilling off screen', () => {
const lines = hud.formatTooltipLines(64, 'right', false)
const placement = SkillHotkeysHud.computeTooltipPlacement(RIGHT_SKILL_BOUNDS, lines, mockFont)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
expect(placement.y + placement.h).toBeLessThanOrEqual(551)
expect(placement.y).toBeGreaterThanOrEqual(8)
})
it('places tooltip above speedbar popup cell and clamps within viewport', () => {
hud.openSpeedbar('left')
const cells = hud.getPopupCells('left')
const cell = cells[0]!
const lines = hud.formatTooltipLines(cell.skill.skillId, 'left', true)
const placement = SkillHotkeysHud.computeTooltipPlacement(cell, lines, mockFont)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
expect(placement.y).toBeGreaterThanOrEqual(8)
})
})
describe('5. Mouse Hover Transitions & Drawing', () => {
it('sets hoveredSlot and hoverSkillId when hovering left skill button', () => {
hud.handleMouseMove(LEFT_SKILL_BOUNDS.x + 10, LEFT_SKILL_BOUNDS.y + 10)
expect(hud.hoverSkillId).toBe(hud.leftSkillId)
expect(hud.hoveredSlot).toBe('left')
expect(hud.hoveredPopupSkill).toBeNull()
// Move away
hud.handleMouseMove(400, 300)
expect(hud.hoverSkillId).toBeNull()
expect(hud.hoveredSlot).toBeNull()
})
it('sets hoveredSlot and hoverSkillId when hovering right skill button', () => {
hud.handleMouseMove(RIGHT_SKILL_BOUNDS.x + 10, RIGHT_SKILL_BOUNDS.y + 10)
expect(hud.hoverSkillId).toBe(hud.rightSkillId)
expect(hud.hoveredSlot).toBe('right')
expect(hud.hoveredPopupSkill).toBeNull()
// Move away
hud.handleMouseMove(400, 300)
expect(hud.hoverSkillId).toBeNull()
expect(hud.hoveredSlot).toBeNull()
})
it('sets hoveredPopupSkill when hovering inside open speedbar', () => {
hud.openSpeedbar('right')
const cells = hud.getPopupCells('right')
const cell = cells[1]!
hud.handleMouseMove(cell.x + 10, cell.y + 10)
expect(hud.hoveredPopupSkill).toEqual({
side: 'right',
skillId: cell.skill.skillId,
})
// Move away
hud.handleMouseMove(10, 10)
expect(hud.hoveredPopupSkill).toBeNull()
})
it('renders authentic D2 double borders and dark translucent fill when tooltip drawn', () => {
const fillRects: any[] = []
const strokeRects: any[] = []
const drawnTexts: any[] = []
const mockCtx = {
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
fillRect: (...args: any[]) => fillRects.push({ style: mockCtx.fillStyle, args }),
strokeRect: (...args: any[]) => strokeRects.push({ style: mockCtx.strokeStyle, args }),
} as unknown as CanvasRenderingContext2D
const testFont = {
measureText: () => 120,
drawText: (...args: any[]) => drawnTexts.push(args),
} as unknown as D2FontRenderer
// Hover left slot
hud.handleMouseMove(LEFT_SKILL_BOUNDS.x + 10, LEFT_SKILL_BOUNDS.y + 10)
hud.drawSkillTooltip(mockCtx, testFont)
// Verify dark background
const darkFill = fillRects.find(f => f.style === 'rgba(4, 4, 4, 0.94)')
expect(darkFill).toBeDefined()
// Verify outer bronze border (#8c7244) and inner dark border (#3d2b1a)
const outerBorder = strokeRects.find(s => s.style === '#8c7244')
const innerBorder = strokeRects.find(s => s.style === '#3d2b1a')
expect(outerBorder).toBeDefined()
expect(innerBorder).toBeDefined()
// Verify texts drawn (title in gold)
expect(drawnTexts.length).toBeGreaterThanOrEqual(3)
const titleDraw = drawnTexts.find(d => d[1].includes('火球') && d[4].color === 'gold')
expect(titleDraw).toBeDefined()
})
})
describe('6. HudManager End-to-End Class Tooltip Resolution', () => {
const mockCanvas = {
width: 800,
height: 600,
style: { cursor: '' },
addEventListener: () => {},
removeEventListener: () => {},
getContext: () => null,
} as unknown as HTMLCanvasElement
it('attaches skillTree.getTooltipViewModel to hotkeys in HudManager and updates on class switch', () => {
const hudMgr = new HudManager(mockCanvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
// Switch to Paladin: Left is Sacrifice (96), Right is Blessed Hammer (112)
hudMgr.switchClass('pal')
expect(hudMgr.hotkeys.tooltipResolver).toBeDefined()
const sacLines = hudMgr.hotkeys.formatTooltipLines(96, 'left', false)
expect(sacLines[0]!.text).toContain('犧牲')
expect(hudMgr.hotkeys.formatTooltipLines(96, 'left', false, 'ENG')[0]!.text).toBe('Sacrifice')
expect(sacLines.some(l => l.text.includes('当前技能等级:'))).toBe(true)
const hammerLines = hudMgr.hotkeys.formatTooltipLines(112, 'right', false)
expect(hammerLines[0]!.text).toContain('祝福之鎚')
expect(hudMgr.hotkeys.formatTooltipLines(112, 'right', false, 'ENG')[0]!.text).toBe('Blessed Hammer')
expect(hammerLines.some(l => l.text.includes('当前技能等级:') && l.text.includes('20'))).toBe(true)
// Switch to Barbarian: Whirlwind (151)
hudMgr.switchClass('bar')
const wwLines = hudMgr.hotkeys.formatTooltipLines(151, 'right', false)
expect(wwLines[0]!.text).toContain('旋風')
expect(hudMgr.hotkeys.formatTooltipLines(151, 'right', false, 'ENG')[0]!.text).toBe('Whirlwind')
expect(wwLines.some(l => l.text.includes('当前技能等级:') && l.text.includes('20'))).toBe(true)
})
})
})