/** * Diablo II v1.13c Quickbar & Speedbar Parity Tests (Issue #382). * * Requirements: * 1. 技能在cd中的时候显示红色不可用遮罩 (Red unavailable overlay on cooldown) * 2. 光环技能图标黄色 (Aura skill icon yellow border & tint) * 3. 充能类技能显示数字 (Charged skills display remaining charges number) * 4. 被动技能不进入快捷栏 (Passive skills do not enter quickbar speedbar) */ import { describe, expect, it, beforeEach, vi } from 'vitest' import { SkillHotkeysHud, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS, DEFAULT_SORCERESS_SKILLS, PASSIVE_SKILL_IDS, PALADIN_AURA_SKILL_IDS, isPassiveSkill, isAuraSkill, getAvailableSkillsForSide, type HotkeySkillEntry, } from '../src/ui/hotkeys.ts' import { D2FontRenderer } from '../src/ui/font.ts' describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)', () => { let hud: SkillHotkeysHud let font: D2FontRenderer beforeEach(() => { hud = new SkillHotkeysHud() font = new D2FontRenderer() }) describe('1. Passive Skills Filtering (被动技能不进入快捷栏)', () => { it('identifies all 26 canonical Diablo II 1.13c passive skills', () => { // Amazon passives (Critical Strike 9, Dodge 13, Avoid 18, Penetrate 23, Evade 29, Pierce 33) expect(isPassiveSkill(9)).toBe(true) // Critical Strike expect(isPassiveSkill(13)).toBe(true) // Dodge expect(isPassiveSkill(18)).toBe(true) // Avoid expect(isPassiveSkill(23)).toBe(true) // Penetrate expect(isPassiveSkill(29)).toBe(true) // Evade expect(isPassiveSkill(33)).toBe(true) // Pierce // Amazon active skills that must not be marked as passive expect(isPassiveSkill(14)).toBe(false) // Power Strike expect(isPassiveSkill(17)).toBe(false) // Slow Missiles expect(isPassiveSkill(22)).toBe(false) // Guided Arrow expect(isPassiveSkill(27)).toBe(false) // Immolation Arrow // Sorceress passives expect(isPassiveSkill(37)).toBe(true) // Warmth expect(isPassiveSkill(61)).toBe(true) // Fire Mastery expect(isPassiveSkill(63)).toBe(true) // Lightning Mastery expect(isPassiveSkill(65)).toBe(true) // Cold Mastery // Necromancer passives expect(isPassiveSkill(69)).toBe(true) // Skeleton Mastery expect(isPassiveSkill(79)).toBe(true) // Golem Mastery expect(isPassiveSkill(89)).toBe(true) // Summon Resist // Barbarian passives expect(isPassiveSkill(127)).toBe(true) // Sword Mastery expect(isPassiveSkill(141)).toBe(true) // Increased Stamina expect(isPassiveSkill(145)).toBe(true) // Iron Skin expect(isPassiveSkill(148)).toBe(true) // Increased Speed expect(isPassiveSkill(153)).toBe(true) // Natural Resistance // Druid & Assassin passives expect(isPassiveSkill(224)).toBe(true) // Lycanthropy expect(isPassiveSkill(252)).toBe(true) // Claw Mastery expect(isPassiveSkill(263)).toBe(true) // Weapon Block // Active skills should return false expect(isPassiveSkill(0)).toBe(false) // Attack expect(isPassiveSkill(36)).toBe(false) // Fire Bolt expect(isPassiveSkill(47)).toBe(false) // Fire Ball expect(isPassiveSkill(54)).toBe(false) // Teleport expect(isPassiveSkill(64)).toBe(false) // Frozen Orb expect(isPassiveSkill(98)).toBe(false) // Might (Aura) }) it('filters out passive skills from getAvailableSkillsForSide', () => { const mixedSkills: HotkeySkillEntry[] = [ { skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, { skillId: 37, name: 'Warmth', nameZh: '暖气', level: 10, manaCost: 0, leftUsable: false, rightUsable: true, isPassive: true }, { skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 9, leftUsable: true, rightUsable: true }, { skillId: 61, name: 'Fire Mastery', nameZh: '火焰强化', level: 5, manaCost: 0, leftUsable: true, rightUsable: true }, ] const leftList = getAvailableSkillsForSide('left', mixedSkills) const rightList = getAvailableSkillsForSide('right', mixedSkills) expect(leftList.map(s => s.skillId)).toEqual([0, 47]) expect(rightList.map(s => s.skillId)).toEqual([0, 47]) }) it('strips passive skills when setAvailableSkills is called', () => { const skillsWithPassives: HotkeySkillEntry[] = [ { skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, { skillId: 37, name: 'Warmth', nameZh: '暖气', level: 1, manaCost: 0, leftUsable: false, rightUsable: true }, { skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 1, manaCost: 9, leftUsable: true, rightUsable: true }, { skillId: 127, name: 'Sword Mastery', nameZh: '剑术支配', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, ] hud.setAvailableSkills(skillsWithPassives) expect(hud.availableSkills.some(s => isPassiveSkill(s.skillId))).toBe(false) expect(hud.availableSkills.map(s => s.skillId)).toEqual([0, 47]) // Speedbar popup should contain 0 passives const speedbarLeft = hud.getSpeedbarSkills('left') const speedbarRight = hud.getSpeedbarSkills('right') expect(speedbarLeft.some(c => isPassiveSkill(c.skill.skillId))).toBe(false) expect(speedbarRight.some(c => isPassiveSkill(c.skill.skillId))).toBe(false) }) it('rejects assigning passive skills to left or right action slots', () => { hud.leftSkillId = 47 hud.rightSkillId = 64 // Attempt assigning Warmth (37) const leftAssigned = hud.assignSkill('left', 37) expect(leftAssigned).toBe(false) expect(hud.leftSkillId).toBe(47) const rightAssigned = hud.assignSkill('right', 61) expect(rightAssigned).toBe(false) expect(hud.rightSkillId).toBe(64) // Attempt setDualSkill const dualAssigned = hud.setDualSkill(127) expect(dualAssigned).toBe(false) expect(hud.leftSkillId).toBe(47) expect(hud.rightSkillId).toBe(64) }) }) describe('2. Aura Skills Mechanics & Yellow Styling (光环技能图标黄色)', () => { it('identifies all 20 Paladin Defensive and Offensive Auras', () => { expect(PALADIN_AURA_SKILL_IDS.size).toBe(20) // Defensive Auras expect(isAuraSkill(99)).toBe(true) // Prayer expect(isAuraSkill(100)).toBe(true) // Resist Fire expect(isAuraSkill(104)).toBe(true) // Defiance expect(isAuraSkill(105)).toBe(true) // Resist Cold expect(isAuraSkill(109)).toBe(true) // Cleansing expect(isAuraSkill(110)).toBe(true) // Resist Lightning expect(isAuraSkill(115)).toBe(true) // Vigor expect(isAuraSkill(120)).toBe(true) // Meditation expect(isAuraSkill(124)).toBe(true) // Redemption expect(isAuraSkill(125)).toBe(true) // Salvation // Offensive Auras expect(isAuraSkill(98)).toBe(true) // Might expect(isAuraSkill(102)).toBe(true) // Holy Fire expect(isAuraSkill(103)).toBe(true) // Thorns expect(isAuraSkill(108)).toBe(true) // Blessed Aim expect(isAuraSkill(113)).toBe(true) // Concentration expect(isAuraSkill(114)).toBe(true) // Holy Freeze expect(isAuraSkill(118)).toBe(true) // Holy Shock expect(isAuraSkill(119)).toBe(true) // Sanctuary expect(isAuraSkill(122)).toBe(true) // Fanaticism expect(isAuraSkill(123)).toBe(true) // Conviction // Non-auras expect(isAuraSkill(96)).toBe(false) // Sacrifice (Combat) expect(isAuraSkill(112)).toBe(false) // Blessed Hammer (Combat) expect(isAuraSkill(47)).toBe(false) // Fire Ball }) it('renders yellow border and yellow tint for aura skills', () => { const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = [] const strokeRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = [] const mockCtx = { save: vi.fn(), restore: vi.fn(), drawImage: vi.fn(), fillRect: vi.fn(function (this: any, x, y, w, h) { fillRectCalls.push({ x, y, w, h, style: String(mockCtx.fillStyle) }) }), strokeRect: vi.fn(function (this: any, x, y, w, h) { strokeRectCalls.push({ x, y, w, h, style: String(mockCtx.strokeStyle) }) }), fillText: vi.fn(), measureText: vi.fn(() => ({ width: 40 })), fillStyle: '#000', strokeStyle: '#000', lineWidth: 1, } as unknown as CanvasRenderingContext2D hud.rightSkillId = 98 // Might (Aura) hud.draw(mockCtx, null, font) // Check yellow aura tint over right skill icon (x=635) const yellowTint = fillRectCalls.find( c => c.x === RIGHT_SKILL_BOUNDS.x + 2 && c.style.includes('rgba(255, 215, 0') ) expect(yellowTint).toBeDefined() // Check yellow border (#ffd700) around right skill button const yellowBorder = strokeRectCalls.find( c => c.x === RIGHT_SKILL_BOUNDS.x + 0.5 && c.style === '#ffd700' ) expect(yellowBorder).toBeDefined() }) it('includes aura narrative tag in tooltip', () => { hud.setAvailableSkills([ { skillId: 98, name: 'Might', nameZh: '力量灵气', level: 5, manaCost: 0, leftUsable: false, rightUsable: true, isAura: true }, ]) const lines = hud.formatTooltipLines(98, 'right', false) const auraTag = lines.find(l => l.text.includes('灵气技能') && l.color === 'yellow') expect(auraTag).toBeDefined() }) }) describe('3. Skill Cooldown (CD) Red Unavailable Mask (技能在cd中的时候显示红色不可用遮罩)', () => { it('manages skill cooldown durations accurately', () => { const now = 10000 expect(hud.isSkillOnCooldown(56, now)).toBe(false) // Meteor hud.setSkillCooldown(56, 1200, now) // 1.2s cooldown expect(hud.isSkillOnCooldown(56, now + 500)).toBe(true) expect(hud.getSkillCooldownRemaining(56, now + 500)).toBe(700) // After cooldown expires expect(hud.isSkillOnCooldown(56, now + 1200)).toBe(false) expect(hud.getSkillCooldownRemaining(56, now + 1200)).toBe(0) }) it('renders red unavailable overlay (rgba(220, 20, 20, 0.45)) when skill is on cooldown', () => { const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = [] const mockCtx = { save: vi.fn(), restore: vi.fn(), drawImage: vi.fn(), fillRect: vi.fn(function (this: any, x, y, w, h) { fillRectCalls.push({ x, y, w, h, style: String(mockCtx.fillStyle) }) }), strokeRect: vi.fn(), fillText: vi.fn(), measureText: vi.fn(() => ({ width: 40 })), fillStyle: '#000', strokeStyle: '#000', lineWidth: 1, } as unknown as CanvasRenderingContext2D hud.leftSkillId = 47 // Fire Ball hud.rightSkillId = 56 // Meteor const now = 20000 // Put Meteor on cooldown hud.setSkillCooldown(56, 1200, now) // Draw at now + 300ms (still on CD) hud.draw(mockCtx, null, font, true, now + 300) // Right slot (Meteor) must have red mask const meteorRedMask = fillRectCalls.find( c => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20') ) expect(meteorRedMask).toBeDefined() // Left slot (Fire Ball, not on CD) must NOT have red mask const fireBallRedMask = fillRectCalls.find( c => c.x === LEFT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20') ) expect(fireBallRedMask).toBeUndefined() }) it('clears cooldown and removes red mask', () => { hud.setSkillCooldown(56, 2000, 1000) expect(hud.isSkillOnCooldown(56, 1500)).toBe(true) hud.clearSkillCooldown(56) expect(hud.isSkillOnCooldown(56, 1500)).toBe(false) }) it('formats remaining cooldown in tooltip', () => { hud.setSkillCooldown(56, 1500, 5000) const lines = hud.formatTooltipLines(56, 'right', false) const cdLine = lines.find(l => l.text.includes('冷却中') && l.color === 'red') expect(cdLine).toBeDefined() }) }) describe('4. Charged Skills Number Display (充能类技能显示数字)', () => { it('sets and retrieves skill charges and max charges', () => { hud.setSkillCharges(54, 20, 25) // Teleport charges 20/25 expect(hud.getSkillCharges(54)).toBe(20) expect(hud.getSkillMaxCharges(54)).toBe(25) hud.setSkillCharges(54, 0, 25) // depleted expect(hud.getSkillCharges(54)).toBe(0) }) it('draws remaining charges count on the skill icon', () => { const drawTextCalls: { text: string; x: number; y: number; color?: string | undefined }[] = [] vi.spyOn(font, 'drawText').mockImplementation((ctx, text, x, y, opts) => { drawTextCalls.push({ text: String(text), x, y, color: opts?.color }) return 20 }) const mockCtx = { save: vi.fn(), restore: vi.fn(), drawImage: vi.fn(), fillRect: vi.fn(), strokeRect: vi.fn(), fillText: vi.fn(), measureText: vi.fn(() => ({ width: 40 })), fillStyle: '#000', strokeStyle: '#000', lineWidth: 1, } as unknown as CanvasRenderingContext2D hud.rightSkillId = 54 // Teleport hud.setSkillCharges(54, 18, 20) hud.draw(mockCtx, null, font) // Charge number '18' drawn at bottom-left corner of right slot (x = 635 + 4 = 639) const chargeTextCall = drawTextCalls.find( c => c.text === '18' && c.x === RIGHT_SKILL_BOUNDS.x + 4 && c.y === RIGHT_SKILL_BOUNDS.y + 44 ) expect(chargeTextCall).toBeDefined() expect(chargeTextCall!.color).toBe('white') }) it('renders red unavailable mask when charges reach 0', () => { const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = [] const mockCtx = { save: vi.fn(), restore: vi.fn(), drawImage: vi.fn(), fillRect: vi.fn(function (this: any, x, y, w, h) { fillRectCalls.push({ x, y, w, h, style: String(mockCtx.fillStyle) }) }), strokeRect: vi.fn(), fillText: vi.fn(), measureText: vi.fn(() => ({ width: 40 })), fillStyle: '#000', strokeStyle: '#000', lineWidth: 1, } as unknown as CanvasRenderingContext2D hud.rightSkillId = 54 hud.setSkillCharges(54, 0, 20) // 0 charges remaining hud.draw(mockCtx, null, font) const redMask = fillRectCalls.find( c => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20') ) expect(redMask).toBeDefined() }) it('formats charge status in tooltip', () => { hud.setSkillCharges(54, 15, 20) const lines = hud.formatTooltipLines(54, 'right', false) const chargeLine = lines.find(l => l.text.includes('剩余充能: 15 / 20')) expect(chargeLine).toBeDefined() expect(chargeLine!.color).toBe('blue') hud.setSkillCharges(54, 0, 20) const depletedLines = hud.formatTooltipLines(54, 'right', false) const depletedLine = depletedLines.find(l => l.text.includes('剩余充能: 0 / 20')) expect(depletedLine).toBeDefined() expect(depletedLine!.color).toBe('red') }) }) })