494 lines
18 KiB
TypeScript
494 lines
18 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
GENERIC_SKILL_TBL_STRINGS,
|
|
SKILLS_TABLE_META,
|
|
SKILL_TBL_STRINGS,
|
|
canUseSkillWithWeapon,
|
|
getCanonicalSkillIconCel,
|
|
getCanonicalSkillListRow,
|
|
getSkillMaxLevel,
|
|
isAuraSkill,
|
|
isLeftUsableSkill,
|
|
isPassiveSkill,
|
|
} from '../../src/common/skills/skills-meta.ts'
|
|
import {
|
|
SkillHotkeysHud,
|
|
createCanonicalGenericSkillEntry,
|
|
getAvailableSkillsForSide,
|
|
getSkillListRow,
|
|
resolveSkillIcon,
|
|
} from '../../src/client/ui/hotkeys.ts'
|
|
import { SkillTreePanel } from '../../src/client/ui/skill-tree-panel.ts'
|
|
import {
|
|
CharacterSheetPanel,
|
|
resolveStatValueColor,
|
|
} from '../../src/client/ui/character-sheet.ts'
|
|
import { D2FontRenderer } from '../../src/client/ui/font.ts'
|
|
import { HudManager } from '../../src/client/ui/hud-manager.ts'
|
|
import { WEAPON_SWAP_TABS_BOUNDS } from '../../src/client/ui/inventory.ts'
|
|
import {
|
|
HudModel,
|
|
itemDataToUiInventoryItem,
|
|
} from '../../src/client/ui-model/hud-model.ts'
|
|
import { ClientWorld } from '../../src/client/world/client-world.ts'
|
|
import { D2StatId } from '../../src/client/world/stat-list.ts'
|
|
import type { ClientCommand, ItemData } from '../../src/netproto/index.ts'
|
|
|
|
function createMockCanvasCtx() {
|
|
const drawTextCalls: { text: string; x: number; y: number; color?: string; font?: string }[] = []
|
|
const font = new D2FontRenderer()
|
|
vi.spyOn(font, 'drawText').mockImplementation((_ctx, text, x, y, opts) => {
|
|
drawTextCalls.push({
|
|
text: String(text),
|
|
x,
|
|
y,
|
|
...(opts?.color !== undefined ? { color: opts.color } : {}),
|
|
...(opts?.font !== undefined ? { font: opts.font } : {}),
|
|
})
|
|
return String(text).length * 8
|
|
})
|
|
const ctx = {
|
|
save: vi.fn(),
|
|
restore: vi.fn(),
|
|
translate: vi.fn(),
|
|
drawImage: vi.fn(),
|
|
fillRect: vi.fn(),
|
|
strokeRect: vi.fn(),
|
|
beginPath: vi.fn(),
|
|
moveTo: vi.fn(),
|
|
lineTo: vi.fn(),
|
|
stroke: vi.fn(),
|
|
fillText: vi.fn(),
|
|
measureText: vi.fn((t: string) => ({ width: t.length * 7 })),
|
|
fillStyle: '#000',
|
|
strokeStyle: '#000',
|
|
lineWidth: 1,
|
|
globalAlpha: 1,
|
|
} as unknown as CanvasRenderingContext2D
|
|
return { ctx, font, drawTextCalls }
|
|
}
|
|
|
|
describe('Batch 6 Phase 3: Skills, Tooltips, Character Sheet & HUD Hooks (#679, #686, #687, #688, #689)', () => {
|
|
describe('Issue #679: Speedbar generic/item skills, skilldesc.txt ListRow/IconCel, weapon gating & fail-fast', () => {
|
|
it('defines canonical skilldesc.txt ListRow, ListPool, and IconCel for generic and scroll/tome skills', () => {
|
|
expect(getCanonicalSkillListRow(0)).toBe(0)
|
|
expect(getCanonicalSkillIconCel(0)).toBe(2)
|
|
|
|
expect(getCanonicalSkillListRow(1)).toBe(-1) // Kick: never in speedbar
|
|
expect(getCanonicalSkillListRow(5)).toBe(-1) // Left Hand Swing: never in speedbar
|
|
|
|
expect(getCanonicalSkillListRow(2)).toBe(0)
|
|
expect(getCanonicalSkillIconCel(2)).toBe(6)
|
|
|
|
expect(getCanonicalSkillListRow(3)).toBe(0)
|
|
expect(getCanonicalSkillIconCel(3)).toBe(4)
|
|
|
|
expect(getCanonicalSkillListRow(4)).toBe(0)
|
|
expect(getCanonicalSkillIconCel(4)).toBe(12)
|
|
|
|
expect(getCanonicalSkillListRow(217)).toBe(4)
|
|
expect(SKILLS_TABLE_META[217]?.listPool).toBe(1)
|
|
expect(getCanonicalSkillIconCel(217)).toBe(14)
|
|
|
|
expect(getCanonicalSkillListRow(218)).toBe(4)
|
|
expect(getCanonicalSkillIconCel(218)).toBe(14)
|
|
|
|
expect(getCanonicalSkillListRow(219)).toBe(4)
|
|
expect(SKILLS_TABLE_META[219]?.listPool).toBe(2)
|
|
expect(getCanonicalSkillIconCel(219)).toBe(18)
|
|
|
|
expect(getCanonicalSkillListRow(220)).toBe(4)
|
|
expect(getCanonicalSkillIconCel(220)).toBe(18)
|
|
})
|
|
|
|
it('always includes Attack (#0) in left and right speedbars and excludes ListRow < 0 (#1 Kick, #5 Left Hand Swing)', () => {
|
|
const kickEntry = {
|
|
skillId: 1,
|
|
name: 'Kick',
|
|
nameZh: '踢擊',
|
|
level: 1,
|
|
manaCost: 0,
|
|
leftUsable: true,
|
|
rightUsable: true,
|
|
}
|
|
const lhsEntry = {
|
|
skillId: 5,
|
|
name: 'Left Hand Swing',
|
|
nameZh: '左手揮擊',
|
|
level: 1,
|
|
manaCost: 0,
|
|
leftUsable: true,
|
|
rightUsable: true,
|
|
}
|
|
const leftList = getAvailableSkillsForSide('left', [kickEntry, lhsEntry])
|
|
const rightList = getAvailableSkillsForSide('right', [kickEntry, lhsEntry])
|
|
|
|
expect(leftList.map((s) => s.skillId)).toEqual([0])
|
|
expect(rightList.map((s) => s.skillId)).toEqual([0])
|
|
})
|
|
|
|
it('gates Throw (#2) and Left Hand Throw (#4) on equipped throwing weapons (itypea1 = thro, weapsel = 1)', () => {
|
|
expect(canUseSkillWithWeapon(2, { rightHandCode: 'ssd', rightHandType: 'swor' })).toBe(false)
|
|
expect(canUseSkillWithWeapon(2, { rightHandCode: 'jav', rightHandType: 'jave' })).toBe(true)
|
|
expect(canUseSkillWithWeapon(4, { rightHandCode: 'jav', leftHandCode: 'ssd' })).toBe(false)
|
|
expect(canUseSkillWithWeapon(4, { rightHandCode: 'ssd', leftHandCode: 'tkf' })).toBe(true)
|
|
})
|
|
|
|
it('syncs generic skills, throwing weapon skills, and inventory scrolls/tomes into hud.hotkeys and fails fast on unknown skillId', () => {
|
|
const world = new ClientWorld()
|
|
const hud = new HudManager()
|
|
const model = new HudModel({ world, hudManager: hud })
|
|
|
|
world.apply({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 0, id: 1, classId: 4, name: 'Barb', x: 5000, y: 5000 },
|
|
})
|
|
world.self.unitId = 1
|
|
world.self.charClass = 4 // Barbarian
|
|
world.apply({
|
|
type: 'SkillList',
|
|
unitId: 1,
|
|
skills: [
|
|
{ skillId: 3, level: 1, baseLevel: 1, bonus: 0 }, // Unsummon
|
|
{ skillId: 126, level: 5, baseLevel: 5, bonus: 0 }, // Bash
|
|
],
|
|
})
|
|
// Equip throwing knife in left hand (bodyLoc 5) and javelin in right hand (bodyLoc 4)
|
|
world.apply({
|
|
type: 'ItemEvent',
|
|
action: 6,
|
|
container: 'equipment',
|
|
item: {
|
|
id: 101,
|
|
code: 'jav',
|
|
location: 1,
|
|
bodyLoc: 4,
|
|
container: 'equipment',
|
|
invWidth: 1,
|
|
invHeight: 3,
|
|
} as unknown as ItemData,
|
|
})
|
|
world.apply({
|
|
type: 'ItemEvent',
|
|
action: 6,
|
|
container: 'equipment',
|
|
item: {
|
|
id: 102,
|
|
code: 'tkf',
|
|
location: 1,
|
|
bodyLoc: 5,
|
|
container: 'equipment',
|
|
invWidth: 1,
|
|
invHeight: 2,
|
|
} as unknown as ItemData,
|
|
})
|
|
// Add Tome of Town Portal (stat 70 = 14) and Scroll of Identify (2 stacks) to inventory
|
|
world.apply({
|
|
type: 'ItemEvent',
|
|
action: 4,
|
|
container: 'inventory',
|
|
item: {
|
|
id: 103,
|
|
code: 'tbk',
|
|
location: 0,
|
|
container: 'inventory',
|
|
gridX: 0,
|
|
gridY: 0,
|
|
invWidth: 1,
|
|
invHeight: 2,
|
|
stats: [{ statId: 70, param: 0, value: 14 }],
|
|
} as unknown as ItemData,
|
|
})
|
|
world.apply({
|
|
type: 'ItemEvent',
|
|
action: 4,
|
|
container: 'inventory',
|
|
item: {
|
|
id: 104,
|
|
code: 'isc',
|
|
location: 0,
|
|
container: 'inventory',
|
|
gridX: 2,
|
|
gridY: 0,
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
} as unknown as ItemData,
|
|
})
|
|
|
|
model.syncFromWorld()
|
|
const ids = hud.hotkeys.availableSkills.map((s) => s.skillId)
|
|
expect(ids).toContain(0) // Attack
|
|
expect(ids).toContain(2) // Throw
|
|
expect(ids).toContain(3) // Unsummon
|
|
expect(ids).toContain(4) // Left Hand Throw
|
|
expect(ids).toContain(126) // Bash
|
|
expect(ids).toContain(217) // Scroll of Identify
|
|
expect(ids).toContain(220) // Book of Town Portal
|
|
expect(hud.hotkeys.getSkillCharges(220)).toBe(14)
|
|
expect(hud.hotkeys.getSkillCharges(217)).toBe(1)
|
|
|
|
// Fail-fast on unknown skillId without skilldesc metadata
|
|
;(world.self.skillEntries as Map<number, any>).set(9999, { skillId: 9999, level: 1, baseLevel: 1, bonus: 0 })
|
|
expect(() => model.syncFromWorld()).toThrow(/skilldesc/)
|
|
expect(() =>
|
|
getSkillListRow({
|
|
skillId: 9999,
|
|
name: 'Invalid',
|
|
nameZh: 'Invalid',
|
|
level: 1,
|
|
manaCost: 0,
|
|
leftUsable: false,
|
|
rightUsable: true,
|
|
}),
|
|
).toThrow(/ListRow/)
|
|
})
|
|
})
|
|
|
|
describe('Issue #686: Canonical skilldesc.txt & .tbl skill tooltips without invented lines', () => {
|
|
it('formats SkillTreePanel tooltips using StrSkill17, StrSkill1, StrSkill18, and Sksyn without invented text', () => {
|
|
const panel = new SkillTreePanel('sor')
|
|
panel.lang = 'ENG'
|
|
panel.characterLevel = 1
|
|
|
|
// Unlearned Fire Bolt (#36) -> shows StrSkill17 ("First Level"), no "Unlearned (Level 1 Preview)", no raw catalog Base Damage
|
|
const unlearnedLines = panel.formatSkillTooltipLines(36)
|
|
const unlearnedTexts = unlearnedLines.map((l) => l.text)
|
|
expect(unlearnedTexts).toContain(SKILL_TBL_STRINGS.ENG.StrSkill17)
|
|
expect(unlearnedTexts.some((t) => t.includes('Unlearned (Level 1 Preview)'))).toBe(false)
|
|
expect(unlearnedTexts.some((t) => t.includes('Base Damage:'))).toBe(false)
|
|
expect(unlearnedTexts.some((t) => t.includes('[Synergy Bonuses]'))).toBe(false)
|
|
expect(unlearnedTexts.some((t) => t.includes('FireBolt Receives Bonuses From:') || t.includes('Fire Bolt Receives Bonuses From:'))).toBe(true)
|
|
|
|
// Learned Meteor (#56) with hardPoints=5, bonusLevels=2 -> no "(Base 5 + Gear +2)", no "Casting Delay:"
|
|
panel.hardPoints.set(56, 5)
|
|
panel.bonusLevels.set(56, 2)
|
|
const learnedLines = panel.formatSkillTooltipLines(56)
|
|
const learnedTexts = learnedLines.map((l) => l.text)
|
|
expect(learnedTexts).toContain(SKILL_TBL_STRINGS.ENG.StrSkill1)
|
|
expect(learnedTexts.some((t) => t.includes('(Base '))).toBe(false)
|
|
expect(learnedTexts.some((t) => t.includes('Casting Delay'))).toBe(false)
|
|
|
|
// Maxed skill (hardPoints=20) -> no "[Max Base Skill Level Reached (20/20)]"
|
|
panel.hardPoints.set(36, 20)
|
|
const maxedLines = panel.formatSkillTooltipLines(36)
|
|
expect(maxedLines.some((l) => l.text.includes('Max Base Skill Level Reached'))).toBe(false)
|
|
})
|
|
|
|
it('formats SkillHotkeysHud tooltips for generic/item skills (#0, #218) without Mana Cost: 0', () => {
|
|
const hotkeys = new SkillHotkeysHud()
|
|
hotkeys.lang = 'ENG'
|
|
const atkEntry = createCanonicalGenericSkillEntry(0, 1)!
|
|
const ibkEntry = createCanonicalGenericSkillEntry(218, 1, 17)!
|
|
hotkeys.setAvailableSkills([atkEntry, ibkEntry])
|
|
hotkeys.setSkillCharges(218, 17)
|
|
|
|
const atkLines = hotkeys.formatTooltipLines(0, 'left', true, 'ENG')
|
|
expect(atkLines[0]?.text).toBe('Attack')
|
|
expect(atkLines.some((l) => l.text.includes('Mana Cost'))).toBe(false)
|
|
|
|
const ibkLines = hotkeys.formatTooltipLines(218, 'right', true, 'ENG')
|
|
expect(ibkLines[0]?.text).toBe(GENERIC_SKILL_TBL_STRINGS[218]!.nameEn) // "Tome of Identify"
|
|
expect(ibkLines.some((l) => l.text === 'Quantity 17')).toBe(true)
|
|
expect(ibkLines.some((l) => l.text.includes('Mana Cost'))).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('Issue #687: Character Sheet .tbl labels, dual AR & Defense boxes, dynamic stat colors, and resistance gold threshold', () => {
|
|
it('computes dynamic stat value colors (current > base -> blue, < base -> red, === base -> white)', () => {
|
|
expect(resolveStatValueColor(25, 20)).toBe('blue')
|
|
expect(resolveStatValueColor(15, 20)).toBe('red')
|
|
expect(resolveStatValueColor(20, 20)).toBe('white')
|
|
})
|
|
|
|
it('renders canonical .tbl labels, Left/Right AR + Defense boxes, dynamic colors, and maxResCap gold threshold', () => {
|
|
const sheet = new CharacterSheetPanel()
|
|
sheet.visible = true
|
|
sheet.lang = 'ENG'
|
|
sheet.attrs.str = 35
|
|
sheet.attrs.baseStr = 25 // buffed -> blue
|
|
sheet.attrs.dex = 15
|
|
sheet.attrs.baseDex = 20 // debuffed -> red
|
|
sheet.attrs.vit = 25
|
|
sheet.attrs.baseVit = 25 // equal -> white
|
|
sheet.attrs.leftSkillName = 'Fire Bolt'
|
|
sheet.attrs.rightSkillName = 'Frozen Orb'
|
|
sheet.attrs.attackRating = 450
|
|
sheet.attrs.baseAttackRating = 300 // buffed -> blue
|
|
sheet.attrs.rightAttackRating = 520
|
|
sheet.attrs.baseRightAttackRating = 520 // equal -> white
|
|
sheet.attrs.defense = 180
|
|
sheet.attrs.baseDefense = 120 // buffed -> blue
|
|
sheet.attrs.fireRes = 75
|
|
sheet.attrs.maxFireRes = 85 // 75 < 85 -> white (NOT gold!)
|
|
sheet.attrs.coldRes = 80
|
|
sheet.attrs.maxColdRes = 80 // 80 >= 80 -> gold!
|
|
sheet.attrs.lightRes = -10
|
|
sheet.attrs.maxLightRes = 75 // < 0 -> red
|
|
|
|
const { ctx, font, drawTextCalls } = createMockCanvasCtx()
|
|
sheet.draw(
|
|
ctx,
|
|
{
|
|
hp: 100,
|
|
maxHp: 120,
|
|
mana: 80,
|
|
maxMana: 80,
|
|
stamina: 90,
|
|
maxStamina: 90,
|
|
leftSkillDmg: '12-18',
|
|
rightSkillDmg: '240-260',
|
|
},
|
|
{
|
|
borderLeftImg: null,
|
|
charSheetImg: null,
|
|
buySellBtnImg: null,
|
|
levelBtnImg: null,
|
|
},
|
|
font,
|
|
)
|
|
|
|
const findCall = (text: string) => drawTextCalls.find((c) => c.text === text)
|
|
expect(findCall('35')?.color).toBe('blue')
|
|
expect(findCall('15')?.color).toBe('red')
|
|
expect(findCall('450')?.color).toBe('blue')
|
|
expect(findCall('520')?.color).toBe('white')
|
|
expect(findCall('180')?.color).toBe('blue')
|
|
expect(findCall('12-18')).toBeDefined()
|
|
expect(findCall('240-260')).toBeDefined()
|
|
|
|
// Fire res 75 with maxFireRes 85 must be white, whereas Cold res 80 with maxColdRes 80 must be gold
|
|
expect(findCall('75%')?.color).toBe('white')
|
|
expect(findCall('80%')?.color).toBe('gold')
|
|
expect(findCall('-10%')?.color).toBe('red')
|
|
|
|
// Two-line .tbl resistance labels ("Fire" + "Resistance")
|
|
expect(findCall('Fire')).toBeDefined()
|
|
expect(findCall('Resistance')).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe('Issue #688: skills.txt column derivation (passive, leftskill, aura, maxlvl) & canAllocate maxlvl check', () => {
|
|
it('derives isPassiveSkill, isAuraSkill, isLeftUsableSkill, and getSkillMaxLevel from skills.txt metadata', () => {
|
|
expect(isPassiveSkill(61)).toBe(true) // Fire Mastery
|
|
expect(isPassiveSkill(36)).toBe(false) // Fire Bolt
|
|
expect(isAuraSkill(98)).toBe(true) // Might
|
|
expect(isAuraSkill(106)).toBe(false) // Zeal
|
|
expect(isLeftUsableSkill(36)).toBe(true) // Fire Bolt (leftskill=1)
|
|
expect(isLeftUsableSkill(56)).toBe(false) // Meteor (leftskill=0)
|
|
expect(getSkillMaxLevel(36)).toBe(20)
|
|
expect(getSkillMaxLevel(36, 25)).toBe(25)
|
|
})
|
|
|
|
it('enforces getSkillMaxLevel(skillId) in SkillTreePanel.canAllocate', () => {
|
|
const panel = new SkillTreePanel('sor')
|
|
panel.unspentSkillPoints = 5
|
|
panel.characterLevel = 99
|
|
panel.hardPoints.set(36, getSkillMaxLevel(36))
|
|
expect(panel.canAllocate(36)).toBe(false)
|
|
panel.hardPoints.set(36, getSkillMaxLevel(36) - 1)
|
|
expect(panel.canAllocate(36)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('Issue #689: Attack (#0) always uses skilldesc.txt IconCel = 2 regardless of hasWeapon', () => {
|
|
it('returns frameIndex = 2 (x = 96) for both armed and unarmed resolveSkillIcon(0)', () => {
|
|
const unarmed = resolveSkillIcon(0, false)
|
|
const armed = resolveSkillIcon(0, true)
|
|
expect(unarmed.frameIndex).toBe(2)
|
|
expect(unarmed.atlasRect.x).toBe(96)
|
|
expect(armed.frameIndex).toBe(2)
|
|
expect(armed.atlasRect.x).toBe(96)
|
|
})
|
|
})
|
|
|
|
describe('Cross-cutting HudModel & HudManager hooks (#664, #665, #673, #681, #683)', () => {
|
|
it('forwards graphicIndex to UiInventoryItem.variant in itemDataToUiInventoryItem (#681)', () => {
|
|
const ringItem = {
|
|
id: 501,
|
|
code: 'rin',
|
|
location: 0,
|
|
container: 'inventory',
|
|
gridX: 0,
|
|
gridY: 0,
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
graphicIndex: 3,
|
|
} as unknown as ItemData
|
|
const ui = itemDataToUiInventoryItem(ringItem)
|
|
expect((ui as any).variant).toBe(3)
|
|
})
|
|
|
|
it('preserves wp.npcMenu.hoveredOptionIdx on sync and anchors menu at Y = Math.max(20, npcScreenY - 150) (#664, #665)', () => {
|
|
const world = new ClientWorld()
|
|
const hud = new HudManager()
|
|
const model = new HudModel({ world, hudManager: hud })
|
|
|
|
world.apply({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 0, id: 1, classId: 1, name: 'Sorc', x: 5000, y: 5000 },
|
|
})
|
|
world.self.unitId = 1
|
|
world.self.charClass = 1
|
|
world.self.x = 5000
|
|
world.self.y = 5000
|
|
|
|
world.apply({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 77, classId: 148, x: 5002, y: 5002 }, // Akara
|
|
})
|
|
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 77 } })
|
|
|
|
model.syncFromWorld()
|
|
expect(hud.worldPanels.npcMenu).not.toBeNull()
|
|
// npcScreenY = 300 + (2 + 2) * 8 = 332 -> anchorY = 332 - 150 = 182
|
|
expect(hud.worldPanels.npcMenu?.y).toBe(182)
|
|
|
|
// Hover option index 1 and re-sync -> hoveredOptionIdx must be preserved
|
|
hud.worldPanels.npcMenu!.hoveredOptionIdx = 1
|
|
model.syncFromWorld()
|
|
expect(hud.worldPanels.npcMenu?.hoveredOptionIdx).toBe(1)
|
|
})
|
|
|
|
it('switches weapon sets only when clicking the inactive half (I vs II) of either weapon swap tab (#683) and omits drawAreaBanner (#673)', () => {
|
|
const world = new ClientWorld()
|
|
const hud = new HudManager()
|
|
const commands: ClientCommand[] = []
|
|
const model = new HudModel({
|
|
world,
|
|
hudManager: hud,
|
|
onCommand: (cmd) => commands.push(cmd),
|
|
})
|
|
|
|
world.apply({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 0, id: 1, classId: 1, name: 'Sorc', x: 5000, y: 5000 },
|
|
})
|
|
world.self.unitId = 1
|
|
world.self.charClass = 1
|
|
model.syncFromWorld()
|
|
|
|
hud.toggleRightPanel('inv')
|
|
expect(hud.inventory.weaponSwapSet).toBe(0)
|
|
|
|
// Clicking left half ('I') of right weapon swap tab when set is already 0 -> no SwapWeapons command
|
|
const rightTab = WEAPON_SWAP_TABS_BOUNDS.right
|
|
hud.handleClick(rightTab.x + 10, rightTab.y + 10, 0)
|
|
expect(commands).toEqual([])
|
|
|
|
// Clicking right half ('II') of left weapon swap tab when set is 0 -> emits SwapWeapons command
|
|
const leftTab = WEAPON_SWAP_TABS_BOUNDS.left
|
|
hud.handleClick(leftTab.x + leftTab.w - 10, leftTab.y + 10, 0)
|
|
expect(commands).toEqual([{ type: 'SwapWeapons' }])
|
|
|
|
// Verify render() never calls worldPanels.drawAreaBanner
|
|
const bannerSpy = vi.spyOn(hud.worldPanels, 'drawAreaBanner')
|
|
const { ctx } = createMockCanvasCtx()
|
|
hud.ctx = ctx
|
|
hud.leftPanel = 'none'
|
|
hud.rightPanel = 'none'
|
|
hud.render()
|
|
expect(bannerSpy).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|