835 lines
32 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
}
|
|
})
|
|
})
|