591 lines
25 KiB
TypeScript
591 lines
25 KiB
TypeScript
/**
|
||
* tests/client/world-panels-batch6.test.ts
|
||
*
|
||
* Diablo II v1.13c Milestone M6 Phase 1 Parity Verification Suite
|
||
* Covers Issues #664 through #678 (15 issues) in `src/client/ui/world-panels.ts` and `src/common/units/npc-table.ts`.
|
||
*/
|
||
|
||
import { describe, expect, it, vi } from 'vitest'
|
||
import {
|
||
ACT_TAB_LAYOUT,
|
||
ACT_WAYPOINTS,
|
||
BUTTON_HOVER_BOX_H,
|
||
BUTTON_HOVER_BOX_MIN_W,
|
||
BUTTON_HOVER_BOX_PAD_X,
|
||
CUBE_GRID_ORIGIN,
|
||
GRID_ITEM_BG_BLUE,
|
||
GRID_ITEM_BG_GREEN,
|
||
GRID_ITEM_BG_RED,
|
||
NPC_MENU_HEAD_OFFSET_Y,
|
||
NPC_MENU_MIN_ANCHOR_Y,
|
||
STONE_BOX_BG_FILL,
|
||
VENDOR_BUTTON_SPECS,
|
||
VENDOR_TABS,
|
||
WAYPOINT_ROW_OFFSETS_Y,
|
||
WAYPOINT_TEXT_OFFSETS_Y,
|
||
WorldPanelsHud,
|
||
buildNpcMenuOptions,
|
||
computeNpcDialogLayout,
|
||
computeNpcMenuAnchor,
|
||
computeNpcMenuAnchorY,
|
||
getActQuests,
|
||
getActWaypoints,
|
||
getWaypointRowOffsetY,
|
||
getWaypointTextOffsetY,
|
||
resolveGridItemBackgroundFill,
|
||
resolvePartyActionCode,
|
||
resolveWaypointName,
|
||
} from '../../src/client/ui/world-panels.ts'
|
||
import { D2FontRenderer } from '../../src/client/ui/font.ts'
|
||
import { getTownNpcDescriptorByHcIdx, getTownNpcDescriptorByName } from '../../src/common/units/npc-table.ts'
|
||
import type { UiInventoryItem } from '../../src/client/ui/inventory.ts'
|
||
|
||
function createMockCtx() {
|
||
const fillRectCalls: Array<{ x: number; y: number; w: number; h: number; fillStyle: string }> = []
|
||
const strokeRectCalls: Array<{ x: number; y: number; w: number; h: number; strokeStyle: string }> = []
|
||
const drawImageCalls: any[][] = []
|
||
const ctx = {
|
||
fillStyle: '',
|
||
strokeStyle: '',
|
||
lineWidth: 1,
|
||
globalAlpha: 1,
|
||
fillRect: vi.fn(function (this: any, x: number, y: number, w: number, h: number) {
|
||
fillRectCalls.push({ x, y, w, h, fillStyle: String(ctx.fillStyle) })
|
||
}),
|
||
strokeRect: vi.fn(function (this: any, x: number, y: number, w: number, h: number) {
|
||
strokeRectCalls.push({ x, y, w, h, strokeStyle: String(ctx.strokeStyle) })
|
||
}),
|
||
drawImage: vi.fn((...args: any[]) => {
|
||
drawImageCalls.push(args)
|
||
}),
|
||
beginPath: vi.fn(),
|
||
moveTo: vi.fn(),
|
||
lineTo: vi.fn(),
|
||
stroke: vi.fn(),
|
||
save: vi.fn(),
|
||
restore: vi.fn(),
|
||
fillText: vi.fn(),
|
||
measureText: vi.fn((text: string) => ({ width: text.length * 8 })),
|
||
} as unknown as CanvasRenderingContext2D
|
||
return { ctx, fillRectCalls, strokeRectCalls, drawImageCalls }
|
||
}
|
||
|
||
function makeTestItem(overrides: Partial<UiInventoryItem> = {}): UiInventoryItem {
|
||
return {
|
||
id: 'item-1',
|
||
code: 'cap',
|
||
invFile: 'invcap',
|
||
name: 'Cap',
|
||
nameZh: '帽子',
|
||
baseNameZh: '帽子',
|
||
quality: 'normal',
|
||
invWidth: 2,
|
||
invHeight: 2,
|
||
allowedSlots: ['helm'],
|
||
stats: [],
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
describe('Milestone M6 Phase 1: World Panels, NPC Menus, Waypoints, Tooltips & Fail-Fast (#664–#678)', () => {
|
||
// ===========================================================================
|
||
// Issue #664: NPC Menu Anchor above NPC Head & 0x6fb53580 Layout Formula
|
||
// ===========================================================================
|
||
describe('Issue #664: NPC menu anchor above NPC head and 0x6fb53580 layout', () => {
|
||
it('computes anchorY = Math.max(20, npcScreenY - 150) per 0x6faf68de..0x6faf68f8', () => {
|
||
expect(NPC_MENU_HEAD_OFFSET_Y).toBe(150)
|
||
expect(NPC_MENU_MIN_ANCHOR_Y).toBe(20)
|
||
expect(computeNpcMenuAnchorY(300)).toBe(150)
|
||
expect(computeNpcMenuAnchorY(160)).toBe(20)
|
||
expect(computeNpcMenuAnchorY(50)).toBe(20)
|
||
expect(computeNpcMenuAnchor(420, 320)).toEqual({ x: 420, y: 170 })
|
||
})
|
||
|
||
it('applies 0x6fb53580 layout formula: width = maxLineW + 20, height = totalStepY + 15, left = anchorX - w/2, top = anchorY - 21', () => {
|
||
const font = new D2FontRenderer()
|
||
const akara = getTownNpcDescriptorByName('akara')!
|
||
const options = buildNpcMenuOptions(akara)
|
||
const layout = computeNpcDialogLayout('Akara', options, 400, 200, font, 'ENG', 800, 600)
|
||
|
||
const headerW = font.measureText('Akara', 'font16')
|
||
const optWidths = options.map(o => font.measureText(o.labelEn, 'font16'))
|
||
const expectedMaxW = Math.max(headerW, ...optWidths)
|
||
const expectedTotalStepY = 21 + options.length * 15
|
||
expect(layout.width).toBe(expectedMaxW + 20)
|
||
expect(layout.height).toBe(expectedTotalStepY + 15)
|
||
expect(layout.left).toBe(400 - Math.trunc(layout.width / 2))
|
||
expect(layout.top).toBe(200 - 21)
|
||
})
|
||
|
||
it('anchors openNpcMenuAtUnitScreen above NPC screen Y (npcScreenY - 150, clamped >= 20)', () => {
|
||
const hud = new WorldPanelsHud()
|
||
const charsi = getTownNpcDescriptorByName('charsi')!
|
||
hud.openNpcMenuAtUnitScreen(charsi, 420, 320, 101)
|
||
expect(hud.npcMenu).not.toBeNull()
|
||
expect(hud.npcMenu!.x).toBe(420)
|
||
expect(hud.npcMenu!.y).toBe(170) // 320 - 150
|
||
})
|
||
})
|
||
|
||
// ===========================================================================
|
||
// Issue #665: Preserve hoveredOptionIdx across syncFromWorld / openNpcMenu
|
||
// ===========================================================================
|
||
describe('Issue #665: Preserve NPC menu hoveredOptionIdx on repeated sync ticks', () => {
|
||
it('preserves hoveredOptionIdx when openNpcMenu is called again for the same npcId', () => {
|
||
const hud = new WorldPanelsHud()
|
||
const gheed = getTownNpcDescriptorByName('gheed')!
|
||
hud.openNpcMenu(gheed, 400, 300, 55)
|
||
expect(hud.npcMenu!.hoveredOptionIdx).toBe(0)
|
||
|
||
// Player hovers option index 2 ('gamble')
|
||
hud.npcMenu!.hoveredOptionIdx = 2
|
||
|
||
// Subsequent sync tick refreshes openNpcMenu for the same NPC (npcId = 55)
|
||
hud.openNpcMenu(gheed, 404, 302, 55)
|
||
expect(hud.npcMenu!.hoveredOptionIdx).toBe(2)
|
||
expect(hud.npcMenu!.x).toBe(404)
|
||
expect(hud.npcMenu!.y).toBe(302)
|
||
})
|
||
})
|
||
|
||
// ===========================================================================
|
||
// Issue #666: Canonical Static NPC Menu Option Sets & Ordering (0x6fba3ba0)
|
||
// ===========================================================================
|
||
describe('Issue #666: Static NPC menu option sets and ordering (0x6fba3ba0)', () => {
|
||
it('halbu (hcIdx 257) has ONLY trade_repair and cancel — NO talk option', () => {
|
||
const halbu = getTownNpcDescriptorByHcIdx(257)!
|
||
expect(halbu.id).toBe('halbu')
|
||
expect(halbu.canTalk).toBe(false)
|
||
const opts = buildNpcMenuOptions(halbu)
|
||
expect(opts.map(o => o.id)).toEqual(['trade_repair', 'cancel'])
|
||
})
|
||
|
||
it('jamella (hcIdx 405) has ONLY trade, gamble, and cancel — NO talk option', () => {
|
||
const jamella = getTownNpcDescriptorByHcIdx(405)!
|
||
expect(jamella.id).toBe('jamella')
|
||
expect(jamella.canTalk).toBe(false)
|
||
const opts = buildNpcMenuOptions(jamella)
|
||
expect(opts.map(o => o.id)).toEqual(['trade', 'gamble', 'cancel'])
|
||
})
|
||
|
||
it('asheara (hcIdx 252) orders hire BEFORE trade: [talk, hire, trade, cancel]', () => {
|
||
const asheara = getTownNpcDescriptorByHcIdx(252)!
|
||
expect(asheara.id).toBe('asheara')
|
||
expect(asheara.hireBeforeTrade).toBe(true)
|
||
const opts = buildNpcMenuOptions(asheara)
|
||
expect(opts.map(o => o.id)).toEqual(['talk', 'hire', 'trade', 'cancel'])
|
||
})
|
||
})
|
||
|
||
// ===========================================================================
|
||
// Issue #667: Waypoint Names via levels.txt -> .tbl & Binary Row/Text Y Table
|
||
// ===========================================================================
|
||
describe('Issue #667: Waypoint names from .tbl and binary row/text geometry (@0x6fb8e6d8)', () => {
|
||
it('resolves Traditional Chinese and English waypoint names from canonical .tbl keys', () => {
|
||
expect(ACT_WAYPOINTS[1]![0]!.nameZh).toBe('蘿格營地')
|
||
expect(ACT_WAYPOINTS[1]![0]!.nameEn).toBe('Rogue Encampment')
|
||
expect(ACT_WAYPOINTS[2]![0]!.nameZh).toBe('魯.高因')
|
||
expect(ACT_WAYPOINTS[3]![0]!.nameZh).toBe('庫拉斯特海港')
|
||
expect(ACT_WAYPOINTS[4]![0]!.nameZh).toBe('群魔堡壘')
|
||
expect(ACT_WAYPOINTS[5]![0]!.nameZh).toBe('哈洛加斯')
|
||
expect(resolveWaypointName(1, 'CHI')).toBe('蘿格營地')
|
||
expect(resolveWaypointName(1, 'ENG')).toBe('Rogue Encampment')
|
||
})
|
||
|
||
it('exports canonical WAYPOINT_ROW_OFFSETS_Y and WAYPOINT_TEXT_OFFSETS_Y from @0x6fb8e6d8 and draws no custom row boxes', () => {
|
||
expect(WAYPOINT_ROW_OFFSETS_Y).toEqual([60, 96, 132, 168, 205, 241, 277, 313, 349])
|
||
expect(WAYPOINT_TEXT_OFFSETS_Y).toEqual([84, 119, 154, 189, 224, 259, 294, 329, 364])
|
||
expect(getWaypointRowOffsetY(0)).toBe(60)
|
||
expect(getWaypointTextOffsetY(0)).toBe(84)
|
||
|
||
const hud = new WorldPanelsHud()
|
||
hud.selectedActTab = 1
|
||
hud.setWaypointUnlocked(0, true)
|
||
hud.setCurrentWaypoint(0, 1)
|
||
|
||
const { ctx, fillRectCalls, strokeRectCalls } = createMockCtx()
|
||
const textCalls: Array<{ text: string; x: number; y: number }> = []
|
||
const font = {
|
||
measureText: vi.fn(() => 80),
|
||
drawText: vi.fn((_c: any, text: string, x: number, y: number) => {
|
||
textCalls.push({ text, x, y })
|
||
}),
|
||
} as unknown as D2FontRenderer
|
||
|
||
const dummyImg = { complete: true, naturalWidth: 640, naturalHeight: 480 } as unknown as HTMLImageElement
|
||
hud.drawLeftDockPanel(
|
||
ctx,
|
||
'waypoint',
|
||
{
|
||
questBgImg: dummyImg,
|
||
waypointBgImg: dummyImg,
|
||
borderLeftImg: dummyImg,
|
||
stashBgImg: dummyImg,
|
||
cubeBgImg: dummyImg,
|
||
vendorBgImg: dummyImg,
|
||
buySellBtnImg: dummyImg,
|
||
waypointTabsImg: dummyImg,
|
||
waypointIconsImg: dummyImg,
|
||
},
|
||
font,
|
||
)
|
||
|
||
// No custom row background fillRect or strokeRect
|
||
expect(fillRectCalls).toHaveLength(0)
|
||
expect(strokeRectCalls).toHaveLength(0)
|
||
|
||
// First row text drawn at ox + 80 = 160, oy + 84 = 144
|
||
const rogueRow = textCalls.find(c => c.text === '蘿格營地')
|
||
expect(rogueRow).toBeDefined()
|
||
expect(rogueRow!.x).toBe(80 + 80)
|
||
expect(rogueRow!.y).toBe(60 + 84)
|
||
})
|
||
})
|
||
|
||
// ===========================================================================
|
||
// Issue #668: Item Hover Tooltip Uniform font16, Speed Line Order, No Border
|
||
// ===========================================================================
|
||
describe('Issue #668: Item hover tooltip appearance, uniform font16, and speed line ordering', () => {
|
||
it('renders all lines with font16, places weapon speed after Required Level, uses STONE_BOX_BG_FILL, and draws no border stroke', () => {
|
||
const hud = new WorldPanelsHud()
|
||
hud.lang = 'ENG'
|
||
const { ctx, fillRectCalls, strokeRectCalls } = createMockCtx()
|
||
const drawnLines: Array<{ text: string; font: string }> = []
|
||
const font = {
|
||
measureText: vi.fn((text: string) => text.length * 8),
|
||
drawText: vi.fn((_c: any, text: string, _x: number, _y: number, opts: any) => {
|
||
drawnLines.push({ text, font: opts.font })
|
||
}),
|
||
} as unknown as D2FontRenderer
|
||
|
||
const weapon = makeTestItem({
|
||
id: 'w1',
|
||
code: '9cr',
|
||
name: 'Phase Blade',
|
||
nameZh: '幻化之刃',
|
||
quality: 'normal',
|
||
invWidth: 2,
|
||
invHeight: 3,
|
||
damageEn: 'One-Hand Damage: 31 to 35',
|
||
reqDex: 136,
|
||
reqStr: 25,
|
||
reqLevel: 54,
|
||
speedTextEn: 'Sword Class - Very Fast Attack Speed',
|
||
allowedSlots: ['weapon1', 'weapon2'],
|
||
stats: [],
|
||
})
|
||
|
||
hud.drawItemTooltip(ctx, { item: weapon, x: 400, y: 300 }, font, false, 800, 600)
|
||
|
||
// Every line uses font16
|
||
expect(drawnLines.length).toBeGreaterThan(0)
|
||
for (const l of drawnLines) {
|
||
expect(l.font).toBe('font16')
|
||
}
|
||
|
||
// Speed line must appear AFTER Required Dexterity, Required Strength, and Required Level
|
||
const reqDexIdx = drawnLines.findIndex(l => l.text.startsWith('Required Dexterity:'))
|
||
const reqStrIdx = drawnLines.findIndex(l => l.text.startsWith('Required Strength:'))
|
||
const reqLvlIdx = drawnLines.findIndex(l => l.text.startsWith('Required Level:'))
|
||
const speedIdx = drawnLines.findIndex(l => l.text.includes('Attack Speed'))
|
||
expect(reqDexIdx).toBeGreaterThan(-1)
|
||
expect(reqStrIdx).toBeGreaterThan(reqDexIdx)
|
||
expect(reqLvlIdx).toBeGreaterThan(reqStrIdx)
|
||
expect(speedIdx).toBeGreaterThan(reqLvlIdx)
|
||
|
||
// Background uses STONE_BOX_BG_FILL and NO border stroke
|
||
expect(fillRectCalls).toHaveLength(1)
|
||
expect(fillRectCalls[0]!.fillStyle).toBe(STONE_BOX_BG_FILL)
|
||
expect(strokeRectCalls).toHaveLength(0)
|
||
|
||
// Box width is maxW + 16 without arbitrary 190px floor on short tooltips
|
||
const shortItem = makeTestItem({
|
||
id: 'k1',
|
||
code: 'key',
|
||
name: 'Key',
|
||
nameZh: '鑰匙',
|
||
quality: 'normal',
|
||
invWidth: 1,
|
||
invHeight: 1,
|
||
allowedSlots: [],
|
||
stats: [],
|
||
})
|
||
fillRectCalls.length = 0
|
||
hud.drawItemTooltip(ctx, { item: shortItem, x: 400, y: 300 }, font, false, 800, 600)
|
||
expect(fillRectCalls[0]!.w).toBe(24 + 16) // 'Key' width 24 + padX*2 (16) = 40 (< 190)
|
||
})
|
||
})
|
||
|
||
// ===========================================================================
|
||
// Issue #669: Horadric Cube Grid X = 198 (80 + 118) & Item Background Fill
|
||
// ===========================================================================
|
||
describe('Issue #669: Horadric Cube grid origin (198, 199) and item background fill', () => {
|
||
it('exports CUBE_GRID_ORIGIN with x = 198 (80 + 118), y = 199 (60 + 139), cols = 3, rows = 4, cellPx = 29', () => {
|
||
expect(CUBE_GRID_ORIGIN).toEqual({
|
||
x: 198,
|
||
y: 199,
|
||
cols: 3,
|
||
rows: 4,
|
||
cellPx: 29,
|
||
})
|
||
})
|
||
|
||
it('uses uniform blue/red/green item cell background fill regardless of unique/set quality', () => {
|
||
const uniqueHelm = makeTestItem({ quality: 'unique', reqLevel: 10 })
|
||
const usableCtx = { level: 20, str: 50, dex: 50 }
|
||
const unusableCtx = { level: 1, str: 10, dex: 10 }
|
||
expect(resolveGridItemBackgroundFill(uniqueHelm, usableCtx)).toBe(GRID_ITEM_BG_BLUE)
|
||
expect(resolveGridItemBackgroundFill(uniqueHelm, unusableCtx)).toBe(GRID_ITEM_BG_RED)
|
||
expect(resolveGridItemBackgroundFill(uniqueHelm, usableCtx, { isHovered: true, hasCursorItem: true })).toBe(
|
||
GRID_ITEM_BG_GREEN,
|
||
)
|
||
})
|
||
})
|
||
|
||
// ===========================================================================
|
||
// Issue #670: Fail-Fast on Missing UI Assets, Fonts, and Item Sprites
|
||
// ===========================================================================
|
||
describe('Issue #670: Fail-fast when required UI assets, fonts, or item sprites are missing', () => {
|
||
it('throws when computeNpcDialogLayout is called without a valid D2FontRenderer', () => {
|
||
const opts = buildNpcMenuOptions(getTownNpcDescriptorByName('akara')!)
|
||
expect(() => computeNpcDialogLayout('Akara', opts, 400, 200, null)).toThrow(/D2FontRenderer/)
|
||
})
|
||
|
||
it('throws when rendering quest or waypoint panel with unloaded/null required images', () => {
|
||
const hud = new WorldPanelsHud()
|
||
const font = new D2FontRenderer()
|
||
const { ctx } = createMockCtx()
|
||
const dummyImg = { complete: true, naturalWidth: 320, naturalHeight: 432 } as unknown as HTMLImageElement
|
||
|
||
expect(() =>
|
||
hud.drawLeftDockPanel(
|
||
ctx,
|
||
'quest',
|
||
{
|
||
questBgImg: dummyImg,
|
||
waypointBgImg: dummyImg,
|
||
borderLeftImg: dummyImg,
|
||
stashBgImg: dummyImg,
|
||
cubeBgImg: dummyImg,
|
||
vendorBgImg: dummyImg,
|
||
buySellBtnImg: dummyImg,
|
||
questsAtlasImg: null,
|
||
},
|
||
font,
|
||
),
|
||
).toThrow(/questsAtlasImg/)
|
||
})
|
||
|
||
it('throws when rendering stash item without a valid itemsAtlasImg', () => {
|
||
const hud = new WorldPanelsHud()
|
||
const font = new D2FontRenderer()
|
||
const { ctx } = createMockCtx()
|
||
const dummyImg = { complete: true, naturalWidth: 320, naturalHeight: 432 } as unknown as HTMLImageElement
|
||
|
||
hud.stashItems = [{ item: makeTestItem({ code: 'cap', quality: 'unique' }), col: 0, row: 0 }]
|
||
|
||
expect(() =>
|
||
hud.drawLeftDockPanel(
|
||
ctx,
|
||
'stash',
|
||
{
|
||
questBgImg: dummyImg,
|
||
waypointBgImg: dummyImg,
|
||
borderLeftImg: dummyImg,
|
||
stashBgImg: dummyImg,
|
||
cubeBgImg: dummyImg,
|
||
vendorBgImg: dummyImg,
|
||
buySellBtnImg: dummyImg,
|
||
itemsAtlasImg: null,
|
||
},
|
||
font,
|
||
),
|
||
).toThrow(/itemsAtlasImg/)
|
||
})
|
||
})
|
||
|
||
// ===========================================================================
|
||
// Issue #671: Fail-Fast on Missing Required Runtime Data (No ?? Fallbacks)
|
||
// ===========================================================================
|
||
describe('Issue #671: Fail-fast on missing required runtime data', () => {
|
||
it('throws on invalid Act index in getActWaypoints and getActQuests instead of falling back to Act 1', () => {
|
||
expect(() => getActWaypoints(0)).toThrow(/Invalid waypoint act index/)
|
||
expect(() => getActWaypoints(6)).toThrow(/Invalid waypoint act index/)
|
||
expect(() => getActQuests(0)).toThrow(/Invalid quest act index/)
|
||
expect(() => getActQuests(6)).toThrow(/Invalid quest act index/)
|
||
expect(getActWaypoints(1)).toBe(ACT_WAYPOINTS[1])
|
||
})
|
||
|
||
it('throws when getWaypointRowOffsetY or getWaypointTextOffsetY receives an out-of-bounds row index', () => {
|
||
expect(() => getWaypointRowOffsetY(-1)).toThrow(/Invalid waypoint row index/)
|
||
expect(() => getWaypointRowOffsetY(9)).toThrow(/Invalid waypoint row index/)
|
||
expect(() => getWaypointTextOffsetY(-1)).toThrow(/Invalid waypoint text row index/)
|
||
expect(() => getWaypointTextOffsetY(9)).toThrow(/Invalid waypoint text row index/)
|
||
})
|
||
})
|
||
|
||
// ===========================================================================
|
||
// Issue #672: Remove Duplicate Offline Local Rules
|
||
// ===========================================================================
|
||
describe('Issue #672: Offline duplicate rules removed from WorldPanelsHud', () => {
|
||
it('does not expose canTransmuteCube, resolveGambleOutcome, or getRepairCost on WorldPanelsHud', () => {
|
||
const hud = new WorldPanelsHud() as unknown as Record<string, unknown>
|
||
expect(hud['canTransmuteCube']).toBeUndefined()
|
||
expect(hud['resolveGambleOutcome']).toBeUndefined()
|
||
expect(hud['getRepairCost']).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
// ===========================================================================
|
||
// Issue #673: Non-Canonical "Entering: <Area>" Banner Removed
|
||
// ===========================================================================
|
||
describe('Issue #673: Non-canonical Entering area banner removed', () => {
|
||
it('keeps areaBanner null and draws nothing on showAreaBanner / drawAreaBanner', () => {
|
||
const hud = new WorldPanelsHud()
|
||
const font = new D2FontRenderer()
|
||
const { ctx, fillRectCalls } = createMockCtx()
|
||
const drawTextSpy = vi.spyOn(font, 'drawText')
|
||
|
||
hud.showAreaBanner('鮮血荒地', 'Blood Moor', 1000)
|
||
expect(hud.areaBanner).toBeNull()
|
||
|
||
hud.drawAreaBanner(ctx, 1500, font, 800)
|
||
expect(drawTextSpy).not.toHaveBeenCalled()
|
||
expect(fillRectCalls).toHaveLength(0)
|
||
})
|
||
})
|
||
|
||
// ===========================================================================
|
||
// Issue #674: Unified Act Tab Draw & Hit-Test Geometry
|
||
// ===========================================================================
|
||
describe('Issue #674: Unified Act tab draw and click hit-test coordinates', () => {
|
||
it('uses identical ACT_TAB_LAYOUT bounds for drawing and hit-testing across all 5 tabs', () => {
|
||
const hud = new WorldPanelsHud()
|
||
const ox = 80
|
||
const oy = 60
|
||
for (const panel of ['quest', 'waypoint'] as const) {
|
||
for (let i = 0; i < ACT_TAB_LAYOUT.count; i++) {
|
||
const tabLeft = ox + ACT_TAB_LAYOUT.startXOffset + i * ACT_TAB_LAYOUT.strideX
|
||
const tabRight =
|
||
i === ACT_TAB_LAYOUT.count - 1
|
||
? tabLeft + ACT_TAB_LAYOUT.frameWidth - 1
|
||
: tabLeft + ACT_TAB_LAYOUT.strideX - 1
|
||
const tabTop = oy + ACT_TAB_LAYOUT.topYOffset
|
||
const tabBottom = oy + ACT_TAB_LAYOUT.topYOffset + ACT_TAB_LAYOUT.height
|
||
|
||
hud.selectedActTab = 1
|
||
hud.handleLeftDockClick(panel, tabLeft, tabTop, {
|
||
onClose: () => {},
|
||
onWaypointTeleport: () => {},
|
||
})
|
||
expect(hud.selectedActTab).toBe(i + 1)
|
||
|
||
hud.selectedActTab = 1
|
||
hud.handleLeftDockClick(panel, tabRight, tabBottom, {
|
||
onClose: () => {},
|
||
onWaypointTeleport: () => {},
|
||
})
|
||
expect(hud.selectedActTab).toBe(i + 1)
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
// ===========================================================================
|
||
// Issue #675: Binary-Grounded Constants Exported & Verified
|
||
// ===========================================================================
|
||
describe('Issue #675: Binary-grounded constants exported with 1.13c provenance', () => {
|
||
it('exports STONE_BOX_BG_FILL, BUTTON_HOVER_BOX_*, and ACT_TAB_LAYOUT', () => {
|
||
expect(STONE_BOX_BG_FILL).toBe('rgba(0, 0, 0, 0.85)')
|
||
expect(BUTTON_HOVER_BOX_PAD_X).toBe(6)
|
||
expect(BUTTON_HOVER_BOX_MIN_W).toBe(24)
|
||
expect(BUTTON_HOVER_BOX_H).toBe(22)
|
||
expect(ACT_TAB_LAYOUT).toEqual({
|
||
startXOffset: 4,
|
||
strideX: 62,
|
||
width: 62,
|
||
frameWidth: 63,
|
||
height: 31,
|
||
topYOffset: 8,
|
||
bottomYOffset: 39,
|
||
count: 5,
|
||
})
|
||
})
|
||
})
|
||
|
||
// ===========================================================================
|
||
// Issue #676: Vendor Buttons & Tabs Resolved Dynamically from .tbl via strIdx
|
||
// ===========================================================================
|
||
describe('Issue #676: Vendor button and tab labels resolved from .tbl via strIdx', () => {
|
||
it('resolves VENDOR_BUTTON_SPECS and VENDOR_TABS labels dynamically via requireTbl', () => {
|
||
expect(VENDOR_BUTTON_SPECS.buy.labelEn).toBe('Buy')
|
||
expect(VENDOR_BUTTON_SPECS.buy.labelZh).toBe('買')
|
||
expect(VENDOR_BUTTON_SPECS.sell.labelEn).toBe('Sell')
|
||
expect(VENDOR_BUTTON_SPECS.sell.labelZh).toBe('賣')
|
||
expect(VENDOR_BUTTON_SPECS.repair.labelEn).toBe('Repair')
|
||
expect(VENDOR_BUTTON_SPECS.repair.labelZh).toBe('修復')
|
||
expect(VENDOR_BUTTON_SPECS.close.labelEn).toBe('Close')
|
||
expect(VENDOR_BUTTON_SPECS.close.labelZh).toBe('關閉')
|
||
|
||
expect(VENDOR_TABS[0]!.labelEn).toBe('Armor')
|
||
expect(VENDOR_TABS[1]!.labelEn).toBe('Weapons')
|
||
expect(VENDOR_TABS[3]!.labelEn).toBe('Misc')
|
||
})
|
||
})
|
||
|
||
// ===========================================================================
|
||
// Issue #677: Single 0x31 QuestMessage Dispatch on Clicking "Talk"
|
||
// ===========================================================================
|
||
describe('Issue #677: Single 0x31 QuestMessage dispatch on clicking Talk', () => {
|
||
it('fires exactly one packet when both onNpcMenuAction and onQuestMessage are bound', () => {
|
||
const hud = new WorldPanelsHud()
|
||
const akara = getTownNpcDescriptorByName('akara')!
|
||
const dispatched: Array<{ source: string; npcId: number; msgId?: number }> = []
|
||
|
||
hud.onNpcMenuAction = (optionId, npcUnitId) => {
|
||
if (optionId === 'talk') {
|
||
dispatched.push({ source: 'onNpcMenuAction', npcId: npcUnitId })
|
||
}
|
||
}
|
||
hud.onQuestMessage = (npcUnitId, messageId) => {
|
||
dispatched.push({ source: 'onQuestMessage', npcId: npcUnitId, msgId: messageId })
|
||
}
|
||
|
||
hud.openNpcMenu(akara, 400, 300, 101)
|
||
expect(hud.clickNpcMenuOption('talk')).toBe(true)
|
||
expect(dispatched).toEqual([{ source: 'onNpcMenuAction', npcId: 101 }])
|
||
})
|
||
})
|
||
|
||
// ===========================================================================
|
||
// Issue #678: Strict Canonical D2GS 0x5E Party Action Codes
|
||
// ===========================================================================
|
||
describe('Issue #678: Strict canonical D2GS 0x5E party action codes in resolvePartyActionCode', () => {
|
||
it('maps canonical relationship states to 0x06, 0x07, 0x08, 0x09 and throws on unknown/missing values', () => {
|
||
expect(resolvePartyActionCode({ relationship: 'none' })).toBe(0x06)
|
||
expect(resolvePartyActionCode({ relationship: 0 })).toBe(0x06)
|
||
expect(resolvePartyActionCode({ partyState: 0x06 })).toBe(0x06)
|
||
|
||
expect(resolvePartyActionCode({ relationship: 'invited' })).toBe(0x07)
|
||
expect(resolvePartyActionCode({ relationship: 'invited_by_me' })).toBe(0x07)
|
||
expect(resolvePartyActionCode({ relationship: 1 })).toBe(0x07)
|
||
expect(resolvePartyActionCode({ partyState: 0x07 })).toBe(0x07)
|
||
|
||
expect(resolvePartyActionCode({ relationship: 'invited_me' })).toBe(0x08)
|
||
expect(resolvePartyActionCode({ relationship: 'invited_by' })).toBe(0x08)
|
||
expect(resolvePartyActionCode({ relationship: 2 })).toBe(0x08)
|
||
expect(resolvePartyActionCode({ partyState: 0x08 })).toBe(0x08)
|
||
|
||
expect(resolvePartyActionCode({ relationship: 'in_party' })).toBe(0x09)
|
||
expect(resolvePartyActionCode({ relationship: 'party' })).toBe(0x09)
|
||
expect(resolvePartyActionCode({ relationship: 3 })).toBe(0x09)
|
||
expect(resolvePartyActionCode({ partyState: 0x09 })).toBe(0x09)
|
||
|
||
expect(() => resolvePartyActionCode(null)).toThrow(/valid party member/)
|
||
expect(() => resolvePartyActionCode({})).toThrow(/unrecognized party relationship/)
|
||
expect(() => resolvePartyActionCode({ relationship: ' bogus ' })).toThrow(/unrecognized party relationship/)
|
||
expect(() => resolvePartyActionCode({ relationship: 99 })).toThrow(/unrecognized party relationship/)
|
||
})
|
||
})
|
||
})
|