diablo2-web/tests/world-panels.test.ts

270 lines
9.6 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
// Set current waypoint to Lut Gholein (waypointId: 9, levelId: 40)
hud.setCurrentWaypoint(9, 40)
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 1 -> sx 1 * 63 = 63)
// Act 2 (active, frame 2 -> sx 2 * 63 = 126)
// Act 3 (inactive, frame 5 -> sx 5 * 63 = 315)
// Act 4 (inactive, frame 7 -> sx 7 * 63 = 441)
// Act 5 (inactive, frame 9 -> sx 9 * 63 = 567)
const tabDraws = drawImageCalls.filter((c) => c[0] === mockTabsImg)
expect(tabDraws.length).toBe(5)
expect(tabDraws[0][1]).toBe(1 * 63) // Act 1 (inactive) sx = 63
expect(tabDraws[1][1]).toBe(2 * 63) // Act 2 (active) sx = 126
expect(tabDraws[2][1]).toBe(5 * 63) // Act 3 (inactive) sx = 315
// Verify waypoint icons drawn from mockIconsImg for Act 2:
// Ground Truth D2Client.dll 1.13c:
// - Index 0 (Lut Gholein): current waypoint -> Frame 0 (sx = 0)
// - Indices 1..8: activated destination waypoints -> Frame 3 (sx = 90)
const iconDraws = drawImageCalls.filter((c) => c[0] === mockIconsImg)
const act2WpCount = (ACT_WAYPOINTS[2] ?? []).length
expect(iconDraws.length).toBe(act2WpCount)
expect(iconDraws[0][1]).toBe(0) // Current waypoint: Frame 0 (sx = 0)
for (let i = 1; i < act2WpCount; i++) {
expect(iconDraws[i][1]).toBe(90) // Destination waypoints: Frame 3 (sx = 90)
expect(iconDraws[i][3]).toBe(30)
expect(iconDraws[i][4]).toBe(30)
}
})
it('Issue #393: renders hovered states and omits locked waypoint icons per D2 1.13c parity', () => {
const hud = new WorldPanelsHud()
const font = new D2FontRenderer()
const mockTabsImg = {
complete: true,
naturalWidth: 630,
naturalHeight: 31,
} as unknown as HTMLImageElement
const mockIconsImg = {
complete: true,
naturalWidth: 150,
naturalHeight: 30,
} as unknown as HTMLImageElement
const assets = {
questBgImg: null,
waypointBgImg: null,
borderLeftImg: null,
stashBgImg: null,
cubeBgImg: null,
vendorBgImg: null,
buySellBtnImg: null,
waypointTabsImg: mockTabsImg,
waypointIconsImg: mockIconsImg,
}
// 1. In Act 4 (3 waypoints: Pandemonium Fortress, City of the Damned, River of Flame)
hud.selectedActTab = 4
hud.setCurrentWaypoint(27, 103) // Pandemonium Fortress is current waypoint
// Hover over current waypoint (index 0)
hud.hoveredWaypointIdx = 0
let drawCalls: any[] = []
const ctx = {
drawImage: vi.fn((...args: any[]) => drawCalls.push(args)),
fillRect: vi.fn(),
strokeRect: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
measureText: vi.fn(() => ({ width: 60 })),
fillText: vi.fn(),
} as unknown as CanvasRenderingContext2D
hud.drawLeftDockPanel(ctx, 'waypoint', assets, font)
let iconDraws = drawCalls.filter((c) => c[0] === mockIconsImg)
expect(iconDraws.length).toBe(3)
expect(iconDraws[0][1]).toBe(30) // Hovered current waypoint: Frame 1 (sx = 30)
expect(iconDraws[1][1]).toBe(90) // Unhovered destination waypoint: Frame 3 (sx = 90)
expect(iconDraws[2][1]).toBe(90) // Unhovered destination waypoint: Frame 3 (sx = 90)
// Hover over destination waypoint (index 1: City of the Damned)
hud.hoveredWaypointIdx = 1
drawCalls = []
hud.drawLeftDockPanel(ctx, 'waypoint', assets, font)
iconDraws = drawCalls.filter((c) => c[0] === mockIconsImg)
expect(iconDraws.length).toBe(3)
expect(iconDraws[0][1]).toBe(0) // Unhovered current waypoint: Frame 0 (sx = 0)
expect(iconDraws[1][1]).toBe(120) // Hovered destination waypoint: Frame 4 (sx = 120)
expect(iconDraws[2][1]).toBe(90) // Unhovered destination waypoint: Frame 3 (sx = 90)
// 2. Locked waypoint: locked waypoints have NO icon drawn per D2Client.dll 0x6fb5b308 (je 0x6fb5b355)
hud.hoveredWaypointIdx = null
hud.setWaypointUnlocked(29, false) // Lock River of Flame (index 2)
drawCalls = []
hud.drawLeftDockPanel(ctx, 'waypoint', assets, font)
iconDraws = drawCalls.filter((c) => c[0] === mockIconsImg)
expect(iconDraws.length).toBe(2) // Only index 0 and 1 drawn, index 2 omitted
expect(iconDraws[0][1]).toBe(0) // Frame 0
expect(iconDraws[1][1]).toBe(90) // Frame 3
})
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:
// Set Kurast Docks (index 0, waypointId: 18, levelId: 75) as current waypoint
hud.setCurrentWaypoint(18, 75)
const wps = ACT_WAYPOINTS[3]!
// Mark index 1 (Spider Forest, waypointId: 19) as locked via hud method
hud.setWaypointUnlocked(19, false)
// Click locked waypoint (index 1): 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 current waypoint (index 0, Kurast Docks): should NOT trigger teleport (player already here)
const currentWy = oy + 62 + 0 * 36 + 10
const currentClick = hud.handleLeftDockClick('waypoint', ox + 50, currentWy, {
onWaypointTeleport,
onClose,
})
expect(currentClick).toBe(true)
expect(onWaypointTeleport).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
// Click unlocked destination waypoint (index 2, Great Marsh): should trigger teleport and close
const destWy = oy + 62 + 2 * 36 + 10
const destClick = hud.handleLeftDockClick('waypoint', ox + 50, destWy, {
onWaypointTeleport,
onClose,
})
expect(destClick).toBe(true)
expect(onWaypointTeleport).toHaveBeenCalledWith(3, wps[2]!.slug, wps[2]!.levelId)
expect(onClose).toHaveBeenCalled()
})
})