185 lines
6.0 KiB
TypeScript
185 lines
6.0 KiB
TypeScript
/**
|
|
* tests/world-panels.test.ts
|
|
*
|
|
* Unit tests for Diablo II 1.13c Waypoint and World Panels UI Parity (Issue #378).
|
|
*/
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { WorldPanelsHud, ACT_WAYPOINTS } from '../src/ui/world-panels.ts'
|
|
import { D2FontRenderer } from '../src/ui/font.ts'
|
|
|
|
function getPngDimensions(filePath: string): { width: number; height: number } {
|
|
const buf = readFileSync(filePath)
|
|
if (buf.toString('ascii', 1, 4) !== 'PNG') throw new Error('Not a PNG file')
|
|
const width = buf.readUInt32BE(16)
|
|
const height = buf.readUInt32BE(20)
|
|
return { width, height }
|
|
}
|
|
|
|
describe('Issue #378: Diablo II 1.13c Waypoint Panel Tabs & Icons', () => {
|
|
it('verifies waypoint-tabs.png, waypoint-icons.png, and quest-tabs.png asset dimensions', () => {
|
|
const waypointTabsPath = join(process.cwd(), 'public/ui/waypoint-tabs.png')
|
|
const waypointIconsPath = join(process.cwd(), 'public/ui/waypoint-icons.png')
|
|
const questTabsPath = join(process.cwd(), 'public/ui/quest-tabs.png')
|
|
|
|
expect(existsSync(waypointTabsPath)).toBe(true)
|
|
expect(existsSync(waypointIconsPath)).toBe(true)
|
|
expect(existsSync(questTabsPath)).toBe(true)
|
|
|
|
// 10 frames of 63x31 = 630x31
|
|
const wpTabsDim = getPngDimensions(waypointTabsPath)
|
|
expect(wpTabsDim.width).toBe(630)
|
|
expect(wpTabsDim.height).toBe(31)
|
|
|
|
// 5 frames of 30x30 = 150x30
|
|
const wpIconsDim = getPngDimensions(waypointIconsPath)
|
|
expect(wpIconsDim.width).toBe(150)
|
|
expect(wpIconsDim.height).toBe(30)
|
|
|
|
// 10 frames of 63x31 = 630x31
|
|
const qTabsDim = getPngDimensions(questTabsPath)
|
|
expect(qTabsDim.width).toBe(630)
|
|
expect(qTabsDim.height).toBe(31)
|
|
})
|
|
|
|
it('renders waypoint tabs and icons using drawImage slices when assets are present', () => {
|
|
const hud = new WorldPanelsHud()
|
|
const font = new D2FontRenderer()
|
|
|
|
const drawImageCalls: any[] = []
|
|
const ctx = {
|
|
drawImage: vi.fn((...args: any[]) => {
|
|
drawImageCalls.push(args)
|
|
}),
|
|
fillRect: vi.fn(),
|
|
strokeRect: vi.fn(),
|
|
fillText: vi.fn(),
|
|
save: vi.fn(),
|
|
restore: vi.fn(),
|
|
measureText: vi.fn(() => ({ width: 60 })),
|
|
fillStyle: '',
|
|
strokeStyle: '',
|
|
globalAlpha: 1,
|
|
} as unknown as CanvasRenderingContext2D
|
|
|
|
const mockTabsImg = {
|
|
complete: true,
|
|
naturalWidth: 630,
|
|
naturalHeight: 31,
|
|
} as unknown as HTMLImageElement
|
|
|
|
const mockIconsImg = {
|
|
complete: true,
|
|
naturalWidth: 150,
|
|
naturalHeight: 30,
|
|
} as unknown as HTMLImageElement
|
|
|
|
hud.selectedActTab = 2
|
|
|
|
hud.drawLeftDockPanel(
|
|
ctx,
|
|
'waypoint',
|
|
{
|
|
questBgImg: null,
|
|
waypointBgImg: null,
|
|
borderLeftImg: null,
|
|
stashBgImg: null,
|
|
cubeBgImg: null,
|
|
vendorBgImg: null,
|
|
buySellBtnImg: null,
|
|
waypointTabsImg: mockTabsImg,
|
|
waypointIconsImg: mockIconsImg,
|
|
},
|
|
font,
|
|
)
|
|
|
|
// Verify 5 tab draws from mockTabsImg:
|
|
// Act 1 (inactive, frame 0 -> sx 0)
|
|
// Act 2 (active, frame 6 -> sx 6 * 63 = 378)
|
|
// Act 3 (inactive, frame 2 -> sx 2 * 63 = 126)
|
|
// Act 4 (inactive, frame 3 -> sx 3 * 63 = 189)
|
|
// Act 5 (inactive, frame 4 -> sx 4 * 63 = 252)
|
|
const tabDraws = drawImageCalls.filter((c) => c[0] === mockTabsImg)
|
|
expect(tabDraws.length).toBe(5)
|
|
expect(tabDraws[0][1]).toBe(0) // Act 1 sx
|
|
expect(tabDraws[1][1]).toBe(6 * 63) // Act 2 (active) sx = 378
|
|
expect(tabDraws[2][1]).toBe(2 * 63) // Act 3 sx = 126
|
|
|
|
// Verify waypoint icons drawn from mockIconsImg for Act 2 (frame index 1 -> sx 30)
|
|
const iconDraws = drawImageCalls.filter((c) => c[0] === mockIconsImg)
|
|
const act2WpCount = (ACT_WAYPOINTS[2] ?? []).length
|
|
expect(iconDraws.length).toBe(act2WpCount)
|
|
for (const call of iconDraws) {
|
|
expect(call[1]).toBe(30) // sx = 30 for Act 2
|
|
expect(call[3]).toBe(30) // sw = 30
|
|
expect(call[4]).toBe(30) // sh = 30
|
|
}
|
|
})
|
|
|
|
it('updates hoveredWaypointIdx on mouse move and highlights hovered waypoint', () => {
|
|
const hud = new WorldPanelsHud()
|
|
hud.selectedActTab = 1
|
|
const ox = 80
|
|
const oy = 60
|
|
|
|
// Hover over first waypoint (i = 0): wy = oy + 62 = 122 .. 153
|
|
hud.handleMouseMove(ox + 50, oy + 70)
|
|
expect(hud.hoveredWaypointIdx).toBe(0)
|
|
|
|
// Move away
|
|
hud.handleMouseMove(ox + 50, oy + 500)
|
|
expect(hud.hoveredWaypointIdx).toBeNull()
|
|
})
|
|
|
|
it('handles clicking waypoint tabs and locked vs unlocked waypoints', () => {
|
|
const hud = new WorldPanelsHud()
|
|
const onWaypointTeleport = vi.fn()
|
|
const onClose = vi.fn()
|
|
|
|
const ox = 80
|
|
const oy = 60
|
|
|
|
// 1. Click Act 3 tab (tab 3: logicalX approx ox + 8 + 2 * 61 + 10 = ox + 140, logicalY oy + 20)
|
|
const tabClickHandled = hud.handleLeftDockClick('waypoint', ox + 140, oy + 20, {
|
|
onWaypointTeleport,
|
|
onClose,
|
|
})
|
|
expect(tabClickHandled).toBe(true)
|
|
expect(hud.selectedActTab).toBe(3)
|
|
|
|
// 2. In Act 3:
|
|
// First waypoint (Kurast Docks) is unlocked
|
|
const wps = ACT_WAYPOINTS[3]!
|
|
expect(wps[0]!.unlocked).toBe(true)
|
|
|
|
// Temporarily mark index 1 as locked
|
|
;(wps[1] as any).unlocked = false
|
|
|
|
try {
|
|
// Click locked waypoint: should NOT trigger teleport or close
|
|
const lockedWy = oy + 62 + 1 * 36 + 10
|
|
const lockedClick = hud.handleLeftDockClick('waypoint', ox + 50, lockedWy, {
|
|
onWaypointTeleport,
|
|
onClose,
|
|
})
|
|
expect(lockedClick).toBe(true)
|
|
expect(onWaypointTeleport).not.toHaveBeenCalled()
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
|
|
// Click unlocked waypoint (index 0): should trigger teleport and close
|
|
const unlockedWy = oy + 62 + 0 * 36 + 10
|
|
const unlockedClick = hud.handleLeftDockClick('waypoint', ox + 50, unlockedWy, {
|
|
onWaypointTeleport,
|
|
onClose,
|
|
})
|
|
expect(unlockedClick).toBe(true)
|
|
expect(onWaypointTeleport).toHaveBeenCalledWith(3, wps[0]!.slug, wps[0]!.levelId)
|
|
expect(onClose).toHaveBeenCalled()
|
|
} finally {
|
|
;(wps[1] as any).unlocked = true
|
|
}
|
|
})
|
|
})
|