diablo2-web/tests/exp-bar-m1-stress.test.ts

298 lines
11 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
ControlBarHud,
EXP_BAR_BOUNDS,
EXP_BAR_HOVER_BOUNDS,
type ControlBarState,
} from '../src/ui/control-bar.ts'
import { D2FontRenderer } from '../src/ui/font.ts'
describe('M1 Empirical Stress Test: ControlBar XP Bar Alignment & 10-Segment Ticks (Issue #146)', () => {
describe('Geometry & Invariants', () => {
it('enforces exact EXP_BAR_BOUNDS dimensions in stone groove (x: 256, y: 560, w: 120, h: 4)', () => {
expect(EXP_BAR_BOUNDS.x).toBe(256)
expect(EXP_BAR_BOUNDS.y).toBe(560)
expect(EXP_BAR_BOUNDS.width).toBe(120)
expect(EXP_BAR_BOUNDS.height).toBe(4)
expect(EXP_BAR_BOUNDS).toEqual({ x: 256, y: 560, width: 120, height: 4 })
})
it('enforces exact EXP_BAR_HOVER_BOUNDS dimensions (x: 256, y: 558, w: 120, h: 8)', () => {
expect(EXP_BAR_HOVER_BOUNDS.x).toBe(256)
expect(EXP_BAR_HOVER_BOUNDS.y).toBe(558)
expect(EXP_BAR_HOVER_BOUNDS.width).toBe(120)
expect(EXP_BAR_HOVER_BOUNDS.height).toBe(8)
expect(EXP_BAR_HOVER_BOUNDS).toEqual({ x: 256, y: 558, width: 120, height: 8 })
// Vertical padding check: expanded by 2px above and 2px below
expect(EXP_BAR_HOVER_BOUNDS.y).toBe(EXP_BAR_BOUNDS.y - 2)
expect(EXP_BAR_HOVER_BOUNDS.height).toBe(EXP_BAR_BOUNDS.height + 4)
})
it('enforces 10 equal 12px segments totalling 120px', () => {
const segmentCount = 10
const segmentWidth = EXP_BAR_BOUNDS.width / segmentCount
expect(segmentWidth).toBe(12)
expect(segmentWidth * segmentCount).toBe(EXP_BAR_BOUNDS.width)
})
it('enforces geometry of the 9 divider ticks strictly at x = 256 + 12*i, height 4', () => {
const expectedTicks = [
{ i: 1, x: 268, y: 560, w: 1, h: 4 },
{ i: 2, x: 280, y: 560, w: 1, h: 4 },
{ i: 3, x: 292, y: 560, w: 1, h: 4 },
{ i: 4, x: 304, y: 560, w: 1, h: 4 },
{ i: 5, x: 316, y: 560, w: 1, h: 4 },
{ i: 6, x: 328, y: 560, w: 1, h: 4 },
{ i: 7, x: 340, y: 560, w: 1, h: 4 },
{ i: 8, x: 352, y: 560, w: 1, h: 4 },
{ i: 9, x: 364, y: 560, w: 1, h: 4 },
]
for (let i = 1; i <= 9; i++) {
const tickX = EXP_BAR_BOUNDS.x + i * 12
const expected = expectedTicks[i - 1]!
expect(tickX).toBe(expected.x)
expect(tickX).toBe(256 + 12 * i)
}
})
})
describe('Hit-Testing Boundary Edge Cases', () => {
const bar = new ControlBarHud()
// Test cases specified directly in mission prompt:
// (256, 560), (375, 563), (255, 560), (376, 560), (256, 557), (256, 558), (256, 566), (256, 567)
it('handles mandatory prompt test coordinates correctly', () => {
// (256, 560) -> Inside groove top-left: HIT
bar.handleMouseMove(256, 560)
expect(bar.hoveredElement).toBe('exp')
// (375, 563) -> Inside groove bottom-right: HIT
bar.handleMouseMove(375, 563)
expect(bar.hoveredElement).toBe('exp')
// (255, 560) -> 1px left of groove: MISS
bar.handleMouseMove(255, 560)
expect(bar.hoveredElement).toBeNull()
// (376, 560) -> At boundary x = 256 + 120 = 376: HIT
bar.handleMouseMove(376, 560)
expect(bar.hoveredElement).toBe('exp')
// (256, 557) -> 1px above padded hover bounds (557 < 558): MISS
bar.handleMouseMove(256, 557)
expect(bar.hoveredElement).toBeNull()
// (256, 558) -> Top edge of padded hover bounds (y = 558): HIT
bar.handleMouseMove(256, 558)
expect(bar.hoveredElement).toBe('exp')
// (256, 566) -> Bottom edge of padded hover bounds (y = 558 + 8 = 566): HIT
bar.handleMouseMove(256, 566)
expect(bar.hoveredElement).toBe('exp')
// (256, 567) -> 1px below padded hover bounds (567 > 566): MISS
bar.handleMouseMove(256, 567)
expect(bar.hoveredElement).toBeNull()
})
it('comprehensively tests all 4 corners and outer edges of EXP_BAR_HOVER_BOUNDS', () => {
// 4 corners of hover box [256..376] x [558..566]
const corners = [
{ x: 256, y: 558, name: 'top-left' },
{ x: 376, y: 558, name: 'top-right' },
{ x: 256, y: 566, name: 'bottom-left' },
{ x: 376, y: 566, name: 'bottom-right' },
]
for (const pt of corners) {
bar.handleMouseMove(pt.x, pt.y)
expect(bar.hoveredElement, `Corner ${pt.name} (${pt.x}, ${pt.y}) should hit`).toBe('exp')
}
// Outer boundary points (1px outside in each cardinal direction)
const outerPoints = [
{ x: 255, y: 558, name: 'left-of-top-left' },
{ x: 255, y: 566, name: 'left-of-bottom-left' },
{ x: 377, y: 558, name: 'right-of-top-right' },
{ x: 377, y: 566, name: 'right-of-bottom-right' },
{ x: 316, y: 557, name: 'above-center' },
{ x: 316, y: 567, name: 'below-center' },
]
for (const pt of outerPoints) {
bar.handleMouseMove(pt.x, pt.y)
expect(bar.hoveredElement, `Outer point ${pt.name} (${pt.x}, ${pt.y}) should miss`).toBeNull()
}
})
})
describe('Canvas Rendering & XP Value Sweeps', () => {
interface DrawCall {
fillStyle: string | CanvasGradient | CanvasPattern
x: number
y: number
w: number
h: number
}
function createMockCtx(): { ctx: CanvasRenderingContext2D; rects: DrawCall[] } {
const rects: DrawCall[] = []
let currentFillStyle: any = '#000000'
const ctx = {
get fillStyle() {
return currentFillStyle
},
set fillStyle(val: any) {
currentFillStyle = val
},
fillRect(x: number, y: number, w: number, h: number) {
rects.push({ fillStyle: currentFillStyle, x, y, w, h })
},
createLinearGradient() {
return {
addColorStop: () => {},
}
},
drawImage() {},
strokeRect() {},
} as unknown as CanvasRenderingContext2D
return { ctx, rects }
}
function createDummyState(xp: number, currentLevelXp: number, nextLevelXp: number): ControlBarState {
return {
level: 80,
xp,
currentLevelXp,
nextLevelXp,
stamina: 300,
maxStamina: 300,
isRunning: false,
unspentStatPoints: 0,
unspentSkillPoints: 0,
}
}
const dummyAssets = {
ctrlPnlImg: null,
runBtnImg: null,
menuBtnImg: null,
miniPanelImg: null,
miniPanelBtnsImg: null,
levelBtnImg: null,
levelSocketImg: null,
}
const dummyFont = new D2FontRenderer()
it('renders exactly background, fill, and 9 ticks in correct order', () => {
const bar = new ControlBarHud()
const { ctx, rects } = createMockCtx()
const state = createDummyState(500, 0, 1000) // 50% XP
bar.draw(ctx, state, dummyAssets, dummyFont)
// Filter rects within EXP_BAR_BOUNDS area
const xpRects = rects.filter(r => r.y === EXP_BAR_BOUNDS.y && r.h === EXP_BAR_BOUNDS.height)
// Expected:
// 1. Background bar: fillStyle '#070605', x: 256, y: 560, w: 120, h: 4
// 2. Fill bar: fillStyle '#d8d0a8', x: 256, y: 560, w: 60, h: 4
// 3. 9 divider ticks: fillStyle '#1e1812', x: 256 + 12*i, y: 560, w: 1, h: 4
expect(xpRects.length).toBe(1 + 1 + 9) // 11 rects
// 1. Background
expect(xpRects[0]).toEqual({
fillStyle: '#070605',
x: 256,
y: 560,
w: 120,
h: 4,
})
// 2. Fill
expect(xpRects[1]).toEqual({
fillStyle: '#d8d0a8',
x: 256,
y: 560,
w: 60,
h: 4,
})
// 3. 9 Ticks
for (let i = 1; i <= 9; i++) {
expect(xpRects[1 + i]).toEqual({
fillStyle: '#1e1812',
x: 256 + i * 12,
y: 560,
w: 1,
h: 4,
})
}
})
it('accurately sweeps all milestone XP values: 0%, 5%, 10%, 12.5%, 50%, 99.9%, 100%, negative, overflow', () => {
const testCases = [
{ name: '0.0 (0%)', xp: 0, cur: 0, next: 1000, expectedW: 0 },
{ name: '0.05 (5%)', xp: 50, cur: 0, next: 1000, expectedW: 6 },
{ name: '0.10 (10% - 1st tick)', xp: 100, cur: 0, next: 1000, expectedW: 12 },
{ name: '0.125 (12.5%)', xp: 125, cur: 0, next: 1000, expectedW: 15 },
{ name: '0.20 (20% - 2nd tick)', xp: 200, cur: 0, next: 1000, expectedW: 24 },
{ name: '0.50 (50% - 5th tick)', xp: 500, cur: 0, next: 1000, expectedW: 60 },
{ name: '0.90 (90% - 9th tick)', xp: 900, cur: 0, next: 1000, expectedW: 108 },
{ name: '0.999 (99.9%)', xp: 999, cur: 0, next: 1000, expectedW: 120 }, // Math.round(120 * 0.999) = 120
{ name: '0.991 (99.1%)', xp: 991, cur: 0, next: 1000, expectedW: 119 }, // Math.round(120 * 0.991) = 119
{ name: '1.0 (100% full)', xp: 1000, cur: 0, next: 1000, expectedW: 120 },
{ name: 'negative XP underflow', xp: -500, cur: 0, next: 1000, expectedW: 0 },
{ name: 'XP below current level', xp: 400, cur: 500, next: 1000, expectedW: 0 },
{ name: 'XP overflow (>1.0)', xp: 1500, cur: 0, next: 1000, expectedW: 120 },
{ name: 'extreme massive overflow', xp: 999999999, cur: 0, next: 1000, expectedW: 120 },
{ name: 'degenerate zero span (cur == next)', xp: 100, cur: 100, next: 100, expectedW: 0 },
]
const bar = new ControlBarHud()
for (const tc of testCases) {
const { ctx, rects } = createMockCtx()
const state = createDummyState(tc.xp, tc.cur, tc.next)
bar.draw(ctx, state, dummyAssets, dummyFont)
// Find the fill rect
const fillRect = rects.find(r => r.fillStyle === '#d8d0a8' && r.y === EXP_BAR_BOUNDS.y)
expect(fillRect, `Fill rect exists for ${tc.name}`).toBeDefined()
expect(fillRect!.w, `Fill width for ${tc.name}`).toBe(tc.expectedW)
expect(fillRect!.x).toBe(256)
expect(fillRect!.y).toBe(560)
expect(fillRect!.h).toBe(4)
}
})
it('performs differential fuzzing across 1,000 fine-grained XP fractions', () => {
const bar = new ControlBarHud()
let prevWidth = 0
for (let i = 0; i <= 1000; i++) {
const fraction = i / 1000
const xp = fraction * 10000
const { ctx, rects } = createMockCtx()
const state = createDummyState(xp, 0, 10000)
bar.draw(ctx, state, dummyAssets, dummyFont)
const fillRect = rects.find(r => r.fillStyle === '#d8d0a8' && r.y === EXP_BAR_BOUNDS.y)!
expect(fillRect.w).toBeGreaterThanOrEqual(0)
expect(fillRect.w).toBeLessThanOrEqual(120)
expect(fillRect.w).toBeGreaterThanOrEqual(prevWidth) // monotonic
expect(Number.isInteger(fillRect.w)).toBe(true)
// Oracle formula: Math.round(120 * fraction)
const expectedW = Math.round(120 * fraction)
expect(fillRect.w).toBe(expectedW)
prevWidth = fillRect.w
}
})
})
})