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