diablo2-web/tests/challenger-m5-stash-grid.te...

652 lines
27 KiB
TypeScript

/**
* tests/challenger-m5-stash-grid.test.ts
*
* Empirical Challenger Stress Test Suite for Milestone M5 (Issue #150):
* Stash Storage Grid (6x8), Item Placement, Bounds Checking, Collision Detection,
* Item Picking/Swapping, and Quick Transfer Mechanics.
*/
import { describe, expect, it, beforeEach } from 'vitest'
import {
STASH_GRID_ORIGIN,
STASH_PANEL_ORIGIN,
WorldPanelsHud,
} from '../src/ui/world-panels.ts'
import {
InventoryPanel,
type UiInventoryItem,
INV_GRID_ORIGIN,
} 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 createItem(id: string, code: string, w: number, h: number, nameZh: string): UiInventoryItem {
return {
id,
code,
invFile: code,
name: nameZh,
nameZh,
baseNameZh: nameZh,
quality: 'normal',
invWidth: w,
invHeight: h,
allowedSlots: [],
stats: [{ text: `Stress item ${id}`, color: 'white' }],
}
}
describe('Challenger M5 Empirical Stress Tests: 6x8 Stash Storage Grid & Mechanics', () => {
let panels: WorldPanelsHud
let inv: InventoryPanel
beforeEach(() => {
panels = new WorldPanelsHud()
inv = new InventoryPanel()
// Clear default inventory items so we start with a clean slate
inv.gridItems = []
})
describe('1. Comprehensive Multi-Size Item Placement & Coordinate Bounds', () => {
const itemSizes: Array<{ name: string; code: string; w: number; h: number }> = [
{ name: '1x1 Ring', code: 'rin', w: 1, h: 1 },
{ name: '1x2 Potion', code: 'hp1', w: 1, h: 2 },
{ name: '2x2 Helm', code: 'cap', w: 2, h: 2 },
{ name: '2x3 Crystal Sword', code: 'crs', w: 2, h: 3 },
{ name: '2x4 Pike', code: 'pik', w: 2, h: 4 },
]
for (const { name, code, w, h } of itemSizes) {
const maxCol = STASH_GRID_ORIGIN.cols - w
const maxRow = STASH_GRID_ORIGIN.rows - h
const totalValid = (maxCol + 1) * (maxRow + 1)
it(`exhaustively places ${name} (${w}x${h}) at all ${totalValid} valid coordinates on 6x8 grid`, () => {
let testedCount = 0
for (let col = 0; col <= maxCol; col++) {
for (let row = 0; row <= maxRow; row++) {
const freshHud = new WorldPanelsHud()
const item = createItem(`item-${code}-${col}-${row}`, code, w, h, `${name} (${col},${row})`)
// Must be accepted
expect(freshHud.canPlaceInStash(col, row, w, h)).toBe(true)
// Place item
freshHud.stashItems.push({ item, col, row })
expect(freshHud.stashItems).toHaveLength(1)
// Verify getStashOverlaps identifies this exact item
const overlaps = freshHud.getStashOverlaps(col, row, w, h)
expect(overlaps).toHaveLength(1)
expect(overlaps[0]!.item.id).toBe(item.id)
// Check every single sub-cell occupied by this item
for (let dc = 0; dc < w; dc++) {
for (let dr = 0; dr < h; dr++) {
expect(freshHud.canPlaceInStash(col + dc, row + dr, 1, 1)).toBe(false)
}
}
testedCount++
}
}
expect(testedCount).toBe(totalValid)
})
}
it('enforces exact boundary conditions for 2x2 item: (4,6) passes; (5,6) and (4,7) rejected', () => {
// (4, 6) -> col+2 = 6 <= 6, row+2 = 8 <= 8 (Valid bottom-rightmost coordinate for 2x2)
expect(panels.canPlaceInStash(4, 6, 2, 2)).toBe(true)
expect(() => panels.getStashOverlaps(4, 6, 2, 2)).not.toThrow()
// (5, 6) -> col+2 = 7 > 6 (Exceeds columns)
expect(panels.canPlaceInStash(5, 6, 2, 2)).toBe(false)
expect(() => panels.getStashOverlaps(5, 6, 2, 2)).toThrow(RangeError)
// (4, 7) -> row+2 = 9 > 8 (Exceeds rows)
expect(panels.canPlaceInStash(4, 7, 2, 2)).toBe(false)
expect(() => panels.getStashOverlaps(4, 7, 2, 2)).toThrow(RangeError)
// (5, 7) -> Exceeds both
expect(panels.canPlaceInStash(5, 7, 2, 2)).toBe(false)
expect(() => panels.getStashOverlaps(5, 7, 2, 2)).toThrow(RangeError)
})
it('enforces boundary conditions for all other multi-cell sizes', () => {
// 1x1: (5, 7) passes; (6, 7), (5, 8) rejected
expect(panels.canPlaceInStash(5, 7, 1, 1)).toBe(true)
expect(panels.canPlaceInStash(6, 7, 1, 1)).toBe(false)
expect(panels.canPlaceInStash(5, 8, 1, 1)).toBe(false)
// 1x2: (5, 6) passes; (6, 6), (5, 7) rejected
expect(panels.canPlaceInStash(5, 6, 1, 2)).toBe(true)
expect(panels.canPlaceInStash(6, 6, 1, 2)).toBe(false)
expect(panels.canPlaceInStash(5, 7, 1, 2)).toBe(false)
// 2x3: (4, 5) passes; (5, 5), (4, 6) rejected
expect(panels.canPlaceInStash(4, 5, 2, 3)).toBe(true)
expect(panels.canPlaceInStash(5, 5, 2, 3)).toBe(false)
expect(panels.canPlaceInStash(4, 6, 2, 3)).toBe(false)
// 2x4: (4, 4) passes; (5, 4), (4, 5) rejected
expect(panels.canPlaceInStash(4, 4, 2, 4)).toBe(true)
expect(panels.canPlaceInStash(5, 4, 2, 4)).toBe(false)
expect(panels.canPlaceInStash(4, 5, 2, 4)).toBe(false)
})
it('rejects negative and out-of-grid coordinates unconditionally', () => {
const negativeCoords = [
[-1, 0],
[0, -1],
[-1, -1],
[-5, 2],
[3, -4],
[-100, -100],
]
for (const [col, row] of negativeCoords) {
expect(panels.canPlaceInStash(col, row, 1, 1)).toBe(false)
expect(() => panels.getStashOverlaps(col, row, 1, 1)).toThrow(RangeError)
}
const outOfGridCoords = [
[6, 0],
[0, 8],
[6, 8],
[7, 0],
[0, 9],
[10, 10],
[100, 100],
]
for (const [col, row] of outOfGridCoords) {
expect(panels.canPlaceInStash(col, row, 1, 1)).toBe(false)
expect(() => panels.getStashOverlaps(col, row, 1, 1)).toThrow(RangeError)
}
})
})
describe('2. Collision Detection & Overlap Prevention', () => {
it('verifies that an item cannot overlap even a single cell of an existing placed item', () => {
// Place a 2x3 sword at (col: 2, row: 2).
// Occupied cells:
// (2,2), (3,2)
// (2,3), (3,3)
// (2,4), (3,4)
const sword = createItem('sword-main', 'crs', 2, 3, '水晶剑 (Crystal Sword)')
panels.stashItems.push({ item: sword, col: 2, row: 2 })
// 1. Every occupied cell cannot host a 1x1 item
for (let c = 2; c <= 3; c++) {
for (let r = 2; r <= 4; r++) {
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(false)
const overlaps = panels.getStashOverlaps(c, r, 1, 1)
expect(overlaps).toHaveLength(1)
expect(overlaps[0]!.item.id).toBe('sword-main')
}
}
// 2. Multi-cell items overlapping only a single corner cell
// Top-Left corner: 2x2 at (1, 1) overlaps sword ONLY at (2, 2)
expect(panels.canPlaceInStash(1, 1, 2, 2)).toBe(false)
expect(panels.getStashOverlaps(1, 1, 2, 2)).toHaveLength(1)
// Top-Right corner: 2x2 at (3, 1) overlaps sword ONLY at (3, 2)
expect(panels.canPlaceInStash(3, 1, 2, 2)).toBe(false)
expect(panels.getStashOverlaps(3, 1, 2, 2)).toHaveLength(1)
// Bottom-Left corner: 2x2 at (1, 4) overlaps sword ONLY at (2, 4)
expect(panels.canPlaceInStash(1, 4, 2, 2)).toBe(false)
expect(panels.getStashOverlaps(1, 4, 2, 2)).toHaveLength(1)
// Bottom-Right corner: 2x2 at (3, 4) overlaps sword ONLY at (3, 4)
expect(panels.canPlaceInStash(3, 4, 2, 2)).toBe(false)
expect(panels.getStashOverlaps(3, 4, 2, 2)).toHaveLength(1)
// 1x2 overlapping only (2, 2) from top at (2, 1)
expect(panels.canPlaceInStash(2, 1, 1, 2)).toBe(false)
// 1x2 overlapping only (3, 4) from bottom at (3, 4)
expect(panels.canPlaceInStash(3, 4, 1, 2)).toBe(false)
// 3. Immediately adjacent non-touching cells are fully free
expect(panels.canPlaceInStash(1, 2, 1, 1)).toBe(true) // left
expect(panels.canPlaceInStash(1, 3, 1, 1)).toBe(true)
expect(panels.canPlaceInStash(1, 4, 1, 1)).toBe(true)
expect(panels.canPlaceInStash(4, 2, 1, 1)).toBe(true) // right
expect(panels.canPlaceInStash(4, 3, 1, 1)).toBe(true)
expect(panels.canPlaceInStash(4, 4, 1, 1)).toBe(true)
expect(panels.canPlaceInStash(2, 1, 1, 1)).toBe(true) // top
expect(panels.canPlaceInStash(3, 1, 1, 1)).toBe(true)
expect(panels.canPlaceInStash(2, 5, 1, 1)).toBe(true) // bottom
expect(panels.canPlaceInStash(3, 5, 1, 1)).toBe(true)
})
it('fills stash to maximum capacity (48 1x1 items) and asserts canPlaceInStash & autoPlaceInStash return false', () => {
// 6 columns * 8 rows = 48 items
for (let c = 0; c < 6; c++) {
for (let r = 0; r < 8; r++) {
const item = createItem(`gem-${c}-${r}`, 'gem', 1, 1, `宝石 (${c},${r})`)
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(true)
panels.stashItems.push({ item, col: c, row: r })
}
}
expect(panels.stashItems).toHaveLength(48)
// Now every coordinate and size must return false
for (let c = 0; c < 6; c++) {
for (let r = 0; r < 8; r++) {
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(false)
}
}
// Any attempt to place any size must fail
const testItem1x1 = createItem('overflow-1x1', 'rin', 1, 1, '溢出戒指')
const testItem2x2 = createItem('overflow-2x2', 'cap', 2, 2, '溢出头盔')
const testItem2x4 = createItem('overflow-2x4', 'pik', 2, 4, '溢出长枪')
expect(panels.autoPlaceInStash(testItem1x1)).toBe(false)
expect(panels.autoPlaceInStash(testItem2x2)).toBe(false)
expect(panels.autoPlaceInStash(testItem2x4)).toBe(false)
expect(panels.stashItems).toHaveLength(48)
})
it('tiles stash completely with 12 2x2 items and asserts zero further placements', () => {
// 3 cols of 2x2 (cols 0, 2, 4) * 4 rows of 2x2 (rows 0, 2, 4, 6) = 12 items (48 cells)
for (let c = 0; c < 6; c += 2) {
for (let r = 0; r < 8; r += 2) {
const shield = createItem(`shield-${c}-${r}`, 'kit', 2, 2, `盾牌 (${c},${r})`)
expect(panels.canPlaceInStash(c, r, 2, 2)).toBe(true)
panels.stashItems.push({ item: shield, col: c, row: r })
}
}
expect(panels.stashItems).toHaveLength(12)
// Stash is 100% saturated
for (let c = 0; c < 6; c++) {
for (let r = 0; r < 8; r++) {
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(false)
}
}
expect(panels.autoPlaceInStash(createItem('fail-1x1', 'rin', 1, 1, '戒指'))).toBe(false)
expect(panels.autoPlaceInStash(createItem('fail-2x2', 'cap', 2, 2, '头盔'))).toBe(false)
})
it('tiles stash completely with 6 2x4 items and asserts zero further placements', () => {
// 3 cols of 2x4 (cols 0, 2, 4) * 2 rows of 2x4 (rows 0, 4) = 6 items (48 cells)
for (let c = 0; c < 6; c += 2) {
for (let r = 0; r < 8; r += 4) {
const pike = createItem(`pike-${c}-${r}`, 'pik', 2, 4, `长枪 (${c},${r})`)
expect(panels.canPlaceInStash(c, r, 2, 4)).toBe(true)
panels.stashItems.push({ item: pike, col: c, row: r })
}
}
expect(panels.stashItems).toHaveLength(6)
// Fully covered
for (let c = 0; c < 6; c++) {
for (let r = 0; r < 8; r++) {
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(false)
}
}
expect(panels.autoPlaceInStash(createItem('fail-1x1', 'rin', 1, 1, '戒指'))).toBe(false)
})
})
describe('3. Item Picking, Swapping, and Drag-and-Drop', () => {
it('picks up multi-cell item onto cursor from any constituent cell and verifies vacancy', () => {
const sword = createItem('sword-pick', 'crs', 2, 3, '水晶剑')
panels.stashItems.push({ item: sword, col: 1, row: 2 })
// The sword spans cols 1..2, rows 2..4 (6 sub-cells).
// Test that clicking ANY of these 6 cells with empty cursor picks up the sword.
const constituentCells = [
[1, 2], [2, 2],
[1, 3], [2, 3],
[1, 4], [2, 4],
]
for (const [col, row] of constituentCells) {
// Reset stash with the sword
panels.stashItems = [{ item: sword, col: 1, row: 2 }]
const pickRes = panels.clickStashCell(col, row, null)
expect(pickRes.handled).toBe(true)
expect(pickRes.newCursorItem).toBeDefined()
expect(pickRes.newCursorItem?.id).toBe('sword-pick')
expect(panels.stashItems).toHaveLength(0)
// All 6 cells must now be vacant
for (const [c, r] of constituentCells) {
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(true)
}
}
// Clicking empty cell returns handled: false
const emptyRes = panels.clickStashCell(0, 0, null)
expect(emptyRes.handled).toBe(false)
expect(emptyRes.newCursorItem).toBeNull()
})
it('drops item from cursor onto empty cells', () => {
const helm = createItem('helm-drop', 'cap', 2, 2, '军帽')
const placeRes = panels.clickStashCell(3, 4, helm)
expect(placeRes.handled).toBe(true)
expect(placeRes.newCursorItem).toBeNull()
expect(panels.stashItems).toHaveLength(1)
expect(panels.stashItems[0]!.item.id).toBe('helm-drop')
expect(panels.stashItems[0]!.col).toBe(3)
expect(panels.stashItems[0]!.row).toBe(4)
})
it('swaps items of equal size (2x2 with 2x2)', () => {
const helmA = createItem('helm-a', 'cap', 2, 2, '暗金军帽')
const helmB = createItem('helm-b', 'ghm', 2, 2, '野蛮人头盔')
panels.stashItems.push({ item: helmA, col: 1, row: 1 })
// Click at (1, 1) holding helmB
const swapRes = panels.clickStashCell(1, 1, helmB)
expect(swapRes.handled).toBe(true)
expect(swapRes.newCursorItem?.id).toBe('helm-a')
expect(panels.stashItems).toHaveLength(1)
expect(panels.stashItems[0]!.item.id).toBe('helm-b')
expect(panels.stashItems[0]!.col).toBe(1)
expect(panels.stashItems[0]!.row).toBe(1)
})
it('swaps items of different sizes (1x1 with 2x2) when surrounding cells are free', () => {
const ring = createItem('ring-swap', 'rin', 1, 1, '乔丹之石')
const shield = createItem('shield-swap', 'kit', 2, 2, '暴风之盾')
panels.stashItems.push({ item: ring, col: 2, row: 2 })
// Shield (2x2) dropped on (2, 2). Only ring is in that 2x2 area.
const swapRes = panels.clickStashCell(2, 2, shield)
expect(swapRes.handled).toBe(true)
expect(swapRes.newCursorItem?.id).toBe('ring-swap')
expect(panels.stashItems).toHaveLength(1)
expect(panels.stashItems[0]!.item.id).toBe('shield-swap')
expect(panels.stashItems[0]!.col).toBe(2)
expect(panels.stashItems[0]!.row).toBe(2)
// Reverse swap: drop ring (1x1) on shield (2x2) at (2, 2)
const reverseSwap = panels.clickStashCell(2, 2, swapRes.newCursorItem)
expect(reverseSwap.handled).toBe(true)
expect(reverseSwap.newCursorItem?.id).toBe('shield-swap')
expect(panels.stashItems).toHaveLength(1)
expect(panels.stashItems[0]!.item.id).toBe('ring-swap')
expect(panels.stashItems[0]!.col).toBe(2)
expect(panels.stashItems[0]!.row).toBe(2)
})
it('safely prevents swap if placement collides with a third item (multiple overlaps)', () => {
const ringA = createItem('ring-a', 'rin', 1, 1, '戒指 A')
const ringC = createItem('ring-c', 'rin', 1, 1, '戒指 C')
const shield = createItem('shield-multi', 'kit', 2, 2, '大盾牌')
// Place ringA at (2, 2) and ringC at (3, 2)
panels.stashItems.push({ item: ringA, col: 2, row: 2 })
panels.stashItems.push({ item: ringC, col: 3, row: 2 })
// Attempt to place shield (2x2) at (2, 2). It overlaps BOTH ringA and ringC.
const failSwap = panels.clickStashCell(2, 2, shield)
expect(failSwap.handled).toBe(false)
expect(failSwap.newCursorItem?.id).toBe('shield-multi') // Still held on cursor!
// Both rings remain completely untouched
expect(panels.stashItems).toHaveLength(2)
expect(panels.stashItems.map(p => p.item.id).sort()).toEqual(['ring-a', 'ring-c'])
// Add a third item: ringD at (2, 3) -> 3 overlaps
const ringD = createItem('ring-d', 'rin', 1, 1, '戒指 D')
panels.stashItems.push({ item: ringD, col: 2, row: 3 })
const failSwap3 = panels.clickStashCell(2, 2, shield)
expect(failSwap3.handled).toBe(false)
expect(failSwap3.newCursorItem?.id).toBe('shield-multi')
expect(panels.stashItems).toHaveLength(3)
})
it('safely prevents swap or drop if placement would cause out-of-bounds', () => {
const ring = createItem('ring-corner', 'rin', 1, 1, '角隅戒指')
const shield = createItem('shield-oob', 'kit', 2, 2, '盾牌')
panels.stashItems.push({ item: ring, col: 5, row: 7 })
// Attempt to drop shield at (5, 7) -> 5+2=7 > 6, 7+2=9 > 8
const failRes = panels.clickStashCell(5, 7, shield)
expect(failRes.handled).toBe(false)
expect(failRes.newCursorItem?.id).toBe('shield-oob')
expect(panels.stashItems).toHaveLength(1)
expect(panels.stashItems[0]!.item.id).toBe('ring-corner')
// Negative coordinates with held item
const negRes = panels.clickStashCell(-1, 0, shield)
expect(negRes.handled).toBe(false)
expect(negRes.newCursorItem?.id).toBe('shield-oob')
})
})
describe('4. Quick Transfer (Shift+Click / Right-Click) Stress Testing', () => {
it('rapidly transfers 20 items from inventory to stash with zero loss and zero duplication', () => {
// 10 1x1 items + 10 1x2 items = 30 cells (fits comfortably in 10x4 inventory)
const generatedItems: UiInventoryItem[] = []
for (let i = 0; i < 10; i++) {
const item1x1 = createItem(`burst-1x1-${i}`, 'rin', 1, 1, `戒指 ${i}`)
generatedItems.push(item1x1)
expect(inv.autoPlaceInGrid(item1x1)).toBe(true)
const item1x2 = createItem(`burst-1x2-${i}`, 'hp1', 1, 2, `药剂 ${i}`)
generatedItems.push(item1x2)
expect(inv.autoPlaceInGrid(item1x2)).toBe(true)
}
expect(inv.gridItems).toHaveLength(20)
expect(panels.stashItems).toHaveLength(0)
// Rapidly transfer all 20 items to stash
for (const item of generatedItems) {
const ok = panels.quickTransferToStash(item, inv)
expect(ok, `Item ${item.id} should successfully transfer`).toBe(true)
}
// Assert inventory is completely empty, stash has all 20 items
expect(inv.gridItems).toHaveLength(0)
expect(panels.stashItems).toHaveLength(20)
// Invariance check: verify exact set of item IDs
const stashItemIds = panels.stashItems.map(p => p.item.id).sort()
const expectedIds = generatedItems.map(i => i.id).sort()
expect(stashItemIds).toEqual(expectedIds)
expect(new Set(stashItemIds).size).toBe(20) // Zero duplicates
})
it('rejects larger items (2x2) when stash has only 1x1 slots free, while 1x1 items successfully transfer', () => {
// Create a fragmented stash layout:
// Fill cols 0..3 for all rows 0..7 (32 cells) with four 2x4 pikes
panels.stashItems.push({ item: createItem('fill-p1', 'pik', 2, 4, '长枪 1'), col: 0, row: 0 })
panels.stashItems.push({ item: createItem('fill-p2', 'pik', 2, 4, '长枪 2'), col: 0, row: 4 })
panels.stashItems.push({ item: createItem('fill-p3', 'pik', 2, 4, '长枪 3'), col: 2, row: 0 })
panels.stashItems.push({ item: createItem('fill-p4', 'pik', 2, 4, '长枪 4'), col: 2, row: 4 })
// Now cols 4..5 are remaining (2 cols * 8 rows = 16 cells).
// We fill rows 0..5 with three 2x2 shields at row 0, row 2, row 4
panels.stashItems.push({ item: createItem('fill-s1', 'kit', 2, 2, '盾牌 1'), col: 4, row: 0 })
panels.stashItems.push({ item: createItem('fill-s2', 'kit', 2, 2, '盾牌 2'), col: 4, row: 2 })
panels.stashItems.push({ item: createItem('fill-s3', 'kit', 2, 2, '盾牌 3'), col: 4, row: 4 })
// Now only rows 6 and 7 in cols 4..5 are left (4 cells: (4,6), (5,6), (4,7), (5,7)).
// Place two 1x1 gems at (4,6) and (5,7) in a checkerboard pattern!
panels.stashItems.push({ item: createItem('gem-4-6', 'gem', 1, 1, '宝石 1'), col: 4, row: 6 })
panels.stashItems.push({ item: createItem('gem-5-7', 'gem', 1, 1, '宝石 2'), col: 5, row: 7 })
// Stash has only TWO isolated 1x1 cells free: (5, 6) and (4, 7).
// Verify no 2x2 or 1x2 or 2x1 item can fit!
expect(panels.canPlaceInStash(5, 6, 1, 1)).toBe(true)
expect(panels.canPlaceInStash(4, 7, 1, 1)).toBe(true)
expect(panels.canPlaceInStash(4, 6, 2, 2)).toBe(false)
expect(panels.canPlaceInStash(5, 6, 1, 2)).toBe(false)
expect(panels.canPlaceInStash(4, 7, 2, 1)).toBe(false)
// Setup inventory with:
// 1 2x2 shield
// 2 1x1 rings (fit perfectly into remaining 2 slots)
// 1 1x1 rune (will overflow once those 2 slots are filled)
const bigShield = createItem('big-shield', 'kit', 2, 2, '巨大盾牌')
const ring1 = createItem('ring-fit-1', 'rin', 1, 1, '契合戒指 1')
const ring2 = createItem('ring-fit-2', 'rin', 1, 1, '契合戒指 2')
const ringOverflow = createItem('ring-overflow', 'rin', 1, 1, '溢出戒指')
inv.autoPlaceInGrid(bigShield)
inv.autoPlaceInGrid(ring1)
inv.autoPlaceInGrid(ring2)
inv.autoPlaceInGrid(ringOverflow)
expect(inv.gridItems).toHaveLength(4)
const initialStashCount = panels.stashItems.length
// 1. Attempt to quick-transfer 2x2 shield: MUST be rejected!
const shieldTransfer = panels.quickTransferToStash(bigShield, inv)
expect(shieldTransfer).toBe(false)
expect(inv.gridItems.some(p => p.item.id === 'big-shield')).toBe(true)
expect(panels.stashItems).toHaveLength(initialStashCount)
// 2. Transfer first 1x1 ring: MUST succeed
const ring1Transfer = panels.quickTransferToStash(ring1, inv)
expect(ring1Transfer).toBe(true)
expect(inv.gridItems.some(p => p.item.id === 'ring-fit-1')).toBe(false)
expect(panels.stashItems).toHaveLength(initialStashCount + 1)
// 3. Transfer second 1x1 ring: MUST succeed
const ring2Transfer = panels.quickTransferToStash(ring2, inv)
expect(ring2Transfer).toBe(true)
expect(inv.gridItems.some(p => p.item.id === 'ring-fit-2')).toBe(false)
expect(panels.stashItems).toHaveLength(initialStashCount + 2)
// 4. Stash is now 100% full (48/48 cells).
// Attempt to transfer third 1x1 ring: MUST fail!
const overflowTransfer = panels.quickTransferToStash(ringOverflow, inv)
expect(overflowTransfer).toBe(false)
expect(inv.gridItems.some(p => p.item.id === 'ring-overflow')).toBe(true)
expect(panels.stashItems).toHaveLength(initialStashCount + 2)
// Zero duplication, zero loss check
expect(inv.gridItems).toHaveLength(2) // bigShield and ringOverflow remain
expect(panels.stashItems.find(p => p.item.id === 'ring-fit-1')).toBeDefined()
expect(panels.stashItems.find(p => p.item.id === 'ring-fit-2')).toBeDefined()
})
it('executes Shift+Click and Right-Click quick transfer via handleLeftDockClick', () => {
const ring = createItem('shift-ring', 'rin', 1, 1, '快捷戒指')
panels.stashItems.push({ item: ring, col: 0, row: 0 })
// Calculate pixel coordinates for stash grid (0, 0)
const cellPx = STASH_GRID_ORIGIN.cellPx
const clickX = STASH_GRID_ORIGIN.x + cellPx / 2
const clickY = STASH_GRID_ORIGIN.y + cellPx / 2
// 1. Shift+Click transfers from stash to inventory
const shiftHandled = panels.handleLeftDockClick('stash', clickX, clickY, {
onClose: () => {},
onWaypointTeleport: () => {},
inventory: inv,
isShiftClick: true,
isRightClick: false,
})
expect(shiftHandled).toBe(true)
expect(panels.stashItems).toHaveLength(0)
expect(inv.gridItems).toHaveLength(1)
expect(inv.gridItems[0]!.item.id).toBe('shift-ring')
// Put it back in stash for Right-Click test
panels.quickTransferToStash(ring, inv)
expect(panels.stashItems).toHaveLength(1)
expect(inv.gridItems).toHaveLength(0)
// 2. Right-Click transfers from stash to inventory
const rightHandled = panels.handleLeftDockClick('stash', clickX, clickY, {
onClose: () => {},
onWaypointTeleport: () => {},
inventory: inv,
isShiftClick: false,
isRightClick: true,
})
expect(rightHandled).toBe(true)
expect(panels.stashItems).toHaveLength(0)
expect(inv.gridItems).toHaveLength(1)
expect(inv.gridItems[0]!.item.id).toBe('shift-ring')
// 3. Shift+Click on empty cell does nothing gracefully
const emptyClickHandled = panels.handleLeftDockClick('stash', clickX, clickY, {
onClose: () => {},
onWaypointTeleport: () => {},
inventory: inv,
isShiftClick: true,
isRightClick: false,
})
expect(emptyClickHandled).toBe(true)
expect(panels.stashItems).toHaveLength(0)
expect(inv.gridItems).toHaveLength(1)
})
it('performs bidirectional ping-pong transfer (15 items x 10 cycles) maintaining 100% invariance', () => {
const items: UiInventoryItem[] = []
// 5 1x1 + 5 1x2 + 5 2x2 = 5 + 10 + 20 = 35 cells (fits in 40-cell inventory)
for (let i = 0; i < 5; i++) {
const i1 = createItem(`ping-1x1-${i}`, 'rin', 1, 1, `戒指 ${i}`)
const i2 = createItem(`ping-1x2-${i}`, 'hp1', 1, 2, `药水 ${i}`)
const i3 = createItem(`ping-2x2-${i}`, 'cap', 2, 2, `头盔 ${i}`)
items.push(i1, i2, i3)
inv.autoPlaceInGrid(i1)
inv.autoPlaceInGrid(i2)
inv.autoPlaceInGrid(i3)
}
expect(items).toHaveLength(15)
expect(inv.gridItems).toHaveLength(15)
expect(panels.stashItems).toHaveLength(0)
const originalItemIds = items.map(it => it.id).sort()
// 10 Round-Trip Cycles
for (let cycle = 1; cycle <= 10; cycle++) {
// Transfer all from Inventory to Stash
for (const item of items) {
const success = panels.quickTransferToStash(item, inv)
expect(success, `Cycle ${cycle}: Quick transfer to stash failed for ${item.id}`).toBe(true)
}
expect(inv.gridItems).toHaveLength(0)
expect(panels.stashItems).toHaveLength(15)
const stashIds = panels.stashItems.map(p => p.item.id).sort()
expect(stashIds).toEqual(originalItemIds)
// Transfer all from Stash to Inventory
for (const item of items) {
const success = panels.quickTransferFromStash(item, inv)
expect(success, `Cycle ${cycle}: Quick transfer from stash failed for ${item.id}`).toBe(true)
}
expect(panels.stashItems).toHaveLength(0)
expect(inv.gridItems).toHaveLength(15)
const invIds = inv.gridItems.map(p => p.item.id).sort()
expect(invIds).toEqual(originalItemIds)
}
// Final verification: 0 items lost, 0 duplicates
expect(inv.gridItems).toHaveLength(15)
expect(panels.stashItems).toHaveLength(0)
})
})
})