diablo2-web/tests/client/hire-dialog.test.ts

192 lines
6.8 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest'
import {
WorldPanelsHud,
HIRE_DIALOG_WIDTH,
HIRE_DIALOG_HEIGHT,
HIRE_CONFIRM_WIDTH,
HIRE_CONFIRM_HEIGHT,
} from '../../src/client/ui/world-panels.ts'
import { resolveHirelingCandidates } from '../../src/common/units/hireling-table.ts'
function createMockCtx() {
const drawnTexts: string[] = []
let fillRectCount = 0
let strokeRectCount = 0
return {
drawnTexts,
get fillRectCount() {
return fillRectCount
},
get strokeRectCount() {
return strokeRectCount
},
fillStyle: '',
strokeStyle: '',
fillRect: vi.fn(() => fillRectCount++),
strokeRect: vi.fn(() => strokeRectCount++),
fillText: vi.fn((text: string) => drawnTexts.push(text)),
drawImage: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
} as unknown as CanvasRenderingContext2D & { drawnTexts: string[]; fillRectCount: number; strokeRectCount: number }
}
describe('WorldPanelsHud — Mercenary Hire Stone Dialog (0x6faf9440 & 0x6faf7500 parity)', () => {
it('identifies dialog hitboxes accurately per D2Client.dll 0x6faf9440', () => {
const wp = new WorldPanelsHud()
wp.activePanel = 'hire'
// Centered at 155, 105 (490x350)
expect(wp.isPointInHireMercDialog(100, 50)).toBe(false)
expect(wp.isPointInHireMercDialog(155, 105)).toBe(true)
expect(wp.isPointInHireMercDialog(400, 300)).toBe(true)
expect(wp.isPointInHireMercDialog(644, 454)).toBe(true)
expect(wp.isPointInHireMercDialog(650, 460)).toBe(false)
// Confirmation dialog bounds (250, 190, 300, 160)
wp.replaceMercConfirmOpen = true
expect(wp.isPointInHireMercDialog(160, 110)).toBe(false)
expect(wp.isPointInHireMercDialog(250, 190)).toBe(true)
expect(wp.isPointInHireMercDialog(400, 250)).toBe(true)
expect(wp.isPointInHireMercDialog(550, 350)).toBe(true)
})
it('draws authentic 490x350 stone dialog with ItemDesc1s gold header and candidate list', () => {
const wp = new WorldPanelsHud()
wp.lang = 'ENG'
wp.activePanel = 'hire'
wp.playerGold = 5000
wp.stashGold = 12000
const rawCandidates = [
{ nameId: 3411, seed: 0x1234 },
{ nameId: 3412, seed: 0x5678 },
]
wp.mercHireList = resolveHirelingCandidates(rawCandidates, 10, 0, 0, true, 'ENG')
const ctx = createMockCtx()
wp.drawHireMercDialog(ctx)
expect(ctx.fillRectCount).toBeGreaterThan(0)
// Title with total gold 17000
expect(ctx.drawnTexts.some((t) => t.includes('17000') && t.includes('Hire which Mercenary?'))).toBe(true)
// Candidates rendered with Lvl, Life, Def, Cost
expect(ctx.drawnTexts.some((t) => t.includes('Aliza - Lvl: 8'))).toBe(true)
expect(ctx.drawnTexts.some((t) => t.includes('Fire Arrow'))).toBe(true)
// Bottom cancel button
expect(ctx.drawnTexts.some((t) => t.includes('cancel'))).toBe(true)
})
it('draws ItemDesc1t when hire list is empty', () => {
const wp = new WorldPanelsHud()
wp.lang = 'ENG'
wp.activePanel = 'hire'
wp.mercHireList = []
const ctx = createMockCtx()
wp.drawHireMercDialog(ctx)
expect(ctx.drawnTexts.some((t) => t.includes('There are no Mercenaries left to hire.'))).toBe(true)
})
it('handles candidate hover, scrolling, and direct hire when player has no existing mercenary', () => {
const wp = new WorldPanelsHud()
wp.activePanel = 'hire'
wp.activeNpcId = 150
const rawCandidates = Array.from({ length: 10 }, (_, i) => ({
nameId: 3411 + i,
seed: 0x1000 + i * 0x100,
}))
wp.mercHireList = resolveHirelingCandidates(rawCandidates, 15, 0, 0, true, 'ENG')
// 1. Wheel scroll
expect(wp.handleHireWheel(1)).toBe(true)
expect(wp.hireScrollOffset).toBe(1)
expect(wp.handleHireWheel(-1)).toBe(true)
expect(wp.hireScrollOffset).toBe(0)
// 2. Mouse move hover
wp.handleHireMercMove(200, 160) // over Candidate 0
expect(wp.hireHoveredIndex).toBe(0)
// 3. Direct click to hire when player has no existing mercenary
let hiredNpcId = 0
let hiredMercId = 0
const onHireMerc = vi.fn((npcId: number, mercId: number) => {
hiredNpcId = npcId
hiredMercId = mercId
})
const handled = wp.handleHireMercClick(200, 160, { onHireMerc })
expect(handled).toBe(true)
expect(onHireMerc).toHaveBeenCalledWith(150, 3411)
expect(wp.activePanel).toBe('none')
})
it('prompts confirmation dialog when player already possesses a mercenary (0x6faf7500)', () => {
const wp = new WorldPanelsHud()
wp.lang = 'ENG'
wp.activePanel = 'hire'
wp.activeNpcId = 150
wp.merc = { mercId: 42, isDead: false } // Existing active mercenary
const rawCandidates = [{ nameId: 3411, seed: 0x1234 }]
wp.mercHireList = resolveHirelingCandidates(rawCandidates, 15, 0, 0, true, 'ENG')
const onHireMerc = vi.fn()
// First click: selects candidate
wp.handleHireMercClick(200, 160, { onHireMerc })
expect(wp.hireSelectedIndex).toBe(0)
expect(wp.replaceMercConfirmOpen).toBe(false)
expect(onHireMerc).not.toHaveBeenCalled()
// Second click on selected candidate: triggers replacement warning dialog
wp.handleHireMercClick(200, 160, { onHireMerc })
expect(wp.replaceMercConfirmOpen).toBe(true)
expect(wp.pendingHireCandidate?.nameId).toBe(3411)
// Render with confirm dialog open
const ctx = createMockCtx()
wp.drawHireMercDialog(ctx)
expect(ctx.drawnTexts.some((t) => t.includes('This Mercenary will replace your current one.'))).toBe(true)
expect(ctx.drawnTexts.some((t) => t.includes('Yes'))).toBe(true)
expect(ctx.drawnTexts.some((t) => t.includes('No'))).toBe(true)
// Click "No" (cx + 150 = 400, cy + 128 = 318)
wp.handleHireMercClick(400, 318, { onHireMerc })
expect(wp.replaceMercConfirmOpen).toBe(false)
expect(wp.activePanel).toBe('hire')
expect(onHireMerc).not.toHaveBeenCalled()
// Open again and click "Yes" (cx + 150 = 400, cy + 104 = 294)
wp.handleHireMercClick(200, 160, { onHireMerc })
expect(wp.replaceMercConfirmOpen).toBe(true)
wp.handleHireMercClick(400, 294, { onHireMerc })
expect(wp.replaceMercConfirmOpen).toBe(false)
expect(onHireMerc).toHaveBeenCalledWith(150, 3411)
expect(wp.activePanel).toBe('none')
})
it('clicking cancel button closes hire dialog and re-opens NPC menu (0x6faf9090)', () => {
const wp = new WorldPanelsHud()
wp.openNpcMenuByName('Kashya', 400, 220)
expect(wp.npcMenu).not.toBeNull()
wp.clickNpcMenuOption('hire')
expect(wp.activePanel).toBe('hire')
expect(wp.npcMenu).toBeNull()
// Click Cancel button at dialogX + 245 = 400, dialogY + 315 = 420
const onClose = vi.fn()
wp.handleHireMercClick(400, 420, { onClose })
expect(wp.activePanel).toBe('none')
expect(onClose).toHaveBeenCalled()
expect(wp.npcMenu).not.toBeNull()
expect(wp.npcMenu?.npcName).toContain('Kashya')
})
})