diablo2-web/tests/item-to-ui-bridge.test.ts

492 lines
19 KiB
TypeScript

/**
* 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 the level-based inventory gold limit (getInventoryGoldLimit)
* 7. Inventory full refusal bounce, audio feedback, and notification retention
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Item, ItemBase } from '../src/common/items/items.ts'
import { ItemQuality } from '../src/common/items/items.ts'
import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts'
import {
itemToUiInventoryItem,
resolveAllowedSlots,
} from '../src/common/items/item-bridge.ts'
import {
InventoryPanel,
resolveItemSpriteRect,
} from '../src/client/ui/inventory.ts'
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
import { GameEngine } from '../src/server/engine/engine.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts'
import { castSkill } from '../src/server/scene/skill-caster.ts'
describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge & Pickup Loop', () => {
const dropTables = getEmbeddedDropTables()
type TestBase = Partial<ItemBase> & { twoHanded?: boolean; oneOrTwoHanded?: boolean }
// Helper to create synthetic engine items
function createTestItem(overrides: Omit<Partial<Item>, '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.baseNameEn).toBe('Cap')
expect(uiItem.nameZh).toBe('帽子')
expect(uiItem.baseNameZh).toBe('帽子')
})
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.baseNameEn).toBe('Leather Armor')
expect(uiItem.nameZh).toContain('皮甲')
expect(uiItem.baseNameZh).toBe('皮甲')
})
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.baseNameEn).toBe('Ring')
expect(uiItem.nameZh).toContain('毁灭之旋回')
expect(uiItem.baseNameZh).toBe('戒指')
})
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.baseNameEn).toContain("Cathan's Traps")
expect(uiItem.nameZh).toContain('卡珊的封印')
expect(uiItem.baseNameZh).toContain('卡珊的陷阱')
})
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.baseNameEn).toBe('Shako')
expect(uiItem.nameZh).toContain('谐角之冠')
expect(uiItem.baseNameZh).toBe('军帽')
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 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 & Inventory Gold Limit 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 authentic inventory gold capacity on Telekinesis pickup', () => {
const engine = createTestEngine()
engine.world.player.level = 10
engine.bag.gold = 90_000
const invPanel = new InventoryPanel()
invPanel.playerLevel = 10
invPanel.gold = 90_000
const hudManager: any = {
inventory: invPanel,
syncPublishedState: vi.fn(),
getSkillLevel: () => 1,
hotkeys: { availableSkills: [{ skillId: 43, manaCost: 0 }] },
}
const goldDrop = engine.dropGold(20_000, 520, 500, undefined, undefined, { fromWorld: true })
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(100_000)
expect(hudManager.syncPublishedState).toHaveBeenCalled()
})
})
})