diablo2-web/tests/hotkeys-parity.test.ts

377 lines
15 KiB
TypeScript

/**
* 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')
})
})
})