835 lines
39 KiB
TypeScript
835 lines
39 KiB
TypeScript
/**
|
|
* 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<string, SpriteRect> = {
|
|
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
|
|
}
|
|
})
|
|
})
|
|
})
|