diablo2-web/tests/skill-tree-panel.test.ts

278 lines
7.5 KiB
TypeScript

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)
}
}
})
})