diablo2-web/tests/widescreen-docking.test.ts

468 lines
17 KiB
TypeScript

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