/** * tests/stash.test.ts * * Comprehensive Unit and Parity Test Suite for Milestone M5 (Issue #150): * Camp Town Stash Entities Across 5 Acts & 6x8 Storage Panel. */ import { describe, expect, it, beforeEach } from 'vitest' import { readFileSync, existsSync } from 'node:fs' import { join } from 'node:path' import { STASH_PANEL_ORIGIN, STASH_GRID_ORIGIN, STASH_GOLD_CAP, PLAYER_GOLD_CAP, STASH_DEPOSIT_BTN_BOUNDS, STASH_WITHDRAW_BTN_BOUNDS, STASH_HEADER_ICON_SOCKET, STASH_HEADER_TOP_RECESS, STASH_HEADER_BOTTOM_RECESS, WorldPanelsHud, } from '../src/ui/world-panels.ts' import type { D2FontRenderer } from '../src/ui/font.ts' import { InventoryPanel, type UiInventoryItem, INV_GRID_ORIGIN, } from '../src/ui/inventory.ts' import { type TownStashEntity, CANONICAL_TOWN_STASHES, SceneMouseController, type MapRuntime, } from '../src/scene/act-scene.ts' import { GameEngine } from '../src/game/engine.ts' import { createIsoTerrain } from '../src/game/iso-terrain.ts' import { ViewportCamera } from '../src/sim/camera.ts' import { KeyboardInput } from '../src/sim/input.ts' import { WaypointNetwork } from '../src/game/portal.ts' import { HudManager } from '../src/ui/hud-manager.ts' class MockWindow extends EventTarget { innerWidth = 1920 innerHeight = 1080 devicePixelRatio = 1 } if (typeof globalThis.window === 'undefined') { // @ts-ignore globalThis.window = new MockWindow() } if (typeof globalThis.HTMLInputElement === 'undefined') { // @ts-ignore globalThis.HTMLInputElement = class HTMLInputElement {} } if (typeof globalThis.HTMLSelectElement === 'undefined') { // @ts-ignore globalThis.HTMLSelectElement = class HTMLSelectElement {} } function createDummyItem(id: string, code: string, w: number, h: number, nameZh: string): UiInventoryItem { return { id, code, invFile: code, name: nameZh, nameZh, baseNameZh: nameZh, quality: 'normal', invWidth: w, invHeight: h, allowedSlots: [], stats: [], } } describe('Milestone M5 (Issue #150): Camp Town Stash Entities Across 5 Acts & 6x8 Storage Panel', () => { describe('1. Authentic Town Stash Entities Across Acts 1..5', () => { it('defines canonical stash entities for all 5 acts with Objects.txt row 267 B6 token', () => { for (let act = 1; act <= 5; act++) { const stash = CANONICAL_TOWN_STASHES[act] expect(stash, `Expected canonical stash for Act ${act}`).toBeDefined() expect(stash.act).toBe(act) expect(stash.objectsTxtId).toBe(267) expect(stash.token).toBe('B6') expect(stash.clickRadiusPx).toBe(48) expect(stash.interactionRangePx).toBe(80) expect(stash.x).toBeGreaterThan(0) expect(stash.y).toBeGreaterThan(0) } }) it('verifies exact 1.13c coordinates for all 5 act towns', () => { // Act 1: Rogue Encampment (towne1 default) expect(CANONICAL_TOWN_STASHES[1].x).toBe(4512) expect(CANONICAL_TOWN_STASHES[1].y).toBe(1720) expect(CANONICAL_TOWN_STASHES[1].subTileX).toBe(112) expect(CANONICAL_TOWN_STASHES[1].subTileY).toBe(86) // Act 2: Lut Gholein expect(CANONICAL_TOWN_STASHES[2].x).toBe(5328) expect(CANONICAL_TOWN_STASHES[2].y).toBe(1568) // Act 3: Kurast Docks expect(CANONICAL_TOWN_STASHES[3].x).toBe(5264) expect(CANONICAL_TOWN_STASHES[3].y).toBe(1584) // Act 4: The Pandemonium Fortress expect(CANONICAL_TOWN_STASHES[4].x).toBe(704) expect(CANONICAL_TOWN_STASHES[4].y).toBe(552) // Act 5: Harrogath expect(CANONICAL_TOWN_STASHES[5].x).toBe(3792) expect(CANONICAL_TOWN_STASHES[5].y).toBe(1448) }) it('verifies prebaked town scenes in samples/d2-packs contain the B6 stash object', () => { const townScenes: Record = { 1: 'samples/d2-packs/act1-towne1/scene.json', 2: 'samples/d2-packs/act2-lutn/scene.json', 3: 'samples/d2-packs/act3-docktown3/scene.json', 4: 'samples/d2-packs/act4-fortress/scene.json', 5: 'samples/d2-packs/act5-townwest/scene.json', } for (let act = 1; act <= 5; act++) { const p = join(process.cwd(), townScenes[act]!) if (existsSync(p)) { const raw = readFileSync(p, 'utf-8') const scene = JSON.parse(raw) const stashObj = scene.objects?.find((o: any) => o.objectsTxtId === 267 || o.token === 'B6') expect(stashObj, `Expected B6 stash object in scene ${townScenes[act]}`).toBeDefined() expect(stashObj.token).toBe('B6') expect(stashObj.objectsTxtId).toBe(267) } } }) }) describe('2. Stash Panel & 6x8 Grid Layout & Dimensions', () => { it('defines authentic 6x8 grid origin and 29x29 cell size inside 320x432 panel', () => { expect(STASH_PANEL_ORIGIN.w).toBe(320) expect(STASH_PANEL_ORIGIN.h).toBe(432) expect(STASH_GRID_ORIGIN.cols).toBe(6) expect(STASH_GRID_ORIGIN.rows).toBe(8) expect(STASH_GRID_ORIGIN.cellPx).toBe(29) // Absolute grid top-left: ox (80) + 74 = 154, oy (60) + 82 = 142 expect(STASH_GRID_ORIGIN.x).toBe(154) expect(STASH_GRID_ORIGIN.y).toBe(142) // Absolute grid width: 6 * 29 = 174px -> right boundary = 154 + 174 = 328 // Absolute grid height: 8 * 29 = 232px -> bottom boundary = 142 + 232 = 374 expect(STASH_GRID_ORIGIN.x + STASH_GRID_ORIGIN.cols * STASH_GRID_ORIGIN.cellPx).toBe(328) expect(STASH_GRID_ORIGIN.y + STASH_GRID_ORIGIN.rows * STASH_GRID_ORIGIN.cellPx).toBe(374) }) it('defines deposit and withdraw button bounds inside stash panel', () => { expect(STASH_DEPOSIT_BTN_BOUNDS.x).toBeGreaterThanOrEqual(STASH_PANEL_ORIGIN.x) expect(STASH_DEPOSIT_BTN_BOUNDS.x + STASH_DEPOSIT_BTN_BOUNDS.w).toBeLessThanOrEqual( STASH_PANEL_ORIGIN.x + STASH_PANEL_ORIGIN.w, ) expect(STASH_WITHDRAW_BTN_BOUNDS.x).toBeGreaterThanOrEqual(STASH_PANEL_ORIGIN.x) expect(STASH_WITHDRAW_BTN_BOUNDS.x + STASH_WITHDRAW_BTN_BOUNDS.w).toBeLessThanOrEqual( STASH_PANEL_ORIGIN.x + STASH_PANEL_ORIGIN.w, ) }) it('confirms stash background graphic asset exists in public/ui/stash-bg.png', () => { const bgPath = join(process.cwd(), 'public/ui/stash-bg.png') expect(existsSync(bgPath)).toBe(true) }) }) describe('3. Stash Grid Placement, Collision, and Bounds Validation', () => { let panels: WorldPanelsHud beforeEach(() => { panels = new WorldPanelsHud() }) it('places an item in free stash cells and checks collision overlaps', () => { const ring = createDummyItem('ring1', 'rin', 1, 1, '乔丹之石 (Stone of Jordan)') expect(panels.canPlaceInStash(0, 0, 1, 1)).toBe(true) panels.stashItems.push({ item: ring, col: 0, row: 0 }) // Same cell is now blocked expect(panels.canPlaceInStash(0, 0, 1, 1)).toBe(false) expect(panels.getStashOverlaps(0, 0, 1, 1)).toHaveLength(1) expect(panels.getStashOverlaps(0, 0, 1, 1)[0]!.item.id).toBe('ring1') // Adjacent cell (1, 0) is free expect(panels.canPlaceInStash(1, 0, 1, 1)).toBe(true) }) it('rejects multi-cell items overlapping existing items', () => { const sword = createDummyItem('sword1', 'crs', 2, 3, '水晶剑 (Crystal Sword)') panels.stashItems.push({ item: sword, col: 1, row: 1 }) // An overlapping 2x2 shield at (0, 1) overlaps sword at col 1 expect(panels.canPlaceInStash(0, 1, 2, 2)).toBe(false) const overlaps = panels.getStashOverlaps(0, 1, 2, 2) expect(overlaps).toHaveLength(1) expect(overlaps[0]!.item.id).toBe('sword1') // Non-overlapping placement at (3, 1) is allowed expect(panels.canPlaceInStash(3, 1, 2, 2)).toBe(true) }) it('strictly enforces 6x8 bounds (throws RangeError in getStashOverlaps, returns false in canPlaceInStash)', () => { // 2x2 item at col 5, row 0 exceeds 6 cols (5 + 2 = 7 > 6) expect(panels.canPlaceInStash(5, 0, 2, 2)).toBe(false) expect(() => panels.getStashOverlaps(5, 0, 2, 2)).toThrow(RangeError) // 1x3 item at col 0, row 6 exceeds 8 rows (6 + 3 = 9 > 8) expect(panels.canPlaceInStash(0, 6, 1, 3)).toBe(false) expect(() => panels.getStashOverlaps(0, 6, 1, 3)).toThrow(RangeError) // Negative coordinates expect(panels.canPlaceInStash(-1, 0, 1, 1)).toBe(false) expect(() => panels.getStashOverlaps(-1, 0, 1, 1)).toThrow(RangeError) }) it('autoPlaceInStash finds the first free slot or returns false when full', () => { const grandCharm = createDummyItem('gc1', 'cm3', 1, 3, '超大型护身符 (Grand Charm)') const placed = panels.autoPlaceInStash(grandCharm) expect(placed).toBe(true) expect(panels.stashItems).toHaveLength(1) expect(panels.stashItems[0]!.col).toBe(0) expect(panels.stashItems[0]!.row).toBe(0) // Second grand charm should be placed at col 0, row 3 (underneath) const gc2 = createDummyItem('gc2', 'cm3', 1, 3, '超大型护身符 (Grand Charm 2)') expect(panels.autoPlaceInStash(gc2)).toBe(true) expect(panels.stashItems[1]!.col).toBe(0) expect(panels.stashItems[1]!.row).toBe(3) }) it('clickStashCell handles picking up, placing, and swapping items', () => { const helm = createDummyItem('helm1', 'cap', 2, 2, '军帽 (Shako)') panels.stashItems.push({ item: helm, col: 0, row: 0 }) // 1. Pick up item with empty cursor const pickRes = panels.clickStashCell(0, 0, null) expect(pickRes.handled).toBe(true) expect(pickRes.newCursorItem?.id).toBe('helm1') expect(panels.stashItems).toHaveLength(0) // 2. Place item down at (2, 2) const placeRes = panels.clickStashCell(2, 2, pickRes.newCursorItem) expect(placeRes.handled).toBe(true) expect(placeRes.newCursorItem).toBeNull() expect(panels.stashItems).toHaveLength(1) expect(panels.stashItems[0]!.col).toBe(2) expect(panels.stashItems[0]!.row).toBe(2) // 3. Swap with held ring const ring = createDummyItem('ring2', 'rin', 1, 1, '布尔凯索之戒') const swapRes = panels.clickStashCell(2, 2, ring) expect(swapRes.handled).toBe(true) expect(swapRes.newCursorItem?.id).toBe('helm1') expect(panels.stashItems).toHaveLength(1) expect(panels.stashItems[0]!.item.id).toBe('ring2') }) }) describe('4. Quick Transfer Between Inventory and Stash', () => { let panels: WorldPanelsHud let inv: InventoryPanel beforeEach(() => { panels = new WorldPanelsHud() inv = new InventoryPanel() }) it('quickTransferToStash moves item from inventory to stash and removes from inventory', () => { const item = createDummyItem('transfer1', 'rin', 1, 1, '测试戒指') inv.gridItems = [{ item, col: 0, row: 0 }] expect(panels.stashItems).toHaveLength(0) const success = panels.quickTransferToStash(item, inv) expect(success).toBe(true) expect(inv.gridItems).toHaveLength(0) expect(panels.stashItems).toHaveLength(1) expect(panels.stashItems[0]!.item.id).toBe('transfer1') }) it('quickTransferToStash fails without removing item if stash is full', () => { // Fill stash with 48 1x1 items for (let r = 0; r < 8; r++) { for (let c = 0; c < 6; c++) { panels.stashItems.push({ item: createDummyItem(`fill-${r}-${c}`, 'rin', 1, 1, '填充物'), col: c, row: r, }) } } expect(panels.stashItems).toHaveLength(48) const overflowItem = createDummyItem('overflow', 'rin', 1, 1, '溢出物品') inv.gridItems = [{ item: overflowItem, col: 0, row: 0 }] const success = panels.quickTransferToStash(overflowItem, inv) expect(success).toBe(false) expect(inv.gridItems).toHaveLength(1) expect(panels.stashItems).toHaveLength(48) }) it('quickTransferFromStash moves item from stash to inventory and removes from stash', () => { const item = createDummyItem('fromStash1', 'rin', 1, 1, '储物箱戒指') panels.stashItems = [{ item, col: 0, row: 0 }] inv.gridItems = [] const success = panels.quickTransferFromStash(item, inv) expect(success).toBe(true) expect(panels.stashItems).toHaveLength(0) expect(inv.gridItems).toHaveLength(1) expect(inv.gridItems[0]!.item.id).toBe('fromStash1') }) it('quickTransferFromStash fails without removing item if inventory is full', () => { // Fill inventory with 40 1x1 items inv.gridItems = [] for (let r = 0; r < 4; r++) { for (let c = 0; c < 10; c++) { inv.gridItems.push({ item: createDummyItem(`inv-fill-${r}-${c}`, 'rin', 1, 1, '背包填充'), col: c, row: r, }) } } expect(inv.gridItems).toHaveLength(40) const stashItem = createDummyItem('cantTransfer', 'rin', 1, 1, '无法转移') panels.stashItems = [{ item: stashItem, col: 0, row: 0 }] const success = panels.quickTransferFromStash(stashItem, inv) expect(success).toBe(false) expect(panels.stashItems).toHaveLength(1) expect(inv.gridItems).toHaveLength(40) }) }) describe('5. Gold Deposit and Withdrawal with Caps', () => { let panels: WorldPanelsHud let inv: InventoryPanel beforeEach(() => { panels = new WorldPanelsHud() inv = new InventoryPanel() inv.gold = 500_000 panels.stashGold = 100_000 }) it('deposits gold from inventory into stash up to capacity', () => { const deposited = panels.depositGold(50_000, inv) expect(deposited).toBe(50_000) expect(inv.gold).toBe(450_000) expect(panels.stashGold).toBe(150_000) }) it('withdraws gold from stash into inventory up to player capacity', () => { const withdrawn = panels.withdrawGold(40_000, inv) expect(withdrawn).toBe(40_000) expect(inv.gold).toBe(540_000) expect(panels.stashGold).toBe(60_000) }) it('respects STASH_GOLD_CAP (2,500,000) and clamps deposit amounts', () => { expect(STASH_GOLD_CAP).toBe(2_500_000) panels.stashGold = 2_490_000 inv.gold = 100_000 const deposited = panels.depositGold(50_000, inv) expect(deposited).toBe(10_000) expect(panels.stashGold).toBe(2_500_000) expect(inv.gold).toBe(90_000) // Further deposits do nothing expect(panels.depositGold(10_000, inv)).toBe(0) expect(panels.stashGold).toBe(2_500_000) }) it('respects PLAYER_GOLD_CAP (2,500,000) and clamps withdrawal amounts', () => { expect(PLAYER_GOLD_CAP).toBe(2_500_000) panels.stashGold = 500_000 inv.gold = 2_480_000 const withdrawn = panels.withdrawGold(50_000, inv) expect(withdrawn).toBe(20_000) expect(inv.gold).toBe(2_500_000) expect(panels.stashGold).toBe(480_000) // Further withdrawals do nothing expect(panels.withdrawGold(10_000, inv)).toBe(0) expect(inv.gold).toBe(2_500_000) }) it('handles zero or negative transfers safely', () => { expect(panels.depositGold(0, inv)).toBe(0) expect(panels.depositGold(-500, inv)).toBe(0) expect(panels.withdrawGold(0, inv)).toBe(0) expect(panels.withdrawGold(-500, inv)).toBe(0) expect(inv.gold).toBe(500_000) expect(panels.stashGold).toBe(100_000) }) }) describe('6. SceneMouseController Stash Click & Interaction', () => { let engine: GameEngine let camera: ViewportCamera let input: KeyboardInput let waypointNetwork: WaypointNetwork let statusElem: HTMLElement let controller: SceneMouseController let runtime: MapRuntime let stashOpenCount: number beforeEach(() => { stashOpenCount = 0 const grid = { originX: 0, originY: 0, cellsX: 100, cellsY: 100, blocked: new Uint8Array(500 * 500), gridWidth: 500, } const terrain = createIsoTerrain(grid, 8000, 4000, { width: 20, height: 10 }) engine = new GameEngine(terrain, { spawn: { x: 4500, y: 1700 }, stats: [], monsterCount: 0, questDefs: [], npcDefs: [], } as any) const mockCanvas = { getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), clientWidth: 800, clientHeight: 600, width: 800, height: 600, addEventListener: () => {}, removeEventListener: () => {}, getContext: () => null, style: { cursor: '' }, } as unknown as HTMLCanvasElement camera = new ViewportCamera(mockCanvas, 8000, 4000) input = new KeyboardInput() waypointNetwork = new WaypointNetwork() statusElem = { textContent: '' } as unknown as HTMLElement const mockHudManager = { leftPanel: 'none', toggleLeftPanel: (panel: any) => { if (panel === 'stash') stashOpenCount++ mockHudManager.leftPanel = panel }, clientToLogical: (x: number, y: number) => ({ x, y }), isPointInterceptedByHud: () => false, } as unknown as HudManager runtime = { source: 'pack', base: 'samples/d2-packs', level: 'Rogue Encampment', act: 1, levelId: 1, entryPath: 'act1-towne1', entrances: [], warps: [], waypoints: [], stashes: [CANONICAL_TOWN_STASHES[1]!], variants: ['towne1'], variantParam: 'level', variant: 'towne1', quadrants: ['towne1'], quadrantParam: 'level', quadrant: 'towne1', cellsX: 100, cellsY: 100, widthPx: 8000, heightPx: 4000, grid, floors: [], walls: [], roofs: [], objectDrawables: [], spawn: { x: 4500, y: 1700 }, npcs: [], monsterPacks: [], monsterTypes: [], objects: 1, frames: 0, notes: [], pages: [], objectPages: [], charBases: [], palette: null, priorityPages: [], loadOrder: [], loadPages: async () => {}, } controller = new SceneMouseController({ canvas: mockCanvas, engine, camera, input, getRuntime: () => runtime, hudManager: mockHudManager, waypointNetwork, status: statusElem, }) }) it('finds stash within 48px click radius and ignores clicks further away', () => { const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720) expect(controller.findNearestStash(stash.x, stash.y, 48)).toEqual(stash) expect(controller.findNearestStash(stash.x + 30, stash.y + 20, 48)).toEqual(stash) // 60px away exceeds 48px radius expect(controller.findNearestStash(stash.x + 50, stash.y + 50, 48)).toBeNull() }) it('opens stash immediately when player is within 80px interaction range', () => { const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720) engine.world.player.x = stash.x - 30 engine.world.player.y = stash.y controller.handleLeftClickWorld(stash.x, stash.y) expect(controller.activeClickType).toBe('stash') expect(controller.pendingInteraction).toBeNull() expect(controller.navTarget).toBeNull() expect(stashOpenCount).toBe(1) expect(statusElem.textContent).toContain('储物箱') }) it('walks toward stash when player is outside 80px range, then opens upon arrival in tick()', () => { const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720) engine.world.player.x = stash.x - 200 engine.world.player.y = stash.y controller.handleLeftClickWorld(stash.x, stash.y) expect(controller.activeClickType).toBe('stash') expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash }) expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y }) expect(stashOpenCount).toBe(0) // Simulate player walking and arriving within 80px engine.world.player.x = stash.x - 60 controller.tick() expect(controller.pendingInteraction).toBeNull() expect(controller.navTarget).toBeNull() expect(stashOpenCount).toBe(1) expect(statusElem.textContent).toContain('储物箱') }) }) describe('7. HUD Manager Integration & Split Dock Synergy', () => { let hudCanvas: HTMLCanvasElement let hud: HudManager beforeEach(() => { hudCanvas = { getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), addEventListener: () => {}, removeEventListener: () => {}, getContext: () => null, style: { cursor: '' }, } as unknown as HTMLCanvasElement hud = new HudManager(hudCanvas, { onWaypointTeleport: () => {}, onToggleAutomap: () => {}, }) }) it('toggleLeftPanel("stash") automatically opens inventory on the right dock', () => { expect(hud.leftPanel).toBe('none') expect(hud.rightPanel).toBe('none') hud.toggleLeftPanel('stash') expect(hud.leftPanel).toBe('stash') expect(hud.rightPanel).toBe('inv') expect(hud.inventory.visible).toBe(true) // Toggling stash off leaves right panel intact or allows closing all hud.toggleLeftPanel('stash') expect(hud.leftPanel).toBe('none') }) it('closeAllPanels closes stash and inventory together', () => { hud.toggleLeftPanel('stash') expect(hud.leftPanel).toBe('stash') expect(hud.rightPanel).toBe('inv') const closed = hud.closeAllPanels() expect(closed).toBe(true) expect(hud.leftPanel).toBe('none') expect(hud.rightPanel).toBe('none') expect(hud.inventory.visible).toBe(false) }) }) describe('8. Stash Header Recessed Boxes & Text Alignment Parity (Issue #493)', () => { it('defines exact TradeStash.dc6 recessed box bounds and centered baselines', () => { // Left square icon socket in top row: relX = 74..93, relY = 24..38 (20x15) expect(STASH_HEADER_ICON_SOCKET).toEqual({ x: 80 + 74, y: 60 + 24, relX: 74, relY: 24, w: 20, h: 15, }) // Right title recess in top row (after relX = 94..96 divider): relX = 97..247, relY = 24..38 (151x15) expect(STASH_HEADER_TOP_RECESS).toEqual({ x: 80 + 97, y: 60 + 24, relX: 97, relY: 24, w: 151, h: 15, textX: 80 + 172, textY: 60 + 35, }) // Full-width gold readout recess in bottom row: relX = 75..246, relY = 49..65 (172x17) expect(STASH_HEADER_BOTTOM_RECESS).toEqual({ x: 80 + 75, y: 60 + 49, relX: 75, relY: 49, w: 172, h: 17, textX: 80 + 160, textY: 60 + 61, }) }) it('renders Private Stash title and gold readout centered inside their respective recessed boxes without crossing dividers', () => { const panels = new WorldPanelsHud() panels.stashGold = 2_500_000 const drawTextCalls: Array<{ text: string x: number y: number opts?: { font?: string; color?: string; align?: string } }> = [] const mockFont = { drawText: (_ctx: CanvasRenderingContext2D, text: string, x: number, y: number, opts?: any) => { drawTextCalls.push({ text, x, y, opts }) }, measureText: (text: string) => text.length * 6.5, } as unknown as D2FontRenderer const mockCtx = { drawImage: () => {}, fillRect: () => {}, strokeRect: () => {}, fillStyle: '', strokeStyle: '', } as unknown as CanvasRenderingContext2D panels.drawLeftDockPanel( mockCtx, 'stash', { questBgImg: null, waypointBgImg: null, borderLeftImg: null, stashBgImg: null, cubeBgImg: null, vendorBgImg: null, buySellBtnImg: null, }, mockFont, ) const titleCall = drawTextCalls.find(c => c.text.includes('私人储物箱')) const goldCall = drawTextCalls.find(c => c.text.includes('储物箱金币')) expect(titleCall).toBeDefined() expect(goldCall).toBeDefined() // 1. Top title must be centered in the right compartment (relX = 97..247 -> center ox + 172 = 252) // and vertically centered in relY = 24..38 (alphabetic baseline oy + 35 = 95) expect(titleCall!.x).toBe(STASH_HEADER_TOP_RECESS.textX) expect(titleCall!.y).toBe(STASH_HEADER_TOP_RECESS.textY) expect(titleCall!.opts?.font).toBe('font8') expect(titleCall!.opts?.color).toBe('gold') expect(titleCall!.opts?.align).toBe('center') // Verify title glyph vertical span [y - 9, y + 1] stays strictly inside top recess [oy + 24, oy + 38] const topRecessTop = STASH_HEADER_TOP_RECESS.y const topRecessBottom = STASH_HEADER_TOP_RECESS.y + STASH_HEADER_TOP_RECESS.h - 1 expect(titleCall!.y - 9).toBeGreaterThanOrEqual(topRecessTop) expect(titleCall!.y + 1).toBeLessThanOrEqual(topRecessBottom) // Verify title horizontal span (~130px in font8) stays strictly inside [ox + 97, ox + 247] (never crossing relX = 94..96 divider) const approxTitleWidth = 130 expect(titleCall!.x - approxTitleWidth / 2).toBeGreaterThan(STASH_HEADER_TOP_RECESS.x) expect(titleCall!.x + approxTitleWidth / 2).toBeLessThan(STASH_HEADER_TOP_RECESS.x + STASH_HEADER_TOP_RECESS.w) // 2. Bottom gold text must be centered in the bottom recess (relX = 75..246 -> center ox + 160 = 240) // and vertically centered in relY = 49..65 (alphabetic baseline oy + 61 = 121) expect(goldCall!.x).toBe(STASH_HEADER_BOTTOM_RECESS.textX) expect(goldCall!.y).toBe(STASH_HEADER_BOTTOM_RECESS.textY) expect(goldCall!.opts?.font).toBe('font8') expect(goldCall!.opts?.color).toBe('white') expect(goldCall!.opts?.align).toBe('center') // Verify gold glyph vertical span [y - 9, y + 1] stays strictly inside bottom recess [oy + 49, oy + 65] const bottomRecessTop = STASH_HEADER_BOTTOM_RECESS.y const bottomRecessBottom = STASH_HEADER_BOTTOM_RECESS.y + STASH_HEADER_BOTTOM_RECESS.h - 1 expect(goldCall!.y - 9).toBeGreaterThanOrEqual(bottomRecessTop) expect(goldCall!.y + 1).toBeLessThanOrEqual(bottomRecessBottom) }) }) })