diablo2-web/tests/client/viewport-formula-profiles.t...

340 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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