206 lines
7.7 KiB
TypeScript
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)
|
|
})
|