377 lines
15 KiB
TypeScript
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')
|
|
})
|
|
})
|
|
})
|