841 lines
32 KiB
TypeScript
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)
|
|
}
|
|
})
|
|
})
|
|
})
|