504 lines
18 KiB
TypeScript
504 lines
18 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { BAKED_UI_MANIFEST } from '../../src/common/items/baked-ui-meta.ts'
|
|
import {
|
|
CANONICAL_VAR_INV_GFX,
|
|
itemDataToUiInventoryItem,
|
|
itemToUiInventoryItem,
|
|
onlineItemDataToUiInventoryItem,
|
|
resolveItemInvFile,
|
|
} from '../../src/common/items/item-bridge.ts'
|
|
import { ItemQuality, type Item } from '../../src/common/items/items.ts'
|
|
import {
|
|
CANONICAL_MISC_USABLE_CODES,
|
|
EQUIP_SLOTS_LAYOUT,
|
|
GEMSOCKET_SPRITE_RECT,
|
|
GRID_ITEM_BG_BLUE,
|
|
GRID_ITEM_BG_GREEN,
|
|
GRID_ITEM_BG_RED,
|
|
INVENTORY_TXT_LAYOUTS,
|
|
INV_GRID_ORIGIN,
|
|
INV_PANEL_ORIGIN,
|
|
InventoryPanel,
|
|
WEAPON_SWAP_TABS_BOUNDS,
|
|
drawItemSocketOverlay,
|
|
getInventoryLayout,
|
|
isUsableRightClickItem,
|
|
parseInventoryTxt,
|
|
resolveGridItemBackgroundFill,
|
|
resolveItemSpriteRect,
|
|
resolvePaperdollSlotBackgroundFill,
|
|
resolveWeaponSwapTabClick,
|
|
type UiInventoryItem,
|
|
} from '../../src/client/ui/inventory.ts'
|
|
import type { D2FontRenderer } from '../../src/client/ui/font.ts'
|
|
|
|
function createMockCanvasContext() {
|
|
const fillRectCalls: { x: number; y: number; w: number; h: number; fillStyle: string }[] = []
|
|
const drawImageCalls: unknown[][] = []
|
|
const arcCalls: unknown[][] = []
|
|
const textCalls: string[] = []
|
|
|
|
const ctx = {
|
|
fillStyle: '',
|
|
strokeStyle: '',
|
|
lineWidth: 1,
|
|
save: vi.fn(),
|
|
restore: vi.fn(),
|
|
beginPath: vi.fn(),
|
|
fill: vi.fn(),
|
|
stroke: vi.fn(),
|
|
fillRect: vi.fn((x: number, y: number, w: number, h: number) => {
|
|
fillRectCalls.push({ x, y, w, h, fillStyle: String(ctx.fillStyle) })
|
|
}),
|
|
strokeRect: vi.fn(),
|
|
drawImage: vi.fn((...args: unknown[]) => {
|
|
drawImageCalls.push(args)
|
|
}),
|
|
arc: vi.fn((...args: unknown[]) => {
|
|
arcCalls.push(args)
|
|
}),
|
|
} as unknown as CanvasRenderingContext2D
|
|
|
|
const font = {
|
|
drawText: vi.fn((_ctx: unknown, text: string) => {
|
|
textCalls.push(text)
|
|
}),
|
|
} as unknown as D2FontRenderer
|
|
|
|
return { ctx, font, fillRectCalls, drawImageCalls, arcCalls, textCalls }
|
|
}
|
|
|
|
describe('Batch 6 Phase 2: Inventory Panel & Item Rendering Parity (Issues #680-#685)', () => {
|
|
describe('Issue #680: Right-Click Usable Items Driven by misc.txt useable Column', () => {
|
|
const EXPECTED_35_USABLE_CODES = [
|
|
'elx', 'hpo', 'mpo', 'hpf', 'mpf', 'vps', 'yps', 'rvs', 'rvl', 'wms',
|
|
'tbk', 'ibk', 'tsc', 'isc', 'xyz', 'box', 'ass',
|
|
'hp1', 'hp2', 'hp3', 'hp4', 'hp5',
|
|
'mp1', 'mp2', 'mp3', 'mp4', 'mp5',
|
|
'hrb', 'rps', 'rpl', 'bps', 'bpl', '0sc', 'tr2', 'toa',
|
|
] as const
|
|
|
|
it('recognizes all 35 canonical misc.txt useable=1 codes and rejects non-canonical codes like cube', () => {
|
|
expect(CANONICAL_MISC_USABLE_CODES.size).toBe(35)
|
|
for (const code of EXPECTED_35_USABLE_CODES) {
|
|
expect(isUsableRightClickItem(code)).toBe(true)
|
|
expect(isUsableRightClickItem(` ${code.toUpperCase()} `)).toBe(true)
|
|
}
|
|
|
|
// Reject non-canonical 'cube' alias and non-usable items
|
|
expect(isUsableRightClickItem('cube')).toBe(false)
|
|
expect(isUsableRightClickItem('cm1')).toBe(false)
|
|
expect(isUsableRightClickItem('rin')).toBe(false)
|
|
expect(isUsableRightClickItem('cap')).toBe(false)
|
|
expect(isUsableRightClickItem('r01')).toBe(false)
|
|
expect(isUsableRightClickItem(undefined)).toBe(false)
|
|
})
|
|
|
|
it('consumes usable potions/scrolls on right-click in InventoryPanel while rejecting non-usable items', () => {
|
|
const panel = new InventoryPanel()
|
|
panel.visible = true
|
|
const usedItems: string[] = []
|
|
|
|
const hpPotion: UiInventoryItem = {
|
|
id: 'pot-1',
|
|
code: 'hp4',
|
|
invFile: 'invbpot',
|
|
name: 'Greater Healing Potion',
|
|
nameZh: '强效治疗药剂',
|
|
baseNameZh: '强效治疗药剂',
|
|
quality: 'normal',
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
allowedSlots: [],
|
|
stats: [],
|
|
}
|
|
panel.gridItems = [{ item: hpPotion, col: 0, row: 0 }]
|
|
|
|
const handled = panel.handleClick(
|
|
INV_GRID_ORIGIN.x + 5,
|
|
INV_GRID_ORIGIN.y + 5,
|
|
{
|
|
onUseItem: item => {
|
|
usedItems.push(item.code)
|
|
},
|
|
},
|
|
2,
|
|
)
|
|
expect(handled).toBe(true)
|
|
expect(usedItems).toEqual(['hp4'])
|
|
expect(panel.gridItems.length).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('Issue #681: Item Variant Graphic Index (graphicIndex / InvGfx1..6) without Name Guessing or String Hashing', () => {
|
|
const itemRects = BAKED_UI_MANIFEST.itemRects
|
|
|
|
it('forwards graphicIndex from D2GS ItemData through itemDataToUiInventoryItem to select InvGfx1..6', () => {
|
|
// Rings: 5 variants (invrin1..invrin5)
|
|
for (let i = 0; i < 5; i++) {
|
|
const uiRing = itemDataToUiInventoryItem({
|
|
id: 100 + i,
|
|
code: 'rin',
|
|
quality: 4,
|
|
identified: true,
|
|
graphicIndex: i,
|
|
})
|
|
expect(uiRing.variant).toBe(i)
|
|
expect(uiRing.graphicIndex).toBe(i)
|
|
expect(uiRing.invFile).toBe(`invrin${i + 1}`)
|
|
expect(resolveItemSpriteRect(uiRing, itemRects)).toEqual(itemRects[`invrin${i + 1}`])
|
|
}
|
|
|
|
// Amulets: 3 variants (invamu1..invamu3)
|
|
for (let i = 0; i < 3; i++) {
|
|
const uiAmu = onlineItemDataToUiInventoryItem({
|
|
id: 200 + i,
|
|
code: 'amu',
|
|
quality: 6,
|
|
identified: true,
|
|
graphicIndex: i,
|
|
})
|
|
expect(uiAmu.variant).toBe(i)
|
|
expect(uiAmu.invFile).toBe(`invamu${i + 1}`)
|
|
expect(resolveItemSpriteRect(uiAmu, itemRects)).toEqual(itemRects[`invamu${i + 1}`])
|
|
}
|
|
|
|
// Jewels: 6 variants (invjw1..invjw6), never misc.txt placeholder invgswe
|
|
for (let i = 0; i < 6; i++) {
|
|
const uiJewel = onlineItemDataToUiInventoryItem({
|
|
id: 300 + i,
|
|
code: 'jew',
|
|
quality: 7,
|
|
identified: true,
|
|
graphicIndex: i,
|
|
})
|
|
expect(uiJewel.variant).toBe(i)
|
|
expect(uiJewel.invFile).toBe(`invjw${i + 1}`)
|
|
expect(resolveItemSpriteRect(uiJewel, itemRects)).toEqual(itemRects[`invjw${i + 1}`])
|
|
}
|
|
|
|
// Charms: cm1 (invch1/4/7), cm2 (invch2/5/8), cm3 (invch3/6/9)
|
|
for (let i = 0; i < 3; i++) {
|
|
const cm1 = onlineItemDataToUiInventoryItem({ id: 400 + i, code: 'cm1', quality: 4, identified: true, graphicIndex: i })
|
|
const cm2 = onlineItemDataToUiInventoryItem({ id: 410 + i, code: 'cm2', quality: 4, identified: true, graphicIndex: i })
|
|
const cm3 = onlineItemDataToUiInventoryItem({ id: 420 + i, code: 'cm3', quality: 4, identified: true, graphicIndex: i })
|
|
expect(cm1.invFile).toBe(CANONICAL_VAR_INV_GFX.cm1![i])
|
|
expect(cm2.invFile).toBe(CANONICAL_VAR_INV_GFX.cm2![i])
|
|
expect(cm3.invFile).toBe(CANONICAL_VAR_INV_GFX.cm3![i])
|
|
}
|
|
})
|
|
|
|
it('does not hash item.id to pick random ring/amulet/jewel graphics and rejects fake cta code', () => {
|
|
// Different item.id strings without variant/graphicIndex must deterministically use index 0
|
|
for (const id of ['ring-alpha', 'ring-beta', 'ring-gamma', '99999', '12345']) {
|
|
expect(resolveItemSpriteRect({ code: 'rin', id }, itemRects)).toEqual(itemRects['invrin1'])
|
|
expect(resolveItemSpriteRect({ code: 'amu', id }, itemRects)).toEqual(itemRects['invamu1'])
|
|
expect(resolveItemSpriteRect({ code: 'jew', id }, itemRects)).toEqual(itemRects['invjw1'])
|
|
}
|
|
|
|
// Canonical crs resolves to invcrs, while fake code 'cta' returns null
|
|
expect(resolveItemSpriteRect({ code: 'crs' }, itemRects)).toEqual(itemRects['invcrs'])
|
|
expect(resolveItemSpriteRect({ code: 'cta' }, itemRects)).toBeNull()
|
|
})
|
|
|
|
it('resolves unique items with dedicated UniqueItems.txt invfile (Annihilus, Hellfire Torch) via table lookup', () => {
|
|
const anniDomain = {
|
|
id: 1,
|
|
code: 'cm1',
|
|
name: 'Annihilus',
|
|
quality: ItemQuality.UNIQUE,
|
|
identified: true,
|
|
uniqueItemDef: { invfile: 'invmss', index: 'Annihilus' },
|
|
base: { id: 'cm1', name: 'Small Charm', kind: 'misc', invWidth: 1, invHeight: 1 },
|
|
} as unknown as Item
|
|
expect(resolveItemInvFile(anniDomain, 'unique', 'cm1').invFile).toBe('invmss')
|
|
expect(itemToUiInventoryItem(anniDomain).invFile).toBe('invmss')
|
|
})
|
|
})
|
|
|
|
describe('Issue #682: Canonical inventory.txt Layout Loading & Coordinates', () => {
|
|
it('parses all 32 rows of canonical 1.13c inventory.txt and exposes 640x480 and 800x600 layouts', () => {
|
|
expect(INVENTORY_TXT_LAYOUTS.length).toBe(32)
|
|
|
|
const ama640 = getInventoryLayout('Amazon', 0)
|
|
expect(ama640.invLeft).toBe(320)
|
|
expect(ama640.invRight).toBe(640)
|
|
expect(ama640.invTop).toBe(0)
|
|
expect(ama640.invBottom).toBe(441)
|
|
expect(ama640.gridLeft).toBe(339)
|
|
expect(ama640.gridTop).toBe(257)
|
|
|
|
const ama800 = getInventoryLayout('ama', 1)
|
|
expect(ama800.class).toBe('Amazon2')
|
|
expect(ama800.invLeft).toBe(400)
|
|
expect(ama800.invRight).toBe(720)
|
|
expect(ama800.invTop).toBe(60)
|
|
expect(ama800.invBottom).toBe(501)
|
|
expect(ama800.invWidth).toBe(320)
|
|
expect(ama800.invHeight).toBe(441)
|
|
expect(ama800.gridLeft).toBe(419)
|
|
expect(ama800.gridTop).toBe(315)
|
|
expect(ama800.gridX).toBe(10)
|
|
expect(ama800.gridY).toBe(4)
|
|
expect(ama800.gridBoxWidth).toBe(29)
|
|
expect(ama800.gridBoxHeight).toBe(29)
|
|
|
|
// Verify INV_PANEL_ORIGIN, INV_GRID_ORIGIN, and all 10 EQUIP_SLOTS_LAYOUT match Amazon2..Assassin2
|
|
expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 441 })
|
|
expect(INV_GRID_ORIGIN).toEqual({ x: 419, y: 315, cols: 10, rows: 4, cellPx: 29 })
|
|
expect(EQUIP_SLOTS_LAYOUT.helm).toMatchObject({ x: 535, y: 68, w: 54, h: 51 })
|
|
expect(EQUIP_SLOTS_LAYOUT.amulet).toMatchObject({ x: 609, y: 95, w: 23, h: 24 })
|
|
expect(EQUIP_SLOTS_LAYOUT.weapon1).toMatchObject({ x: 420, y: 107, w: 55, h: 112 })
|
|
expect(EQUIP_SLOTS_LAYOUT.armor).toMatchObject({ x: 533, y: 137, w: 56, h: 82 })
|
|
expect(EQUIP_SLOTS_LAYOUT.weapon2).toMatchObject({ x: 651, y: 107, w: 55, h: 112 })
|
|
expect(EQUIP_SLOTS_LAYOUT.gloves).toMatchObject({ x: 421, y: 241, w: 54, h: 53 })
|
|
expect(EQUIP_SLOTS_LAYOUT.ring1).toMatchObject({ x: 495, y: 240, w: 23, h: 24 })
|
|
expect(EQUIP_SLOTS_LAYOUT.belt).toMatchObject({ x: 536, y: 239, w: 52, h: 25 })
|
|
expect(EQUIP_SLOTS_LAYOUT.ring2).toMatchObject({ x: 609, y: 240, w: 23, h: 24 })
|
|
expect(EQUIP_SLOTS_LAYOUT.boots).toMatchObject({ x: 652, y: 242, w: 54, h: 52 })
|
|
|
|
// Fail-fast on unknown layout or malformed TSV
|
|
expect(() => getInventoryLayout('NonExistentPanel', 1)).toThrow()
|
|
expect(() => parseInventoryTxt('')).toThrow()
|
|
})
|
|
})
|
|
|
|
describe('Issue #683: Weapon Swap I / II Tabs Hitboxes & No Duplicate Text Overlay', () => {
|
|
it('splits left and right 62x22 weapon swap tab strips into I (set 0) and II (set 1) sub-tabs', () => {
|
|
expect(WEAPON_SWAP_TABS_BOUNDS.left).toEqual({ x: 415, y: 83, w: 62, h: 22 })
|
|
expect(WEAPON_SWAP_TABS_BOUNDS.right).toEqual({ x: 645, y: 83, w: 62, h: 22 })
|
|
expect(resolveWeaponSwapTabClick(420, 90)).toBe(0) // left I
|
|
expect(resolveWeaponSwapTabClick(455, 90)).toBe(1) // left II
|
|
expect(resolveWeaponSwapTabClick(650, 90)).toBe(0) // right I
|
|
expect(resolveWeaponSwapTabClick(685, 90)).toBe(1) // right II
|
|
expect(resolveWeaponSwapTabClick(500, 90)).toBeNull()
|
|
|
|
const panel = new InventoryPanel()
|
|
panel.visible = true
|
|
expect(panel.weaponSwapSet).toBe(0)
|
|
|
|
// Clicking already-active 'I' sub-tab is a no-op
|
|
panel.handleClick(420, 90)
|
|
expect(panel.weaponSwapSet).toBe(0)
|
|
|
|
// Clicking 'II' sub-tab switches to set 1
|
|
panel.handleClick(455, 90)
|
|
expect(panel.weaponSwapSet).toBe(1)
|
|
|
|
// Clicking 'II' again on right strip is a no-op
|
|
panel.handleClick(685, 90)
|
|
expect(panel.weaponSwapSet).toBe(1)
|
|
|
|
// Clicking 'I' on right strip switches back to set 0
|
|
panel.handleClick(650, 90)
|
|
expect(panel.weaponSwapSet).toBe(0)
|
|
})
|
|
|
|
it('renders invTab0Img / invTab1Img without drawing duplicate Exocet Roman numeral text on top', () => {
|
|
const panel = new InventoryPanel()
|
|
panel.visible = true
|
|
const { ctx, font, textCalls } = createMockCanvasContext()
|
|
panel.draw(
|
|
ctx,
|
|
{
|
|
borderRightImg: null,
|
|
invSheetImg: null,
|
|
invTab0Img: {} as HTMLImageElement,
|
|
invTab1Img: {} as HTMLImageElement,
|
|
buySellBtnImg: null,
|
|
itemsAtlasImg: null,
|
|
itemRects: BAKED_UI_MANIFEST.itemRects,
|
|
},
|
|
font,
|
|
)
|
|
expect(textCalls).not.toContain('I')
|
|
expect(textCalls).not.toContain('II')
|
|
})
|
|
})
|
|
|
|
describe('Issue #684: Grid & Paperdoll Item Background Colors Follow 1.13c Usability Rules', () => {
|
|
const player = { level: 30, str: 60, dex: 60, classCode: 'sor' as const }
|
|
|
|
const usableUnique: UiInventoryItem = {
|
|
id: 'u-1',
|
|
code: 'cap',
|
|
invFile: 'invcap',
|
|
name: 'Biggin\'s Bonnet',
|
|
nameZh: '比金的软帽',
|
|
baseNameZh: '帽子',
|
|
quality: 'unique',
|
|
invWidth: 2,
|
|
invHeight: 2,
|
|
allowedSlots: ['helm'],
|
|
reqLevel: 3,
|
|
stats: [],
|
|
}
|
|
|
|
const unusableArmor: UiInventoryItem = {
|
|
id: 'a-1',
|
|
code: 'uar',
|
|
invFile: 'invful',
|
|
name: 'Sacred Armor',
|
|
nameZh: '神圣盔甲',
|
|
baseNameZh: '神圣盔甲',
|
|
quality: 'set',
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
allowedSlots: ['armor'],
|
|
reqStr: 232,
|
|
stats: [],
|
|
}
|
|
|
|
it('uses blue for usable grid items regardless of quality, red for unusable/unidentified, and no green fill on normal equipped paperdoll slots', () => {
|
|
expect(resolveGridItemBackgroundFill(usableUnique, player)).toBe(GRID_ITEM_BG_BLUE)
|
|
expect(resolveGridItemBackgroundFill(unusableArmor, player)).toBe(GRID_ITEM_BG_RED)
|
|
expect(resolveGridItemBackgroundFill(usableUnique, player, { isHovered: true, hasCursorItem: true })).toBe(
|
|
GRID_ITEM_BG_GREEN,
|
|
)
|
|
|
|
// Paperdoll slots have no fill when usable, red when requirements unmet, green when hovered with cursor item
|
|
expect(resolvePaperdollSlotBackgroundFill(usableUnique, player)).toBeNull()
|
|
expect(resolvePaperdollSlotBackgroundFill(unusableArmor, player)).toBe(GRID_ITEM_BG_RED)
|
|
expect(resolvePaperdollSlotBackgroundFill(usableUnique, player, { isHovered: true, hasCursorItem: true })).toBe(
|
|
GRID_ITEM_BG_GREEN,
|
|
)
|
|
|
|
const panel = new InventoryPanel()
|
|
panel.visible = true
|
|
panel.playerContext = player
|
|
panel.equipped.helm = usableUnique
|
|
panel.gridItems = [{ item: usableUnique, col: 0, row: 0 }]
|
|
|
|
const { ctx, font, fillRectCalls } = createMockCanvasContext()
|
|
panel.draw(
|
|
ctx,
|
|
{
|
|
borderRightImg: null,
|
|
invSheetImg: {} as HTMLImageElement,
|
|
invTab0Img: null,
|
|
invTab1Img: null,
|
|
buySellBtnImg: null,
|
|
itemsAtlasImg: {} as HTMLImageElement,
|
|
itemRects: BAKED_UI_MANIFEST.itemRects,
|
|
},
|
|
font,
|
|
)
|
|
|
|
// Only the 10x4 grid item draws a background fillRect (in GRID_ITEM_BG_BLUE); the usable equipped helm draws no fillRect
|
|
expect(fillRectCalls).toHaveLength(1)
|
|
expect(fillRectCalls[0]?.fillStyle).toBe(GRID_ITEM_BG_BLUE)
|
|
})
|
|
})
|
|
|
|
describe('Issue #685: Hover-Only Socket Overlays Using gemsocket DC6 Art (Zero Procedural Circles)', () => {
|
|
const socketedSword: UiInventoryItem = {
|
|
id: 'sw-1',
|
|
code: 'crs',
|
|
invFile: 'invcrs',
|
|
name: 'Crystal Sword',
|
|
nameZh: '水晶剑',
|
|
baseNameZh: '水晶剑',
|
|
quality: 'normal',
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
allowedSlots: ['weapon1', 'weapon2'],
|
|
sockets: 2,
|
|
socketedItems: [
|
|
{
|
|
id: 'r-1',
|
|
code: 'r07',
|
|
invFile: 'invr07',
|
|
name: 'Tal Rune',
|
|
nameZh: '塔尔符文',
|
|
baseNameZh: '塔尔符文',
|
|
quality: 'rune',
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
allowedSlots: [],
|
|
stats: [],
|
|
},
|
|
],
|
|
stats: [],
|
|
}
|
|
|
|
it('draws socket overlays in InventoryPanel ONLY when the item is hovered and never calls ctx.arc', () => {
|
|
const panel = new InventoryPanel()
|
|
panel.visible = true
|
|
panel.gridItems = [{ item: socketedSword, col: 0, row: 0 }]
|
|
|
|
const mockAtlas = {} as HTMLImageElement
|
|
const mockGemSocket = {} as HTMLImageElement
|
|
|
|
// 1. Not hovered: only the base item sprite is drawn (1 drawImage call)
|
|
const unhovered = createMockCanvasContext()
|
|
panel.draw(
|
|
unhovered.ctx,
|
|
{
|
|
borderRightImg: null,
|
|
invSheetImg: null,
|
|
invTab0Img: null,
|
|
invTab1Img: null,
|
|
buySellBtnImg: null,
|
|
itemsAtlasImg: mockAtlas,
|
|
gemSocketImg: mockGemSocket,
|
|
itemRects: BAKED_UI_MANIFEST.itemRects,
|
|
},
|
|
unhovered.font,
|
|
)
|
|
expect(unhovered.arcCalls).toHaveLength(0)
|
|
expect(unhovered.drawImageCalls).toHaveLength(1)
|
|
|
|
// 2. Hovered: base item sprite + 2 gemsocket frames (28x28) + 1 socketed rune sprite = 4 drawImage calls, 0 ctx.arc calls
|
|
panel.handleMouseMove(INV_GRID_ORIGIN.x + 10, INV_GRID_ORIGIN.y + 10)
|
|
expect(panel.hoveredItem?.item).toBe(socketedSword)
|
|
|
|
const hovered = createMockCanvasContext()
|
|
panel.draw(
|
|
hovered.ctx,
|
|
{
|
|
borderRightImg: null,
|
|
invSheetImg: null,
|
|
invTab0Img: null,
|
|
invTab1Img: null,
|
|
buySellBtnImg: null,
|
|
itemsAtlasImg: mockAtlas,
|
|
gemSocketImg: mockGemSocket,
|
|
itemRects: BAKED_UI_MANIFEST.itemRects,
|
|
},
|
|
hovered.font,
|
|
)
|
|
expect(hovered.arcCalls).toHaveLength(0)
|
|
expect(hovered.drawImageCalls).toHaveLength(4)
|
|
expect(GEMSOCKET_SPRITE_RECT).toEqual({ x: 0, y: 0, w: 28, h: 28 })
|
|
})
|
|
|
|
it('fails fast by throwing when a socketed child item has an unresolvable sprite rect', () => {
|
|
const brokenHost: UiInventoryItem = {
|
|
...socketedSword,
|
|
socketedItems: [
|
|
{
|
|
id: 'bad-sock',
|
|
code: 'nonexistent_socket_code',
|
|
invFile: 'nonexistent_invfile',
|
|
name: 'Invalid Socket Item',
|
|
nameZh: '无效镶嵌物',
|
|
baseNameZh: '无效镶嵌物',
|
|
quality: 'normal',
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
allowedSlots: [],
|
|
stats: [],
|
|
},
|
|
],
|
|
}
|
|
const { ctx } = createMockCanvasContext()
|
|
expect(() =>
|
|
drawItemSocketOverlay(ctx, brokenHost, 0, 0, 58, 87, {
|
|
itemsAtlasImg: {} as HTMLImageElement,
|
|
itemRects: BAKED_UI_MANIFEST.itemRects,
|
|
}),
|
|
).toThrow(/Missing sprite rect for socketed item/)
|
|
})
|
|
})
|
|
})
|