diablo2-web/tests/challenger-m3-font-cursor-s...

841 lines
32 KiB
TypeScript

/**
* Empirical Verification & Adversarial Stress Tests:
* Batch 3 Parity Gate (Issue #642, #643, #646, #650, #651)
*
* Verifies:
* 1. Font parsing and metric calculations (src/client/ui/font.ts):
* - Narrow characters (', |, i, l, !, .) retain true widths (2-3px) without synthetic min-width 4 clamping.
* - Chinese DC6 typography metrics (lineMultiplier 14, lineAdvance 14, baselineConstant 2).
* - PL2 13-color shift matrices and text formatting codes (ÿc< = #00c800, ÿc4 = gold, all 13 colors).
* - Drop shadow removal (shadow defaults to false, zero unrequested black outline artifacts).
* 2. Cursor state machine (src/client/ui/cursor.ts):
* - Discrete state transitions: POINTING -> OPENING -> OPEN_IDLE -> RELEASE -> POINTING, and CLICK_DOWN.
* - Authentic 5000ms idle timer countdown with 8.8 fixed-point counters and rewind on movement.
* - Fail-fast on missing cursor artwork (zero canvas solid rectangles, reports missing art).
* 3. Adversarial Fuzzing & Differential Oracles:
* - 1,000-iteration random string parsing & width oracle.
* - 1,000-step cursor event & timeline stress simulator.
*/
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import {
D2FontRenderer,
D2_COLOR_HEX,
D2_PL2_TEXT_COLORS,
D2_COLOR_INDEX_TO_NAME,
CODE_CHAR_TO_COLOR,
D2_FONT_METRICS_CONSTANTS,
computeFontMetrics113c,
parseFontTblMetrics,
parseColoredText,
FONT_BASELINE_OFFSET,
FONT_PX_SIZE,
type D2ColorCode,
type D2FontName,
type FontAtlasMeta,
} from '../src/client/ui/font.ts'
import {
CursorController,
type CursorState,
BUYSELL_CURSOR_FRAMES,
} from '../src/client/ui/cursor.ts'
import {
CURSOR_METADATA,
type CursorAtlasMetadata,
} from '../src/common/items/cursor-meta.ts'
import {
getMissingArtEntries,
clearMissingArtEntries,
} from '../src/client/scene/world-renderer.ts'
describe('Challenger M3: Font & Cursor Adversarial Stress Verification', () => {
const metricsJsonPath = join(process.cwd(), 'public', 'ui', 'fonts', 'metrics.json')
let metricsData: Record<string, FontAtlasMeta>
beforeEach(() => {
clearMissingArtEntries()
if (existsSync(metricsJsonPath)) {
metricsData = JSON.parse(readFileSync(metricsJsonPath, 'utf8'))
}
})
// =========================================================================
// Section 1: Narrow Characters & True Glyph Widths (Zero 4px Clamping)
// =========================================================================
describe('1. Font Metrics: Narrow Characters & Anti-Clamping', () => {
it('preserves true narrow glyph widths (2px - 3px) across font atlases without min-width 4 clamping', () => {
expect(metricsData).toBeDefined()
// font6 narrow characters
const f6 = metricsData.font6!
expect(f6).toBeDefined()
expect(f6.glyphs[39]!.width).toBe(2) // "'" -> true width 2px
expect(f6.glyphs[124]!.width).toBe(2) // "|" -> true width 2px
expect(f6.glyphs[46]!.width).toBe(3) // "." -> true width 3px
expect(f6.glyphs[44]!.width).toBe(3) // "," -> true width 3px
expect(f6.glyphs[58]!.width).toBe(3) // ":" -> true width 3px
expect(f6.glyphs[59]!.width).toBe(3) // ";" -> true width 3px
expect(f6.glyphs[96]!.width).toBe(3) // "`" -> true width 3px
// font8 narrow characters
const f8 = metricsData.font8!
expect(f8.glyphs[39]!.width).toBe(3) // "'" -> true width 3px
// font16 narrow characters
const f16 = metricsData.font16!
expect(f16.glyphs[124]!.width).toBe(3) // "|" -> true width 3px
// fontexocet10 narrow characters
const fExo = metricsData.fontexocet10!
expect(fExo.glyphs[124]!.width).toBe(3) // "|" -> true width 3px
// fontformal12 narrow characters
const fFormal = metricsData.fontformal12!
expect(fFormal.glyphs[39]!.width).toBe(3) // "'" -> true width 3px
})
it('computes exact un-clamped string measurement in measureText for narrow character sequences', () => {
const renderer = new D2FontRenderer()
const mockAtlas = { width: 256, height: 256 } as unknown as HTMLImageElement
renderer.setFontAtlas('font6', metricsData.font6!, mockAtlas)
renderer.setFontAtlas('font8', metricsData.font8!, mockAtlas)
renderer.setFontAtlas('font16', metricsData.font16!, mockAtlas)
// Single characters
expect(renderer.measureText("'", 'font6')).toBe(2)
expect(renderer.measureText('|', 'font6')).toBe(2)
expect(renderer.measureText('.', 'font6')).toBe(3)
expect(renderer.measureText("'", 'font8')).toBe(3)
expect(renderer.measureText('|', 'font16')).toBe(3)
// Repetitive sequences: if clamped to 4, "'".repeat(10) would be 40px instead of 20px
expect(renderer.measureText("'".repeat(10), 'font6')).toBe(20)
expect(renderer.measureText('|'.repeat(10), 'font6')).toBe(20)
expect(renderer.measureText('.'.repeat(10), 'font6')).toBe(30)
expect(renderer.measureText("'".repeat(10), 'font8')).toBe(30)
expect(renderer.measureText('|'.repeat(10), 'font16')).toBe(30)
// Mixed narrow sequence: "'" (2) + "|" (2) + "." (3) + ":" (3) = 10px in font6
expect(renderer.measureText("'|.:", 'font6')).toBe(10)
})
it('advances drawText cursor strictly by true glyph width without synthetic 4px jump', () => {
const renderer = new D2FontRenderer()
const mockAtlas = { width: 256, height: 256 } as unknown as HTMLImageElement
renderer.setFontAtlas('font6', metricsData.font6!, mockAtlas)
const drawCalls: Array<{ sx: number; dx: number; dw: number }> = []
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
measureText: vi.fn((t: string) => ({ width: t.length * 5 })),
fillText: vi.fn(),
drawImage: vi.fn((_img, sx, _sy, sw, _sh, dx, _dy, dw, _dh) => {
drawCalls.push({ sx, dx, dw })
}),
font: '',
textBaseline: 'alphabetic',
} as unknown as CanvasRenderingContext2D
// Draw 4 single quotes in font6 starting at x = 100
renderer.drawText(mockCtx, "''''", 100, 50, { font: 'font6', shadow: false })
// Exactly 4 drawImage calls (one per glyph, no shadow)
expect(drawCalls).toHaveLength(4)
expect(drawCalls[0]!.dx).toBe(100)
expect(drawCalls[1]!.dx).toBe(102) // 100 + 2
expect(drawCalls[2]!.dx).toBe(104) // 102 + 2
expect(drawCalls[3]!.dx).toBe(106) // 104 + 2
// Verify the advance difference between each glyph is strictly 2px, NEVER 4px
for (let i = 1; i < drawCalls.length; i++) {
const step = drawCalls[i]!.dx - drawCalls[i - 1]!.dx
expect(step).toBe(2)
}
})
it('parses raw font tbl binaries preserving narrow widths 1..3 with zero clamping or fallbacks', () => {
// Construct a valid Woo!\x01 3596-byte font tbl buffer with narrow widths
const buf = new Uint8Array(12 + 256 * 14)
// Magic: 'Woo!\x01'
buf[0] = 0x57 // 'W'
buf[1] = 0x6f // 'o'
buf[2] = 0x6f // 'o'
buf[3] = 0x21 // '!'
buf[4] = 0x01
buf[10] = 12 // nHeight
buf[11] = 8 // nWidth
// Character 39 ("'"): width = 1, height = 5
const off39 = 12 + 39 * 14
buf[off39 + 3] = 1
buf[off39 + 4] = 5
// Character 124 ("|"): width = 2, height = 8
const off124 = 12 + 124 * 14
buf[off124 + 3] = 2
buf[off124 + 4] = 8
// Character 46 ("."): width = 3, height = 3
const off46 = 12 + 46 * 14
buf[off46 + 3] = 3
buf[off46 + 4] = 3
const parsed = parseFontTblMetrics(buf, 'ENG')
expect(parsed.widths[39]).toBe(1)
expect(parsed.widths[124]).toBe(2)
expect(parsed.widths[46]).toBe(3)
expect(parsed.lineHeight).toBe(12)
expect(parsed.capHeight).toBe(8)
})
})
// =========================================================================
// Section 2: Chinese DC6 Typography Metrics (14, 14, 2)
// =========================================================================
describe('2. Chinese DC6 Typography Metrics (1.13c D2WinFont.cpp:119-124)', () => {
it('defines authentic 1.13c constants for Latin (16/16/3) and Chinese (14/14/2)', () => {
expect(D2_FONT_METRICS_CONSTANTS.latin).toEqual({
lineMultiplier: 16,
lineAdvance: 16,
baselineConstant: 3,
})
expect(D2_FONT_METRICS_CONSTANTS.chi).toEqual({
lineMultiplier: 14,
lineAdvance: 14,
baselineConstant: 2,
})
})
it('computes 1.13c metrics across variable font heights with language-specific formulas', () => {
// In Chinese mode:
// lineHeight = round(14 * nHeight / 10)
// lineAdvance = round(14 * nHeight / 10)
// baseline = round(2 * nHeight / 10)
const heights = [10, 12, 14, 16, 20, 24, 30]
for (const h of heights) {
const chi = computeFontMetrics113c(h, 'CHI')
expect(chi.lineHeight).toBe(Math.round((14 * h) / 10))
expect(chi.lineAdvance).toBe(Math.round((14 * h) / 10))
expect(chi.baseline).toBe(Math.round((2 * h) / 10))
const eng = computeFontMetrics113c(h, 'ENG')
expect(eng.lineHeight).toBe(Math.round((16 * h) / 10))
expect(eng.lineAdvance).toBe(Math.round((16 * h) / 10))
expect(eng.baseline).toBe(Math.round((3 * h) / 10))
}
// Exact evaluation for base nHeight = 10
const chi10 = computeFontMetrics113c(10, 'CHI')
expect(chi10).toEqual({
lineHeight: 14,
lineAdvance: 14,
baseline: 2,
})
})
it('applies 14/16 baseline adjustment to drawText when lang is CHI', () => {
const renderer = new D2FontRenderer()
const mockAtlas = { width: 512, height: 512 } as unknown as HTMLImageElement
renderer.setFontAtlas('font16', metricsData.font16!, mockAtlas)
const dyCalls: number[] = []
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
measureText: vi.fn(() => ({ width: 10 })),
fillText: vi.fn(),
drawImage: vi.fn((_img, _sx, _sy, _sw, _sh, _dx, dy) => {
dyCalls.push(dy)
}),
font: '',
textBaseline: 'alphabetic',
} as unknown as CanvasRenderingContext2D
const baseY = 200
// 1. ENG mode for font16 (baseline offset 13)
renderer.drawText(mockCtx, 'A', 100, baseY, { font: 'font16', lang: 'ENG', shadow: false })
expect(dyCalls[0]).toBe(baseY - FONT_BASELINE_OFFSET.font16) // 200 - 13 = 187
// 2. CHI mode for font16 (baseline offset = Math.round(13 * 14 / 16) = 11)
dyCalls.length = 0
renderer.drawText(mockCtx, 'A', 100, baseY, { font: 'font16', lang: 'CHI', shadow: false })
const expectedChiOffset = Math.round(FONT_BASELINE_OFFSET.font16 * (14 / 16))
expect(expectedChiOffset).toBe(11)
expect(dyCalls[0]).toBe(baseY - expectedChiOffset) // 200 - 11 = 189
})
})
// =========================================================================
// Section 3: PL2 13-Color Shift Matrices & Formatting Codes
// =========================================================================
describe('3. PL2 13-Color Shift Matrices & Text Formatting Codes', () => {
it('maps all 13 canonical color codes 0..9, :, ;, < matching D2WinFont.cpp:826-836', () => {
expect(D2_COLOR_INDEX_TO_NAME).toHaveLength(13)
expect(D2_COLOR_INDEX_TO_NAME).toEqual([
'white', // 0
'red', // 1
'green', // 2
'blue', // 3
'gold', // 4
'gray', // 5
'black', // 6
'tan', // 7
'orange', // 8
'yellow', // 9
'darkgreen', // 10 (':')
'purple', // 11 (';')
'brightgreen', // 12 ('<')
])
expect(CODE_CHAR_TO_COLOR['<']).toBe('brightgreen')
expect(CODE_CHAR_TO_COLOR['4']).toBe('gold')
expect(CODE_CHAR_TO_COLOR[':']).toBe('darkgreen')
expect(CODE_CHAR_TO_COLOR[';']).toBe('purple')
})
it('defines authentic RGB colors in D2_PL2_TEXT_COLORS and D2_COLOR_HEX', () => {
// ÿc< = #00c800 (brightgreen)
expect(D2_PL2_TEXT_COLORS.brightgreen).toBe('#00c800')
expect(D2_COLOR_HEX.brightgreen).toBe('#00c800')
// ÿc4 = gold (#c7b377 in pal.pl2, #c8a15a in D2_COLOR_HEX for UI compatibility)
expect(D2_PL2_TEXT_COLORS.gold).toBe('#c7b377')
expect(D2_COLOR_HEX.gold).toBe('#c8a15a')
// Verify all 13 colors exist in both maps
for (const colorName of D2_COLOR_INDEX_TO_NAME) {
expect(D2_PL2_TEXT_COLORS[colorName]).toMatch(/^#[0-9a-f]{6}$/i)
expect(D2_COLOR_HEX[colorName]).toMatch(/^#[0-9a-f]{6}$/i)
}
})
it('parses formatted strings with ÿc< and ÿc4 into colored spans', () => {
const text = 'ÿc4Gold Item ÿc<Set Item'
const spans = parseColoredText(text)
expect(spans).toEqual([
{ text: 'Gold Item ', color: 'gold' },
{ text: 'Set Item', color: 'brightgreen' },
])
// Test with \xffc prefix as well
const hexPrefix = '\xffc<Bright Green\xffc4Gold'
const spansHex = parseColoredText(hexPrefix)
expect(spansHex).toEqual([
{ text: 'Bright Green', color: 'brightgreen' },
{ text: 'Gold', color: 'gold' },
])
})
it('resets color to white on out-of-range codes (>= 13) per D2WinFont.cpp:832', () => {
// In D2WinFont.cpp:832:
// v11 = v14 - '0';
// if (v11 >= '\r') v11 = 0; ('\r' = 13)
// Character '=' has ASCII 61 -> 61 - 48 = 13 -> resets to 0 ('white')
const text = 'ÿc4Gold ÿc=InvalidCode'
const spans = parseColoredText(text)
expect(spans).toEqual([
{ text: 'Gold ', color: 'gold' },
{ text: 'InvalidCode', color: 'white' },
])
// Character '>' has ASCII 62 -> 62 - 48 = 14 -> resets to 0 ('white')
const text2 = 'ÿc4Gold ÿc>InvalidCode'
const spans2 = parseColoredText(text2)
expect(spans2).toEqual([
{ text: 'Gold ', color: 'gold' },
{ text: 'InvalidCode', color: 'white' },
])
})
it('handles all 13 codes sequentially in a single stress string', () => {
let testStr = ''
const chars = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';', '<']
for (let i = 0; i < chars.length; i++) {
testStr += `ÿc${chars[i]}T${i}`
}
const spans = parseColoredText(testStr)
expect(spans).toHaveLength(13)
for (let i = 0; i < 13; i++) {
expect(spans[i]!.color).toBe(D2_COLOR_INDEX_TO_NAME[i])
expect(spans[i]!.text).toBe(`T${i}`)
}
})
})
// =========================================================================
// Section 4: Drop Shadow Removal & Integrity
// =========================================================================
describe('4. Drop Shadow Removal: Zero Unrequested Black Outline Artifacts', () => {
it('defaults shadow to false in drawText (no unrequested shadow pass)', () => {
const renderer = new D2FontRenderer()
const mockAtlas = { width: 256, height: 256 } as unknown as HTMLImageElement
renderer.setFontAtlas('font16', metricsData.font16!, mockAtlas)
const drawImageCalls: Array<{ dx: number; dy: number }> = []
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
measureText: vi.fn(() => ({ width: 10 })),
fillText: vi.fn(),
drawImage: vi.fn((_img, _sx, _sy, _sw, _sh, dx, dy) => {
drawImageCalls.push({ dx, dy })
}),
font: '',
textBaseline: 'alphabetic',
} as unknown as CanvasRenderingContext2D
// When options.shadow is omitted entirely
renderer.drawText(mockCtx, 'A', 100, 100, { font: 'font16', color: 'gold' })
// MUST be exactly 1 call (foreground only, ZERO shadow call at dx+1, dy+1)
expect(drawImageCalls).toHaveLength(1)
expect(drawImageCalls[0]!.dx).toBe(100)
expect(drawImageCalls[0]!.dy).toBe(100 - FONT_BASELINE_OFFSET.font16)
})
it('renders exactly 2 passes per glyph when shadow is explicitly set to true', () => {
const renderer = new D2FontRenderer()
const mockAtlas = { width: 256, height: 256 } as unknown as HTMLImageElement
renderer.setFontAtlas('font16', metricsData.font16!, mockAtlas)
const drawImageCalls: Array<{ dx: number; dy: number }> = []
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
measureText: vi.fn(() => ({ width: 10 })),
fillText: vi.fn(),
drawImage: vi.fn((_img, _sx, _sy, _sw, _sh, dx, dy) => {
drawImageCalls.push({ dx, dy })
}),
font: '',
textBaseline: 'alphabetic',
} as unknown as CanvasRenderingContext2D
// Explicit shadow: true
renderer.drawText(mockCtx, 'A', 100, 100, { font: 'font16', color: 'gold', shadow: true })
// MUST be exactly 2 calls: shadow at (dx+1, dy+1), then foreground at (dx, dy)
expect(drawImageCalls).toHaveLength(2)
const expectedY = 100 - FONT_BASELINE_OFFSET.font16
expect(drawImageCalls[0]).toEqual({ dx: 101, dy: expectedY + 1 })
expect(drawImageCalls[1]).toEqual({ dx: 100, dy: expectedY })
})
it('does not emit 3-point shadow outline for CJK text when shadow is false', () => {
const renderer = new D2FontRenderer()
const fillTextCalls: Array<{ text: string; fillStyle: string; x: number; y: number }> = []
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
measureText: vi.fn((t: string) => ({ width: t.length * 14 })),
fillText: vi.fn(function (this: { fillStyle: string }, text, x, y) {
fillTextCalls.push({ text, fillStyle: this.fillStyle, x, y })
}),
font: '',
textBaseline: 'alphabetic',
fillStyle: '',
} as unknown as CanvasRenderingContext2D
// Default shadow (false)
renderer.drawText(mockCtx, '测试文本', 100, 100, { color: 'white' })
// Exactly 1 call with foreground color, 0 calls with black (#000000)
expect(fillTextCalls).toHaveLength(1)
expect(fillTextCalls[0]!.fillStyle).toBe(D2_COLOR_HEX.white)
expect(fillTextCalls.some(c => c.fillStyle === '#000000')).toBe(false)
})
})
// =========================================================================
// Section 5: Cursor State Machine Discrete Transitions & 5000ms Idle
// =========================================================================
describe('5. Cursor State Machine: 5000ms Idle & Fixed-Point Transitions', () => {
it('maintains POINTING state during active mouse movements or idle < 5000ms', () => {
const cursor = new CursorController(CURSOR_METADATA, 0)
expect(cursor.state).toBe('POINTING')
expect(cursor.fixedPointCounter).toBe(0)
// Active movements
cursor.handleMouseMove(50, 50, 1000)
expect(cursor.state).toBe('POINTING')
cursor.handleMouseMove(80, 90, 2500)
expect(cursor.state).toBe('POINTING')
// Idle at 4999ms from last activity (2500 + 4999 = 7499ms)
expect(cursor.update(7499)).toBe('POINTING')
expect(cursor.state).toBe('POINTING')
expect(cursor.fixedPointCounter).toBe(0)
})
it('transitions to OPENING at exactly 5000ms idle and advances 8 frames in 8.8 fixed-point', () => {
const cursor = new CursorController(CURSOR_METADATA, 0)
cursor.frameDurationMs = 125
cursor.openingFrameDurationMs = 125
// At exactly 5000ms idle
expect(cursor.update(5000)).toBe('OPENING')
expect(cursor.state).toBe('OPENING')
// Frame 0 at t = 5000ms: fixedPointCounter = 0
let frame = cursor.getCurrentFrame(5000)
expect(frame.state).toBe('OPENING')
expect(frame.animation).toBe('ohand')
expect(frame.frameIndex).toBe(0)
expect(cursor.fixedPointCounter).toBe(0)
// Halfway through frame 0 (t = 5062.5ms): frame 0, frac ~ 128
cursor.getCurrentFrame(5062)
expect(cursor.fixedPointCounter >> 8).toBe(0)
expect(cursor.fixedPointCounter & 0xff).toBeGreaterThan(120)
// Frame 1 at t = 5125ms: fixedPointCounter = 1 << 8 = 256
frame = cursor.getCurrentFrame(5125)
expect(frame.frameIndex).toBe(1)
expect(cursor.fixedPointCounter >> 8).toBe(1)
// Frame 4 at t = 5500ms: fixedPointCounter = 4 << 8 = 1024
frame = cursor.getCurrentFrame(5500)
expect(frame.frameIndex).toBe(4)
expect(cursor.fixedPointCounter >> 8).toBe(4)
// Frame 7 at t = 5875ms: fixedPointCounter = 7 << 8 = 1792
frame = cursor.getCurrentFrame(5875)
expect(frame.frameIndex).toBe(7)
expect(cursor.fixedPointCounter >> 8).toBe(7)
// Clamped at frame 7 at t = 5999ms
frame = cursor.getCurrentFrame(5999)
expect(frame.state).toBe('OPENING')
expect(frame.frameIndex).toBe(7)
})
it('transitions from OPENING to OPEN_IDLE after 6000ms (5000ms idle + 1000ms opening) and loops orotate', () => {
const cursor = new CursorController(CURSOR_METADATA, 0)
cursor.frameDurationMs = 125
cursor.openingFrameDurationMs = 125
// Exactly at 6000ms
expect(cursor.update(6000)).toBe('OPEN_IDLE')
expect(cursor.state).toBe('OPEN_IDLE')
expect(cursor.fixedPointCounter).toBe(8 << 8)
// Loops orotate frames 0..7
let frame = cursor.getCurrentFrame(6000)
expect(frame.animation).toBe('orotate')
expect(frame.frameIndex).toBe(0)
frame = cursor.getCurrentFrame(6125)
expect(frame.frameIndex).toBe(1)
frame = cursor.getCurrentFrame(6875)
expect(frame.frameIndex).toBe(7)
// Loops back to frame 0 at 7000ms (1000ms after OPEN_IDLE start)
frame = cursor.getCurrentFrame(7000)
expect(frame.animation).toBe('orotate')
expect(frame.frameIndex).toBe(0)
})
it('rewinds to POINTING via RELEASE state upon mouse movement when open', () => {
const cursor = new CursorController(CURSOR_METADATA, 0)
cursor.update(7000)
expect(cursor.state).toBe('OPEN_IDLE')
// Movement while open triggers RELEASE state with rewind counter
cursor.handleMouseMove(200, 200, 7001)
expect(cursor.state).toBe('RELEASE')
expect(cursor.releaseCounter).toBe((8 << 8) - 0x100) // 1792 (7 << 8)
// Frame during RELEASE renders ohand in reverse
// Calling getCurrentFrame(7001) calls update(7001), decrementing releaseCounter by 0x40: 1792 - 64 = 1728
let frame = cursor.getCurrentFrame(7001)
expect(frame.state).toBe('RELEASE')
expect(frame.animation).toBe('ohand')
expect(cursor.releaseCounter).toBe(1728)
expect(frame.frameIndex).toBe(6) // 1728 >> 8 = 6
// Multiple ticks decrement releaseCounter by 0x40 each until exactly 0
let ticks = 0
while (cursor.state === 'RELEASE') {
ticks++
cursor.update(7001 + ticks)
}
// Exactly 27 more ticks (28 ticks total) from 1728 to 0 (1728 / 64 = 27)
expect(ticks).toBe(27)
// Reverts cleanly to POINTING
expect(cursor.state).toBe('POINTING')
expect(cursor.releaseCounter).toBe(0)
frame = cursor.getCurrentFrame(7001 + ticks)
expect(frame.state).toBe('POINTING')
expect(frame.animation).toBe('protate')
})
it('rewinds partially opened cursor when mouse moves during OPENING state', () => {
const cursor = new CursorController(CURSOR_METADATA, 0)
cursor.openingFrameDurationMs = 125
cursor.update(5375) // Frame 3 in OPENING
expect(cursor.state).toBe('OPENING')
const currentCounter = cursor.fixedPointCounter
// Move mouse mid-opening
cursor.handleMouseMove(150, 150, 5376)
expect(cursor.state).toBe('RELEASE')
expect(cursor.releaseCounter).toBe(currentCounter)
// Rewinds back to POINTING
while (cursor.state === 'RELEASE') {
cursor.update(5377)
}
expect(cursor.state).toBe('POINTING')
})
it('transitions to CLICK_DOWN on mousedown and reverts to POINTING on mouseup', () => {
const cursor = new CursorController(CURSOR_METADATA, 0)
cursor.handleMouseDown(100)
expect(cursor.state).toBe('CLICK_DOWN')
const frame = cursor.getCurrentFrame(100)
expect(frame.state).toBe('CLICK_DOWN')
expect(frame.animation).toBe('protate')
expect(frame.frameIndex).toBe(0) // Fixed at pointing index 0
cursor.handleMouseUp(150)
expect(cursor.state).toBe('POINTING')
})
it('suppresses idle transitions when in tradeCursorMode (buy, sell, repair, identify)', () => {
const cursor = new CursorController(CURSOR_METADATA, 0)
cursor.setTradeCursorMode('buy', 0)
// Idle for 20,000ms
expect(cursor.update(20000)).toBe('POINTING')
const frame = cursor.getCurrentFrame(20000)
expect(frame.animation).toBe('buysell')
expect(frame.frameIndex).toBe(BUYSELL_CURSOR_FRAMES.buy)
})
})
// =========================================================================
// Section 6: Fail-Fast on Missing Cursor Artwork (Zero Solid Rectangles)
// =========================================================================
describe('6. Cursor Fail-Fast: Zero Solid Rectangles & Clear Missing Art Reports', () => {
it('calls reportMissingArt and draws ZERO canvas solid rectangles when cursorAtlas is null', () => {
const cursor = new CursorController(CURSOR_METADATA, 0)
cursor.handleMouseMove(120, 140, 0)
const fillRectSpy = vi.fn()
const strokeRectSpy = vi.fn()
const drawImageSpy = vi.fn()
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
fillRect: fillRectSpy,
strokeRect: strokeRectSpy,
drawImage: drawImageSpy,
} as unknown as CanvasRenderingContext2D
// Render with null atlas
cursor.render(mockCtx, null, 0)
// Assert ZERO canvas drawing calls (no colored boxes or fallback rectangles)
expect(fillRectSpy).toHaveBeenCalledTimes(0)
expect(strokeRectSpy).toHaveBeenCalledTimes(0)
expect(drawImageSpy).toHaveBeenCalledTimes(0)
// Assert reportMissingArt was registered
const missing = getMissingArtEntries()
expect(missing.length).toBeGreaterThan(0)
expect(missing.some(e => e.category === 'ui-cursor' && e.id === 'cursorAtlas')).toBe(true)
})
it('throws explicit descriptive errors when cursor animation sequences are missing from metadata', () => {
// Corrupt metadata with missing protate animation
const brokenMeta = {
...CURSOR_METADATA,
protate: [],
} as unknown as CursorAtlasMetadata
const brokenCursor = new CursorController(brokenMeta, 0)
expect(() => brokenCursor.getCurrentFrame(0)).toThrow(
/Missing cursor animation sequence 'protate'/,
)
})
it('throws explicit error when specific frame index is missing in buysell mode', () => {
const brokenMeta = {
...CURSOR_METADATA,
buysell: [], // Missing buysell frames
} as unknown as CursorAtlasMetadata
const brokenCursor = new CursorController(brokenMeta, 0)
brokenCursor.setTradeCursorMode('buy', 0)
expect(() => brokenCursor.getCurrentFrame(0)).toThrow(
/Missing buysell cursor frame 3 for mode 'buy'/,
)
})
it('renders held item centered at cursor hotspot when cursorItem is present', () => {
const cursor = new CursorController(CURSOR_METADATA, 0)
cursor.handleMouseMove(300, 200, 0)
const drawImageSpy = vi.fn()
const mockCtx = {
drawImage: drawImageSpy,
} as unknown as CanvasRenderingContext2D
const mockItemsAtlas = { tag: 'items-atlas' } as unknown as CanvasImageSource
const mockCursorAtlas = { tag: 'cursor-atlas' } as unknown as CanvasImageSource
// Held 2x3 sword (w=28, h=84)
cursor.render(mockCtx, mockCursorAtlas, 0, {
atlas: mockItemsAtlas,
rect: { x: 10, y: 20, w: 28, h: 84 },
})
// Renders held item centered at (300, 200): dx = 300 - 14 = 286, dy = 200 - 42 = 158
expect(drawImageSpy).toHaveBeenCalledTimes(1)
expect(drawImageSpy).toHaveBeenCalledWith(
mockItemsAtlas,
10,
20,
28,
84,
286,
158,
28,
84,
)
})
})
// =========================================================================
// Section 7: Differential Oracles & Fuzz Stress Harnesses
// =========================================================================
describe('7. Adversarial Fuzzing & Differential Oracles (1,000 Iterations)', () => {
it('passes 1,000 differential fuzz tests comparing measureText against naive sum of glyph widths', () => {
const renderer = new D2FontRenderer()
const mockAtlas = { width: 512, height: 512 } as unknown as HTMLImageElement
renderer.setFontAtlas('font16', metricsData.font16!, mockAtlas)
renderer.setFontAtlas('font6', metricsData.font6!, mockAtlas)
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '|.!?:;-,_+=*&^%$#@"
const colorCodes = ['ÿc0', 'ÿc1', 'ÿc2', 'ÿc3', 'ÿc4', 'ÿc5', 'ÿc6', 'ÿc7', 'ÿc8', 'ÿc9', 'ÿc:', 'ÿc;', 'ÿc<']
for (let iter = 0; iter < 1000; iter++) {
// Construct random test string with mixed chars and color codes
const length = 5 + (iter % 25)
let rawStr = ''
let strippedStr = ''
for (let i = 0; i < length; i++) {
if (i % 5 === 0 && iter % 2 === 0) {
const cc = colorCodes[(iter + i) % colorCodes.length]!
rawStr += cc
}
const ch = chars[(iter * 7 + i * 13) % chars.length]!
rawStr += ch
strippedStr += ch
}
// Font16 oracle: sum widths of stripped characters
const f16Meta = metricsData.font16!
let expectedF16Width = 0
for (let i = 0; i < strippedStr.length; i++) {
const code = strippedStr.charCodeAt(i)
expectedF16Width += f16Meta.glyphs[code]!.width
}
const measuredF16 = renderer.measureText(rawStr, 'font16')
expect(measuredF16).toBe(expectedF16Width)
// Font6 oracle
const f6Meta = metricsData.font6!
let expectedF6Width = 0
for (let i = 0; i < strippedStr.length; i++) {
const code = strippedStr.charCodeAt(i)
expectedF6Width += f6Meta.glyphs[code]!.width
}
const measuredF6 = renderer.measureText(rawStr, 'font6')
expect(measuredF6).toBe(expectedF6Width)
}
})
it('passes 1,000-step cursor event & timeline stress simulation with zero state corruption', () => {
const cursor = new CursorController(CURSOR_METADATA, 0)
let simulatedTime = 0
for (let step = 0; step < 1000; step++) {
const action = step % 6
const dt = 10 + (step * 37) % 1500
simulatedTime += dt
switch (action) {
case 0:
// Mouse move
cursor.handleMouseMove(
(step * 17) % 800,
(step * 23) % 600,
simulatedTime,
)
break
case 1:
// Mouse down
cursor.handleMouseDown(simulatedTime)
break
case 2:
// Mouse up
cursor.handleMouseUp(simulatedTime)
break
case 3:
// Trade mode toggle
cursor.setTradeCursorMode(
step % 12 === 0 ? 'buy' : step % 12 === 3 ? 'sell' : null,
simulatedTime,
)
break
default:
// Regular tick update
cursor.update(simulatedTime)
break
}
// Fetch frame info at current time
const info = cursor.getCurrentFrame(simulatedTime)
// Invariants:
// 1. Valid discrete state
const validStates: CursorState[] = ['POINTING', 'OPENING', 'OPEN_IDLE', 'RELEASE', 'CLICK_DOWN']
expect(validStates).toContain(info.state)
// 2. Valid frame indices
if (info.animation === 'buysell') {
expect(info.frameIndex).toBeGreaterThanOrEqual(0)
expect(info.frameIndex).toBeLessThan(10)
} else {
expect(info.frameIndex).toBeGreaterThanOrEqual(0)
expect(info.frameIndex).toBeLessThan(8)
}
// 3. Finite, non-NaN coordinates
expect(Number.isFinite(info.drawX)).toBe(true)
expect(Number.isFinite(info.drawY)).toBe(true)
expect(Number.isFinite(info.bounds.width)).toBe(true)
expect(Number.isFinite(info.bounds.height)).toBe(true)
expect(info.bounds.width).toBeGreaterThan(0)
expect(info.bounds.height).toBeGreaterThan(0)
}
})
})
})