340 lines
14 KiB
TypeScript
340 lines
14 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
||
import { ViewportCamera } from '../../src/client/input/command-mapper.ts'
|
||
import {
|
||
normalizeViewportResolution,
|
||
sanitizeClientSettings,
|
||
VALID_VIEWPORT_RESOLUTIONS,
|
||
type ViewportResolution,
|
||
} from '../../src/client/settings/client-settings.ts'
|
||
import { SettingsStore, type StorageLike } from '../../src/client/settings/settings-store.ts'
|
||
import { Toolbar } from '../../src/client/toolbar/toolbar.ts'
|
||
import { computeDockingLayout, HudManager } from '../../src/client/ui/hud-manager.ts'
|
||
import {
|
||
computeViewportAnchors,
|
||
computeViewportFitSize,
|
||
createViewportProfile,
|
||
formatViewportLabel,
|
||
getViewportProfile,
|
||
getViewportProfileBySize,
|
||
isExtendedViewport,
|
||
VIEWPORT_1024x768,
|
||
VIEWPORT_1068x600,
|
||
VIEWPORT_1280x720,
|
||
VIEWPORT_800x600,
|
||
VIEWPORT_PROFILES,
|
||
type ViewportProfile,
|
||
} from '../../src/client/view/viewport-profile.ts'
|
||
|
||
class MemoryStorage implements StorageLike {
|
||
readonly data = new Map<string, string>()
|
||
|
||
getItem(key: string): string | null {
|
||
return this.data.get(key) ?? null
|
||
}
|
||
|
||
setItem(key: string, value: string): void {
|
||
this.data.set(key, value)
|
||
}
|
||
|
||
removeItem(key: string): void {
|
||
this.data.delete(key)
|
||
}
|
||
}
|
||
|
||
interface ExpectedProfile {
|
||
readonly profile: ViewportProfile
|
||
readonly id: ViewportResolution
|
||
readonly width: number
|
||
readonly height: number
|
||
readonly barOffsetX: number
|
||
readonly barOffsetY: number
|
||
readonly panelOffsetY: number
|
||
readonly label: string
|
||
}
|
||
|
||
/** Expected HUD anchors per registered resolution: bar `(W-800)/2, H-600`, panels `(H-600)/2`. */
|
||
const EXPECTED: readonly ExpectedProfile[] = [
|
||
{
|
||
profile: VIEWPORT_800x600,
|
||
id: '800x600',
|
||
width: 800,
|
||
height: 600,
|
||
barOffsetX: 0,
|
||
barOffsetY: 0,
|
||
panelOffsetY: 0,
|
||
label: '800×600',
|
||
},
|
||
{
|
||
profile: VIEWPORT_1024x768,
|
||
id: '1024x768',
|
||
width: 1024,
|
||
height: 768,
|
||
barOffsetX: 112,
|
||
barOffsetY: 168,
|
||
panelOffsetY: 84,
|
||
label: '1024×768',
|
||
},
|
||
{
|
||
profile: VIEWPORT_1068x600,
|
||
id: '1068x600',
|
||
width: 1068,
|
||
height: 600,
|
||
barOffsetX: 134,
|
||
barOffsetY: 0,
|
||
panelOffsetY: 0,
|
||
label: '1068×600',
|
||
},
|
||
{
|
||
profile: VIEWPORT_1280x720,
|
||
id: '1280x720',
|
||
width: 1280,
|
||
height: 720,
|
||
barOffsetX: 240,
|
||
barOffsetY: 120,
|
||
panelOffsetY: 60,
|
||
label: '1280×720',
|
||
},
|
||
]
|
||
|
||
function createHudAt(profile: ViewportProfile): HudManager {
|
||
const { width, height } = profile
|
||
const canvas = {
|
||
width,
|
||
height,
|
||
clientWidth: width,
|
||
clientHeight: height,
|
||
style: { cursor: '', pointerEvents: '' },
|
||
addEventListener: () => {},
|
||
getBoundingClientRect: () => ({ left: 0, top: 0, width, height }),
|
||
} as unknown as HTMLCanvasElement
|
||
const hud = new HudManager(canvas, {
|
||
onToggleAutomap: () => {},
|
||
onWaypointTeleport: () => {},
|
||
})
|
||
hud.resize(width, height)
|
||
return hud
|
||
}
|
||
|
||
describe('Formula-derived viewport profiles (800x600, 1024x768, 1068x600, 1280x720)', () => {
|
||
it('derives every registered profile from barOffsetX=(W-800)/2, barOffsetY=H-600, panelOffsetY=(H-600)/2', () => {
|
||
for (const e of EXPECTED) {
|
||
expect(e.profile).toEqual({
|
||
id: e.id,
|
||
width: e.width,
|
||
height: e.height,
|
||
barOffsetX: e.barOffsetX,
|
||
barOffsetY: e.barOffsetY,
|
||
panelOffsetY: e.panelOffsetY,
|
||
})
|
||
expect(e.profile.barOffsetX).toBe((e.profile.width - 800) / 2)
|
||
expect(e.profile.barOffsetY).toBe(e.profile.height - 600)
|
||
expect(e.profile.panelOffsetY).toBe((e.profile.height - 600) / 2)
|
||
expect(formatViewportLabel(e.id)).toBe(e.label)
|
||
}
|
||
})
|
||
|
||
it('keeps the profile table, the settings whitelist and the selector order in sync, with unique widths', () => {
|
||
expect(Object.keys(VIEWPORT_PROFILES)).toEqual(Array.from(VALID_VIEWPORT_RESOLUTIONS))
|
||
expect(VALID_VIEWPORT_RESOLUTIONS).toEqual(['800x600', '1024x768', '1068x600', '1280x720'])
|
||
for (const [id, profile] of Object.entries(VIEWPORT_PROFILES)) {
|
||
expect(profile.id).toBe(id)
|
||
expect(`${profile.width}x${profile.height}`).toBe(id)
|
||
}
|
||
const widths = Object.values(VIEWPORT_PROFILES).map((p) => p.width)
|
||
expect(new Set(widths).size).toBe(widths.length)
|
||
})
|
||
|
||
it('fails fast on profiles that cannot host the native 1:1 HUD', () => {
|
||
expect(() => createViewportProfile('1280x720', 1280, 768)).toThrow(/does not match/)
|
||
expect(() => createViewportProfile('640x480' as ViewportResolution, 640, 480)).toThrow(
|
||
/smaller than the native/,
|
||
)
|
||
expect(() => createViewportProfile('1067x600' as ViewportResolution, 1067, 600)).toThrow(/even margins/)
|
||
expect(() => createViewportProfile('1068x601' as ViewportResolution, 1068, 601)).toThrow(/even margins/)
|
||
expect(() => createViewportProfile('1068.5x600' as ViewportResolution, 1068.5, 600)).toThrow(/integer/)
|
||
})
|
||
|
||
it('classifies only profiles larger than 800x600 as extended', () => {
|
||
expect(isExtendedViewport(VIEWPORT_800x600)).toBe(false)
|
||
expect(isExtendedViewport(VIEWPORT_1024x768)).toBe(true)
|
||
expect(isExtendedViewport(VIEWPORT_1068x600)).toBe(true)
|
||
expect(isExtendedViewport(VIEWPORT_1280x720)).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('Viewport profile lookups', () => {
|
||
it('resolves ids (case/whitespace-insensitive) and profile-like objects by exact size', () => {
|
||
expect(getViewportProfile('1068x600')).toBe(VIEWPORT_1068x600)
|
||
expect(getViewportProfile(' 1280X720 ')).toBe(VIEWPORT_1280x720)
|
||
expect(getViewportProfile(Object.assign({}, VIEWPORT_1280x720))).toBe(VIEWPORT_1280x720)
|
||
expect(getViewportProfile(VIEWPORT_1068x600)).toBe(VIEWPORT_1068x600)
|
||
// Documented permissive fallback (tier2 F17.B1); prototype keys are not mistaken for ids.
|
||
expect(getViewportProfile('toString')).toBe(VIEWPORT_800x600)
|
||
expect(getViewportProfile('1920x1080')).toBe(VIEWPORT_800x600)
|
||
})
|
||
|
||
it('strict size lookup returns registered profiles and throws for anything else', () => {
|
||
expect(getViewportProfileBySize(1068, 600)).toBe(VIEWPORT_1068x600)
|
||
expect(getViewportProfileBySize(1280, 720)).toBe(VIEWPORT_1280x720)
|
||
expect(getViewportProfileBySize(1024)).toBe(VIEWPORT_1024x768)
|
||
expect(() => getViewportProfileBySize(1920, 1080)).toThrow(/No viewport profile for 1920x1080/)
|
||
expect(() => getViewportProfileBySize(1024, 600)).toThrow(/No viewport profile/)
|
||
expect(() => getViewportProfileBySize(1366)).toThrow(/No viewport profile/)
|
||
})
|
||
})
|
||
|
||
describe('Settings, SettingsStore and Toolbar with the new resolutions', () => {
|
||
it('normalizes ids and bare-width shorthands, and still sanitizes unknown values to 800x600', () => {
|
||
expect(normalizeViewportResolution('1068x600')).toBe('1068x600')
|
||
expect(normalizeViewportResolution(' 1280X720 ')).toBe('1280x720')
|
||
expect(normalizeViewportResolution('1068')).toBe('1068x600')
|
||
expect(normalizeViewportResolution('1280')).toBe('1280x720')
|
||
expect(normalizeViewportResolution('1024')).toBe('1024x768')
|
||
expect(normalizeViewportResolution('1920x1080')).toBe('800x600')
|
||
expect(normalizeViewportResolution(1280)).toBe('800x600')
|
||
expect(sanitizeClientSettings({ viewport: '1280x720' }).viewport).toBe('1280x720')
|
||
})
|
||
|
||
it('persists 1068x600 across a SettingsStore reload', () => {
|
||
const storage = new MemoryStorage()
|
||
const store = new SettingsStore({ storage, defaultLang: 'en', syncGlobalLang: false })
|
||
store.set({ viewport: '1068x600' })
|
||
const reloaded = new SettingsStore({ storage, defaultLang: 'en', syncGlobalLang: false })
|
||
expect(reloaded.get().viewport).toBe('1068x600')
|
||
})
|
||
|
||
it('toggleViewport cycles 800x600 -> 1024x768 -> 1068x600 -> 1280x720 -> 800x600 and reports each profile', () => {
|
||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||
const onViewportChange = vi.fn()
|
||
const toolbar = new Toolbar({ store, callbacks: { onViewportChange } })
|
||
expect(store.get().viewport).toBe('800x600')
|
||
|
||
expect(toolbar.toggleViewport()).toBe(VIEWPORT_1024x768)
|
||
expect(toolbar.toggleViewport()).toBe(VIEWPORT_1068x600)
|
||
expect(toolbar.toggleViewport()).toBe(VIEWPORT_1280x720)
|
||
expect(store.get().viewport).toBe('1280x720')
|
||
expect(onViewportChange).toHaveBeenLastCalledWith(VIEWPORT_1280x720)
|
||
expect(toolbar.toggleViewport()).toBe(VIEWPORT_800x600)
|
||
expect(store.get().viewport).toBe('800x600')
|
||
|
||
expect(toolbar.setViewport('1068x600')).toBe(VIEWPORT_1068x600)
|
||
expect(onViewportChange).toHaveBeenLastCalledWith(VIEWPORT_1068x600)
|
||
toolbar.destroy()
|
||
})
|
||
})
|
||
|
||
describe('HUD anchors, docking and interception for 1068x600 / 1280x720', () => {
|
||
it('anchors the bar centered at the bottom and the panels to the edges', () => {
|
||
const a1068 = computeViewportAnchors('1068x600')
|
||
expect(a1068.bottomBar).toEqual({ x: 134, y: 552, width: 800, height: 48 })
|
||
expect(a1068.leftPanel).toEqual({ x: 0, y: 0, width: 400, height: 552 })
|
||
expect(a1068.rightPanel).toEqual({ x: 668, y: 0, width: 400, height: 552 })
|
||
expect(a1068.centerGapWidth).toBe(268)
|
||
|
||
const a1280 = computeViewportAnchors('1280x720')
|
||
expect(a1280.bottomBar).toEqual({ x: 240, y: 672, width: 800, height: 48 })
|
||
expect(a1280.leftPanel).toEqual({ x: 0, y: 60, width: 400, height: 552 })
|
||
expect(a1280.rightPanel).toEqual({ x: 880, y: 60, width: 400, height: 552 })
|
||
expect(a1280.centerGapWidth).toBe(480)
|
||
})
|
||
|
||
it('docks panels at 0 and width-400 with a width-800 corridor (1024x768 keeps 624/224)', () => {
|
||
const cases = [
|
||
{ profile: VIEWPORT_1024x768, rightDockX: 624, corridor: 224 },
|
||
{ profile: VIEWPORT_1068x600, rightDockX: 668, corridor: 268 },
|
||
{ profile: VIEWPORT_1280x720, rightDockX: 880, corridor: 480 },
|
||
]
|
||
for (const { profile, rightDockX, corridor } of cases) {
|
||
const dock = computeDockingLayout(profile.width, profile.height, 1, profile)
|
||
expect(dock.uiScale).toBe(1)
|
||
expect(dock.marginW).toBe(0)
|
||
expect(dock.leftDockX).toBe(0)
|
||
expect(dock.rightDockX).toBe(rightDockX)
|
||
expect(dock.channelLeft).toBe(400)
|
||
expect(dock.channelRight).toBe(rightDockX)
|
||
expect(dock.channelWidthLogical).toBe(corridor)
|
||
expect(dock.channelWidthCss).toBe(corridor)
|
||
expect(dock.isWidescreen).toBe(true)
|
||
expect(dock.barOffsetX).toBe(profile.barOffsetX)
|
||
expect(dock.barOffsetY).toBe(profile.barOffsetY)
|
||
expect(dock.panelOffsetY).toBe(profile.panelOffsetY)
|
||
// render() shifts the natively-authored right panel (x = 400) onto its dock by rightDockX - 400.
|
||
expect(dock.rightDockX - 400).toBe(profile.width - 800)
|
||
}
|
||
|
||
// Scaled stage: 1280x720 stretched to 1920x1080 CSS keeps the logical dock and scales the corridor.
|
||
const scaled = computeDockingLayout(1920, 1080, 1, VIEWPORT_1280x720)
|
||
expect(scaled.uiScale).toBe(1.5)
|
||
expect(scaled.rightDockX).toBe(880)
|
||
expect(scaled.channelWidthCss).toBe(720)
|
||
|
||
// The classic 800x600 profile still docks against the window margins.
|
||
const classic = computeDockingLayout(1920, 1080)
|
||
expect(classic.marginW).toBeCloseTo(133.333, 2)
|
||
expect(classic.leftDockX).toBeCloseTo(-133.333, 2)
|
||
expect(classic.rightDockX).toBeCloseTo(533.333, 2)
|
||
})
|
||
|
||
it('intercepts panels and the bar in 1280x720 while leaving the 480px center corridor open', () => {
|
||
const hud = createHudAt(VIEWPORT_1280x720)
|
||
expect(hud.getViewportProfile()).toBe(VIEWPORT_1280x720)
|
||
hud.toggleLeftPanel('char')
|
||
hud.toggleRightPanel('inv')
|
||
|
||
expect(hud.isPointInterceptedByHud(200, 300)).toBe(true) // left panel, y from 60 to 613
|
||
expect(hud.isPointInterceptedByHud(200, 40)).toBe(false) // above the left panel
|
||
expect(hud.isPointInterceptedByHud(1000, 300)).toBe(true) // right panel, x from 880 to 1280
|
||
expect(hud.isPointInterceptedByHud(640, 300)).toBe(false) // corridor, x from 400 to 880
|
||
expect(hud.isPointInterceptedByHud(640, 700)).toBe(true) // bar, x from 240 to 1040, y from 672
|
||
expect(hud.isPointInterceptedByHud(100, 700)).toBe(false) // left of the bar
|
||
expect(hud.isPointInterceptedByHud(1200, 700)).toBe(false) // right of the bar
|
||
})
|
||
|
||
it('intercepts panels and the bar in 1068x600 while leaving the 268px center corridor open', () => {
|
||
const hud = createHudAt(VIEWPORT_1068x600)
|
||
expect(hud.getViewportProfile()).toBe(VIEWPORT_1068x600)
|
||
hud.toggleLeftPanel('char')
|
||
hud.toggleRightPanel('inv')
|
||
|
||
expect(hud.isPointInterceptedByHud(200, 300)).toBe(true) // left panel
|
||
expect(hud.isPointInterceptedByHud(900, 300)).toBe(true) // right panel, x from 668 to 1068
|
||
expect(hud.isPointInterceptedByHud(534, 300)).toBe(false) // corridor, x from 400 to 668
|
||
expect(hud.isPointInterceptedByHud(534, 580)).toBe(true) // bar, x from 134 to 934, y from 552
|
||
expect(hud.isPointInterceptedByHud(60, 580)).toBe(false) // left of the bar
|
||
expect(hud.isPointInterceptedByHud(1040, 580)).toBe(false) // right of the bar
|
||
})
|
||
|
||
it('HudManager.resize and ViewportCamera select the exact profile and reject unregistered sizes', () => {
|
||
const hud = createHudAt(VIEWPORT_800x600)
|
||
expect(hud.resize(1068, 600)).toBe(VIEWPORT_1068x600)
|
||
expect(hud.screenWidth).toBe(1068)
|
||
expect(hud.resize(1280, 720)).toBe(VIEWPORT_1280x720)
|
||
expect(hud.screenHeight).toBe(720)
|
||
expect(() => hud.resize(1920, 1080)).toThrow(/No viewport profile/)
|
||
expect(hud.getViewportProfile()).toBe(VIEWPORT_1280x720)
|
||
|
||
const camera = new ViewportCamera(1280, 720)
|
||
expect(camera.viewportProfile).toBe(VIEWPORT_1280x720)
|
||
camera.snapTo(100, 100)
|
||
expect(camera.subtileToScreen(100, 100)).toEqual({ x: 640, y: 360 })
|
||
expect(camera.screenToSubtile(640, 360)).toEqual({ x: 100, y: 100 })
|
||
camera.resize(1068, 600)
|
||
expect(camera.viewportProfile).toBe(VIEWPORT_1068x600)
|
||
expect(camera.subtileToScreen(100, 100)).toEqual({ x: 534, y: 300 })
|
||
expect(() => new ViewportCamera(1366, 768)).toThrow(/No viewport profile/)
|
||
})
|
||
|
||
it('fits 16:9 profiles into a 16:9 container without pillarboxing', () => {
|
||
const fit1280 = computeViewportFitSize('1280x720', 1920, 1080)
|
||
expect(fit1280).toMatchObject({
|
||
scale: 1.5,
|
||
cssWidth: 1920,
|
||
cssHeight: 1080,
|
||
letterboxLeft: 0,
|
||
letterboxTop: 0,
|
||
})
|
||
const fit1068 = computeViewportFitSize('1068x600', 1920, 1080)
|
||
expect(fit1068).toMatchObject({ cssWidth: 1920, cssHeight: 1079, letterboxLeft: 0, letterboxTop: 0 })
|
||
})
|
||
})
|