/** * tests/item-tooltip-color-dye.test.ts * * Comprehensive 4-Tier Requirement-Driven E2E Test Suite for Diablo II v1.13c * Equipment Tooltips, Color Dyeing Parity, and Starter Gear Data Integrity. * * Derived strictly from: * - ORIGINAL_REQUEST.md (Header ## 2026-09-21T08:47:59Z, Requirements R1 to R5) * - PROJECT.md & TEST_INFRA.md * - Explorer Survey report.md (Section 6: Comprehensive Test Plan) * * Test Architecture: * - Tier 1: Feature Coverage (Happy Path) * 1.1 resolveItemSpriteRect resolves dyed sprites: invcap_cgrn, invgth_dpur, invtgl_lgry, invvbl_blac * 1.2 STARTER_EQUIPPED_GEAR definitions contain canonical 1.13c stats, durability, speed text, dye tokens * 1.3 Call to Arms Crystal Sword in STARTER_BAG_ITEMS contains runewordRunes, durability 20/20, reqStr 43, reqLevel 57, sockets 5, speedText, damage 17-52, and 11 canonical stats * 1.4 Tooltip header hierarchy (Title fontexocet10 Gold, Runes font8 Gold, Base Type font8 White/Grey) * 1.5 Base stats block (Damage/Defense, Durability, Speed Class, Req Strength, Req Level in White/Grey) * 1.6 Magic affixes in Blue, Sockets line in Blue ('凹槽 (5)') * * - Tier 2: Boundary & Corner Cases * 2.1 Table 98 black pixel transparency guard: opaque pixels must NOT be index 0 (must be index 172 or non-zero) * 2.2 Base item fallback when invtransform is missing, undefined, or unrecognized * 2.3 Dynamic vertical flip threshold: hover.y < 280 flips below cursor (hover.y + 24); hover.y >= 280 places above cursor * 2.4 Viewport boundary clamping: bx in [8, 800 - boxW - 8], by in [8, 600 - boxH - 8] * 2.5 Missing optional fields handling (items without durability, damage, or sockets) * * - Tier 3: Cross-Feature Interactions * 3.1 State preservation: picking up equipped helm with invtransform: 'cgrn' into cursorItem preserves invtransform and resolves invcap_cgrn; placing into bag preserves invtransform; picking back up preserves invtransform * 3.2 Tooltip rendering across different item qualities (runeword, unique, set, magic, normal) * * - Tier 4: Real-World Workloads * 4.1 Paperdoll traversal: hover simulation across all equipped slots * 4.2 Bag traversal: hover simulation across CTA, Torch, Anni, Gheed's Fortune * 4.3 Full canvas draw pass with numeric coordinate and boundary sanity validation */ import { describe, expect, it, vi } from 'vitest' import { InventoryPanel, EQUIP_SLOTS_LAYOUT, INV_GRID_ORIGIN, resolveItemSpriteRect, STARTER_BAG_ITEMS, STARTER_EQUIPPED_GEAR, type EquipSlotId, type ResolvableItem, type SpriteRect, type UiInventoryItem, } from '../src/ui/inventory.ts' import { WorldPanelsHud } from '../src/ui/world-panels.ts' import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' import type { D2FontName, D2ColorCode, D2FontRenderer } from '../src/ui/font.ts' // --- Mock & Test Utilities --- interface DrawCall { text: string x: number y: number options?: { font?: D2FontName color?: D2ColorCode align?: 'left' | 'center' | 'right' shadow?: boolean } } interface MockFontRenderer extends D2FontRenderer { drawCalls: DrawCall[] } function createMockFontRenderer(): MockFontRenderer { const drawCalls: DrawCall[] = [] return { metas: {} as any, images: new Map(), tintedCache: new Map(), getTintedAtlas: vi.fn(), measureText: (text: string, _font: D2FontName = 'font8') => { // Approximate 7px per character for layout computation return text.length * 7 }, drawText: (_ctx: CanvasRenderingContext2D, text: string, x: number, y: number, options?: any) => { drawCalls.push({ text, x, y, options }) }, drawCalls, } as unknown as MockFontRenderer } interface MockCanvasContext extends CanvasRenderingContext2D { fillRectCalls: [number, number, number, number][] strokeRectCalls: [number, number, number, number][] } function createMockCanvasContext(): MockCanvasContext { const fillRectCalls: [number, number, number, number][] = [] const strokeRectCalls: [number, number, number, number][] = [] return { fillStyle: '', strokeStyle: '', lineWidth: 1, fillRect: (x: number, y: number, w: number, h: number) => { fillRectCalls.push([x, y, w, h]) }, strokeRect: (x: number, y: number, w: number, h: number) => { strokeRectCalls.push([x, y, w, h]) }, save: vi.fn(), restore: vi.fn(), fillRectCalls, strokeRectCalls, } as unknown as MockCanvasContext } const MOCK_DYED_ITEM_RECTS: Record = { invcap: { x: 0, y: 0, w: 56, h: 56 }, invcap_cgrn: { x: 56, y: 0, w: 56, h: 56 }, invgth: { x: 0, y: 56, w: 56, h: 84 }, invgth_dpur: { x: 56, y: 56, w: 56, h: 84 }, invtgl: { x: 0, y: 140, w: 56, h: 56 }, invtgl_lgry: { x: 56, y: 140, w: 56, h: 56 }, invvbl: { x: 0, y: 196, w: 56, h: 28 }, invvbl_blac: { x: 56, y: 196, w: 56, h: 28 }, invcrs: { x: 0, y: 224, w: 56, h: 84 }, invtow: { x: 56, y: 224, w: 56, h: 84 }, invbox: { x: 112, y: 0, w: 56, h: 56 }, invrin: { x: 112, y: 56, w: 28, h: 28 }, invamu: { x: 112, y: 84, w: 28, h: 28 }, invbst: { x: 112, y: 112, w: 28, h: 84 }, invtbt: { x: 112, y: 196, w: 56, h: 56 }, invtrch: { x: 140, y: 56, w: 28, h: 56 }, invmss: { x: 140, y: 112, w: 28, h: 28 }, invch3: { x: 140, y: 140, w: 28, h: 84 }, invbbk: { x: 168, y: 0, w: 28, h: 56 }, invrbk: { x: 168, y: 56, w: 28, h: 56 }, } describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', () => { // ========================================================================= // Tier 1: Feature Coverage (Happy Path) // ========================================================================= describe('Tier 1: Feature Coverage', () => { it('1.1 resolves dyed sprites via invtransform: invcap_cgrn, invgth_dpur, invtgl_lgry, invvbl_blac', () => { // 1. Harlequin Crest: invcap + cgrn -> invcap_cgrn const shako: ResolvableItem & { invtransform?: string } = { name: 'Harlequin Crest', invFile: 'invcap', invtransform: 'cgrn', } const shakoRect = resolveItemSpriteRect(shako as any, MOCK_DYED_ITEM_RECTS) expect(shakoRect, 'Harlequin Crest must resolve to invcap_cgrn').toEqual(MOCK_DYED_ITEM_RECTS.invcap_cgrn) // 2. Tal Rasha\'s Guardianship: invgth + dpur -> invgth_dpur const talArmor: ResolvableItem & { invtransform?: string } = { name: "Tal Rasha's Guardianship", invFile: 'invgth', invtransform: 'dpur', } const talRect = resolveItemSpriteRect(talArmor as any, MOCK_DYED_ITEM_RECTS) expect(talRect, "Tal Rasha's Guardianship must resolve to invgth_dpur").toEqual(MOCK_DYED_ITEM_RECTS.invgth_dpur) // 3. Magefist: invtgl + lgry -> invtgl_lgry const magefist: ResolvableItem & { invtransform?: string } = { name: 'Magefist', invFile: 'invtgl', invtransform: 'lgry', } const mageRect = resolveItemSpriteRect(magefist as any, MOCK_DYED_ITEM_RECTS) expect(mageRect, 'Magefist must resolve to invtgl_lgry').toEqual(MOCK_DYED_ITEM_RECTS.invtgl_lgry) // 4. Arachnid Mesh: invvbl + blac -> invvbl_blac const arach: ResolvableItem & { invtransform?: string } = { name: 'Arachnid Mesh', invFile: 'invvbl', invtransform: 'blac', } const arachRect = resolveItemSpriteRect(arach as any, MOCK_DYED_ITEM_RECTS) expect(arachRect, 'Arachnid Mesh must resolve to invvbl_blac').toEqual(MOCK_DYED_ITEM_RECTS.invvbl_blac) }) it('1.2 defines STARTER_EQUIPPED_GEAR with canonical 1.13c stats, durability, speed text, and dye tokens', () => { // Helm: Harlequin Crest (Shako) const helm = STARTER_EQUIPPED_GEAR.helm as any expect(helm, 'STARTER_EQUIPPED_GEAR.helm must exist').toBeDefined() expect(helm.invtransform, 'Harlequin Crest must have invtransform: cgrn').toBe('cgrn') expect(helm.defense, 'Shako defense must be 141').toBe(141) expect(helm.durability, 'Shako durability must be 12/12').toEqual({ current: 12, max: 12 }) expect(helm.reqStr, 'Shako reqStr must be 50').toBe(50) expect(helm.reqLevel, 'Shako reqLevel must be 62').toBe(62) expect(helm.stats.length, 'Shako must have exactly 6 canonical modifiers').toBe(6) // Armor: Tal Rasha\'s Guardianship (Lacquered Plate) const armor = STARTER_EQUIPPED_GEAR.armor as any expect(armor, 'STARTER_EQUIPPED_GEAR.armor must exist').toBeDefined() expect(armor.invtransform, 'Tal Rasha Armor must have invtransform: dpur').toBe('dpur') expect(armor.defense, 'Tal Rasha Armor defense must be 941').toBe(941) expect(armor.durability, 'Tal Rasha Armor durability must be 55/55').toEqual({ current: 55, max: 55 }) expect(armor.reqStr, 'Tal Rasha Armor reqStr must be 84').toBe(84) expect(armor.reqLevel, 'Tal Rasha Armor reqLevel must be 71').toBe(71) expect(armor.setPieces, 'Tal Rasha Armor must declare setPieces array').toBeDefined() expect(armor.setPieces.length, 'Tal Rasha set must contain 5 set pieces').toBeGreaterThanOrEqual(5) // Gloves: Magefist (Light Gauntlets) const gloves = STARTER_EQUIPPED_GEAR.gloves as any expect(gloves, 'STARTER_EQUIPPED_GEAR.gloves must exist').toBeDefined() expect(gloves.invtransform, 'Magefist must have invtransform: lgry').toBe('lgry') expect(gloves.defense, 'Magefist defense must be 25').toBe(25) expect(gloves.durability, 'Magefist durability must be 14/14').toEqual({ current: 14, max: 14 }) expect(gloves.reqStr, 'Magefist reqStr must be 45').toBe(45) expect(gloves.reqLevel, 'Magefist reqLevel must be 23').toBe(23) // Belt: Arachnid Mesh (Spiderweb Sash) const belt = STARTER_EQUIPPED_GEAR.belt as any expect(belt, 'STARTER_EQUIPPED_GEAR.belt must exist').toBeDefined() expect(belt.invtransform, 'Arachnid Mesh must have invtransform: blac').toBe('blac') expect(belt.defense, 'Arachnid Mesh defense must be 138').toBe(138) expect(belt.durability, 'Arachnid Mesh durability must be 12/12').toEqual({ current: 12, max: 12 }) expect(belt.reqStr, 'Arachnid Mesh reqStr must be 50').toBe(50) expect(belt.reqLevel, 'Arachnid Mesh reqLevel must be 80').toBe(80) // Weapon 1: The Oculus const oculus = STARTER_EQUIPPED_GEAR.weapon1 as any expect(oculus, 'STARTER_EQUIPPED_GEAR.weapon1 must exist').toBeDefined() expect(oculus.damage, 'Oculus damage must be 18 - 42').toBe('18 - 42') expect(oculus.durability, 'Oculus durability must be 50/50').toEqual({ current: 50, max: 50 }) expect(oculus.speedText, 'Oculus weapon speed class must be defined').toBe('法球类 - 普通攻击速度') expect(oculus.reqLevel, 'Oculus reqLevel must be 42').toBe(42) // Weapon 2: Spirit Monarch const spirit = STARTER_EQUIPPED_GEAR.weapon2 as any expect(spirit, 'STARTER_EQUIPPED_GEAR.weapon2 must exist').toBeDefined() expect(spirit.runewordRunes, 'Spirit Monarch must declare runeword runes').toBe("'TalThulOrtAmn'") expect(spirit.defense, 'Spirit Monarch defense must be 148').toBe(148) expect(spirit.durability, 'Spirit Monarch durability must be 148/148').toEqual({ current: 148, max: 148 }) expect(spirit.reqStr, 'Spirit Monarch reqStr must be 156').toBe(156) expect(spirit.reqLevel, 'Spirit Monarch reqLevel must be 54').toBe(54) expect(spirit.sockets, 'Spirit Monarch must have 4 sockets').toBe(4) // Boots: War Traveler const boots = STARTER_EQUIPPED_GEAR.boots as any expect(boots, 'STARTER_EQUIPPED_GEAR.boots must exist').toBeDefined() expect(boots.defense, 'War Traveler defense must be 139').toBe(139) expect(boots.durability, 'War Traveler durability must be 48/48').toEqual({ current: 48, max: 48 }) expect(boots.reqStr, 'War Traveler reqStr must be 95').toBe(95) expect(boots.reqLevel, 'War Traveler reqLevel must be 42').toBe(42) }) it('1.3 defines Call to Arms Crystal Sword in STARTER_BAG_ITEMS with all 11 canonical runeword & rune stats', () => { const ctaPlacement = STARTER_BAG_ITEMS.find(p => p.item.id === 'bag-cta') expect(ctaPlacement, 'Call to Arms must be present in STARTER_BAG_ITEMS').toBeDefined() const cta = ctaPlacement!.item as any expect(cta.runewordRunes, 'Call to Arms must have runewordRunes: AmnRalMalIstOhm').toBe("'AmnRalMalIstOhm'") expect(cta.durability, 'Call to Arms durability must be 20/20').toEqual({ current: 20, max: 20 }) expect(cta.reqStr, 'Call to Arms reqStr must be 43').toBe(43) expect(cta.reqLevel, 'Call to Arms reqLevel must be 57').toBe(57) expect(cta.sockets, 'Call to Arms must have 5 sockets').toBe(5) expect(cta.speedText, 'Call to Arms speedText must be 剑类 - 极快的攻击速度').toBe('剑类 - 极快的攻击速度') expect(cta.damage, 'Call to Arms base damage scaled by +255% ED must be 17 - 52').toBe('17 - 52') // Exactly 11 canonical modifier lines: expect(cta.stats, 'Call to Arms must have stats array').toBeDefined() expect(cta.stats.length, 'Call to Arms must have exactly 11 canonical stat lines').toBe(11) const statTexts = cta.stats.map((s: any) => s.text) // 1. +1 All Skills expect(statTexts.some((t: string) => t.includes('所有技能') || t.includes('All Skills')), '+1 All Skills must be present').toBe(true) // 2. +40% IAS expect(statTexts.some((t: string) => t.includes('增加攻击速度') || t.includes('Attack Speed') || t.includes('IAS')), '+40% IAS must be present').toBe(true) // 3. +255% ED expect(statTexts.some((t: string) => t.includes('增强伤害') || t.includes('Enhanced Damage') || t.includes('ED')), '+255% ED must be present').toBe(true) // 4. Adds 5-30 Fire Dmg (Ral) expect(statTexts.some((t: string) => t.includes('火焰伤害') || t.includes('Fire Damage')), 'Fire damage must be present').toBe(true) // 5. 7% Life Steal (Amn) expect(statTexts.some((t: string) => t.includes('偷取生命') || t.includes('Life Stolen')), '7% Life Steal must be present').toBe(true) // 6. Prevent Monster Heal (Mal) expect(statTexts.some((t: string) => t.includes('防止怪物自疗') || t.includes('Prevent Monster Heal')), 'Prevent Monster Heal must be present').toBe(true) // 7. +6 Battle Orders expect(statTexts.some((t: string) => t.includes('战斗体制') || t.includes('Battle Orders')), '+6 Battle Orders must be present').toBe(true) // 8. +6 Battle Command expect(statTexts.some((t: string) => t.includes('战斗指挥') || t.includes('Battle Command')), '+6 Battle Command must be present').toBe(true) // 9. +4 Battle Cry expect(statTexts.some((t: string) => t.includes('战嚎') || t.includes('Battle Cry')), '+4 Battle Cry must be present').toBe(true) // 10. +12 Replenish Life expect(statTexts.some((t: string) => t.includes('生命补满') || t.includes('Replenish Life')), '+12 Replenish Life must be present').toBe(true) // 11. 30% MF (Ist) expect(statTexts.some((t: string) => t.includes('取得魔法装备') || t.includes('Magic Items') || t.includes('MF')), '30% MF must be present').toBe(true) }) it('1.4 formats tooltip header hierarchy (Title fontexocet10 Gold, Runes font8 Gold, Base Type font8 White/Grey)', () => { const worldPanels = new WorldPanelsHud() const mockCtx = createMockCanvasContext() const mockFont = createMockFontRenderer() const ctaItem: UiInventoryItem & { runewordRunes?: string durability?: { current: number; max: number } speedText?: string sockets?: number } = { id: 'test-cta', code: 'crs', invFile: 'invcrs', name: 'Call to Arms', nameZh: '战争召唤 (Call to Arms)', baseNameZh: '水晶剑 (Crystal Sword)', quality: 'rune', runewordRunes: "'AmnRalMalIstOhm'", invWidth: 2, invHeight: 3, allowedSlots: ['weapon1'], damage: '17 - 52', durability: { current: 20, max: 20 }, speedText: '剑类 - 极快的攻击速度', reqStr: 43, reqLevel: 57, sockets: 5, stats: [{ text: '+1 所有技能', color: 'blue' }], } worldPanels.drawItemTooltip(mockCtx, { item: ctaItem as any, x: 400, y: 350 }, mockFont) const calls = mockFont.drawCalls expect(calls.length, 'Tooltip must render multiple text lines').toBeGreaterThanOrEqual(3) // Line 0: Runeword Title in fontexocet10 with gold color expect(calls[0]!.text).toBe('战争召唤 (Call to Arms)') expect(calls[0]!.options?.font).toBe('fontexocet10') expect(calls[0]!.options?.color).toBe('gold') // Line 1: Socketed Runes line in font8 with gold color expect(calls[1]!.text).toBe("'AmnRalMalIstOhm'") expect(calls[1]!.options?.font).toBe('font8') expect(calls[1]!.options?.color).toBe('gold') // Line 2: Base Item Name in font8 with white/gray color expect(calls[2]!.text).toBe('水晶剑 (Crystal Sword)') expect(calls[2]!.options?.font).toBe('font8') expect(['white', 'gray']).toContain(calls[2]!.options?.color) }) it('1.5 formats base stats block (Damage/Defense, Durability, Speed Class, Req Strength, Req Level in White/Grey)', () => { const worldPanels = new WorldPanelsHud() const mockCtx = createMockCanvasContext() const mockFont = createMockFontRenderer() const ctaItem = { id: 'test-cta-stats', code: 'crs', invFile: 'invcrs', name: 'Call to Arms', nameZh: '战争召唤 (Call to Arms)', baseNameZh: '水晶剑 (Crystal Sword)', quality: 'rune' as const, runewordRunes: "'AmnRalMalIstOhm'", invWidth: 2, invHeight: 3, allowedSlots: ['weapon1'] as any, damage: '17 - 52', durability: { current: 20, max: 20 }, speedText: '剑类 - 极快的攻击速度', reqStr: 43, reqLevel: 57, sockets: 5, stats: [], } worldPanels.drawItemTooltip(mockCtx, { item: ctaItem as any, x: 400, y: 350 }, mockFont) const calls = mockFont.drawCalls // Find damage line const dmgCall = calls.find(c => c.text.includes('17') && c.text.includes('52')) expect(dmgCall, 'Damage line 17 - 52 must be drawn').toBeDefined() expect(dmgCall!.options?.font).toBe('font8') expect(['white', 'gray']).toContain(dmgCall!.options?.color) // Find durability line const duraCall = calls.find(c => c.text.includes('耐久度') && c.text.includes('20')) expect(duraCall, 'Durability line 20 之 20 must be drawn').toBeDefined() expect(duraCall!.options?.font).toBe('font8') expect(['white', 'gray']).toContain(duraCall!.options?.color) // Find speed class line const speedCall = calls.find(c => c.text.includes('极快的攻击速度')) expect(speedCall, 'Weapon speed class line must be drawn').toBeDefined() expect(speedCall!.options?.font).toBe('font8') expect(['white', 'gray']).toContain(speedCall!.options?.color) // Find reqStr line const reqStrCall = calls.find(c => c.text.includes('需要力量') && c.text.includes('43')) expect(reqStrCall, 'Req strength 43 must be drawn').toBeDefined() expect(reqStrCall!.options?.font).toBe('font8') expect(['white', 'gray']).toContain(reqStrCall!.options?.color) // Find reqLevel line const reqLvlCall = calls.find(c => c.text.includes('需要等级') && c.text.includes('57')) expect(reqLvlCall, 'Req level 57 must be drawn').toBeDefined() expect(reqLvlCall!.options?.font).toBe('font8') expect(['white', 'gray']).toContain(reqLvlCall!.options?.color) }) it('1.6 formats magic affixes in Blue and Sockets line in Blue (凹槽 (5))', () => { const worldPanels = new WorldPanelsHud() const mockCtx = createMockCanvasContext() const mockFont = createMockFontRenderer() const ctaItem = { id: 'test-affixes', code: 'crs', invFile: 'invcrs', name: 'Call to Arms', nameZh: '战争召唤', baseNameZh: '水晶剑', quality: 'rune' as const, runewordRunes: "'AmnRalMalIstOhm'", invWidth: 2, invHeight: 3, allowedSlots: ['weapon1'] as any, sockets: 5, stats: [ { text: '+1 所有技能', color: 'blue' as const }, { text: '+40% 增加攻击速度', color: 'blue' as const }, ], } worldPanels.drawItemTooltip(mockCtx, { item: ctaItem as any, x: 400, y: 350 }, mockFont) const calls = mockFont.drawCalls // Check magic affixes are blue const skillCall = calls.find(c => c.text.includes('所有技能')) expect(skillCall, 'Magic affix +1 All Skills must be drawn in blue').toBeDefined() expect(skillCall!.options?.color).toBe('blue') const iasCall = calls.find(c => c.text.includes('增加攻击速度')) expect(iasCall, 'Magic affix +40% IAS must be drawn in blue').toBeDefined() expect(iasCall!.options?.color).toBe('blue') // Check sockets line is blue const socketsCall = calls.find(c => c.text.includes('凹槽') && c.text.includes('5')) expect(socketsCall, 'Sockets line 凹槽 (5) must be drawn in blue').toBeDefined() expect(socketsCall!.options?.font).toBe('font8') expect(socketsCall!.options?.color).toBe('blue') }) }) // ========================================================================= // Tier 2: Boundary & Corner Cases // ========================================================================= describe('Tier 2: Boundary & Corner Cases', () => { it('2.1 enforces Table 98 black pixel transparency guard: opaque pixels must NOT map to transparent index 0', () => { // In PL2 colormap decoding, Table 98 maps all 256 palette indices to index 0 (jet black). // But in indexed PNG DC6 sprites, index 0 is transparent. // The guard specifies: for any opaque pixel (source index > 0), if mapped to 0, remap to index 172 (RGB [4,4,4]). const table98Raw = new Uint8Array(256) // all zeros const table98Guard = (srcIndex: number): number => { if (srcIndex === 0) return 0 // keep transparent pixels transparent const mapped = table98Raw[srcIndex] ?? 0 return mapped === 0 ? 172 : mapped } // Test all 255 opaque palette entries: for (let i = 1; i < 256; i++) { const transformed = table98Guard(i) expect(transformed, `Opaque source index ${i} must NOT be 0`).not.toBe(0) expect(transformed, `Opaque source index ${i} must be remapped to 172`).toBe(172) } // Index 0 (transparent background) must remain 0 expect(table98Guard(0), 'Transparent index 0 must remain 0').toBe(0) }) it('2.2 falls back to base item sprite when invtransform is missing, undefined, or unrecognized', () => { // 1. Missing / undefined invtransform on Harlequin Crest returns base invcap const noTransformItem: ResolvableItem = { name: 'Cap', invFile: 'invcap', } const rectBase = resolveItemSpriteRect(noTransformItem, MOCK_DYED_ITEM_RECTS) expect(rectBase, 'Missing invtransform must return base invcap').toEqual(MOCK_DYED_ITEM_RECTS.invcap) // 2. Explicit undefined invtransform const undefinedTransformItem: ResolvableItem & { invtransform?: string | undefined } = { name: 'Cap', invFile: 'invcap', invtransform: undefined, } const rectUndef = resolveItemSpriteRect(undefinedTransformItem as any, MOCK_DYED_ITEM_RECTS) expect(rectUndef, 'Undefined invtransform must return base invcap').toEqual(MOCK_DYED_ITEM_RECTS.invcap) // 3. Unrecognized / invalid invtransform token falls back to base invcap const unknownTransformItem: ResolvableItem & { invtransform?: string } = { name: 'Cap', invFile: 'invcap', invtransform: 'invalid_dye_token_xyz', } const rectUnknown = resolveItemSpriteRect(unknownTransformItem as any, MOCK_DYED_ITEM_RECTS) expect(rectUnknown, 'Unrecognized invtransform must fall back to base invcap').toEqual(MOCK_DYED_ITEM_RECTS.invcap) }) it('2.3 evaluates dynamic vertical flip threshold: hover.y < 280 places by below cursor; hover.y >= 280 places above', () => { const worldPanels = new WorldPanelsHud() const mockFont = createMockFontRenderer() const item: UiInventoryItem = { id: 'test-flip', code: 'cap', invFile: 'invcap', name: 'Cap', nameZh: '便帽', baseNameZh: '便帽', quality: 'normal', invWidth: 2, invHeight: 2, allowedSlots: ['helm'], stats: [], } // Case A: Near top of screen (hover.y = 200 < 280) -> flip below cursor at hover.y + 24 const ctxBelow = createMockCanvasContext() worldPanels.drawItemTooltip(ctxBelow, { item, x: 400, y: 200 }, mockFont) expect(ctxBelow.fillRectCalls.length).toBe(1) const [, byBelow, , boxHBelow] = ctxBelow.fillRectCalls[0]! expect(byBelow, 'When hover.y < 280, tooltip box by must be below cursor (hover.y + 24)').toBe(200 + 24) expect(byBelow + boxHBelow).toBeLessThanOrEqual(592) // Case B: Boundary right below threshold (hover.y = 279 < 280) -> flip below cursor const ctxBoundaryBelow = createMockCanvasContext() worldPanels.drawItemTooltip(ctxBoundaryBelow, { item, x: 400, y: 279 }, mockFont) const [, byBoundaryBelow] = ctxBoundaryBelow.fillRectCalls[0]! expect(byBoundaryBelow, 'When hover.y = 279, tooltip box by must be hover.y + 24').toBe(279 + 24) // Case C: At threshold (hover.y = 280 >= 280) -> place above cursor const ctxThreshold = createMockCanvasContext() worldPanels.drawItemTooltip(ctxThreshold, { item, x: 400, y: 280 }, mockFont) const [, byThreshold, , boxHThreshold] = ctxThreshold.fillRectCalls[0]! expect(byThreshold, 'When hover.y = 280, tooltip box by must be above cursor (hover.y - boxH - 8)').toBe(280 - boxHThreshold - 8) // Case D: Deep lower screen (hover.y = 450 >= 280) -> place above cursor const ctxAbove = createMockCanvasContext() worldPanels.drawItemTooltip(ctxAbove, { item, x: 400, y: 450 }, mockFont) const [, byAbove, , boxHAbove] = ctxAbove.fillRectCalls[0]! expect(byAbove, 'When hover.y >= 280, tooltip box by must be hover.y - boxH - 8').toBe(450 - boxHAbove - 8) }) it('2.4 clamps tooltip box to viewport boundaries [8, 800 - boxW - 8] and [8, 600 - boxH - 8]', () => { const worldPanels = new WorldPanelsHud() const mockFont = createMockFontRenderer() const item: UiInventoryItem = { id: 'test-clamp', code: 'cap', invFile: 'invcap', name: 'Cap', nameZh: '测试长文本道具提示框边界限制', baseNameZh: '军帽', quality: 'unique', invWidth: 2, invHeight: 2, allowedSlots: ['helm'], stats: [{ text: '+2 所有技能', color: 'blue' }], } // Extreme Left: hover.x = 0 const ctxLeft = createMockCanvasContext() worldPanels.drawItemTooltip(ctxLeft, { item, x: 0, y: 350 }, mockFont) const [bxLeft, , boxWLeft] = ctxLeft.fillRectCalls[0]! expect(bxLeft, 'Left clamped bx must be at least 8').toBe(8) expect(bxLeft + boxWLeft).toBeLessThanOrEqual(792) // Extreme Right: hover.x = 800 const ctxRight = createMockCanvasContext() worldPanels.drawItemTooltip(ctxRight, { item, x: 800, y: 350 }, mockFont) const [bxRight, , boxWRight] = ctxRight.fillRectCalls[0]! expect(bxRight, 'Right clamped bx must not push box past 800 - 8').toBe(800 - boxWRight - 8) // Extreme Top: hover.y = 0 (< 280, flips to y + 24 = 24 >= 8) const ctxTop = createMockCanvasContext() worldPanels.drawItemTooltip(ctxTop, { item, x: 400, y: 0 }, mockFont) const [, byTop] = ctxTop.fillRectCalls[0]! expect(byTop).toBeGreaterThanOrEqual(8) // Extreme Bottom: hover.y = 600 (>= 280, places above cursor, must not exceed 600 - boxH - 8) const ctxBottom = createMockCanvasContext() worldPanels.drawItemTooltip(ctxBottom, { item, x: 400, y: 600 }, mockFont) const [, byBottom, , boxHBottom] = ctxBottom.fillRectCalls[0]! expect(byBottom).toBeLessThanOrEqual(600 - boxHBottom - 8) expect(byBottom).toBeGreaterThanOrEqual(8) }) it('2.5 handles missing optional fields gracefully without durability, damage, or sockets lines', () => { const worldPanels = new WorldPanelsHud() const mockFont = createMockFontRenderer() const mockCtx = createMockCanvasContext() // Item with NO defense, NO damage, NO durability, NO sockets, NO speedText const minimalAmulet: UiInventoryItem = { id: 'test-minimal-amulet', code: 'amu', invFile: 'invamu', name: "Mara's Kaleidoscope", nameZh: "马拉的万花筒", baseNameZh: '项链', quality: 'unique', invWidth: 1, invHeight: 1, allowedSlots: ['amulet'], reqLevel: 67, stats: [ { text: '+2 所有技能', color: 'blue' }, { text: '+5 所有属性', color: 'blue' }, ], } expect(() => { worldPanels.drawItemTooltip(mockCtx, { item: minimalAmulet, x: 400, y: 300 }, mockFont) }).not.toThrow() const texts = mockFont.drawCalls.map(c => c.text) // Durability, Damage, and Sockets lines MUST NOT appear expect(texts.some(t => t.includes('耐久度')), 'Must not render durability line when durability is missing').toBe(false) expect(texts.some(t => t.includes('伤害')), 'Must not render damage line when damage is missing').toBe(false) expect(texts.some(t => t.includes('凹槽')), 'Must not render sockets line when sockets is missing').toBe(false) // Core attributes must appear expect(texts).toContain('马拉的万花筒') expect(texts).toContain('项链') expect(texts.some(t => t.includes('需要等级: 67'))).toBe(true) expect(texts).toContain('+2 所有技能') }) }) // ========================================================================= // Tier 3: Cross-Feature Interactions // ========================================================================= describe('Tier 3: Cross-Feature Interactions', () => { it('3.1 preserves dyed sprite through equip slot pickup, bag placement, and re-equip workflow', () => { const inv = new InventoryPanel() // 1. Initial State: Equipped Helm has invtransform 'cgrn' const equippedHelm = inv.equipped.helm as any expect(equippedHelm, 'Equipped helm must exist').toBeDefined() expect(equippedHelm.invtransform, 'Equipped helm must have invtransform: cgrn').toBe('cgrn') // 2. Click helm slot to pick up into cursorItem expect(inv.cursorItem).toBeNull() expect(inv.clickEquipSlot('helm')).toBe(true) expect(inv.cursorItem, 'Cursor must hold the picked up helm').not.toBeNull() const cursorItem = inv.cursorItem as any expect(cursorItem.invtransform, 'cursorItem must retain invtransform: cgrn').toBe('cgrn') expect(resolveItemSpriteRect(cursorItem, MOCK_DYED_ITEM_RECTS), 'cursorItem must resolve invcap_cgrn').toEqual(MOCK_DYED_ITEM_RECTS.invcap_cgrn) // 3. Place into empty bag grid cell (col 5, row 0) expect(inv.clickGridCell(5, 0), 'Placing 2x2 helm at (5, 0) should succeed').toBe(true) expect(inv.cursorItem, 'Cursor must be empty after placement').toBeNull() // 4. Verify placed item in bag retains invtransform 'cgrn' const placed = inv.gridItems.find(p => p.col === 5 && p.row === 0) expect(placed, 'Item must be in grid at (5, 0)').toBeDefined() const placedItem = placed!.item as any expect(placedItem.invtransform, 'Grid placed item must retain invtransform: cgrn').toBe('cgrn') expect(resolveItemSpriteRect(placedItem, MOCK_DYED_ITEM_RECTS), 'Grid placed item must resolve invcap_cgrn').toEqual(MOCK_DYED_ITEM_RECTS.invcap_cgrn) // 5. Pick it back up to cursor from bag cell expect(inv.clickGridCell(5, 0), 'Picking item back up from (5, 0) should succeed').toBe(true) expect(inv.cursorItem, 'Cursor must hold item again').not.toBeNull() const cursorAgain = inv.cursorItem as any expect(cursorAgain.invtransform, 'Re-picked cursorItem must retain invtransform: cgrn').toBe('cgrn') // 6. Re-equip to helm slot expect(inv.clickEquipSlot('helm'), 'Re-equipping to helm slot should succeed').toBe(true) expect(inv.cursorItem).toBeNull() const reEquipped = inv.equipped.helm as any expect(reEquipped.invtransform, 'Re-equipped helm must retain invtransform: cgrn').toBe('cgrn') expect(resolveItemSpriteRect(reEquipped, MOCK_DYED_ITEM_RECTS)).toEqual(MOCK_DYED_ITEM_RECTS.invcap_cgrn) }) it('3.2 renders correct title and base colors across different item qualities (runeword, unique, set, magic, normal)', () => { const worldPanels = new WorldPanelsHud() const qualitiesToTest: { quality: UiInventoryItem['quality'] expectedTitleColor: D2ColorCode nameZh: string baseNameZh: string }[] = [ { quality: 'rune', expectedTitleColor: 'gold', nameZh: '战争召唤 (Call to Arms)', baseNameZh: '水晶剑' }, { quality: 'unique', expectedTitleColor: 'gold', nameZh: '谐角之冠 (Harlequin Crest)', baseNameZh: '军帽' }, { quality: 'set', expectedTitleColor: 'green', nameZh: "塔·拉夏的守护", baseNameZh: '漆甲' }, { quality: 'magic', expectedTitleColor: 'blue', nameZh: '工匠之巨鲸的大盾', baseNameZh: '统治者大盾' }, { quality: 'normal', expectedTitleColor: 'white', nameZh: '超强的超大型巨剑', baseNameZh: '超大型巨剑' }, ] for (const itemDef of qualitiesToTest) { const mockFont = createMockFontRenderer() const mockCtx = createMockCanvasContext() const item: UiInventoryItem = { id: `test-${itemDef.quality}`, code: 'test', invFile: 'invcap', name: itemDef.nameZh, nameZh: itemDef.nameZh, baseNameZh: itemDef.baseNameZh, quality: itemDef.quality, invWidth: 2, invHeight: 2, allowedSlots: ['helm'], stats: [], } worldPanels.drawItemTooltip(mockCtx, { item, x: 400, y: 350 }, mockFont) const titleCall = mockFont.drawCalls[0] expect(titleCall, `Quality ${itemDef.quality} must draw a title line`).toBeDefined() expect(titleCall!.text).toBe(itemDef.nameZh) expect(titleCall!.options?.font).toBe('fontexocet10') expect(titleCall!.options?.color, `Quality ${itemDef.quality} title color must be ${itemDef.expectedTitleColor}`).toBe(itemDef.expectedTitleColor) } }) }) // ========================================================================= // Tier 4: Real-World Workload Scenarios // ========================================================================= describe('Tier 4: Real-World Workloads', () => { it('4.1 simulates paperdoll traversal across all 10 equipped slots without errors', () => { const worldPanels = new WorldPanelsHud() const inv = new InventoryPanel() const mockFont = createMockFontRenderer() const mockCtx = createMockCanvasContext() const slots: EquipSlotId[] = [ 'helm', 'amulet', 'weapon1', 'armor', 'weapon2', 'gloves', 'ring1', 'belt', 'ring2', 'boots', ] for (const slotId of slots) { const item = inv.equipped[slotId] if (!item) continue const layout = EQUIP_SLOTS_LAYOUT[slotId] const hoverX = layout.x + Math.floor(layout.w / 2) const hoverY = layout.y + Math.floor(layout.h / 2) expect(() => { worldPanels.drawItemTooltip(mockCtx, { item, x: hoverX, y: hoverY }, mockFont) }, `Hovering slot ${slotId} must not throw`).not.toThrow() // Check latest fillRect call from this slot const lastFill = mockCtx.fillRectCalls[mockCtx.fillRectCalls.length - 1] expect(lastFill).toBeDefined() const [bx, by, boxW, boxH] = lastFill! expect(bx).toBeGreaterThanOrEqual(8) expect(bx + boxW).toBeLessThanOrEqual(792) expect(by).toBeGreaterThanOrEqual(8) expect(by + boxH).toBeLessThanOrEqual(592) } }) it('4.2 simulates bag inventory traversal across Call to Arms, Torch, Anni, and Gheed\'s Fortune', () => { const worldPanels = new WorldPanelsHud() const mockFont = createMockFontRenderer() const mockCtx = createMockCanvasContext() const targetBagIds = ['bag-cta', 'bag-torch', 'bag-anni', 'bag-gheeds'] for (const placement of STARTER_BAG_ITEMS) { if (!targetBagIds.includes(placement.item.id)) continue const hoverX = INV_GRID_ORIGIN.x + placement.col * INV_GRID_ORIGIN.cellPx + 14 const hoverY = INV_GRID_ORIGIN.y + placement.row * INV_GRID_ORIGIN.cellPx + 14 expect(() => { worldPanels.drawItemTooltip(mockCtx, { item: placement.item, x: hoverX, y: hoverY }, mockFont) }, `Hovering ${placement.item.name} must not throw`).not.toThrow() const lastFill = mockCtx.fillRectCalls[mockCtx.fillRectCalls.length - 1] expect(lastFill).toBeDefined() const [bx, by, boxW, boxH] = lastFill! expect(bx).toBeGreaterThanOrEqual(8) expect(by).toBeGreaterThanOrEqual(8) expect(bx + boxW).toBeLessThanOrEqual(792) expect(by + boxH).toBeLessThanOrEqual(592) } }) it('4.3 performs canvas draw pass with strict numeric coordinates and box geometry coverage', () => { const worldPanels = new WorldPanelsHud() const mockFont = createMockFontRenderer() const mockCtx = createMockCanvasContext() const ctaPlacement = STARTER_BAG_ITEMS.find(p => p.item.id === 'bag-cta')! worldPanels.drawItemTooltip(mockCtx, { item: ctaPlacement.item, x: 400, y: 350 }, mockFont) // Verify fillRect and strokeRect expect(mockCtx.fillRectCalls.length).toBe(1) expect(mockCtx.strokeRectCalls.length).toBe(1) const [fx, fy, fw, fh] = mockCtx.fillRectCalls[0]! expect(Number.isFinite(fx)).toBe(true) expect(Number.isFinite(fy)).toBe(true) expect(Number.isFinite(fw)).toBe(true) expect(Number.isFinite(fh)).toBe(true) expect(fw).toBeGreaterThanOrEqual(190) expect(fh).toBeGreaterThanOrEqual(50) // Verify all text draw calls have finite numeric coordinates for (const call of mockFont.drawCalls) { expect(Number.isFinite(call.x)).toBe(true) expect(Number.isFinite(call.y)).toBe(true) expect(call.text.length).toBeGreaterThan(0) expect(call.y).toBeGreaterThanOrEqual(fy) expect(call.y).toBeLessThanOrEqual(fy + fh + 5) // Text is within box vertical bounds } }) }) })