/** * 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) }) }) })