/** * 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() }) })