import { describe, it, expect, beforeEach } from 'vitest' import { HudManager, computeDockingLayout, computeHudLayout, CHAR_PANEL_ORIGIN, INV_PANEL_ORIGIN, INV_GRID_ORIGIN, SKILL_PANEL_ORIGIN, } from '../src/ui/hud-manager.ts' import { STASH_PANEL_ORIGIN, STASH_GRID_ORIGIN } from '../src/ui/world-panels.ts' import type { UiInventoryItem } from '../src/ui/inventory.ts' class MockWindow extends EventTarget { innerWidth = 1920 innerHeight = 1080 devicePixelRatio = 1 } if (typeof globalThis.window === 'undefined') { // @ts-ignore globalThis.window = new MockWindow() } if (typeof globalThis.HTMLInputElement === 'undefined') { // @ts-ignore globalThis.HTMLInputElement = class HTMLInputElement {} } if (typeof globalThis.HTMLSelectElement === 'undefined') { // @ts-ignore globalThis.HTMLSelectElement = class HTMLSelectElement {} } function createMockCanvas(width: number, height: number): HTMLCanvasElement { return { clientWidth: width, clientHeight: height, width, height, style: { pointerEvents: 'none' }, getBoundingClientRect: () => ({ left: 0, top: 0, width, height }), getContext: () => null, addEventListener: () => {}, removeEventListener: () => {}, } as unknown as HTMLCanvasElement } function createDummyItem(id: string, code: string, invWidth: number, invHeight: number, nameZh: string): UiInventoryItem { return { id, code, invFile: code, name: nameZh, nameZh, baseNameZh: nameZh, quality: 'normal', invWidth, invHeight, allowedSlots: [], stats: [], } } describe('Widescreen UI Panels Left/Right Docking & Central Vision Corridor (Issue #153)', () => { describe('Docking Layout Mathematics & Metrics Across Resolutions', () => { it('computes 800x600 classic layout with 0px margin and 0px central channel (classic D2 parity)', () => { const docking = computeDockingLayout(800, 600) expect(docking.uiScale).toBe(1.0) expect(docking.offsetX).toBe(0) expect(docking.offsetY).toBe(0) expect(docking.marginW).toBe(0) expect(docking.leftDockX).toBe(0) expect(docking.rightDockX).toBe(400) expect(docking.channelLeft).toBe(400) expect(docking.channelRight).toBe(400) expect(docking.channelWidthLogical).toBe(0) expect(docking.channelWidthCss).toBe(0) expect(docking.isWidescreen).toBe(false) }) it('computes 1024x768 4:3 layout with 0px margin and 0px central channel', () => { const docking = computeDockingLayout(1024, 768) expect(docking.uiScale).toBe(1.28) expect(docking.offsetX).toBe(0) expect(docking.offsetY).toBe(0) expect(docking.marginW).toBe(0) expect(docking.leftDockX).toBe(0) expect(docking.rightDockX).toBe(400) expect(docking.channelLeft).toBe(400) expect(docking.channelRight).toBe(400) expect(docking.channelWidthLogical).toBe(0) expect(docking.channelWidthCss).toBe(0) expect(docking.isWidescreen).toBe(false) }) it('computes 1280x720 16:9 layout with 320px CSS central channel', () => { const docking = computeDockingLayout(1280, 720) expect(docking.uiScale).toBeCloseTo(1.2, 3) expect(docking.offsetX).toBe(160) expect(docking.offsetY).toBe(0) expect(docking.marginW).toBeCloseTo(133.333, 2) expect(docking.leftDockX).toBeCloseTo(-133.333, 2) expect(docking.rightDockX).toBeCloseTo(533.333, 2) expect(docking.channelLeft).toBeCloseTo(266.667, 2) expect(docking.channelRight).toBeCloseTo(533.333, 2) expect(docking.channelWidthLogical).toBeCloseTo(266.667, 2) expect(docking.channelWidthCss).toBe(320) expect(docking.isWidescreen).toBe(true) }) it('computes 1920x1080 Full HD 16:9 layout with 480px CSS central channel', () => { const docking = computeDockingLayout(1920, 1080) expect(docking.uiScale).toBeCloseTo(1.8, 3) expect(docking.offsetX).toBe(240) expect(docking.offsetY).toBe(0) expect(docking.marginW).toBeCloseTo(133.333, 2) expect(docking.leftDockX).toBeCloseTo(-133.333, 2) expect(docking.rightDockX).toBeCloseTo(533.333, 2) expect(docking.channelLeft).toBeCloseTo(266.667, 2) expect(docking.channelRight).toBeCloseTo(533.333, 2) expect(docking.channelWidthLogical).toBeCloseTo(266.667, 2) expect(docking.channelWidthCss).toBe(480) expect(docking.isWidescreen).toBe(true) }) it('computes 2560x1440 QHD 16:9 layout with 640px CSS central channel', () => { const docking = computeDockingLayout(2560, 1440) expect(docking.uiScale).toBeCloseTo(2.4, 3) expect(docking.offsetX).toBe(320) expect(docking.offsetY).toBe(0) expect(docking.marginW).toBeCloseTo(133.333, 2) expect(docking.channelWidthLogical).toBeCloseTo(266.667, 2) expect(docking.channelWidthCss).toBe(640) expect(docking.isWidescreen).toBe(true) }) it('computes 3440x1440 Ultrawide 21:9 layout with 1520px CSS central channel', () => { const docking = computeDockingLayout(3440, 1440) expect(docking.uiScale).toBeCloseTo(2.4, 3) expect(docking.offsetX).toBe(760) expect(docking.offsetY).toBe(0) expect(docking.marginW).toBeCloseTo(316.667, 2) expect(docking.channelWidthLogical).toBeCloseTo(633.333, 2) expect(docking.channelWidthCss).toBe(1520) expect(docking.isWidescreen).toBe(true) }) it('guarantees channelRight >= channelLeft and zero panel overlap across all resolutions', () => { const resolutions = [ [800, 600], [1024, 768], [1280, 720], [1280, 800], [1366, 768], [1600, 900], [1920, 1080], [1920, 1200], [2560, 1440], [3440, 1440], [3840, 2160], [5120, 1440], ] for (const [w, h] of resolutions) { const d = computeDockingLayout(w, h) expect(d.channelRight).toBeGreaterThanOrEqual(d.channelLeft) expect(d.channelWidthLogical).toBeGreaterThanOrEqual(0) expect(d.channelWidthCss).toBeGreaterThanOrEqual(0) // Left panel right edge = channelLeft. Right panel left edge = channelRight. // Overlap amount = Math.max(0, channelLeft - channelRight) must be 0. const overlap = Math.max(0, d.channelLeft - d.channelRight) expect(overlap).toBe(0) } }) }) describe('Invariant Preservation', () => { it('preserves canonical panel and grid origin constants unchanged', () => { expect(CHAR_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432 }) expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 }) expect(INV_GRID_ORIGIN).toEqual({ x: 418, y: 316, cols: 10, rows: 4, cellPx: 29 }) expect(SKILL_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 }) expect(STASH_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 }) expect(STASH_GRID_ORIGIN).toEqual({ x: 154, y: 142, cols: 6, rows: 8, cellPx: 29 }) }) }) describe('Central Vision Corridor & Input Interception (isPointInterceptedByHud)', () => { it('returns false at (400, 300) when dual panels are open on widescreen (1920x1080)', () => { const canvas = createMockCanvas(1920, 1080) const hud = new HudManager(canvas, { onToggleAutomap: () => {}, onWaypointTeleport: () => {}, }) hud.leftPanel = 'char' hud.rightPanel = 'inv' // Screen center (400, 300) in logical coordinates is in the open central corridor expect(hud.isPointInterceptedByHud(400, 300)).toBe(false) // Test across the entire central corridor: 400 - marginW < x < 400 + marginW const docking = hud.getDockingLayout() expect(docking.isWidescreen).toBe(true) const channelMid = (docking.channelLeft + docking.channelRight) / 2 expect(channelMid).toBeCloseTo(400, 2) expect(hud.isPointInterceptedByHud(channelMid, 250)).toBe(false) expect(hud.isPointInterceptedByHud(docking.channelLeft + 10, 250)).toBe(false) expect(hud.isPointInterceptedByHud(docking.channelRight - 10, 250)).toBe(false) // Points inside left panel are intercepted expect(hud.isPointInterceptedByHud(docking.channelLeft - 20, 250)).toBe(true) expect(hud.isPointInterceptedByHud(docking.leftDockX + 10, 250)).toBe(true) // Points inside right panel are intercepted expect(hud.isPointInterceptedByHud(docking.channelRight + 20, 250)).toBe(true) expect(hud.isPointInterceptedByHud(800 + docking.marginW - 10, 250)).toBe(true) }) it('returns true at (400, 300) when dual panels are open in 800x600 (classic D2)', () => { const canvas = createMockCanvas(800, 600) const hud = new HudManager(canvas, { onToggleAutomap: () => {}, onWaypointTeleport: () => {}, }) hud.leftPanel = 'char' hud.rightPanel = 'inv' // In 800x600, panels touch at 400 with 0px corridor, intercepting x=400 expect(hud.isPointInterceptedByHud(400, 300)).toBe(true) // When panels are closed, x=400 is not intercepted hud.leftPanel = 'none' hud.rightPanel = 'none' expect(hud.isPointInterceptedByHud(400, 300)).toBe(false) }) }) describe('HudManager getDockingLayout and syncPublishedState', () => { it('exposes getDockingLayout() matching current canvas dimensions', () => { const canvas = createMockCanvas(1920, 1080) const hud = new HudManager(canvas, { onToggleAutomap: () => {}, onWaypointTeleport: () => {}, }) const layout = hud.getDockingLayout() expect(layout.isWidescreen).toBe(true) expect(layout.channelWidthCss).toBe(480) expect(layout.marginW).toBeCloseTo(133.333, 2) }) it('includes docking metrics in published state', () => { const canvas = createMockCanvas(1920, 1080) const hud = new HudManager(canvas, { onToggleAutomap: () => {}, onWaypointTeleport: () => {}, }) hud.syncPublishedState() expect(hud.state.docking).toBeDefined() expect(hud.state.docking.isWidescreen).toBe(true) expect(hud.state.docking.channelWidthCss).toBe(480) expect(hud.state.docking.channelLeft).toBeCloseTo(266.667, 2) expect(hud.state.docking.channelRight).toBeCloseTo(533.333, 2) }) }) describe('Pointer Event Coordinate Translation in Widescreen', () => { class MockCanvas extends EventTarget { clientWidth = 1920 clientHeight = 1080 width = 1920 height = 1080 style = { pointerEvents: 'none' } getBoundingClientRect() { return { left: 0, top: 0, width: this.clientWidth, height: this.clientHeight } } getContext() { return null } } class SyntheticMouseEvent extends Event { readonly clientX: number readonly clientY: number readonly button: number readonly shiftKey: boolean constructor( type: string, init: { clientX: number; clientY: number; button?: number; shiftKey?: boolean }, ) { super(type, { cancelable: true, bubbles: true }) this.clientX = init.clientX this.clientY = init.clientY this.button = init.button ?? 0 this.shiftKey = init.shiftKey ?? false } } let canvas: MockCanvas let hud: HudManager beforeEach(() => { canvas = new MockCanvas() hud = new HudManager(canvas as unknown as HTMLCanvasElement, { onToggleAutomap: () => {}, onWaypointTeleport: () => {}, }) hud['bindEvents']() }) it('translates mousemove coordinates for right dock panels and adjusts item tooltip hover position', () => { hud.toggleRightPanel('inv') const item = createDummyItem('item1', 'rin', 1, 1, '乔丹之石') hud.inventory.gridItems = [{ item, col: 0, row: 0 }] const docking = hud.getDockingLayout() const deltaRight = docking.marginW // ~133.333 // Target item in col 0: canonical logical position is INV_GRID_ORIGIN.x + 14.5 = 418 + 14.5 = 432.5 // In widescreen, visual position is 432.5 + deltaRight. const visualLogicalX = INV_GRID_ORIGIN.x + 14.5 + deltaRight const clientX = visualLogicalX * docking.uiScale + docking.offsetX const clientY = (INV_GRID_ORIGIN.y + 14.5) * docking.uiScale window.dispatchEvent(new SyntheticMouseEvent('mousemove', { clientX, clientY })) // Inventory should have detected hover expect(hud.inventory.hoveredItem).not.toBeNull() expect(hud.inventory.hoveredItem?.item.id).toBe('item1') // hoveredItem.x must be translated by +deltaRight so the tooltip renders over visual position expect(hud.inventory.hoveredItem?.x).toBeCloseTo(visualLogicalX, 1) }) it('translates mousemove coordinates for left dock stash items', () => { hud.leftPanel = 'stash' const item = createDummyItem('stash-item1', 'rin', 1, 1, '储物箱戒指') hud.worldPanels.stashItems = [{ item, col: 0, row: 0 }] const docking = hud.getDockingLayout() const deltaLeft = -docking.marginW // ~ -133.333 // Stash col 0 cell center: STASH_GRID_ORIGIN.x + 14.5 = 154 + 14.5 = 168.5 // In widescreen, visual position is 168.5 + deltaLeft = 168.5 - 133.333 = 35.167 const visualLogicalX = STASH_GRID_ORIGIN.x + 14.5 + deltaLeft const clientX = visualLogicalX * docking.uiScale + docking.offsetX const clientY = (STASH_GRID_ORIGIN.y + 14.5) * docking.uiScale window.dispatchEvent(new SyntheticMouseEvent('mousemove', { clientX, clientY })) expect(hud.inventory.hoveredItem).not.toBeNull() expect(hud.inventory.hoveredItem?.item.id).toBe('stash-item1') expect(hud.inventory.hoveredItem?.x).toBeCloseTo(visualLogicalX, 1) }) it('executes quick transfer from Inventory to Stash via Shift+Click in widescreen', () => { hud.leftPanel = 'stash' hud.rightPanel = 'inv' const item = createDummyItem('quick1', 'rin', 1, 1, '传送戒指') hud.inventory.gridItems = [{ item, col: 0, row: 0 }] expect(hud.worldPanels.stashItems).toHaveLength(0) const docking = hud.getDockingLayout() const deltaRight = docking.marginW // Visual position of item in Inventory col 0, row 0 const visualLogicalX = INV_GRID_ORIGIN.x + 10 + deltaRight const visualLogicalY = INV_GRID_ORIGIN.y + 10 const clientX = visualLogicalX * docking.uiScale + docking.offsetX const clientY = visualLogicalY * docking.uiScale + docking.offsetY canvas.dispatchEvent( new SyntheticMouseEvent('mousedown', { clientX, clientY, button: 0, shiftKey: true, }), ) // Quick transfer should succeed: item moved from inventory to stash expect(hud.inventory.gridItems).toHaveLength(0) expect(hud.worldPanels.stashItems).toHaveLength(1) expect(hud.worldPanels.stashItems[0]!.item.id).toBe('quick1') }) it('executes quick transfer from Stash to Inventory via Shift+Click in widescreen', () => { hud.leftPanel = 'stash' hud.rightPanel = 'inv' const item = createDummyItem('stash-quick1', 'rin', 1, 1, '马拉的万花筒') hud.worldPanels.stashItems = [{ item, col: 0, row: 0 }] hud.inventory.gridItems = [] const docking = hud.getDockingLayout() const deltaLeft = -docking.marginW // Visual position of item in Stash col 0, row 0 const visualLogicalX = STASH_GRID_ORIGIN.x + 10 + deltaLeft const visualLogicalY = STASH_GRID_ORIGIN.y + 10 const clientX = visualLogicalX * docking.uiScale + docking.offsetX const clientY = visualLogicalY * docking.uiScale + docking.offsetY canvas.dispatchEvent( new SyntheticMouseEvent('mousedown', { clientX, clientY, button: 0, shiftKey: true, }), ) // Quick transfer should succeed: item moved from stash to inventory expect(hud.worldPanels.stashItems).toHaveLength(0) expect(hud.inventory.gridItems).toHaveLength(1) expect(hud.inventory.gridItems[0]!.item.id).toBe('stash-quick1') }) it('closes left and right panels via close buttons at widescreen coordinates', () => { hud.leftPanel = 'char' hud.rightPanel = 'inv' const docking = hud.getDockingLayout() const deltaLeft = -docking.marginW const deltaRight = docking.marginW // Left panel close button: CHAR_CLOSE_BTN_BOUNDS: x = 208, y = 448 const visualLeftCloseX = 208 + deltaLeft const visualLeftCloseY = 448 const clientLeftX = visualLeftCloseX * docking.uiScale + docking.offsetX const clientLeftY = visualLeftCloseY * docking.uiScale + docking.offsetY canvas.dispatchEvent( new SyntheticMouseEvent('mousedown', { clientX: clientLeftX, clientY: clientLeftY, button: 0, shiftKey: false, }), ) expect(hud.leftPanel).toBe('none') // Right panel close button: INV_CLOSE_BTN_BOUNDS: x = 418, y = 445 const visualRightCloseX = 418 + deltaRight const visualRightCloseY = 445 const clientRightX = visualRightCloseX * docking.uiScale + docking.offsetX const clientRightY = visualRightCloseY * docking.uiScale + docking.offsetY canvas.dispatchEvent( new SyntheticMouseEvent('mousedown', { clientX: clientRightX, clientY: clientRightY, button: 0, shiftKey: false, }), ) expect(hud.rightPanel).toBe('none') }) }) })