import { describe, it, expect, vi } from 'vitest' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { SkillTreePanel, SKILL_PANEL_ORIGIN, SKILL_CLOSE_BTN_BOUNDS, SKILL_CLOSE_BTN_OFFSET_X, } from '../src/ui/skill-tree-panel.ts' import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' import type { D2FontRenderer } from '../src/ui/font.ts' describe('Authentic D2 v1.13c Skill Tree Panel Layout & Alignment', () => { it('manifest contains skillTabs and skillPoints image references', () => { expect(BAKED_UI_MANIFEST.images.skillTabs).toBe('/ui/skill-tree-tabs.png') expect(BAKED_UI_MANIFEST.images.skillPoints).toBe('/ui/skillpoints.png') const tabsPath = join(process.cwd(), 'public', 'ui', 'skill-tree-tabs.png') const spPath = join(process.cwd(), 'public', 'ui', 'skillpoints.png') expect(existsSync(tabsPath), 'skill-tree-tabs.png must exist in public/ui').toBe(true) expect(existsSync(spPath), 'skillpoints.png must exist in public/ui').toBe(true) }) it('skill-tree-tabs.png has authentic 192x432 strip dimensions (3 states x 64x432)', () => { const tabsPath = join(process.cwd(), 'public', 'ui', 'skill-tree-tabs.png') const buf = readFileSync(tabsPath) // PNG width and height at bytes 16..24 const width = buf.readUInt32BE(16) const height = buf.readUInt32BE(20) expect(width).toBe(192) expect(height).toBe(432) }) it('draws authentic 64x432 tab column slice according to activeTab', () => { const panel = new SkillTreePanel() panel.visible = true const mockCtx = { save: vi.fn(), restore: vi.fn(), drawImage: vi.fn(), fillRect: vi.fn(), strokeRect: vi.fn(), fillStyle: '', strokeStyle: '', lineWidth: 1, globalAlpha: 1, } as unknown as CanvasRenderingContext2D const mockFont = { drawText: vi.fn(), measureText: vi.fn(() => 50), } as unknown as D2FontRenderer const mockTabsImg = { complete: true, naturalWidth: 192, naturalHeight: 432, } as unknown as HTMLImageElement const mockSpImg = { complete: true, naturalWidth: 135, naturalHeight: 23, } as unknown as HTMLImageElement // 1. Tab 2 (Cold Spells, Top Tab): slice sx = 0 panel.activeTab = 2 panel.draw(mockCtx, { borderRightImg: null, skillPointsImg: mockSpImg, buySellBtnImg: null, skillTabsImg: mockTabsImg, }, mockFont) expect(mockCtx.drawImage).toHaveBeenCalledWith( mockTabsImg, 0, // sx = 0 0, 64, 432, SKILL_PANEL_ORIGIN.x + 256, SKILL_PANEL_ORIGIN.y, 64, 432, ) // 2. Tab 1 (Lightning Spells, Middle Tab): slice sx = 64 vi.clearAllMocks() panel.activeTab = 1 panel.draw(mockCtx, { borderRightImg: null, skillPointsImg: mockSpImg, buySellBtnImg: null, skillTabsImg: mockTabsImg, }, mockFont) expect(mockCtx.drawImage).toHaveBeenCalledWith( mockTabsImg, 64, // sx = 64 0, 64, 432, SKILL_PANEL_ORIGIN.x + 256, SKILL_PANEL_ORIGIN.y, 64, 432, ) // 3. Tab 0 (Fire Spells, Bottom Tab): slice sx = 128 vi.clearAllMocks() panel.activeTab = 0 panel.draw(mockCtx, { borderRightImg: null, skillPointsImg: mockSpImg, buySellBtnImg: null, skillTabsImg: mockTabsImg, }, mockFont) expect(mockCtx.drawImage).toHaveBeenCalledWith( mockTabsImg, 128, // sx = 128 0, 64, 432, SKILL_PANEL_ORIGIN.x + 256, SKILL_PANEL_ORIGIN.y, 64, 432, ) }) it('aligns top-right remaining skill points inside built-in DC6 recessed box and omits redundant bottom skillpoints banner', () => { const panel = new SkillTreePanel() panel.visible = true panel.unspentSkillPoints = 12 const mockCtx = { save: vi.fn(), restore: vi.fn(), drawImage: vi.fn(), fillRect: vi.fn(), strokeRect: vi.fn(), fillStyle: '', strokeStyle: '', lineWidth: 1, globalAlpha: 1, } as unknown as CanvasRenderingContext2D const mockFont = { drawText: vi.fn(), measureText: vi.fn(() => 50), } as unknown as D2FontRenderer const mockTabsImg = { complete: true, naturalWidth: 192, naturalHeight: 432, } as unknown as HTMLImageElement const mockSpImg = { complete: true, naturalWidth: 135, naturalHeight: 23, } as unknown as HTMLImageElement panel.draw(mockCtx, { borderRightImg: null, skillPointsImg: mockSpImg, buySellBtnImg: null, skillTabsImg: mockTabsImg, }, mockFont) // Top-right remaining skill points label and number aligned to recessed stone box center (ox + 276, oy + 78) expect(mockFont.drawText).toHaveBeenCalledWith( mockCtx, '剩余技能点', SKILL_PANEL_ORIGIN.x + 276, SKILL_PANEL_ORIGIN.y + 38, expect.objectContaining({ font: 'font8', color: 'gold', align: 'center' }), ) expect(mockFont.drawText).toHaveBeenCalledWith( mockCtx, '12', SKILL_PANEL_ORIGIN.x + 276, SKILL_PANEL_ORIGIN.y + 78, expect.objectContaining({ font: 'font16', color: 'red', align: 'center' }), ) // Redundant bottom skillpoints.png banner is NOT drawn expect(mockCtx.drawImage).not.toHaveBeenCalledWith( mockSpImg, expect.anything(), expect.anything(), expect.anything(), expect.anything(), ) }) it('places and hit-tests the 32x32 Close Button inside the authentic per-tab DC6 stone socket', () => { const panel = new SkillTreePanel() panel.visible = true const mockCtx = { save: vi.fn(), restore: vi.fn(), drawImage: vi.fn(), fillRect: vi.fn(), strokeRect: vi.fn(), fillStyle: '', strokeStyle: '', lineWidth: 1, globalAlpha: 1, } as unknown as CanvasRenderingContext2D const mockFont = { drawText: vi.fn(), measureText: vi.fn(() => 50), } as unknown as D2FontRenderer const mockBuySellImg = { complete: true, naturalWidth: 512, naturalHeight: 32, } as unknown as HTMLImageElement // Default Sorceress Cold tab (tab 2): socket at ox + 171, oy + 385 expect(SKILL_CLOSE_BTN_BOUNDS).toEqual({ x: SKILL_PANEL_ORIGIN.x + 171, y: SKILL_PANEL_ORIGIN.y + 385, w: 32, h: 32, }) expect(panel.getCloseButtonBounds()).toEqual(SKILL_CLOSE_BTN_BOUNDS) panel.draw(mockCtx, { borderRightImg: null, skillPointsImg: null, buySellBtnImg: mockBuySellImg, }, mockFont) expect(mockCtx.drawImage).toHaveBeenCalledWith( mockBuySellImg, 10 * 32, 0, 32, 32, SKILL_PANEL_ORIGIN.x + 171, SKILL_PANEL_ORIGIN.y + 385, 32, 32, ) // Switch to Sorceress Fire tab (tab 0): socket at ox + 16, oy + 385 panel.activeTab = 0 expect(panel.getCloseButtonBounds()).toEqual({ x: SKILL_PANEL_ORIGIN.x + 16, y: SKILL_PANEL_ORIGIN.y + 385, w: 32, h: 32, }) // Clicking inside the active tab's close button socket closes the panel expect(panel.handleClick(SKILL_PANEL_ORIGIN.x + 16 + 16, SKILL_PANEL_ORIGIN.y + 385 + 16)).toBe(true) expect(panel.visible).toBe(false) // Verify all 7 classes x 3 tabs have valid authentic socket X offsets (16, 101, or 171) for (const offsets of Object.values(SKILL_CLOSE_BTN_OFFSET_X)) { expect(offsets).toHaveLength(3) for (const ox of offsets) { expect([16, 101, 171]).toContain(ox) } } }) })