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 } }) }) })