/** * Comprehensive Unit Test Suite for Milestone M11.4 (Issue #407): * Drop Item to UiInventoryItem Bridge & Pickup Loop. * * Verifies strict Diablo II v1.13c ground truth parity: * 1. Quality Mapping across all tiers (White, Blue, Yellow, Green, Gold, Runes, Potions, Gems) * 2. 100% invFile Atlas Matching against BAKED_UI_MANIFEST.itemRects * 3. BodyLoc1/BodyLoc2 Equipment Slots Resolution (allowedSlots) with category inheritance * 4. Bilingual naming (nameZh, baseNameZh) and rich tooltip stats formatting * 5. Normal and Telekinesis ground item pickup conversion via itemToUiInventoryItem * 6. Gold accumulation clamped to PLAYER_GOLD_CAP (2,500,000) * 7. Inventory full refusal bounce, audio feedback, and notification retention */ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { Item, ItemBase } from '../src/game/items.ts' import { ItemQuality } from '../src/game/items.ts' import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts' import { itemToUiInventoryItem, resolveAllowedSlots, } from '../src/ui/item-bridge.ts' import { InventoryPanel, resolveItemSpriteRect, PLAYER_GOLD_CAP, } from '../src/ui/inventory.ts' import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' import { GameEngine } from '../src/game/engine.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts' import { SceneMouseController, castSkill } from '../src/scene/act-scene.ts' describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge & Pickup Loop', () => { const dropTables = getEmbeddedDropTables() type TestBase = Partial & { twoHanded?: boolean; oneOrTwoHanded?: boolean } // Helper to create synthetic engine items function createTestItem(overrides: Omit, 'base'> & { base?: TestBase }): Item { const b = overrides.base ?? {} const base: ItemBase = { id: b.id ?? 'hax', name: b.name ?? 'Hand Axe', kind: b.kind ?? 'weapon', invWidth: b.invWidth ?? 1, invHeight: b.invHeight ?? 2, maxStack: b.maxStack ?? 1, value: b.value ?? 100, damage: b.damage ?? 6, defense: b.defense ?? 0, tags: b.tags ?? ['weap', 'axe'], level: b.level ?? 1, ...(b as any), } return { prefix: null, suffix: null, level: overrides.level ?? 1, name: overrides.name ?? base.name, stats: overrides.stats ?? {}, invWidth: overrides.invWidth ?? base.invWidth, invHeight: overrides.invHeight ?? base.invHeight, stack: overrides.stack ?? 1, value: overrides.value ?? base.value, ...overrides, base: base as ItemBase, } } describe('1. Quality Tier Mapping & Bilingual Localization', () => { it('maps Normal / White quality with bilingual item and base names', () => { const item = createTestItem({ base: { id: 'cap', name: 'Cap', kind: 'armor', defense: 3, tags: ['helm', 'armo'], invWidth: 2, invHeight: 2 }, quality: ItemQuality.NORMAL, }) const uiItem = itemToUiInventoryItem(item, dropTables) expect(uiItem.quality).toBe('normal') expect(uiItem.name).toBe('Cap') expect(uiItem.nameZh).toBe('帽子 (Cap)') expect(uiItem.baseNameZh).toBe('帽子 (Cap)') }) it('maps Magic / Blue quality with rolled affixes and bilingual naming', () => { const item = createTestItem({ base: { id: 'lea', name: 'Leather Armor', kind: 'armor', defense: 14, tags: ['tors', 'armo'], invWidth: 2, invHeight: 3 }, quality: ItemQuality.MAGIC, name: 'Sturdy Leather Armor', rolledMagicAffixes: { name: 'Sturdy Leather Armor', prefix: { id: 'Sturdy', name: 'Sturdy', mods: [{ code: 'ac%', min: 20, max: 30, value: 25 }] }, }, }) const uiItem = itemToUiInventoryItem(item, dropTables) expect(uiItem.quality).toBe('magic') expect(uiItem.name).toBe('Sturdy Leather Armor') expect(uiItem.nameZh).toContain('皮甲') expect(uiItem.baseNameZh).toContain('皮甲 (Leather Armor)') }) it('maps Rare / Yellow quality with rolled rare affixes and bilingual naming', () => { const item = createTestItem({ base: { id: 'rin', name: 'Ring', kind: 'misc', tags: ['ring'], invWidth: 1, invHeight: 1 }, quality: ItemQuality.RARE, name: 'Doom Gyre', rolledRareAffixes: { name: 'Doom Gyre', prefix1: { id: 'Doom', name: 'Doom' }, suffix1: { id: 'Gyre', name: 'Gyre' }, }, }) const uiItem = itemToUiInventoryItem(item, dropTables) expect(uiItem.quality).toBe('rare') expect(uiItem.name).toBe('Doom Gyre') expect(uiItem.nameZh).toContain('毁灭之旋回') expect(uiItem.baseNameZh).toContain('戒指 (Ring)') }) it('maps Set / Green quality with set definition, pieces, and bonuses', () => { const item = createTestItem({ base: { id: 'rin', name: 'Ring', kind: 'misc', tags: ['ring'], invWidth: 1, invHeight: 1 }, quality: ItemQuality.SET, name: "Cathan's Seal", setItemDef: { index: "Cathan's Seal", set: "Cathan's Traps", invfile: 'invrin', }, }) const uiItem = itemToUiInventoryItem(item, dropTables) expect(uiItem.quality).toBe('set') expect(uiItem.name).toBe("Cathan's Seal") expect(uiItem.nameZh).toContain("Cathan's Seal") expect(uiItem.baseNameZh).toContain("Cathan's Traps") }) it('maps Unique / Gold quality with unique item definition and color dye', () => { const item = createTestItem({ base: { id: 'uap', name: 'Shako', kind: 'armor', defense: 141, tags: ['helm', 'armo'], invWidth: 2, invHeight: 2 }, quality: ItemQuality.UNIQUE, name: 'Harlequin Crest', uniqueItemDef: { index: 'Harlequin Crest', invtransform: 'cgrn', }, }) const uiItem = itemToUiInventoryItem(item, dropTables) expect(uiItem.quality).toBe('unique') expect(uiItem.name).toBe('Harlequin Crest') expect(uiItem.nameZh).toContain('谐角之冠') expect(uiItem.baseNameZh).toContain('军帽 (Shako)') expect(uiItem.invtransform).toBe('cgrn') }) it('maps Runes correctly with quality rune', () => { const item = createTestItem({ base: { id: 'r01', name: 'El Rune', kind: 'misc', tags: ['rune'], invWidth: 1, invHeight: 1 }, code: 'r01', }) const uiItem = itemToUiInventoryItem(item, dropTables) expect(uiItem.quality).toBe('rune') expect(uiItem.nameZh).toContain('艾尔') }) it('maps Potions and Gems correctly as normal quality misc items', () => { const potion = createTestItem({ base: { id: 'hp1', name: 'Minor Healing Potion', kind: 'misc', tags: ['potion'], invWidth: 1, invHeight: 1 }, }) const uiPotion = itemToUiInventoryItem(potion, dropTables) expect(uiPotion.quality).toBe('normal') expect(uiPotion.nameZh).toContain('轻微治疗药剂') const gem = createTestItem({ base: { id: 'gsv', name: 'Flawed Amethyst', kind: 'misc', tags: ['gem'], invWidth: 1, invHeight: 1 }, }) const uiGem = itemToUiInventoryItem(gem, dropTables) expect(uiGem.quality).toBe('normal') expect(uiGem.nameZh).toContain('裂开的紫宝石') }) }) describe('2. 100% invFile Atlas Resolution against BAKED_UI_MANIFEST.itemRects', () => { it('guarantees resolved invFile exists in BAKED_UI_MANIFEST.itemRects for every item', () => { const testCases = [ { id: 'hax', name: 'Hand Axe', kind: 'weapon' }, { id: 'swd', name: 'Short Sword', kind: 'weapon' }, { id: 'cap', name: 'Cap', kind: 'armor' }, { id: 'lea', name: 'Leather Armor', kind: 'armor' }, { id: 'buc', name: 'Buckler', kind: 'armor' }, { id: 'rin', name: 'Ring', kind: 'misc' }, { id: 'amu', name: 'Amulet', kind: 'misc' }, { id: 'jew', name: 'Jewel', kind: 'misc' }, { id: 'cm1', name: 'Small Charm', kind: 'misc' }, { id: 'cm2', name: 'Large Charm', kind: 'misc' }, { id: 'cm3', name: 'Grand Charm', kind: 'misc' }, { id: 'r01', name: 'El Rune', kind: 'misc' }, { id: 'r33', name: 'Zod Rune', kind: 'misc' }, { id: 'hp1', name: 'Minor Healing Potion', kind: 'misc' }, { id: 'tbk', name: 'Tome of Town Portal', kind: 'misc' }, { id: 'ibk', name: 'Tome of Identify', kind: 'misc' }, { id: 'box', name: 'Horadric Cube', kind: 'misc' }, ] for (const tc of testCases) { const item = createTestItem({ base: { id: tc.id, name: tc.name, kind: tc.kind as any, invWidth: 1, invHeight: 1 }, code: tc.id, }) const uiItem = itemToUiInventoryItem(item, dropTables) expect(uiItem.invFile).toBeTruthy() const rect = BAKED_UI_MANIFEST.itemRects[uiItem.invFile] ?? BAKED_UI_MANIFEST.itemRects[uiItem.invFile.toLowerCase()] expect(rect, `Missing atlas rect for ${tc.name} (invFile: ${uiItem.invFile})`).toBeDefined() expect(rect.w).toBeGreaterThan(0) expect(rect.h).toBeGreaterThan(0) const spriteRect = resolveItemSpriteRect(uiItem) expect(spriteRect).not.toBeNull() } }) it('resolves unique Harlequin Crest to dyed invcap with cgrn transform', () => { const shako = createTestItem({ base: { id: 'uap', name: 'Shako', kind: 'armor', invWidth: 2, invHeight: 2 }, name: 'Harlequin Crest', quality: ItemQuality.UNIQUE, uniqueItemDef: { index: 'Harlequin Crest', invtransform: 'cgrn', }, }) const uiItem = itemToUiInventoryItem(shako, dropTables) expect(uiItem.invFile).toBe('invcap') expect(uiItem.invtransform).toBe('cgrn') const rect = resolveItemSpriteRect(uiItem) expect(rect).not.toBeNull() }) it('resolves authentic charm sprites cm1 -> invch1, cm2 -> invch2, cm3 -> invch3', () => { const sc = itemToUiInventoryItem(createTestItem({ base: { id: 'cm1', name: 'Small Charm', kind: 'misc', invWidth: 1, invHeight: 1 }, code: 'cm1' })) const lc = itemToUiInventoryItem(createTestItem({ base: { id: 'cm2', name: 'Large Charm', kind: 'misc', invWidth: 1, invHeight: 2 }, code: 'cm2' })) const gc = itemToUiInventoryItem(createTestItem({ base: { id: 'cm3', name: 'Grand Charm', kind: 'misc', invWidth: 1, invHeight: 3 }, code: 'cm3' })) expect(sc.invFile).toBe('invch1') expect(lc.invFile).toBe('invch2') expect(gc.invFile).toBe('invch3') expect(BAKED_UI_MANIFEST.itemRects[sc.invFile]).toBeDefined() expect(BAKED_UI_MANIFEST.itemRects[lc.invFile]).toBeDefined() expect(BAKED_UI_MANIFEST.itemRects[gc.invFile]).toBeDefined() }) }) describe('3. BodyLoc1/BodyLoc2 Equipment Slots Resolution (allowedSlots)', () => { it('resolves Helm equipment slot', () => { const helm = createTestItem({ base: { id: 'cap', kind: 'armor', tags: ['helm'] } }) expect(resolveAllowedSlots(helm.base, dropTables)).toEqual(['helm']) }) it('resolves Amulet equipment slot', () => { const amulet = createTestItem({ base: { id: 'amu', kind: 'misc', tags: ['amul'] } }) expect(resolveAllowedSlots(amulet.base, dropTables)).toEqual(['amulet']) }) it('resolves Torso / Body Armor equipment slot', () => { const armor = createTestItem({ base: { id: 'lea', kind: 'armor', tags: ['tors'] } }) expect(resolveAllowedSlots(armor.base, dropTables)).toEqual(['armor']) }) it('resolves Gloves equipment slot', () => { const gloves = createTestItem({ base: { id: 'lgl', kind: 'armor', tags: ['glov'] } }) expect(resolveAllowedSlots(gloves.base, dropTables)).toEqual(['gloves']) }) it('resolves Boots equipment slot', () => { const boots = createTestItem({ base: { id: 'lbt', kind: 'armor', tags: ['boot'] } }) expect(resolveAllowedSlots(boots.base, dropTables)).toEqual(['boots']) }) it('resolves Belt equipment slot', () => { const belt = createTestItem({ base: { id: 'lbl', kind: 'armor', tags: ['belt'] } }) expect(resolveAllowedSlots(belt.base, dropTables)).toEqual(['belt']) }) it('resolves Ring equipment slot to ring1 and ring2', () => { const ring = createTestItem({ base: { id: 'rin', kind: 'misc', tags: ['ring'] } }) expect(resolveAllowedSlots(ring.base, dropTables)).toEqual(['ring1', 'ring2']) }) it('resolves One-Handed weapons to weapon1 and weapon2 (dual-wield capable)', () => { const axe = createTestItem({ base: { id: 'hax', kind: 'weapon', tags: ['weap', 'axe'], twoHanded: false } }) expect(resolveAllowedSlots(axe.base, dropTables)).toEqual(['weapon1', 'weapon2']) }) it('resolves Two-Handed weapons strictly to weapon1', () => { const bow = createTestItem({ base: { id: 'sbw', kind: 'weapon', tags: ['weap', 'bow'], twoHanded: true } }) expect(resolveAllowedSlots(bow.base, dropTables)).toEqual(['weapon1']) }) it('resolves Shields strictly to weapon2 offhand', () => { const shield = createTestItem({ base: { id: 'buc', kind: 'armor', tags: ['shld'] } }) expect(resolveAllowedSlots(shield.base, dropTables)).toEqual(['weapon2']) }) it('resolves Quivers strictly to weapon2 offhand', () => { const quiver = createTestItem({ base: { id: 'aqv', kind: 'misc', tags: ['bowq'] } }) expect(resolveAllowedSlots(quiver.base, dropTables)).toEqual(['weapon2']) }) it('resolves Non-equippable items (Runes, Gems, Potions, Charms) to empty array', () => { const rune = createTestItem({ base: { id: 'r01', kind: 'misc', tags: ['rune'] } }) const potion = createTestItem({ base: { id: 'hp1', kind: 'misc', tags: ['potion'] } }) const gem = createTestItem({ base: { id: 'gsv', kind: 'misc', tags: ['gem'] } }) const charm = createTestItem({ base: { id: 'cm1', kind: 'misc', tags: ['charm'] } }) expect(resolveAllowedSlots(rune.base, dropTables)).toEqual([]) expect(resolveAllowedSlots(potion.base, dropTables)).toEqual([]) expect(resolveAllowedSlots(gem.base, dropTables)).toEqual([]) expect(resolveAllowedSlots(charm.base, dropTables)).toEqual([]) }) }) describe('4. Tooltip Stats & Equipment Attributes Formatting', () => { it('populates defense, damage, requirements, durability and stats lines', () => { const weapon = createTestItem({ base: { id: 'swd', name: 'Short Sword', kind: 'weapon', damage: 6, invWidth: 1, invHeight: 2, level: 1 }, durability: 24, maxDurability: 24, rolledProps: [ { code: 'dmg-min', min: 2, max: 2, value: 2 }, { code: 'dmg-max', min: 4, max: 4, value: 4 }, ], }) const uiWeapon = itemToUiInventoryItem(weapon, dropTables) expect(uiWeapon.damage).toBeDefined() expect(uiWeapon.durability).toEqual({ current: 24, max: 24 }) expect(uiWeapon.stats.length).toBeGreaterThan(0) expect(uiWeapon.stats.some(s => s.text.includes('伤害') || s.text.includes('Damage') || s.text.includes('+'))).toBe(true) }) }) describe('5. Normal and Telekinesis Ground Item Pickup Loop Conversion', () => { let mockCanvas: any let mockStatus: { textContent: string } beforeEach(() => { mockStatus = { textContent: '' } mockCanvas = { getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), addEventListener: vi.fn(), removeEventListener: vi.fn(), } }) function createTestEngine() { const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 } const engine = new GameEngine(terrain, { spawn: { x: 500, y: 500 }, stats: [], xpTable: DEMO_EXPERIENCE, skills: DEMO_SKILLS, questDefs: DEMO_QUESTS, combatOptions: { playerSpeed: 4, playerReach: 50, playerCooldownTicks: 10, playerDamage: 5, playerManaPerAttack: 1, respawnTicks: 100, }, talkRadius: 48, pickupRadius: 48, inventoryCols: 10, inventoryRows: 4, lootSeed: 1234, npcDefs: [], }) engine.setDropTables(dropTables) return engine } it('converts raw ground item into UiInventoryItem upon normal pickup and places into inventory grid', () => { const engine = createTestEngine() const invPanel = new InventoryPanel() invPanel.gridItems = [] const hudManager: any = { inventory: invPanel, syncPublishedState: vi.fn(), } const rawItem = createTestItem({ base: { id: 'hax', name: 'Hand Axe', kind: 'weapon', damage: 6, invWidth: 1, invHeight: 2 }, }) const dropped = engine.dropItem(rawItem as any, 510, 500) const controller = new SceneMouseController({ canvas: mockCanvas, engine, camera: { zoom: 1 } as any, input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any, getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any, hudManager, waypointNetwork: null as any, status: mockStatus as any, playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any, getCharacter: () => null, }) controller.pickupGroundItem(dropped) // Item should be picked up into invPanel.gridItems expect(invPanel.gridItems.length).toBe(1) const placedItem = invPanel.gridItems[0]!.item expect(placedItem.allowedSlots).toEqual(['weapon1', 'weapon2']) expect(placedItem.invFile).toBe('invhax') expect(placedItem.nameZh).toContain('斧') expect(hudManager.syncPublishedState).toHaveBeenCalled() expect(engine.groundItems.count).toBe(0) }) it('converts raw ground item into UiInventoryItem upon Telekinesis pickup', () => { const engine = createTestEngine() const invPanel = new InventoryPanel() invPanel.gridItems = [] const hudManager: any = { inventory: invPanel, syncPublishedState: vi.fn(), getSkillLevel: () => 1, hotkeys: { availableSkills: [{ skillId: 43, manaCost: 0 }] }, } const rawPotion = createTestItem({ base: { id: 'hp1', name: 'Minor Healing Potion', kind: 'misc', invWidth: 1, invHeight: 1 }, }) const dropped = engine.dropItem(rawPotion as any, 520, 500) const handled = castSkill(43, dropped.x, dropped.y, { engine, runtime: { waypoints: [], stashes: [] } as any, hudManager, status: mockStatus as any, }) expect(handled).toBe(true) expect(invPanel.gridItems.length).toBe(1) const placedPotion = invPanel.gridItems[0]!.item expect(placedPotion.invFile).toBe('invhp1') expect(placedPotion.nameZh).toContain('轻微治疗药剂') expect(engine.groundItems.count).toBe(0) }) }) describe('6. Gold Accumulation & PLAYER_GOLD_CAP Parity', () => { let mockCanvas: any let mockStatus: { textContent: string } beforeEach(() => { mockStatus = { textContent: '' } mockCanvas = { getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), addEventListener: vi.fn(), removeEventListener: vi.fn(), } }) function createTestEngine() { const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 } return new GameEngine(terrain, { spawn: { x: 500, y: 500 }, stats: [], xpTable: DEMO_EXPERIENCE, skills: DEMO_SKILLS, questDefs: DEMO_QUESTS, combatOptions: { playerSpeed: 4, playerReach: 50, playerCooldownTicks: 10, playerDamage: 5, playerManaPerAttack: 1, respawnTicks: 100, }, talkRadius: 48, pickupRadius: 48, inventoryCols: 10, inventoryRows: 4, lootSeed: 1234, npcDefs: [], }) } it('accumulates gold and clamps to PLAYER_GOLD_CAP (2,500,000) on normal pickup', () => { const engine = createTestEngine() const invPanel = new InventoryPanel() invPanel.gold = 2_450_000 const hudManager: any = { inventory: invPanel, syncPublishedState: vi.fn(), } // Drop 100,000 gold pile const goldDrop = engine.dropGold(100_000, 510, 500) expect(goldDrop).not.toBeNull() const controller = new SceneMouseController({ canvas: mockCanvas, engine, camera: { zoom: 1 } as any, input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any, getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any, hudManager, waypointNetwork: null as any, status: mockStatus as any, playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any, getCharacter: () => null, }) controller.pickupGroundItem(goldDrop!) // Gold clamped to 2,500,000 instead of 2,550,000 expect(invPanel.gold).toBe(PLAYER_GOLD_CAP) expect(hudManager.syncPublishedState).toHaveBeenCalled() expect(mockStatus.textContent).toContain('拾起金币:100000') }) it('accumulates gold and clamps to PLAYER_GOLD_CAP on Telekinesis pickup', () => { const engine = createTestEngine() const invPanel = new InventoryPanel() invPanel.gold = 2_490_000 const hudManager: any = { inventory: invPanel, syncPublishedState: vi.fn(), getSkillLevel: () => 1, hotkeys: { availableSkills: [{ skillId: 43, manaCost: 0 }] }, } const goldDrop = engine.dropGold(50_000, 520, 500) expect(goldDrop).not.toBeNull() castSkill(43, goldDrop!.x, goldDrop!.y, { engine, runtime: { waypoints: [], stashes: [] } as any, hudManager, status: mockStatus as any, }) expect(invPanel.gold).toBe(PLAYER_GOLD_CAP) expect(hudManager.syncPublishedState).toHaveBeenCalled() }) }) describe('7. Inventory Full Refusal & Parabolic Flippy Bounce', () => { let mockCanvas: any let mockStatus: { textContent: string } beforeEach(() => { mockStatus = { textContent: '' } mockCanvas = { getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), addEventListener: vi.fn(), removeEventListener: vi.fn(), } }) it('refuses pickup when 10x4 grid has no free space, triggering bounce and refusal feedback', () => { const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 } const engine = new GameEngine(terrain, { spawn: { x: 500, y: 500 }, stats: [], xpTable: DEMO_EXPERIENCE, skills: DEMO_SKILLS, questDefs: DEMO_QUESTS, combatOptions: { playerSpeed: 4, playerReach: 50, playerCooldownTicks: 10, playerDamage: 5, playerManaPerAttack: 1, respawnTicks: 100, }, talkRadius: 48, pickupRadius: 48, inventoryCols: 10, inventoryRows: 4, lootSeed: 1234, npcDefs: [], }) engine.setDropTables(dropTables) const invPanel = new InventoryPanel() invPanel.gridItems = [] // Completely fill the 10x4 inventory grid with 2x2 items (10 items = 40 cells) for (let c = 0; c < 10; c += 2) { for (let r = 0; r < 4; r += 2) { invPanel.gridItems.push({ item: { id: `filler-${c}-${r}`, code: 'cap', invFile: 'invcap', name: 'Cap', nameZh: '帽子 (Cap)', baseNameZh: '帽子 (Cap)', quality: 'normal', invWidth: 2, invHeight: 2, allowedSlots: ['helm'], stats: [], }, col: c, row: r, }) } } expect(invPanel.gridItems.length).toBe(10) // 10 * 4 = 40 cells, 100% full const dropped = engine.dropItem( createTestItem({ base: { id: 'hax', name: 'Hand Axe', kind: 'weapon', invWidth: 1, invHeight: 2 }, }) as any, 510, 500, ) const hudManager: any = { inventory: invPanel, syncPublishedState: vi.fn(), } const controller = new SceneMouseController({ canvas: mockCanvas, engine, camera: { zoom: 1 } as any, input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any, getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any, hudManager, waypointNetwork: null as any, status: mockStatus as any, playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any, getCharacter: () => null, }) const feedbackSpy = vi.spyOn(controller, 'playInventoryFullFeedback') const notificationSpy = vi.spyOn(controller, 'showNotification') controller.pickupGroundItem(dropped) // Item refused expect(engine.metrics.inventoryRefusals).toBe(1) expect(engine.groundItems.count).toBe(1) expect(dropped.bounceState).toBeDefined() expect(dropped.bounceState!.phase).toBe('primary') expect(dropped.bounceState!.durationMs).toBe(350) expect(dropped.bounceState!.peakHeightPx).toBe(28) expect(feedbackSpy).toHaveBeenCalled() expect(notificationSpy).toHaveBeenCalledWith('包裹已满。') expect(mockStatus.textContent).toBe('包裹已满。') }) }) })