369 lines
12 KiB
TypeScript
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')
|
|
})
|
|
})
|
|
})
|