298 lines
11 KiB
TypeScript
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
|
|
}
|
|
})
|
|
})
|
|
})
|