diablo2-web/tests/challenger-m3-tooltip-stres...

612 lines
23 KiB
TypeScript

/**
* tests/challenger-m3-tooltip-stress.test.ts
*
* Adversarial Stress Testing & Fuzzing Suite for Milestone M3:
* Diablo II v1.13c Item Tooltip Rendering (`WorldPanelsHud.drawItemTooltip`).
*
* Implements Solution Stress Testing Playbook:
* 1. Deterministic Coordinate Sweeps & Exact Boundary Matrix:
* - hover.x in [-500, 1500], hover.y in [-500, 1500]
* - Exact boundary values: 0, 8, 279, 280, 599, 600, 800
* - Strict viewport clamping: bx in [8, 800 - boxW - 8], by in [8, 600 - boxH - 8]
* - Dynamic vertical flip predicate: hover.y < 280 -> hover.y + 24; hover.y >= 280 -> hover.y - boxH - 8
* 2. Content & Attribute Stress Variations:
* - Items with 0 stats, 50 stats, very long text strings (>300 chars), special characters, unicode, emojis
* - Items with missing optional fields: undefined durability, undefined sockets, missing defense/damage/req
* - Items with negative durability, negative sockets, zero durability, zero sockets
* - Empty strings for nameZh, baseNameZh, speedText, stat lines
* - All 8 qualities: unique, set, rune, rare, magic, craft, superior, normal
* 3. 10,000-Iteration High-Entropy Differential Fuzzing Harness:
* - Seeded PRNG (Mulberry32) for bit-exact reproducibility
* - Full coverage of coordinate extremes, quality routing, stat distributions, and boundary constraints
* - Coordinate sanity assertions: no NaN, no Infinity, no undefined coordinates
*/
import { describe, expect, it } from 'vitest'
import { WorldPanelsHud } from '../src/ui/world-panels.ts'
import { D2FontRenderer, type D2ColorCode, type D2FontName } from '../src/ui/font.ts'
import type { UiInventoryItem, UiItemQuality } from '../src/ui/inventory.ts'
// --- Mock Harness Infrastructure ---
interface DrawTextRecord {
text: string
x: number
y: number
font?: D2FontName
color?: D2ColorCode
align?: string
}
function createStressMockFont(): D2FontRenderer & { drawRecords: DrawTextRecord[] } {
const drawRecords: DrawTextRecord[] = []
const fontRenderer = new D2FontRenderer()
// Mock measureText: 7px per character as standard test metric
fontRenderer.measureText = (text: string, _font: D2FontName = 'font8'): number => {
if (!text) return 0
return text.length * 7
}
fontRenderer.drawText = (
_ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
options: any = {},
): void => {
drawRecords.push({
text,
x,
y,
font: options.font,
color: options.color,
align: options.align,
})
}
return Object.assign(fontRenderer, { drawRecords })
}
interface MockCanvasCallState {
fillRects: [number, number, number, number][]
strokeRects: [number, number, number, number][]
fillStyle: string
strokeStyle: string
}
function createStressMockContext(): CanvasRenderingContext2D & MockCanvasCallState {
const state: MockCanvasCallState = {
fillRects: [],
strokeRects: [],
fillStyle: '',
strokeStyle: '',
}
const ctx = {
get fillStyle() {
return state.fillStyle
},
set fillStyle(val: string) {
state.fillStyle = val
},
get strokeStyle() {
return state.strokeStyle
},
set strokeStyle(val: string) {
state.strokeStyle = val
},
fillRect(x: number, y: number, w: number, h: number) {
state.fillRects.push([x, y, w, h])
},
strokeRect(x: number, y: number, w: number, h: number) {
state.strokeRects.push([x, y, w, h])
},
} as unknown as CanvasRenderingContext2D & MockCanvasCallState
return Object.assign(ctx, state)
}
// Seeded PRNG (Mulberry32) for reproducible fuzzing
function createMulberry32(seed: number) {
let s = seed >>> 0
return function next(): number {
s = (s + 0x6d2b79f5) >>> 0
let t = Math.imul(s ^ (s >>> 15), 1 | s)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
describe('Challenger M3 Tooltip Stress Testing & Adversarial Harness', () => {
const hud = new WorldPanelsHud()
// =========================================================================
// Suite 1: Boundary Values & Extreme Coordinates
// =========================================================================
describe('Suite 1: Deterministic Coordinate Sweeps & Boundary Values', () => {
const baseItem: UiInventoryItem = {
id: 'stress-base',
code: 'cap',
invFile: 'invcap',
name: 'Cap',
nameZh: '便帽',
baseNameZh: '便帽',
quality: 'normal',
invWidth: 2,
invHeight: 2,
allowedSlots: ['helm'],
stats: [],
}
const testBoundariesX = [-500, -100, 0, 8, 279, 280, 400, 599, 600, 800, 1000, 1500]
const testBoundariesY = [-500, -100, 0, 8, 279, 280, 350, 599, 600, 800, 1000, 1500]
for (const x of testBoundariesX) {
for (const y of testBoundariesY) {
it(`evaluates tooltip clamping at exact coordinates (${x}, ${y})`, () => {
const font = createStressMockFont()
const ctx = createStressMockContext()
hud.drawItemTooltip(ctx, { item: baseItem, x, y }, font)
expect(ctx.fillRects.length).toBe(1)
const [bx, by, boxW, boxH] = ctx.fillRects[0]!
// Invariant 1: No NaN or undefined coordinates
expect(Number.isFinite(bx), `bx must be finite number at (${x}, ${y})`).toBe(true)
expect(Number.isFinite(by), `by must be finite number at (${x}, ${y})`).toBe(true)
expect(Number.isFinite(boxW), `boxW must be finite number at (${x}, ${y})`).toBe(true)
expect(Number.isFinite(boxH), `boxH must be finite number at (${x}, ${y})`).toBe(true)
// Invariant 2: Dimensions sanity
expect(boxW).toBeGreaterThanOrEqual(190)
expect(boxH).toBeGreaterThanOrEqual(41)
// Invariant 3: Clamping within viewport
if (boxW <= 784) {
expect(bx, `bx (${bx}) must be >= 8`).toBeGreaterThanOrEqual(8)
expect(bx, `bx (${bx}) must be <= 800 - boxW - 8 (${800 - boxW - 8})`).toBeLessThanOrEqual(800 - boxW - 8)
} else {
expect(bx).toBe(8)
}
if (boxH <= 584) {
expect(by, `by (${by}) must be >= 8`).toBeGreaterThanOrEqual(8)
expect(by, `by (${by}) must be <= 600 - boxH - 8 (${600 - boxH - 8})`).toBeLessThanOrEqual(600 - boxH - 8)
} else {
expect(by).toBe(8)
}
// Invariant 4: Vertical flip threshold parity
if (y < 280) {
const expectedRawBy = y + 24
const expectedBy = Math.max(8, Math.min(600 - boxH - 8, expectedRawBy))
expect(by, `When y < 280 (${y}), by must flip below cursor`).toBe(expectedBy)
} else {
const expectedRawBy = y - boxH - 8
const expectedBy = Math.max(8, Math.min(600 - boxH - 8, expectedRawBy))
expect(by, `When y >= 280 (${y}), by must be above cursor`).toBe(expectedBy)
}
// Invariant 5: Text draw coordinates are all valid finite numbers
for (const rec of font.drawRecords) {
expect(Number.isFinite(rec.x)).toBe(true)
expect(Number.isFinite(rec.y)).toBe(true)
expect(rec.x).toBe(bx + Math.round(boxW / 2))
}
})
}
}
})
// =========================================================================
// Suite 2: Content & Attribute Stress Variations
// =========================================================================
describe('Suite 2: Content & Attribute Stress Variations', () => {
it('handles items with 0 stats gracefully', () => {
const font = createStressMockFont()
const ctx = createStressMockContext()
const item0Stats: UiInventoryItem = {
id: 'item-0-stats',
code: 'scl',
invFile: 'invscl',
name: 'Scroll of Identify',
nameZh: '辨识卷轴',
baseNameZh: '卷轴',
quality: 'normal',
invWidth: 1,
invHeight: 1,
allowedSlots: [],
stats: [],
}
hud.drawItemTooltip(ctx, { item: item0Stats, x: 400, y: 300 }, font)
expect(ctx.fillRects.length).toBe(1)
const [, , boxW, boxH] = ctx.fillRects[0]!
expect(boxW).toBeGreaterThanOrEqual(190)
expect(boxH).toBeGreaterThanOrEqual(41)
const texts = font.drawRecords.map(r => r.text)
expect(texts).toContain('辨识卷轴')
expect(texts).toContain('卷轴')
})
it('handles items with 50 stats without overflow or geometry corruption', () => {
const font = createStressMockFont()
const ctx = createStressMockContext()
const stats50 = Array.from({ length: 50 }, (_, i) => ({
text: `+${i + 1}% 极端属性测试加成 (#${i + 1})`,
color: 'blue' as D2ColorCode,
}))
const item50Stats: UiInventoryItem = {
id: 'item-50-stats',
code: 'rin',
invFile: 'invrin',
name: 'Ring of Fifty Stats',
nameZh: '五十词条极品神戒',
baseNameZh: '戒指',
quality: 'rare',
invWidth: 1,
invHeight: 1,
allowedSlots: ['ring1', 'ring2'],
stats: stats50,
}
hud.drawItemTooltip(ctx, { item: item50Stats, x: 400, y: 300 }, font)
expect(ctx.fillRects.length).toBe(1)
const [bx, by, boxW, boxH] = ctx.fillRects[0]!
expect(Number.isFinite(bx)).toBe(true)
expect(Number.isFinite(by)).toBe(true)
expect(by).toBeGreaterThanOrEqual(8)
// 50 stats + header lines (2) = 52 lines. 52 * 17 + 24 = 908px.
expect(boxH).toBe(52 * 17 + 24)
expect(font.drawRecords.length).toBe(52)
})
it('handles very long text strings and preserves box expansion', () => {
const font = createStressMockFont()
const ctx = createStressMockContext()
const longString = '非常长非常长非常长非常长非常长的符文之语属性描述词条测试字符串,用来验证提示框的最大宽度自动扩展是否正常工作并正确渲染。'.repeat(3)
const itemLongText: UiInventoryItem = {
id: 'item-long-text',
code: 'swor',
invFile: 'invcrs',
name: 'Long Sword',
nameZh: '巨长文本之剑',
baseNameZh: '水晶剑',
quality: 'magic',
invWidth: 2,
invHeight: 3,
allowedSlots: ['weapon1'],
stats: [{ text: longString, color: 'blue' }],
}
hud.drawItemTooltip(ctx, { item: itemLongText, x: 400, y: 300 }, font)
const [bx, by, boxW, boxH] = ctx.fillRects[0]!
expect(boxW).toBeGreaterThan(500)
expect(Number.isFinite(bx)).toBe(true)
expect(Number.isFinite(by)).toBe(true)
expect(bx).toBeGreaterThanOrEqual(8)
})
it('handles special characters, D2 color escape codes, and unicode emojis', () => {
const font = createStressMockFont()
const ctx = createStressMockContext()
const specialStats = [
{ text: 'ÿc1红色火焰伤害 10-20 (Adds 10-20 Fire Dmg)', color: 'blue' as D2ColorCode },
{ text: 'ÿc4金色抗性 +30% & 冰冻持续时间 -50%', color: 'gold' as D2ColorCode },
{ text: 'Unicode: ★★★ ⚔️ 🛡️ 💀 🔥 ❄️ ⚡ 特殊符号', color: 'green' as D2ColorCode },
{ text: 'Math & Quotes: <x> & "y" \\ \'z\' / [a] {b} ~ +100% 伤害', color: 'orange' as D2ColorCode },
]
const itemSpecial: UiInventoryItem = {
id: 'item-special',
code: 'axe',
invFile: 'invhax',
name: 'Special Axe',
nameZh: '特殊符号战斧',
baseNameZh: '手斧',
quality: 'unique',
invWidth: 2,
invHeight: 3,
allowedSlots: ['weapon1'],
stats: specialStats,
}
hud.drawItemTooltip(ctx, { item: itemSpecial, x: 400, y: 300 }, font)
expect(ctx.fillRects.length).toBe(1)
const texts = font.drawRecords.map(r => r.text)
expect(texts).toContain('特殊符号战斧')
expect(texts).toContain('ÿc1红色火焰伤害 10-20 (Adds 10-20 Fire Dmg)')
expect(texts).toContain('Unicode: ★★★ ⚔️ 🛡️ 💀 🔥 ❄️ ⚡ 特殊符号')
})
it('handles negative durability, negative sockets, zero durability, and zero sockets', () => {
const font = createStressMockFont()
const ctx = createStressMockContext()
const itemNegativeEdge: UiInventoryItem = {
id: 'item-edge-vals',
code: 'shd',
invFile: 'invsml',
name: 'Edge Shield',
nameZh: '边缘数值之盾',
baseNameZh: '圆盾',
quality: 'rare',
invWidth: 2,
invHeight: 2,
allowedSlots: ['weapon2'],
durability: { current: -5, max: 0 },
sockets: -2,
defense: 0,
reqLevel: -1,
reqStr: 0,
stats: [],
}
hud.drawItemTooltip(ctx, { item: itemNegativeEdge, x: 400, y: 300 }, font)
const texts = font.drawRecords.map(r => r.text)
// Negative durability should still format
expect(texts.some(t => t.includes('耐久度: -5 之 0'))).toBe(true)
// Negative sockets (sockets <= 0) must NOT format a sockets line
expect(texts.some(t => t.includes('凹槽'))).toBe(false)
// Defense 0, reqStr 0, reqLevel -1 should format
expect(texts).toContain('防御: 0')
expect(texts).toContain('需要力量: 0')
expect(texts).toContain('需要等级: -1')
})
it('handles empty strings in text fields without crashing or producing NaN', () => {
const font = createStressMockFont()
const ctx = createStressMockContext()
const itemEmptyStrings: UiInventoryItem = {
id: 'item-empty',
code: 'emp',
invFile: 'invemp',
name: '',
nameZh: '',
baseNameZh: '',
speedText: '',
quality: 'normal',
invWidth: 1,
invHeight: 1,
allowedSlots: [],
stats: [{ text: '', color: 'white' }],
}
hud.drawItemTooltip(ctx, { item: itemEmptyStrings, x: 400, y: 300 }, font)
expect(ctx.fillRects.length).toBe(1)
const [bx, by, boxW, boxH] = ctx.fillRects[0]!
expect(Number.isFinite(bx)).toBe(true)
expect(Number.isFinite(by)).toBe(true)
expect(boxW).toBeGreaterThanOrEqual(190)
expect(boxH).toBeGreaterThanOrEqual(41)
})
it('routes titleColor correctly for all 8 item qualities', () => {
const qualities: Array<{ quality: UiItemQuality; expectedColor: D2ColorCode }> = [
{ quality: 'unique', expectedColor: 'gold' },
{ quality: 'rune', expectedColor: 'gold' },
{ quality: 'set', expectedColor: 'green' },
{ quality: 'rare', expectedColor: 'yellow' },
{ quality: 'magic', expectedColor: 'blue' },
{ quality: 'craft', expectedColor: 'orange' },
{ quality: 'normal', expectedColor: 'white' },
// 'superior' quality as tested per specification
{ quality: 'superior' as UiItemQuality, expectedColor: 'white' },
]
for (const { quality, expectedColor } of qualities) {
const font = createStressMockFont()
const ctx = createStressMockContext()
const item: UiInventoryItem = {
id: `item-q-${quality}`,
code: 'tst',
invFile: 'invtst',
name: `${quality} Item`,
nameZh: `${quality} 测试道具`,
baseNameZh: '基础底材',
quality,
invWidth: 1,
invHeight: 1,
allowedSlots: [],
stats: [],
}
hud.drawItemTooltip(ctx, { item, x: 400, y: 300 }, font)
const titleRecord = font.drawRecords.find(r => r.text === `${quality} 测试道具`)
expect(titleRecord, `Title for ${quality} must be rendered`).toBeDefined()
expect(titleRecord?.color, `Title color for ${quality} must be ${expectedColor}`).toBe(expectedColor)
expect(titleRecord?.font).toBe('fontexocet10')
}
})
it('safely handles null hover target without side effects', () => {
const font = createStressMockFont()
const ctx = createStressMockContext()
expect(() => {
hud.drawItemTooltip(ctx, null, font)
}).not.toThrow()
expect(ctx.fillRects.length).toBe(0)
expect(font.drawRecords.length).toBe(0)
})
})
// =========================================================================
// Suite 3: 10,000-Iteration High-Entropy Randomized Fuzzing Harness
// =========================================================================
describe('Suite 3: 10,000-Iteration Differential Fuzzing Harness', () => {
it('executes 10,000 randomized fuzz items across extreme coordinates and property combinations', () => {
const rng = createMulberry32(0xd2113c) // Deterministic seed
const allQualities: UiItemQuality[] = ['unique', 'set', 'rune', 'rare', 'magic', 'craft', 'normal', 'superior' as UiItemQuality]
const colorChoices: D2ColorCode[] = ['white', 'blue', 'green', 'gold', 'yellow', 'orange', 'red', 'gray']
const statSnippets = [
'+1 所有技能',
'+20% 快速施法速度 (FCR)',
'+55% 快速打击恢复 (FHR)',
'+15-25 火焰伤害',
'所有抗性 +15',
'物理伤害减少 10%',
'凹槽 (4)',
'无法冰冻 (Cannot be Frozen)',
'+20 力量',
'+20 敏捷',
'+100 法力',
'200% 对不死生物的伤害',
'极长极端文本测试描述词条用于验证单行非常长时的排版与度量表现是否稳健',
'ÿc4金色文字 ÿc1红色数值 ÿc2绿色特效',
'Special characters: !@#$%^&*()_+~|}{[]:;?><,./',
'Unicode 🌟⚡🔥💎⚔️🛡️👑',
]
let passedCount = 0
for (let i = 0; i < 10000; i++) {
// Randomized or boundary coordinates
let x: number
let y: number
const coordMode = rng()
if (coordMode < 0.25) {
// Exact boundary value spikes
const boundaryList = [-500, -1, 0, 8, 279, 280, 599, 600, 800, 1500]
x = boundaryList[Math.floor(rng() * boundaryList.length)]!
y = boundaryList[Math.floor(rng() * boundaryList.length)]!
} else {
// Continuous range [-500, 1500]
x = Math.round(rng() * 2000 - 500)
y = Math.round(rng() * 2000 - 500)
}
// Random quality
const quality = allQualities[Math.floor(rng() * allQualities.length)]!
// Random stat count (0 to 50, with probability spikes at 0 and 50)
let numStats: number
const statMode = rng()
if (statMode < 0.1) numStats = 0
else if (statMode < 0.15) numStats = 50
else numStats = Math.floor(rng() * 25)
const stats = Array.from({ length: numStats }, () => ({
text: statSnippets[Math.floor(rng() * statSnippets.length)]!,
color: colorChoices[Math.floor(rng() * colorChoices.length)],
}))
// Random optional fields
const hasDefense = rng() > 0.5
const defense = hasDefense ? Math.floor(rng() * 2000) : undefined
const hasDamage = rng() > 0.5
const damage = hasDamage ? `${Math.floor(rng() * 50 + 1)} - ${Math.floor(rng() * 150 + 51)}` : undefined
const hasDurability = rng() > 0.3
let durability: { current: number; max: number } | undefined
if (hasDurability) {
const maxDur = Math.floor(rng() * 100)
// Occasionally negative or 0 current durability
const curDur = rng() < 0.1 ? -Math.floor(rng() * 10) : Math.floor(rng() * maxDur)
durability = { current: curDur, max: maxDur }
}
const hasSpeedText = rng() > 0.5
const speedText = hasSpeedText ? '剑类 - 极快的攻击速度' : undefined
const hasRunes = quality === 'rune' || (quality === 'unique' && rng() > 0.7)
const runewordRunes = hasRunes ? "'AmnRalMalIstOhm'" : undefined
const hasSockets = rng() > 0.4
const sockets = hasSockets ? Math.floor(rng() * 8) - 1 : undefined // -1, 0, 1..6
const hasSetPieces = quality === 'set' && rng() > 0.5
const setPieces = hasSetPieces ? ['塔·拉夏的守护', '塔·拉夏的织细衣服', '塔·拉夏的判决'] : undefined
const hasSetBonuses = hasSetPieces && rng() > 0.5
const setBonuses = hasSetBonuses ? [{ text: '+50 生命 (2 件装备)', color: 'green' as D2ColorCode }] : undefined
const item: UiInventoryItem = {
id: `fuzz-item-${i}`,
code: `code${i}`,
invFile: `inv${i}`,
name: `Fuzz Item ${i}`,
nameZh: rng() > 0.05 ? `随机道具 #${i}` : '',
baseNameZh: rng() > 0.05 ? `底材类别 #${i}` : '',
quality,
invWidth: 2,
invHeight: 2,
allowedSlots: ['helm'],
defense,
damage,
durability,
speedText,
runewordRunes,
sockets,
setPieces,
setBonuses,
stats,
}
const font = createStressMockFont()
const ctx = createStressMockContext()
hud.drawItemTooltip(ctx, { item, x, y }, font)
// Assertions for every single fuzz iteration
expect(ctx.fillRects.length).toBe(1)
const [bx, by, boxW, boxH] = ctx.fillRects[0]!
// Invariant: Finite numbers, no NaN
if (!Number.isFinite(bx) || !Number.isFinite(by) || !Number.isFinite(boxW) || !Number.isFinite(boxH)) {
throw new Error(`Fuzz iteration ${i} produced non-finite coordinates: bx=${bx}, by=${by}, boxW=${boxW}, boxH=${boxH}`)
}
// Invariant: Dimensions >= minimums
if (boxW < 190) {
throw new Error(`Fuzz iteration ${i} produced boxW < 190: ${boxW}`)
}
if (boxH < 41) {
throw new Error(`Fuzz iteration ${i} produced boxH < 41: ${boxH}`)
}
// Invariant: Clamping logic
if (boxW <= 784) {
if (bx < 8 || bx > 800 - boxW - 8) {
throw new Error(`Fuzz iteration ${i} bx (${bx}) outside [8, ${800 - boxW - 8}] for hover.x=${x}`)
}
} else {
if (bx !== 8) {
throw new Error(`Fuzz iteration ${i} oversized bx (${bx}) !== 8`)
}
}
if (boxH <= 584) {
if (by < 8 || by > 600 - boxH - 8) {
throw new Error(`Fuzz iteration ${i} by (${by}) outside [8, ${600 - boxH - 8}] for hover.y=${y}`)
}
} else {
if (by !== 8) {
throw new Error(`Fuzz iteration ${i} oversized by (${by}) !== 8`)
}
}
// Invariant: Text drawing alignment & finite numbers
for (const rec of font.drawRecords) {
if (!Number.isFinite(rec.x) || !Number.isFinite(rec.y)) {
throw new Error(`Fuzz iteration ${i} drawText produced non-finite coords: x=${rec.x}, y=${rec.y}`)
}
}
passedCount++
}
expect(passedCount).toBe(10000)
})
})
})