diablo2-web/tests/cursor.test.ts

369 lines
12 KiB
TypeScript

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