diablo2-web/tests/challenger-m8-input-routing...

835 lines
32 KiB
TypeScript

/**
* Challenger M8 Stress Test Suite: Pointer Hit-Testing, Coordinate Translation,
* Quick Transfer, and HUD Interception under Widescreen Docking.
*
* Implements empirical challenger verification following the solution-stress-testing
* methodology (generator, oracle, property invariants, edge-case probing).
*/
import { describe, it, expect, beforeEach } from 'vitest'
import {
HudManager,
computeDockingLayout,
computeHudLayout,
clientToLogical,
logicalToClient,
CHAR_PANEL_ORIGIN,
INV_PANEL_ORIGIN,
INV_GRID_ORIGIN,
SKILL_PANEL_ORIGIN,
} from '../src/ui/hud-manager.ts'
import {
InventoryPanel,
EQUIP_SLOTS_LAYOUT,
INV_CLOSE_BTN_BOUNDS,
type EquipSlotId,
type UiInventoryItem,
} from '../src/ui/inventory.ts'
import { CHAR_CLOSE_BTN_BOUNDS } from '../src/ui/character-sheet.ts'
import { STASH_GRID_ORIGIN, STASH_PANEL_ORIGIN } from '../src/ui/world-panels.ts'
class MockWindow extends EventTarget {
innerWidth = 1920
innerHeight = 1080
devicePixelRatio = 1
}
if (typeof (globalThis as any).window === 'undefined') {
;(globalThis as any).window = new MockWindow()
}
if (typeof (globalThis as any).HTMLInputElement === 'undefined') {
;(globalThis as any).HTMLInputElement = class HTMLInputElement {}
}
if (typeof (globalThis as any).HTMLSelectElement === 'undefined') {
;(globalThis as any).HTMLSelectElement = class HTMLSelectElement {}
}
class MockCanvas extends EventTarget {
clientWidth: number
clientHeight: number
width: number
height: number
style = { pointerEvents: 'none' }
constructor(width: number, height: number) {
super()
this.clientWidth = width
this.clientHeight = height
this.width = width
this.height = height
}
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
}
}
function createDummyItem(
id: string,
code: string,
invWidth: number,
invHeight: number,
nameZh: string,
allowedSlots: readonly EquipSlotId[] = [],
): UiInventoryItem {
return {
id,
code,
invFile: code,
name: nameZh,
nameZh,
baseNameZh: nameZh,
quality: 'normal',
invWidth,
invHeight,
allowedSlots,
stats: [],
}
}
const WIDESCREEN_RESOLUTIONS = [
{ name: '1280x720 (16:9)', width: 1280, height: 720 },
{ name: '1920x1080 (16:9 Full HD)', width: 1920, height: 1080 },
{ name: '2560x1440 (16:9 QHD)', width: 2560, height: 1440 },
{ name: '3440x1440 (21:9 Ultrawide)', width: 3440, height: 1440 },
] as const
describe('Challenger M8: Pointer Hit-Testing & Coordinate Translation under Widescreen Docking', () => {
describe('1. Pointer Hit-Testing Round-Trip Accuracy Across Widescreen Resolutions', () => {
for (const res of WIDESCREEN_RESOLUTIONS) {
describe(`Resolution ${res.name}`, () => {
let canvas: MockCanvas
let hud: HudManager
beforeEach(() => {
const win = new MockWindow()
win.innerWidth = res.width
win.innerHeight = res.height
;(globalThis as any).window = win
canvas = new MockCanvas(res.width, res.height)
hud = new HudManager(canvas as unknown as HTMLCanvasElement, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud['bindEvents']()
hud.rightPanel = 'inv'
hud.inventory.visible = true
})
it(`tests Inventory Grid (10x4) hit-testing at all 40 cells with dynamic offset (rightDockX + 40 + col * 29)`, () => {
const docking = hud.getDockingLayout()
expect(docking.isWidescreen).toBe(true)
const deltaRight = docking.marginW
for (let col = 0; col < INV_GRID_ORIGIN.cols; col++) {
for (let row = 0; row < INV_GRID_ORIGIN.rows; row++) {
// Formula specified by user request: rightDockX + 40 + col * 29
const dynamicLogicalX = docking.rightDockX + 40 + col * INV_GRID_ORIGIN.cellPx
const dynamicLogicalY = INV_GRID_ORIGIN.y + 14.5 + row * INV_GRID_ORIGIN.cellPx
// Convert logical to client coordinates
const clientPos = logicalToClient(dynamicLogicalX, dynamicLogicalY, canvas.getBoundingClientRect())
// Test 1: Mathematical round-trip translation
const pt = clientToLogical(clientPos.x, clientPos.y, canvas.getBoundingClientRect())
const translatedRightX = pt.x - deltaRight
const translatedLogicalY = pt.y
const hitCol = Math.floor((translatedRightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
const hitRow = Math.floor((translatedLogicalY - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
// Assert translated rightX hits exact corresponding col and row with 0 pixel drift
expect(hitCol).toBe(col)
expect(hitRow).toBe(row)
const cellBaseX = INV_GRID_ORIGIN.x + col * INV_GRID_ORIGIN.cellPx
const offsetInCell = translatedRightX - cellBaseX
// Since rightDockX = 400 + marginW and INV_GRID_ORIGIN.x = 418:
// translatedRightX - INV_GRID_ORIGIN.x = (400 + 40 + col*29) - 418 = 22 + col*29
// So offsetInCell must be exactly 22px, with zero drift
expect(offsetInCell).toBeCloseTo(22, 5)
// Test 2: Empirical click round-trip (pick up and put back item at this cell)
const testItem = createDummyItem(`item-${col}-${row}`, 'rin', 1, 1, `TestRing-${col}-${row}`)
hud.inventory.gridItems = [{ item: testItem, col, row }]
hud.inventory.cursorItem = null
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientPos.x,
clientY: clientPos.y,
button: 0,
shiftKey: false,
}),
)
// Item must have been picked up from exact cell into cursorItem
expect(hud.inventory.cursorItem).not.toBeNull()
expect((hud.inventory.cursorItem as UiInventoryItem | null)?.id).toBe(`item-${col}-${row}`)
expect(hud.inventory.gridItems).toHaveLength(0)
// Click again at same cell to drop it back
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientPos.x,
clientY: clientPos.y,
button: 0,
shiftKey: false,
}),
)
expect(hud.inventory.cursorItem).toBeNull()
expect(hud.inventory.gridItems).toHaveLength(1)
expect(hud.inventory.gridItems[0]!.col).toBe(col)
expect(hud.inventory.gridItems[0]!.row).toBe(row)
}
}
})
it('tests Equipment Slots (all 10 slots) bounding box hit-testing accuracy', () => {
const docking = hud.getDockingLayout()
const deltaRight = docking.marginW
const slotIds: EquipSlotId[] = [
'helm',
'amulet',
'weapon1',
'armor',
'weapon2',
'gloves',
'ring1',
'belt',
'ring2',
'boots',
]
for (const slotId of slotIds) {
const rect = EQUIP_SLOTS_LAYOUT[slotId]
const canonicalCenterX = rect.x + rect.w / 2
const canonicalCenterY = rect.y + rect.h / 2
// Visual position in widescreen right dock
const visualX = canonicalCenterX + deltaRight
const visualY = canonicalCenterY
const clientPos = logicalToClient(visualX, visualY, canvas.getBoundingClientRect())
// Mathematical translation check
const pt = clientToLogical(clientPos.x, clientPos.y, canvas.getBoundingClientRect())
const rightX = pt.x - deltaRight
expect(rightX).toBeGreaterThanOrEqual(rect.x)
expect(rightX).toBeLessThanOrEqual(rect.x + rect.w)
expect(pt.y).toBeGreaterThanOrEqual(rect.y)
expect(pt.y).toBeLessThanOrEqual(rect.y + rect.h)
// Empirical click interaction check
const eqItem = createDummyItem(`eq-${slotId}`, 'cap', 2, 2, `Item-${slotId}`, [slotId])
hud.inventory.equipped[slotId] = eqItem
hud.inventory.cursorItem = null
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientPos.x,
clientY: clientPos.y,
button: 0,
shiftKey: false,
}),
)
// Item unequipped to cursor
expect((hud.inventory.cursorItem as UiInventoryItem | null)?.id).toBe(`eq-${slotId}`)
expect(hud.inventory.equipped[slotId]).toBeUndefined()
// Click slot again to re-equip
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientPos.x,
clientY: clientPos.y,
button: 0,
shiftKey: false,
}),
)
expect(hud.inventory.cursorItem).toBeNull()
expect(hud.inventory.equipped[slotId]?.id).toBe(`eq-${slotId}`)
}
})
it('tests Character Sheet close button (left bezel) and Inventory close button (right bezel)', () => {
hud.leftPanel = 'char'
hud.charSheet.visible = true
hud.rightPanel = 'inv'
hud.inventory.visible = true
const docking = hud.getDockingLayout()
const deltaLeft = -docking.marginW
const deltaRight = docking.marginW
// 1. Character Sheet Close Button
const charCloseCenterLogicalX = CHAR_CLOSE_BTN_BOUNDS.x + CHAR_CLOSE_BTN_BOUNDS.w / 2 + deltaLeft
const charCloseCenterLogicalY = CHAR_CLOSE_BTN_BOUNDS.y + CHAR_CLOSE_BTN_BOUNDS.h / 2
const charClientPos = logicalToClient(charCloseCenterLogicalX, charCloseCenterLogicalY, canvas.getBoundingClientRect())
const charPt = clientToLogical(charClientPos.x, charClientPos.y, canvas.getBoundingClientRect())
const translatedLeftX = charPt.x - deltaLeft
expect(translatedLeftX).toBeGreaterThanOrEqual(CHAR_CLOSE_BTN_BOUNDS.x)
expect(translatedLeftX).toBeLessThanOrEqual(CHAR_CLOSE_BTN_BOUNDS.x + CHAR_CLOSE_BTN_BOUNDS.w)
expect(charPt.y).toBeGreaterThanOrEqual(CHAR_CLOSE_BTN_BOUNDS.y)
expect(charPt.y).toBeLessThanOrEqual(CHAR_CLOSE_BTN_BOUNDS.y + CHAR_CLOSE_BTN_BOUNDS.h)
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: charClientPos.x,
clientY: charClientPos.y,
button: 0,
}),
)
expect(hud.leftPanel).toBe('none')
expect(hud.charSheet.visible).toBe(false)
// 2. Inventory Close Button
const invCloseCenterLogicalX = INV_CLOSE_BTN_BOUNDS.x + INV_CLOSE_BTN_BOUNDS.w / 2 + deltaRight
const invCloseCenterLogicalY = INV_CLOSE_BTN_BOUNDS.y + INV_CLOSE_BTN_BOUNDS.h / 2
const invClientPos = logicalToClient(invCloseCenterLogicalX, invCloseCenterLogicalY, canvas.getBoundingClientRect())
const invPt = clientToLogical(invClientPos.x, invClientPos.y, canvas.getBoundingClientRect())
const translatedRightX = invPt.x - deltaRight
expect(translatedRightX).toBeGreaterThanOrEqual(INV_CLOSE_BTN_BOUNDS.x)
expect(translatedRightX).toBeLessThanOrEqual(INV_CLOSE_BTN_BOUNDS.x + INV_CLOSE_BTN_BOUNDS.w)
expect(invPt.y).toBeGreaterThanOrEqual(INV_CLOSE_BTN_BOUNDS.y)
expect(invPt.y).toBeLessThanOrEqual(INV_CLOSE_BTN_BOUNDS.y + INV_CLOSE_BTN_BOUNDS.h)
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: invClientPos.x,
clientY: invClientPos.y,
button: 0,
}),
)
expect(hud.rightPanel).toBe('none')
expect(hud.inventory.visible).toBe(false)
})
})
}
})
describe('2. Central Vision Corridor Passthrough Stress Testing (1920x1080, marginW = 133.33)', () => {
let canvas: MockCanvas
let hud: HudManager
beforeEach(() => {
const win = new MockWindow()
win.innerWidth = 1920
win.innerHeight = 1080
;(globalThis as any).window = win
canvas = new MockCanvas(1920, 1080)
hud = new HudManager(canvas as unknown as HTMLCanvasElement, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud['bindEvents']()
})
const panelPairs: { left: 'char' | 'stash' | 'quest'; right: 'inv' | 'skill' }[] = [
{ left: 'char', right: 'inv' },
{ left: 'stash', right: 'inv' },
{ left: 'quest', right: 'skill' },
]
for (const pair of panelPairs) {
describe(`Dual Panels: '${pair.left}' + '${pair.right}'`, () => {
beforeEach(() => {
hud.leftPanel = pair.left
if (pair.left === 'char') hud.charSheet.visible = true
hud.rightPanel = pair.right
if (pair.right === 'inv') hud.inventory.visible = true
if (pair.right === 'skill') hud.skillTree.visible = true
})
it('probes points strictly inside central corridor (400 - marginW, 400 + marginW): returns false', () => {
const docking = hud.getDockingLayout()
const marginW = docking.marginW // 133.333...
const leftBound = 400 - marginW
const rightBound = 400 + marginW
// Dense sweep of points across corridor width
const testXs = [
leftBound + 0.5,
leftBound + 5,
leftBound + 20,
300,
350,
400, // Screen midpoint
450,
500,
rightBound - 20,
rightBound - 5,
rightBound - 0.5,
]
// Sweep across heights in normal gameplay viewport (above bottom control bar)
const testYs = [50, 150, 250, 300, 350, 450, 490]
for (const x of testXs) {
for (const y of testYs) {
expect(
hud.isPointInterceptedByHud(x, y),
`Expected isPointInterceptedByHud(${x}, ${y}) to be false in central corridor`,
).toBe(false)
}
}
})
it('probes points inside left panel [-marginW, 400 - marginW]: returns true', () => {
const docking = hud.getDockingLayout()
const marginW = docking.marginW
const leftPanelRightEdge = 400 - marginW
const testXs = [
-marginW,
-marginW + 5,
-marginW + 50,
0,
50,
100,
200,
leftPanelRightEdge - 10,
leftPanelRightEdge - 1,
leftPanelRightEdge,
]
for (const x of testXs) {
expect(
hud.isPointInterceptedByHud(x, 300),
`Expected isPointInterceptedByHud(${x}, 300) to be true on left panel`,
).toBe(true)
}
})
it('probes points inside right panel [400 + marginW, 800 + marginW]: returns true', () => {
const docking = hud.getDockingLayout()
const marginW = docking.marginW
const rightPanelLeftEdge = 400 + marginW
const rightPanelRightEdge = 800 + marginW
const testXs = [
rightPanelLeftEdge,
rightPanelLeftEdge + 1,
rightPanelLeftEdge + 10,
600,
700,
800,
rightPanelRightEdge - 20,
rightPanelRightEdge - 1,
rightPanelRightEdge,
]
for (const x of testXs) {
expect(
hud.isPointInterceptedByHud(x, 300),
`Expected isPointInterceptedByHud(${x}, 300) to be true on right panel`,
).toBe(true)
}
})
it('probes points out of horizontal bounds (< -marginW or > 800 + marginW): returns false', () => {
const docking = hud.getDockingLayout()
const marginW = docking.marginW
expect(hud.isPointInterceptedByHud(-marginW - 1, 300)).toBe(false)
expect(hud.isPointInterceptedByHud(-marginW - 100, 300)).toBe(false)
expect(hud.isPointInterceptedByHud(800 + marginW + 1, 300)).toBe(false)
expect(hud.isPointInterceptedByHud(800 + marginW + 200, 300)).toBe(false)
})
})
}
})
describe('3. Quick Transfer Mechanics Under Widescreen Docking (1920x1080)', () => {
let canvas: MockCanvas
let hud: HudManager
beforeEach(() => {
const win = new MockWindow()
win.innerWidth = 1920
win.innerHeight = 1080
;(globalThis as any).window = win
canvas = new MockCanvas(1920, 1080)
hud = new HudManager(canvas as unknown as HTMLCanvasElement, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud['bindEvents']()
hud.leftPanel = 'stash'
hud.rightPanel = 'inv'
hud.inventory.visible = true
hud.inventory.gridItems = []
hud.worldPanels.stashItems = []
})
it('transfers multiple varied-size items cleanly from Inventory to Stash via Shift+Click with 0 loss and 0 dupes', () => {
const docking = hud.getDockingLayout()
const deltaRight = docking.marginW
// Setup 5 varied-dimension items in Inventory grid
const itemsToTest = [
createDummyItem('item-1x1', 'rin', 1, 1, '乔丹之石'),
createDummyItem('item-1x2', 'amu', 1, 2, '马拉的万花筒'),
createDummyItem('item-2x2', 'cap', 2, 2, '军帽 (Shako)'),
createDummyItem('item-2x3', 'crs', 2, 3, '水晶剑 (Crystal Sword)'),
createDummyItem('item-2x4', 'gpa', 2, 4, '无限 (Infinity)'),
]
hud.inventory.gridItems = [
{ item: itemsToTest[0]!, col: 0, row: 0 },
{ item: itemsToTest[1]!, col: 1, row: 0 },
{ item: itemsToTest[2]!, col: 2, row: 0 },
{ item: itemsToTest[3]!, col: 4, row: 0 },
{ item: itemsToTest[4]!, col: 6, row: 0 },
]
expect(hud.inventory.gridItems).toHaveLength(5)
expect(hud.worldPanels.stashItems).toHaveLength(0)
// Shift+Click each item in Inventory grid
for (const targetItem of itemsToTest) {
const placement = hud.inventory.gridItems.find(p => p.item.id === targetItem.id)
expect(placement).toBeDefined()
const visualLogicalX = INV_GRID_ORIGIN.x + (placement!.col + 0.5) * INV_GRID_ORIGIN.cellPx + deltaRight
const visualLogicalY = INV_GRID_ORIGIN.y + (placement!.row + 0.5) * INV_GRID_ORIGIN.cellPx
const clientPos = logicalToClient(visualLogicalX, visualLogicalY, canvas.getBoundingClientRect())
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientPos.x,
clientY: clientPos.y,
button: 0,
shiftKey: true,
}),
)
}
// Assertions: 0 item duplication, 0 item loss
expect(hud.inventory.gridItems).toHaveLength(0)
expect(hud.worldPanels.stashItems).toHaveLength(5)
const stashItemIds = hud.worldPanels.stashItems.map(p => p.item.id)
expect(new Set(stashItemIds).size).toBe(5)
for (const expected of itemsToTest) {
expect(stashItemIds).toContain(expected.id)
}
// Assert zero coordinate translation errors: all items properly placed in 6x8 stash grid
for (const p of hud.worldPanels.stashItems) {
expect(p.col).toBeGreaterThanOrEqual(0)
expect(p.col + p.item.invWidth).toBeLessThanOrEqual(STASH_GRID_ORIGIN.cols)
expect(p.row).toBeGreaterThanOrEqual(0)
expect(p.row + p.item.invHeight).toBeLessThanOrEqual(STASH_GRID_ORIGIN.rows)
}
// Assert no overlapping items in Stash
for (let i = 0; i < hud.worldPanels.stashItems.length; i++) {
for (let j = i + 1; j < hud.worldPanels.stashItems.length; j++) {
const a = hud.worldPanels.stashItems[i]!
const b = hud.worldPanels.stashItems[j]!
const xOverlap = a.col < b.col + b.item.invWidth && a.col + a.item.invWidth > b.col
const yOverlap = a.row < b.row + b.item.invHeight && a.row + a.item.invHeight > b.row
expect(xOverlap && yOverlap).toBe(false)
}
}
})
it('transfers multiple items cleanly from Stash to Inventory via Shift+Click with 0 loss and 0 dupes', () => {
const docking = hud.getDockingLayout()
const deltaLeft = -docking.marginW
const itemsInStash = [
createDummyItem('stash-1x1', 'rin', 1, 1, '布尔凯索婚戒'),
createDummyItem('stash-1x2', 'tbk', 1, 2, '回城之书'),
createDummyItem('stash-2x2', 'box', 2, 2, '赫拉迪克方块'),
createDummyItem('stash-2x3', 'crs', 2, 3, '橡树之心'),
]
hud.worldPanels.stashItems = [
{ item: itemsInStash[0]!, col: 0, row: 0 },
{ item: itemsInStash[1]!, col: 1, row: 0 },
{ item: itemsInStash[2]!, col: 2, row: 0 },
{ item: itemsInStash[3]!, col: 4, row: 0 },
]
expect(hud.worldPanels.stashItems).toHaveLength(4)
expect(hud.inventory.gridItems).toHaveLength(0)
for (const targetItem of itemsInStash) {
const placement = hud.worldPanels.stashItems.find(p => p.item.id === targetItem.id)
expect(placement).toBeDefined()
const visualLogicalX = STASH_GRID_ORIGIN.x + (placement!.col + 0.5) * STASH_GRID_ORIGIN.cellPx + deltaLeft
const visualLogicalY = STASH_GRID_ORIGIN.y + (placement!.row + 0.5) * STASH_GRID_ORIGIN.cellPx
const clientPos = logicalToClient(visualLogicalX, visualLogicalY, canvas.getBoundingClientRect())
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientPos.x,
clientY: clientPos.y,
button: 0,
shiftKey: true,
}),
)
}
// Assertions: 0 item duplication, 0 item loss
expect(hud.worldPanels.stashItems).toHaveLength(0)
expect(hud.inventory.gridItems).toHaveLength(4)
const invItemIds = hud.inventory.gridItems.map(p => p.item.id)
expect(new Set(invItemIds).size).toBe(4)
for (const expected of itemsInStash) {
expect(invItemIds).toContain(expected.id)
}
// Assert all items legally positioned in 10x4 inventory grid without overlap
for (const p of hud.inventory.gridItems) {
expect(p.col).toBeGreaterThanOrEqual(0)
expect(p.col + p.item.invWidth).toBeLessThanOrEqual(INV_GRID_ORIGIN.cols)
expect(p.row).toBeGreaterThanOrEqual(0)
expect(p.row + p.item.invHeight).toBeLessThanOrEqual(INV_GRID_ORIGIN.rows)
}
})
it('handles destination full gracefully without item loss or state corruption', () => {
const docking = hud.getDockingLayout()
const deltaRight = docking.marginW
// Completely pack stash 6x8 grid with 48 1x1 items
for (let col = 0; col < STASH_GRID_ORIGIN.cols; col++) {
for (let row = 0; row < STASH_GRID_ORIGIN.rows; row++) {
hud.worldPanels.stashItems.push({
item: createDummyItem(`fill-${col}-${row}`, 'rin', 1, 1, 'Filler'),
col,
row,
})
}
}
expect(hud.worldPanels.stashItems).toHaveLength(48)
// Place 1 item in inventory
const extraItem = createDummyItem('overflow-ring', 'rin', 1, 1, '溢出之戒')
hud.inventory.gridItems = [{ item: extraItem, col: 0, row: 0 }]
const visualLogicalX = INV_GRID_ORIGIN.x + 14.5 + deltaRight
const visualLogicalY = INV_GRID_ORIGIN.y + 14.5
const clientPos = logicalToClient(visualLogicalX, visualLogicalY, canvas.getBoundingClientRect())
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientPos.x,
clientY: clientPos.y,
button: 0,
shiftKey: true,
}),
)
// Transfer must fail gracefully: item stays in inventory, stash remains full
expect(hud.worldPanels.stashItems).toHaveLength(48)
expect(hud.inventory.gridItems).toHaveLength(1)
expect(hud.inventory.gridItems[0]!.item.id).toBe('overflow-ring')
})
it('transfers items cleanly from Inventory to Stash and Stash to Inventory via Right-Click (button: 2)', () => {
const docking = hud.getDockingLayout()
const deltaRight = docking.marginW
const deltaLeft = -docking.marginW
const rightClickItem = createDummyItem('rc-item', 'rin', 1, 1, '右键转移戒指')
hud.inventory.gridItems = [{ item: rightClickItem, col: 0, row: 0 }]
expect(hud.worldPanels.stashItems).toHaveLength(0)
// Right click item in Inventory (button: 2)
const visualX = INV_GRID_ORIGIN.x + 14.5 + deltaRight
const visualY = INV_GRID_ORIGIN.y + 14.5
const clientPos = logicalToClient(visualX, visualY, canvas.getBoundingClientRect())
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientPos.x,
clientY: clientPos.y,
button: 2,
shiftKey: false,
}),
)
expect(hud.inventory.gridItems).toHaveLength(0)
expect(hud.worldPanels.stashItems).toHaveLength(1)
expect(hud.worldPanels.stashItems[0]!.item.id).toBe('rc-item')
// Right click item back in Stash (button: 2)
const stashP = hud.worldPanels.stashItems[0]!
const stashVisualX = STASH_GRID_ORIGIN.x + (stashP.col + 0.5) * STASH_GRID_ORIGIN.cellPx + deltaLeft
const stashVisualY = STASH_GRID_ORIGIN.y + (stashP.row + 0.5) * STASH_GRID_ORIGIN.cellPx
const stashClientPos = logicalToClient(stashVisualX, stashVisualY, canvas.getBoundingClientRect())
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: stashClientPos.x,
clientY: stashClientPos.y,
button: 2,
shiftKey: false,
}),
)
expect(hud.worldPanels.stashItems).toHaveLength(0)
expect(hud.inventory.gridItems).toHaveLength(1)
expect(hud.inventory.gridItems[0]!.item.id).toBe('rc-item')
})
})
describe('4. Tooltip Anchoring Compensation Across Resolutions', () => {
for (const res of WIDESCREEN_RESOLUTIONS) {
describe(`Resolution ${res.name}`, () => {
let canvas: MockCanvas
let hud: HudManager
beforeEach(() => {
const win = new MockWindow()
win.innerWidth = res.width
win.innerHeight = res.height
;(globalThis as any).window = win
canvas = new MockCanvas(res.width, res.height)
hud = new HudManager(canvas as unknown as HTMLCanvasElement, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud['bindEvents']()
hud.leftPanel = 'stash'
hud.rightPanel = 'inv'
hud.inventory.visible = true
})
it('asserts hovered item tooltip on Inventory item has visual anchor properly compensated by +deltaRight', () => {
const docking = hud.getDockingLayout()
const deltaRight = docking.marginW
const item = createDummyItem('inv-tooltip-test', 'cap', 2, 2, '军帽')
hud.inventory.gridItems = [{ item, col: 3, row: 1 }]
// Canonical center anchor for (col: 3, row: 1, w: 2, h: 2):
// x = INV_GRID_ORIGIN.x + (col + w/2) * cellPx = 418 + (3 + 1) * 29 = 418 + 116 = 534
// y = INV_GRID_ORIGIN.y + row * cellPx = 316 + 29 = 345
const canonicalAnchorX = INV_GRID_ORIGIN.x + (3 + 2 / 2) * INV_GRID_ORIGIN.cellPx
const canonicalAnchorY = INV_GRID_ORIGIN.y + 1 * INV_GRID_ORIGIN.cellPx
const expectedVisualAnchorX = canonicalAnchorX + deltaRight
// Mouse is over this item at visual position
const visualMouseLogicalX = canonicalAnchorX + deltaRight
const visualMouseLogicalY = canonicalAnchorY + 15
const clientPos = logicalToClient(visualMouseLogicalX, visualMouseLogicalY, canvas.getBoundingClientRect())
;(globalThis as any).window.dispatchEvent(
new SyntheticMouseEvent('mousemove', {
clientX: clientPos.x,
clientY: clientPos.y,
}),
)
expect(hud.inventory.hoveredItem).not.toBeNull()
expect(hud.inventory.hoveredItem?.item.id).toBe('inv-tooltip-test')
// Assert visual anchor x is compensated by deltaRight
expect(hud.inventory.hoveredItem?.x).toBeCloseTo(expectedVisualAnchorX, 2)
expect(hud.inventory.hoveredItem?.y).toBeCloseTo(canonicalAnchorY, 2)
})
it('asserts hovered item tooltip on Stash item has visual anchor properly compensated by +deltaLeft', () => {
const docking = hud.getDockingLayout()
const deltaLeft = -docking.marginW
const item = createDummyItem('stash-tooltip-test', 'amu', 1, 1, '马拉万花筒')
hud.worldPanels.stashItems = [{ item, col: 2, row: 3 }]
// Canonical center anchor for (col: 2, row: 3, w: 1, h: 1):
// x = STASH_GRID_ORIGIN.x + (col + w/2) * cellPx = 154 + (2 + 0.5) * 29 = 154 + 72.5 = 226.5
// y = STASH_GRID_ORIGIN.y + row * cellPx = 142 + 3 * 29 = 142 + 87 = 229
const canonicalAnchorX = STASH_GRID_ORIGIN.x + (2 + 0.5) * STASH_GRID_ORIGIN.cellPx
const canonicalAnchorY = STASH_GRID_ORIGIN.y + 3 * STASH_GRID_ORIGIN.cellPx
const expectedVisualAnchorX = canonicalAnchorX + deltaLeft
const visualMouseLogicalX = canonicalAnchorX + deltaLeft
const visualMouseLogicalY = canonicalAnchorY + 14.5
const clientPos = logicalToClient(visualMouseLogicalX, visualMouseLogicalY, canvas.getBoundingClientRect())
;(globalThis as any).window.dispatchEvent(
new SyntheticMouseEvent('mousemove', {
clientX: clientPos.x,
clientY: clientPos.y,
}),
)
expect(hud.inventory.hoveredItem).not.toBeNull()
expect(hud.inventory.hoveredItem?.item.id).toBe('stash-tooltip-test')
// Assert visual anchor x is compensated by deltaLeft
expect(hud.inventory.hoveredItem?.x).toBeCloseTo(expectedVisualAnchorX, 2)
expect(hud.inventory.hoveredItem?.y).toBeCloseTo(canonicalAnchorY, 2)
})
it('asserts hovered item tooltip on Equipment slot has visual anchor compensated by +deltaRight', () => {
const docking = hud.getDockingLayout()
const deltaRight = docking.marginW
const helmItem = createDummyItem('eq-helm-tooltip', 'cap', 2, 2, '暗金头盔', ['helm'])
hud.inventory.equipped.helm = helmItem
const rect = EQUIP_SLOTS_LAYOUT.helm
const canonicalAnchorX = rect.x + rect.w / 2
const canonicalAnchorY = rect.y
const expectedVisualAnchorX = canonicalAnchorX + deltaRight
const visualMouseLogicalX = canonicalAnchorX + deltaRight
const visualMouseLogicalY = canonicalAnchorY + rect.h / 2
const clientPos = logicalToClient(visualMouseLogicalX, visualMouseLogicalY, canvas.getBoundingClientRect())
;(globalThis as any).window.dispatchEvent(
new SyntheticMouseEvent('mousemove', {
clientX: clientPos.x,
clientY: clientPos.y,
}),
)
expect(hud.inventory.hoveredItem).not.toBeNull()
expect(hud.inventory.hoveredItem?.item.id).toBe('eq-helm-tooltip')
expect(hud.inventory.hoveredItem?.x).toBeCloseTo(expectedVisualAnchorX, 2)
expect(hud.inventory.hoveredItem?.y).toBeCloseTo(canonicalAnchorY, 2)
})
})
}
})
})