652 lines
27 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|