347 lines
13 KiB
TypeScript
347 lines
13 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
|
|
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)
|
|
})
|
|
})
|
|
})
|