278 lines
7.5 KiB
TypeScript
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)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|