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 expect(lines[0]!.text).toBe('普通攻击 (Attack)') expect(lines[0]!.color).toBe('gold') expect(lines[0]!.font).toBe('fontexocet10') // 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('投掷 (Throw)') expect(lines[0]!.color).toBe('gold') 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('取消召唤 (Unsummon)') expect(lines[0]!.color).toBe('gold') 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('火球 (Fire Ball)') expect(lines[0]!.color).toBe('gold') // 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('火球 (Fire Ball)') expect(lines[0]!.color).toBe('gold') 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('Sacrifice') expect(sacLines.some(l => l.text.includes('当前技能等级:'))).toBe(true) const hammerLines = hudMgr.hotkeys.formatTooltipLines(112, 'right', false) expect(hammerLines[0]!.text).toContain('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('Whirlwind') expect(wwLines.some(l => l.text.includes('当前技能等级:') && l.text.includes('20'))).toBe(true) }) }) })