/** * Unit & Parity Tests for Diablo II v1.13c Authentic Dynamic Cursor System (Issue #148). * * Verifies: * 1. Offline DC6 Cursor Extraction & Metadata Parity: * - All 8 frames for protate.dc6, ohand.dc6, and orotate.dc6. * - Zero runtime MPQ/DLL dependencies. * 2. 3-State Cursor State Machine Transitions: * - Initial state is POINTING. * - Inactivity >= 2000ms advances to OPENING (plays ohand 8 frames sequentially). * - Completion of OPENING advances to OPEN_IDLE (loops orotate 8 frames). * - Any mouse movement or mouse event (mousedown/mouseup) resets the timer and reverts to POINTING. * 3. Coordinates, Hotspot Alignment, and Rendering Bounds: * - (mouseX, mouseY) tracking and bounds calculation. * - Held items (cursorItem) rendered centered at cursor hotspot. * 4. Integration with HudManager & Canvas. */ import { describe, expect, it, vi } from 'vitest' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { CursorController, type CursorState, type CursorRenderInfo, } from '../src/ui/cursor.ts' import { CURSOR_METADATA, type CursorAtlasMetadata, type CursorFrameMeta, } from '../src/ui/cursor-meta.ts' import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' import { HudManager } from '../src/ui/hud-manager.ts' describe('Diablo II v1.13c Dynamic Cursor System (Issue #148)', () => { describe('1. Asset Extraction & Atlas Metadata', () => { it('verifies public/ui/cursor.png exists and is non-empty', () => { const atlasPath = join(process.cwd(), 'public', 'ui', 'cursor.png') expect(existsSync(atlasPath)).toBe(true) const data = readFileSync(atlasPath) expect(data.length).toBeGreaterThan(0) }) it('loads authentic D2 1.13c cursor atlas metadata with 24 total frames (8 each)', () => { expect(CURSOR_METADATA.atlasWidth).toBe(256) expect(CURSOR_METADATA.atlasHeight).toBe(96) expect(CURSOR_METADATA.frameWidth).toBe(32) expect(CURSOR_METADATA.frameHeight).toBe(32) // Exactly 8 frames per animation expect(CURSOR_METADATA.protate).toHaveLength(8) expect(CURSOR_METADATA.ohand).toHaveLength(8) expect(CURSOR_METADATA.orotate).toHaveLength(8) // 24 frames indexed in frames map expect(Object.keys(CURSOR_METADATA.frames)).toHaveLength(24) }) it('verifies all frame coordinates are bounded inside the 256x96 atlas', () => { const allFrames = [ ...CURSOR_METADATA.protate, ...CURSOR_METADATA.ohand, ...CURSOR_METADATA.orotate, ] for (const frame of allFrames) { expect(frame.x).toBeGreaterThanOrEqual(0) expect(frame.y).toBeGreaterThanOrEqual(0) expect(frame.w).toBeGreaterThan(0) expect(frame.h).toBeGreaterThan(0) expect(frame.x + frame.w).toBeLessThanOrEqual(CURSOR_METADATA.atlasWidth) expect(frame.y + frame.h).toBeLessThanOrEqual(CURSOR_METADATA.atlasHeight) } }) it('verifies DC6 offset parity and hotspot anchor definitions', () => { // protate: offsetX = -1, hotspot = (0, 0) for (const frame of CURSOR_METADATA.protate) { expect(frame.offsetX).toBe(-1) expect(frame.offsetY).toBe(24) expect(frame.hotspotX).toBe(0) expect(frame.hotspotY).toBe(0) } // ohand: frames 0..3 have offsetX = -1, frames 4..7 have offsetX = -2 for (let i = 0; i < 4; i++) { expect(CURSOR_METADATA.ohand[i]!.offsetX).toBe(-1) } for (let i = 4; i < 8; i++) { expect(CURSOR_METADATA.ohand[i]!.offsetX).toBe(-2) } // orotate: all frames have offsetX = -2 for (const frame of CURSOR_METADATA.orotate) { expect(frame.offsetX).toBe(-2) expect(frame.offsetY).toBe(24) } }) it('verifies baked UI manifest includes cursorAtlas entry', () => { expect(BAKED_UI_MANIFEST.images.cursorAtlas).toBe('/ui/cursor.png') }) }) describe('2. State Machine Logic & Idle Transitions', () => { it('starts in POINTING state initially and while actively moving', () => { const cursor = new CursorController(CURSOR_METADATA, 0) expect(cursor.state).toBe('POINTING') // Mouse movements keep resetting idle timer cursor.handleMouseMove(100, 100, 500) expect(cursor.state).toBe('POINTING') expect(cursor.lastActivityTime).toBe(500) cursor.handleMouseMove(150, 120, 1200) expect(cursor.state).toBe('POINTING') expect(cursor.lastActivityTime).toBe(1200) // Below 2000ms idle, remains POINTING expect(cursor.update(3199)).toBe('POINTING') expect(cursor.state).toBe('POINTING') }) it('transitions to OPENING after 2000ms idle and plays 8 frames sequentially', () => { const cursor = new CursorController(CURSOR_METADATA, 1000) cursor.frameDurationMs = 125 cursor.openingFrameDurationMs = 125 // At exactly 2000ms idle (t = 3000ms) const stateAt2000 = cursor.update(3000) expect(stateAt2000).toBe('OPENING') expect(cursor.state).toBe('OPENING') // Check frame progression during OPENING (8 frames * 125ms = 1000ms total) // Frame 0 at t = 3000ms (0ms into opening) let info = cursor.getCurrentFrame(3000) expect(info.state).toBe('OPENING') expect(info.animation).toBe('ohand') expect(info.frameIndex).toBe(0) // Frame 1 at t = 3125ms (125ms into opening) info = cursor.getCurrentFrame(3125) expect(info.frameIndex).toBe(1) // Frame 4 at t = 3500ms (500ms into opening) info = cursor.getCurrentFrame(3500) expect(info.frameIndex).toBe(4) // Frame 7 at t = 3875ms (875ms into opening) info = cursor.getCurrentFrame(3875) expect(info.frameIndex).toBe(7) // Frame 7 clamped right before completion at t = 3999ms info = cursor.getCurrentFrame(3999) expect(info.state).toBe('OPENING') expect(info.frameIndex).toBe(7) }) it('advances from OPENING to OPEN_IDLE after ohand completes and loops orotate', () => { const cursor = new CursorController(CURSOR_METADATA, 0) cursor.frameDurationMs = 125 cursor.openingFrameDurationMs = 125 // 2000ms idle + 1000ms opening = 3000ms total to reach OPEN_IDLE expect(cursor.update(2999)).toBe('OPENING') expect(cursor.update(3000)).toBe('OPEN_IDLE') expect(cursor.state).toBe('OPEN_IDLE') // Frame 0 of orotate at t = 3000ms let info = cursor.getCurrentFrame(3000) expect(info.state).toBe('OPEN_IDLE') expect(info.animation).toBe('orotate') expect(info.frameIndex).toBe(0) // Frame 1 of orotate at t = 3125ms info = cursor.getCurrentFrame(3125) expect(info.frameIndex).toBe(1) // Frame 7 of orotate at t = 3875ms info = cursor.getCurrentFrame(3875) expect(info.frameIndex).toBe(7) // Loops back to frame 0 at t = 4000ms (1000ms into open idle) info = cursor.getCurrentFrame(4000) expect(info.state).toBe('OPEN_IDLE') expect(info.frameIndex).toBe(0) // Loops to frame 3 at t = 4375ms info = cursor.getCurrentFrame(4375) expect(info.frameIndex).toBe(3) }) it('reverts immediately to POINTING upon mouse movement at any stage', () => { const cursor = new CursorController(CURSOR_METADATA, 0) // Fast forward to OPEN_IDLE (t = 5000ms) cursor.update(5000) expect(cursor.state).toBe('OPEN_IDLE') // Mouse moves cursor.handleMouseMove(400, 300, 5001) expect(cursor.state).toBe('POINTING') expect(cursor.lastActivityTime).toBe(5001) expect(cursor.mouseX).toBe(400) expect(cursor.mouseY).toBe(300) const info = cursor.getCurrentFrame(5001) expect(info.state).toBe('POINTING') expect(info.animation).toBe('protate') }) it('reverts immediately to POINTING upon mouse click (mousedown/mouseup) events', () => { const cursor = new CursorController(CURSOR_METADATA, 0) // In OPENING state (t = 2500ms) cursor.update(2500) expect(cursor.state).toBe('OPENING') // Mousedown triggers reset cursor.handleMouseDown(2501) expect(cursor.state).toBe('POINTING') // Advance again to OPENING (t = 4501ms) cursor.update(4502) expect(cursor.state).toBe('OPENING') // Mouseup triggers reset cursor.handleMouseUp(4503) expect(cursor.state).toBe('POINTING') }) }) describe('3. Coordinates, Hotspot & Rendering Bounds', () => { it('accurately aligns draw coordinates with mouse position and frame hotspots', () => { const cursor = new CursorController(CURSOR_METADATA, 0) cursor.handleMouseMove(320, 240, 0) const info = cursor.getCurrentFrame(0) expect(info.drawX).toBe(320) expect(info.drawY).toBe(240) expect(info.bounds).toEqual({ x: 320, y: 240, width: info.frame.w, height: info.frame.h, }) const bounds = cursor.getRenderBounds(0) expect(bounds).toEqual(info.bounds) }) it('renders hand cursor frames onto Canvas 2D context', () => { const cursor = new CursorController(CURSOR_METADATA, 0) cursor.handleMouseMove(100, 200, 0) const mockCtx = { drawImage: vi.fn(), } as unknown as CanvasRenderingContext2D const mockAtlas = {} as CanvasImageSource cursor.render(mockCtx, mockAtlas, 0) expect(mockCtx.drawImage).toHaveBeenCalledTimes(1) const frame0 = CURSOR_METADATA.protate[0]! expect(mockCtx.drawImage).toHaveBeenCalledWith( mockAtlas, frame0.x, frame0.y, frame0.w, frame0.h, 100, 200, frame0.w, frame0.h, ) }) it('renders held item centered at cursor hotspot when cursorItem is present', () => { const cursor = new CursorController(CURSOR_METADATA, 0) cursor.handleMouseMove(400, 300, 0) const mockCtx = { drawImage: vi.fn(), } as unknown as CanvasRenderingContext2D const mockItemsAtlas = {} as CanvasImageSource const mockCursorAtlas = {} as CanvasImageSource // Held 2x3 broad sword (width 28, height 84) cursor.render(mockCtx, mockCursorAtlas, 0, { atlas: mockItemsAtlas, rect: { x: 50, y: 100, w: 28, h: 84 }, }) // Held item should be drawn centered at mouseX=400, mouseY=300 expect(mockCtx.drawImage).toHaveBeenCalledTimes(1) expect(mockCtx.drawImage).toHaveBeenCalledWith( mockItemsAtlas, 50, 100, 28, 84, 400 - 14, // 386 300 - 42, // 258 28, 84, ) }) }) describe('4. HudManager Integration', () => { it('initializes HudManager with cursor: none and tracks cursor state in published state', () => { const mockCanvas = { style: {} as CSSStyleDeclaration, width: 800, height: 600, getContext: vi.fn(() => ({ save: vi.fn(), restore: vi.fn(), clearRect: vi.fn(), translate: vi.fn(), scale: vi.fn(), drawImage: vi.fn(), })), addEventListener: vi.fn(), getBoundingClientRect: vi.fn(() => ({ left: 0, top: 0, width: 800, height: 600, })), } as unknown as HTMLCanvasElement const hud = new HudManager(mockCanvas, { onToggleAutomap: vi.fn(), onWaypointTeleport: vi.fn(), }) // Canvas cursor style hidden expect(mockCanvas.style.cursor).toBe('none') // Initial cursor state expect(hud.cursor).toBeInstanceOf(CursorController) expect(hud.cursor.state).toBe('POINTING') expect(hud.state.cursorState).toBe('POINTING') // Moving mouse updates hud.cursor position and stays POINTING hud.cursor.handleMouseMove(200, 150, 100) hud.syncPublishedState() expect(hud.cursor.mouseX).toBe(200) expect(hud.cursor.mouseY).toBe(150) expect(hud.state.cursorState).toBe('POINTING') // Advancing past 2000ms idle updates hud.cursor and published state to OPENING hud.cursor.update(2200) hud.syncPublishedState() expect(hud.cursor.state).toBe('OPENING') expect(hud.state.cursorState).toBe('OPENING') // Advancing past opening duration updates to OPEN_IDLE hud.cursor.update(3500) hud.syncPublishedState() expect(hud.cursor.state).toBe('OPEN_IDLE') expect(hud.state.cursorState).toBe('OPEN_IDLE') }) }) })