diablo2-web/scripts/verify-exp-bar-m1.ts

206 lines
7.7 KiB
TypeScript

/**
* Empirical verification harness for Milestone M1 (Issue #146: ControlBar XP bar alignment & 10-segment ticks).
*
* Runs headless verification of:
* 1. Geometry of the 9 divider ticks: strictly at x = 256 + 12*i, height 4.
* 2. Hit-testing boundary edge cases: (256, 560), (375, 563), (255, 560), (376, 560), (256, 557), (256, 558), (256, 566), (256, 567).
* 3. All XP values from 0.0 to 1.0 (0%, 5%, 10%, 12.5%, 50%, 99.9%, 100%, negative, >1.0 overflow).
*/
import {
ControlBarHud,
EXP_BAR_BOUNDS,
EXP_BAR_HOVER_BOUNDS,
type ControlBarState,
} from '../src/ui/control-bar.ts'
import { D2FontRenderer } from '../src/ui/font.ts'
interface DrawCall {
fillStyle: string
x: number
y: number
w: number
h: number
}
function createMockCtx(): { ctx: CanvasRenderingContext2D; rects: DrawCall[] } {
const rects: DrawCall[] = []
let currentFillStyle = '#000000'
const ctx = {
get fillStyle() {
return currentFillStyle
},
set fillStyle(val: any) {
currentFillStyle = String(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, cur: number, next: number): ControlBarState {
return {
level: 85,
xp,
currentLevelXp: cur,
nextLevelXp: next,
stamina: 465,
maxStamina: 465,
isRunning: true,
unspentStatPoints: 0,
unspentSkillPoints: 0,
}
}
async function runEmpiricalVerification() {
console.log('=== [M1 EMPIRICAL VERIFICATION HARNESS: ISSUE #146] ===\n')
let allPassed = true
function assert(condition: boolean, msg: string) {
if (condition) {
console.log(` [PASS] ${msg}`)
} else {
console.error(` [FAIL] ${msg}`)
allPassed = false
}
}
// --- SECTION 1: GEOMETRY & BOUNDS ---
console.log('1. Checking EXP_BAR_BOUNDS & EXP_BAR_HOVER_BOUNDS:')
assert(EXP_BAR_BOUNDS.x === 256, `EXP_BAR_BOUNDS.x === 256 (actual: ${EXP_BAR_BOUNDS.x})`)
assert(EXP_BAR_BOUNDS.y === 560, `EXP_BAR_BOUNDS.y === 560 (actual: ${EXP_BAR_BOUNDS.y})`)
assert(EXP_BAR_BOUNDS.width === 120, `EXP_BAR_BOUNDS.width === 120 (actual: ${EXP_BAR_BOUNDS.width})`)
assert(EXP_BAR_BOUNDS.height === 4, `EXP_BAR_BOUNDS.height === 4 (actual: ${EXP_BAR_BOUNDS.height})`)
assert(EXP_BAR_HOVER_BOUNDS.x === 256, `EXP_BAR_HOVER_BOUNDS.x === 256 (actual: ${EXP_BAR_HOVER_BOUNDS.x})`)
assert(EXP_BAR_HOVER_BOUNDS.y === 558, `EXP_BAR_HOVER_BOUNDS.y === 558 (actual: ${EXP_BAR_HOVER_BOUNDS.y})`)
assert(EXP_BAR_HOVER_BOUNDS.width === 120, `EXP_BAR_HOVER_BOUNDS.width === 120 (actual: ${EXP_BAR_HOVER_BOUNDS.width})`)
assert(EXP_BAR_HOVER_BOUNDS.height === 8, `EXP_BAR_HOVER_BOUNDS.height === 8 (actual: ${EXP_BAR_HOVER_BOUNDS.height})`)
// --- SECTION 2: 9 DIVIDER TICKS GEOMETRY ---
console.log('\n2. Checking 9 Divider Ticks Geometry (strictly at x = 256 + 12*i, height 4):')
const bar = new ControlBarHud()
const { ctx: ctxTicks, rects: rectsTicks } = createMockCtx()
bar.draw(ctxTicks, createDummyState(500, 0, 1000), {
ctrlPnlImg: null,
runBtnImg: null,
menuBtnImg: null,
miniPanelImg: null,
miniPanelBtnsImg: null,
levelBtnImg: null,
levelSocketImg: null,
}, new D2FontRenderer())
const tickRects = rectsTicks.filter(r => r.fillStyle === '#1e1812' && r.y === 560 && r.w === 1 && r.h === 4)
assert(tickRects.length === 9, `Rendered exactly 9 divider ticks (actual: ${tickRects.length})`)
for (let i = 1; i <= 9; i++) {
const expectedX = 256 + 12 * i
const tick = tickRects[i - 1]
const matches = tick && tick.x === expectedX && tick.y === 560 && tick.w === 1 && tick.h === 4
assert(!!matches, `Tick #${i}: x=${expectedX}, y=560, w=1, h=4 (actual: ${JSON.stringify(tick)})`)
}
// --- SECTION 3: HIT-TESTING BOUNDARY EDGE CASES ---
console.log('\n3. Checking Hit-Testing Boundary Edge Cases:')
const hitCases = [
{ x: 256, y: 560, expected: 'exp', desc: '(256, 560) Top-left of inner groove' },
{ x: 375, y: 563, expected: 'exp', desc: '(375, 563) Bottom-right interior of groove' },
{ x: 255, y: 560, expected: null, desc: '(255, 560) 1px left of groove' },
{ x: 376, y: 560, expected: 'exp', desc: '(376, 560) Right edge boundary (x = 256+120)' },
{ x: 256, y: 557, expected: null, desc: '(256, 557) 1px above padded hover bounds' },
{ x: 256, y: 558, expected: 'exp', desc: '(256, 558) Top edge of padded hover bounds' },
{ x: 256, y: 566, expected: 'exp', desc: '(256, 566) Bottom edge of padded hover bounds' },
{ x: 256, y: 567, expected: null, desc: '(256, 567) 1px below padded hover bounds' },
]
for (const hc of hitCases) {
bar.handleMouseMove(hc.x, hc.y)
const actual = bar.hoveredElement
assert(
actual === hc.expected,
`${hc.desc} -> expected: ${hc.expected}, actual: ${actual}`,
)
}
// --- SECTION 4: XP RENDERING & VALUE SWEEPS ---
console.log('\n4. Checking XP Fill Widths for Various XP Values (0.0 to 1.0, negative, overflow):')
const xpCases = [
{ label: '0% (0.0)', xp: 0, cur: 0, next: 1000, expectedW: 0 },
{ label: '5% (0.05)', xp: 50, cur: 0, next: 1000, expectedW: 6 },
{ label: '10% (0.10)', xp: 100, cur: 0, next: 1000, expectedW: 12 },
{ label: '12.5% (0.125)', xp: 125, cur: 0, next: 1000, expectedW: 15 },
{ label: '50% (0.50)', xp: 500, cur: 0, next: 1000, expectedW: 60 },
{ label: '99.9% (0.999)', xp: 999, cur: 0, next: 1000, expectedW: 120 },
{ label: '100% (1.0)', xp: 1000, cur: 0, next: 1000, expectedW: 120 },
{ label: 'Negative XP (-500)', xp: -500, cur: 0, next: 1000, expectedW: 0 },
{ label: 'XP below currentLevelXp', xp: 200, cur: 300, next: 1000, expectedW: 0 },
{ label: 'XP overflow (>1.0: 1500)', xp: 1500, cur: 0, next: 1000, expectedW: 120 },
{ label: 'Massive XP overflow (10000000)', xp: 10000000, cur: 0, next: 1000, expectedW: 120 },
]
for (const xc of xpCases) {
const { ctx, rects } = createMockCtx()
bar.draw(ctx, createDummyState(xc.xp, xc.cur, xc.next), {
ctrlPnlImg: null,
runBtnImg: null,
menuBtnImg: null,
miniPanelImg: null,
miniPanelBtnsImg: null,
levelBtnImg: null,
levelSocketImg: null,
}, new D2FontRenderer())
const fillRect = rects.find(r => r.fillStyle === '#d8d0a8' && r.y === 560)
const actualW = fillRect ? fillRect.w : -1
assert(
actualW === xc.expectedW,
`${xc.label}: expected width ${xc.expectedW}, actual width ${actualW}`,
)
}
// --- SECTION 5: DIFFERENTIAL FUZZING ---
console.log('\n5. Differential Fuzzing Across 1,000 Step Fractions:')
let fuzzerFailures = 0
for (let i = 0; i <= 1000; i++) {
const fraction = i / 1000
const xp = fraction * 50000
const { ctx, rects } = createMockCtx()
bar.draw(ctx, createDummyState(xp, 0, 50000), {
ctrlPnlImg: null,
runBtnImg: null,
menuBtnImg: null,
miniPanelImg: null,
miniPanelBtnsImg: null,
levelBtnImg: null,
levelSocketImg: null,
}, new D2FontRenderer())
const fillRect = rects.find(r => r.fillStyle === '#d8d0a8' && r.y === 560)
const expectedW = Math.round(120 * fraction)
if (!fillRect || fillRect.w !== expectedW || fillRect.w < 0 || fillRect.w > 120) {
fuzzerFailures++
}
}
assert(fuzzerFailures === 0, `1,000 differential fuzz iterations had 0 mismatches`)
console.log(`\n=== FINAL VERDICT: ${allPassed ? 'ALL CHECKS PASSED (APPROVE)' : 'FAILED (REJECT)'} ===\n`)
if (!allPassed) {
process.exit(1)
}
}
runEmpiricalVerification().catch(err => {
console.error('Unhandled verification error:', err)
process.exit(1)
})