diablo2-web/tests/challenger-m7-tooltip-clamp...

545 lines
22 KiB
TypeScript

/**
* Empirical Stress Test Suite for Milestone M7 (Issue #152)
*
* Adversarially tests:
* 1. SkillTreePanel tooltip placement and canvas boundary clamping across all 18 skill grid nodes (rows 1..6, cols 1..3).
* 2. Multi-line formatting, authentic color coding, text measurement, and hierarchy.
* 3. Non-damaging skills (Warmth, Teleport) suppressing undefined/blank damage lines.
* 4. Maxed skill point allocation (20/20) omitting next-level preview and showing gold max notice.
* 5. Unlearned skill requirement warnings (red highlighting for player level < reqlevel and missing prereqs).
*/
import { describe, expect, it } from 'vitest'
import {
SkillTreePanel,
SORCERESS_SKILL_TREE,
SKILL_PANEL_ORIGIN,
SKILL_TREE_COL_OFFSETS_X,
SKILL_TREE_ROW_OFFSETS_Y,
type FormattedTooltipLine,
type SkillNodeDef,
} from '../src/ui/skill-tree-panel.ts'
import {
buildTooltipViewModel,
createInitialState,
allocatePoint,
} from '../src/game/skill-calc-engine.ts'
import { D2FontRenderer, type D2ColorCode } from '../src/ui/font.ts'
const VALID_D2_COLOR_CODES: readonly D2ColorCode[] = [
'gold',
'white',
'blue',
'green',
'red',
'tan',
'gray',
]
describe('Milestone M7 Challenger Stress Tests: Tooltip Clamping & Line Formatting (Issue #152)', () => {
const mockFont = {
measureText: (txt: string, font?: string) => {
// Exocet is wider than standard font8
const charW = font === 'fontexocet10' ? 11 : 8
return txt.length * charW
},
} as unknown as D2FontRenderer
// Helper to construct synthetic grid node at row (1..6) and col (1..3)
function makeGridNode(row: 1 | 2 | 3 | 4 | 5 | 6, col: 1 | 2 | 3): SkillNodeDef {
return {
skillId: 9000 + row * 10 + col,
tab: 0,
row,
col,
reqLevel: row === 1 ? 1 : (row - 1) * 6,
prereqs: [],
nameEn: `Test Skill R${row}C${col}`,
nameZh: `测试技能R${row}C${col}`,
descZh: `测试描述行`,
baseMana: 10,
baseDmgMin: 10,
baseDmgMax: 20,
dmgPerLevel: 5,
}
}
describe('1. Placement & Boundary Clamping across All 18 Skill Grid Nodes', () => {
const gridRows: readonly (1 | 2 | 3 | 4 | 5 | 6)[] = [1, 2, 3, 4, 5, 6]
const gridCols: readonly (1 | 2 | 3)[] = [1, 2, 3]
it('enforces authentic grid origin and exact spacing per D2Client.dll', () => {
expect(SKILL_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
// Verify exact screen coordinates for all 18 grid combinations
for (const row of gridRows) {
for (const col of gridCols) {
const node = makeGridNode(row, col)
const pos = SkillTreePanel.nodeScreenPos(node)
const expectedX = 400 + SKILL_TREE_COL_OFFSETS_X[col - 1]!
const expectedY = 60 + SKILL_TREE_ROW_OFFSETS_Y[row - 1]!
expect(pos.x).toBe(expectedX)
expect(pos.y).toBe(expectedY)
expect(pos.w).toBe(48)
expect(pos.h).toBe(48)
}
}
})
it('clamps tooltip within canvas viewport [8, 800 - 8] and strictly above control bar (y <= 552) across all 18 nodes with real skill lines', () => {
// Prepare a realistic, rich skill tooltip (Fire Ball with synergies and next level)
const state = createInitialState('sor')
allocatePoint(state, 36, 5) // Fire Bolt (synergy)
allocatePoint(state, 47, 10) // Fire Ball (main)
allocatePoint(state, 56, 1) // Meteor (synergy)
const vm = buildTooltipViewModel(state, 47, 85)
expect(vm).not.toBeNull()
const realLines = SkillTreePanel.formatSkillTooltipLines(vm!)
expect(realLines.length).toBeGreaterThanOrEqual(10)
let testedNodeCount = 0
for (const row of gridRows) {
for (const col of gridCols) {
testedNodeCount++
const node = makeGridNode(row, col)
const nodePos = SkillTreePanel.nodeScreenPos(node)
const placement = SkillTreePanel.computeTooltipPlacement(nodePos, realLines, mockFont)
// 1. Box width invariants
expect(placement.w).toBeGreaterThanOrEqual(240)
expect(placement.w).toBeLessThanOrEqual(800 - 16) // <= 784
// 2. Horizontal clamping: tx >= 8 and tx + boxW <= 800 - 8 (792)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(800 - 8)
// 3. Vertical clamping: ty >= 8 and ty + boxH <= 552 (strictly above control bar)
expect(placement.y).toBeGreaterThanOrEqual(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
// 4. Box height matches exact line formula
expect(placement.h).toBe(realLines.length * 17 + 24)
}
}
expect(testedNodeCount).toBe(18)
})
it('guarantees clamping for short tooltips (min-width 240px clamp) across all 18 nodes', () => {
const shortLines: FormattedTooltipLine[] = [
{ text: '火弹', color: 'gold', font: 'fontexocet10', align: 'center' },
{ text: '当前等级: 1', color: 'white', font: 'font8', align: 'center' },
]
for (const row of gridRows) {
for (const col of gridCols) {
const node = makeGridNode(row, col)
const nodePos = SkillTreePanel.nodeScreenPos(node)
const placement = SkillTreePanel.computeTooltipPlacement(nodePos, shortLines, mockFont)
expect(placement.w).toBe(240)
expect(placement.h).toBe(2 * 17 + 24) // 58px
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
expect(placement.y).toBeGreaterThanOrEqual(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
}
}
})
it('guarantees bottom boundary containment for high-line-count tooltips (up to 25 lines) on row 6', () => {
// 25 lines generates boxH = 25 * 17 + 24 = 449px
const tallLines: FormattedTooltipLine[] = Array.from({ length: 25 }, (_, i) => ({
text: `测试详尽技能描述行 ${i + 1}: 增加各项抗性与效果`,
color: 'white' as const,
font: 'font8' as const,
align: 'center' as const,
}))
for (const col of gridCols) {
const node = makeGridNode(6, col)
const nodePos = SkillTreePanel.nodeScreenPos(node)
const placement = SkillTreePanel.computeTooltipPlacement(nodePos, tallLines, mockFont)
expect(placement.h).toBe(25 * 17 + 24)
expect(placement.w).toBeGreaterThanOrEqual(240)
expect(placement.w).toBeLessThanOrEqual(784)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
// Row 6 is near the bottom; verify it does not push below control bar (y=552)
expect(placement.y).toBeGreaterThanOrEqual(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
// Verify it pinned to the bottom limit
expect(placement.y + placement.h).toBe(552)
}
})
it('guarantees top boundary containment for high-line-count tooltips on row 1', () => {
const tallLines: FormattedTooltipLine[] = Array.from({ length: 20 }, (_, i) => ({
text: `测试顶部技能描述行 ${i + 1}`,
color: 'white' as const,
font: 'font8' as const,
align: 'center' as const,
}))
for (const col of gridCols) {
const node = makeGridNode(1, col)
const nodePos = SkillTreePanel.nodeScreenPos(node)
const placement = SkillTreePanel.computeTooltipPlacement(nodePos, tallLines, mockFont)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
// Row 1 node y is 78. Center is 102. rawY would be 102 - 364/2 = -80.
// Clamping must pin ty to 8
expect(placement.y).toBe(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
}
})
it('handles font-less fallback measurement cleanly', () => {
const lines: FormattedTooltipLine[] = [
{ text: '测试无字体对象降级宽度计算', color: 'gold', font: 'fontexocet10', align: 'center' },
]
const nodePos = SkillTreePanel.nodeScreenPos(makeGridNode(2, 2))
const placement = SkillTreePanel.computeTooltipPlacement(nodePos, lines, null as unknown as D2FontRenderer)
// Fallback is text.length * 8 = 13 * 8 = 104 -> clamped to min 240
expect(placement.w).toBe(240)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
expect(placement.y).toBeGreaterThanOrEqual(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
})
})
describe('2. Text Measurement & Multi-Line Hierarchy for Diverse Skills', () => {
it('verifies formatted lines for Fire Ball, Warmth, Blizzard, Meteor, and Shiver Armor contain valid color codes', () => {
const targetSkills = [
{ id: 47, name: 'Fire Ball', prereqs: [36] },
{ id: 37, name: 'Warmth', prereqs: [] },
{ id: 59, name: 'Blizzard', prereqs: [39, 44, 45, 55] },
{ id: 56, name: 'Meteor', prereqs: [36, 41, 46, 47, 51] },
{ id: 50, name: 'Shiver Armor', prereqs: [39, 40] },
]
for (const target of targetSkills) {
const state = createInitialState('sor')
// Allocate prerequisites
for (const prereq of target.prereqs) {
allocatePoint(state, prereq, 1)
}
allocatePoint(state, target.id, 5)
const vm = buildTooltipViewModel(state, target.id, 85)
expect(vm, `ViewModel for ${target.name} should exist`).not.toBeNull()
const lines = SkillTreePanel.formatSkillTooltipLines(vm!)
expect(lines.length, `${target.name} should have formatted lines`).toBeGreaterThan(0)
// Title must be gold fontexocet10 centered
expect(lines[0]!.color).toBe('gold')
expect(lines[0]!.font).toBe('fontexocet10')
expect(lines[0]!.align).toBe('center')
// Every line must use a valid D2 color code, valid font, and have non-empty text
for (const line of lines) {
expect(
VALID_D2_COLOR_CODES,
`Line "${line.text}" in ${target.name} has invalid color: ${line.color}`,
).toContain(line.color)
expect(['font8', 'fontexocet10']).toContain(line.font)
expect(line.text.trim().length).toBeGreaterThan(0)
expect(line.text).not.toContain('undefined')
expect(line.text).not.toContain('NaN')
expect(line.text).not.toContain('null')
}
}
})
it('verifies all 30 skills in SORCERESS_SKILL_TREE produce valid colors across unlearned, learned, and maxed levels', () => {
for (const node of SORCERESS_SKILL_TREE) {
for (const lvl of [0, 1, 10, 20]) {
const state = createInitialState('sor')
if (lvl > 0) {
// Allocate prerequisites so it can be learned
for (const p of node.prereqs) {
state.hardPoints[p] = 1
}
state.hardPoints[node.skillId] = lvl
}
const vm = buildTooltipViewModel(state, node.skillId, 85)
if (!vm) continue
const lines = SkillTreePanel.formatSkillTooltipLines(vm)
for (const line of lines) {
expect(
VALID_D2_COLOR_CODES,
`Skill ${node.nameEn} (lvl ${lvl}) has invalid color code "${line.color}"`,
).toContain(line.color)
expect(line.text).not.toContain('undefined')
expect(line.text).not.toContain('NaN')
}
}
}
})
it('verifies non-damaging skills (Warmth, Teleport) do NOT output blank or undefined damage lines', () => {
// 1. Warmth (passive mana regeneration)
const warmthState = createInitialState('sor')
allocatePoint(warmthState, 37, 5)
const warmthVm = buildTooltipViewModel(warmthState, 37, 85)
expect(warmthVm).not.toBeNull()
expect(warmthVm!.current.damage.hasDamage).toBe(false)
expect(warmthVm!.nextLevel?.damage.hasDamage).toBe(false)
const warmthLines = SkillTreePanel.formatSkillTooltipLines(warmthVm!)
for (const line of warmthLines) {
expect(line.text).not.toContain('undefined')
expect(line.text).not.toContain('NaN')
expect(line.text.trim().length).toBeGreaterThan(0)
// Must not contain damage lines
expect(line.text).not.toMatch(/^(火焰|冰冷|闪电|魔法|物理|毒素)?伤害:/)
expect(line.text).not.toMatch(/Damage:/)
}
// Must contain mana regeneration rate in blue (distinguish effect stat line from narrative description)
const regenLine = warmthLines.find(l => l.text.startsWith('法力回復速度:+'))
expect(regenLine).toBeDefined()
expect(regenLine?.color).toBe('blue')
// 2. Teleport (movement spell)
const teleState = createInitialState('sor')
allocatePoint(teleState, 43, 1) // Telekinesis
allocatePoint(teleState, 54, 5) // Teleport
const teleVm = buildTooltipViewModel(teleState, 54, 85)
expect(teleVm).not.toBeNull()
expect(teleVm!.current.damage.hasDamage).toBe(false)
expect(teleVm!.nextLevel?.damage.hasDamage).toBe(false)
const teleLines = SkillTreePanel.formatSkillTooltipLines(teleVm!)
for (const line of teleLines) {
expect(line.text).not.toContain('undefined')
expect(line.text).not.toContain('NaN')
expect(line.text.trim().length).toBeGreaterThan(0)
expect(line.text).not.toMatch(/^(火焰|冰冷|闪电|魔法|物理|毒素)?伤害:/)
}
// Must contain mana cost
const manaLine = teleLines.find(l => l.text.includes('法力消耗'))
expect(manaLine).toBeDefined()
expect(manaLine?.color).toBe('white')
})
it('verifies maxed skill (20 base points) contains max points notice [已达技能点投入上限 (20/20)] and NO green next level section', () => {
const testSkills = [
{ id: 47, name: 'Fire Ball', prereqs: [36] },
{ id: 37, name: 'Warmth', prereqs: [] },
{ id: 59, name: 'Blizzard', prereqs: [39, 44, 45, 55] },
{ id: 56, name: 'Meteor', prereqs: [36, 41, 46, 47, 51] },
{ id: 50, name: 'Shiver Armor', prereqs: [39, 40] },
]
for (const t of testSkills) {
const state = createInitialState('sor')
for (const p of t.prereqs) state.hardPoints[p] = 1
state.hardPoints[t.id] = 20 // 20 hard points
const vm = buildTooltipViewModel(state, t.id, 85)
expect(vm, `${t.name} ViewModel should exist`).not.toBeNull()
expect(vm!.hardPoints).toBe(20)
expect(vm!.nextLevel).toBeNull() // Engine must suppress nextLevel
const lines = SkillTreePanel.formatSkillTooltipLines(vm!)
// 1. Must contain gold max notice
const maxNotice = lines.find(l => l.text === '[已达技能点投入上限 (20/20)]')
expect(maxNotice, `${t.name} must show max points notice`).toBeDefined()
expect(maxNotice?.color).toBe('gold')
expect(maxNotice?.font).toBe('font8')
// 2. Must NOT contain any next-level header
expect(lines.some(l => l.text.includes('[下一等级]'))).toBe(false)
// 3. Must NOT contain any green lines
const greenLines = lines.filter(l => l.color === 'green')
expect(greenLines.length, `${t.name} must not have any green lines when maxed`).toBe(0)
}
})
it('verifies maxed skill invariant holds when soft points (+skills gear) are present', () => {
const state = createInitialState('sor')
state.hardPoints[47] = 20 // 20 base points in Fire Ball
state.allSkillsSoftPoints = 8 // +8 all skills from gear
state.tabSoftPoints[0] = 3 // +3 fire tab
const vm = buildTooltipViewModel(state, 47, 85)
expect(vm).not.toBeNull()
expect(vm!.effectiveLevel).toBe(31) // 20 + 8 + 3
expect(vm!.hardPoints).toBe(20)
const lines = SkillTreePanel.formatSkillTooltipLines(vm!)
// Current level line displays breakdown in blue
const lvlLine = lines.find(l => l.text.includes('当前技能等级: 31'))
expect(lvlLine).toBeDefined()
expect(lvlLine?.color).toBe('blue')
expect(lvlLine?.text).toContain('(基础 20 + 装备 +11)')
// Max notice is still present in gold
const maxNotice = lines.find(l => l.text === '[已达技能点投入上限 (20/20)]')
expect(maxNotice).toBeDefined()
expect(maxNotice?.color).toBe('gold')
// Next level section is completely omitted
expect(lines.some(l => l.text.includes('[下一等级]'))).toBe(false)
expect(lines.filter(l => l.color === 'green').length).toBe(0)
})
it('verifies unlearned skill contains red warning when player level < reqlevel', () => {
const state = createInitialState('sor')
// 1. Level 1 Sorceress inspecting Frozen Orb (id 64, reqlevel 30)
const orbVm = buildTooltipViewModel(state, 64, 1)
expect(orbVm).not.toBeNull()
expect(orbVm!.levelMet).toBe(false)
const orbLines = SkillTreePanel.formatSkillTooltipLines(orbVm!)
const orbReqLevel = orbLines.find(l => l.text === '需要角色等级: 30')
expect(orbReqLevel).toBeDefined()
expect(orbReqLevel?.color).toBe('red')
// 2. Level 10 Sorceress inspecting Blizzard (id 59, reqlevel 24)
const blizzVm = buildTooltipViewModel(state, 59, 10)
expect(blizzVm).not.toBeNull()
expect(blizzVm!.levelMet).toBe(false)
const blizzLines = SkillTreePanel.formatSkillTooltipLines(blizzVm!)
const blizzReqLevel = blizzLines.find(l => l.text === '需要角色等级: 24')
expect(blizzReqLevel).toBeDefined()
expect(blizzReqLevel?.color).toBe('red')
// 3. Level 1 Sorceress inspecting Fire Bolt (id 36, reqlevel 1): requirements met
const boltVm = buildTooltipViewModel(state, 36, 1)
expect(boltVm).not.toBeNull()
expect(boltVm!.levelMet).toBe(true)
const boltLines = SkillTreePanel.formatSkillTooltipLines(boltVm!)
expect(boltLines.some(l => l.text.includes('需要角色等级'))).toBe(false)
})
it('verifies unlearned skill contains red warning when missing prerequisites', () => {
const state = createInitialState('sor')
// Sorceress lvl 35 inspecting Frozen Orb (id 64) with 0 points in Blizzard
const orbVm = buildTooltipViewModel(state, 64, 35)
expect(orbVm).not.toBeNull()
expect(orbVm!.levelMet).toBe(true)
expect(orbVm!.prereqs.some(p => !p.met)).toBe(true)
const orbLines = SkillTreePanel.formatSkillTooltipLines(orbVm!)
// Level is met, so no level warning
expect(orbLines.some(l => l.text.includes('需要角色等级'))).toBe(false)
// Prerequisite warning is red
const prereqWarning = orbLines.find(l => l.text.includes('需要前置技能: 暴風雪'))
expect(prereqWarning).toBeDefined()
expect(prereqWarning?.color).toBe('red')
// Unlearned status line is gray
const unlearnedNotice = orbLines.find(l => l.text === '未习得 (预览 1 级效果)')
expect(unlearnedNotice).toBeDefined()
expect(unlearnedNotice?.color).toBe('gray')
// Unlearned skill MUST NOT display [下一等级] preview
expect(orbLines.some(l => l.text.includes('[下一等级]'))).toBe(false)
})
it('verifies unlearned skill displays BOTH red warnings when level < reqlevel AND prerequisites are missing', () => {
const state = createInitialState('sor')
// Level 5 Sorceress inspecting Meteor (id 56, reqlevel 24, prereqs: Fire Bolt, Inferno, Blaze, Fire Ball, Fire Wall)
const meteorVm = buildTooltipViewModel(state, 56, 5)
expect(meteorVm).not.toBeNull()
expect(meteorVm!.levelMet).toBe(false)
expect(meteorVm!.prereqs.some(p => !p.met)).toBe(true)
const lines = SkillTreePanel.formatSkillTooltipLines(meteorVm!)
const reqLvlLine = lines.find(l => l.text === '需要角色等级: 24')
expect(reqLvlLine).toBeDefined()
expect(reqLvlLine?.color).toBe('red')
const redPrereqs = lines.filter(l => l.color === 'red' && l.text.includes('需要前置技能'))
expect(redPrereqs.length).toBeGreaterThanOrEqual(1)
// Unlearned status line is gray
expect(lines.some(l => l.text === '未习得 (预览 1 级效果)' && l.color === 'gray')).toBe(true)
})
})
describe('3. Adversarial Edge Cases: Pathological Coordinates & Extreme Strings', () => {
it('clamps gracefully even if node coordinates are placed outside canvas viewport', () => {
const dummyLines: FormattedTooltipLine[] = [
{ text: '测试极端坐标边界截断', color: 'gold', font: 'fontexocet10', align: 'center' },
{ text: '数值测试行', color: 'white', font: 'font8', align: 'center' },
]
// Test extreme positions: top-left (-100, -100), far-right (1000, 200), bottom-right (900, 700)
const pathologicalPositions = [
{ x: -100, y: -100, w: 48, h: 48 },
{ x: 0, y: 0, w: 48, h: 48 },
{ x: 1000, y: 200, w: 48, h: 48 },
{ x: 500, y: 700, w: 48, h: 48 },
{ x: 900, y: 900, w: 48, h: 48 },
]
for (const pos of pathologicalPositions) {
const placement = SkillTreePanel.computeTooltipPlacement(pos, dummyLines, mockFont)
expect(placement.w).toBeGreaterThanOrEqual(240)
expect(placement.w).toBeLessThanOrEqual(784)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
expect(placement.y).toBeGreaterThanOrEqual(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
}
})
it('correctly styles active vs inactive synergies in multi-line formatting', () => {
const state = createInitialState('sor')
allocatePoint(state, 36, 10) // Fire Bolt +10 (active synergy)
// Meteor has 0 points (inactive synergy)
allocatePoint(state, 47, 1) // Fire Ball
const vm = buildTooltipViewModel(state, 47, 85)
expect(vm).not.toBeNull()
const lines = SkillTreePanel.formatSkillTooltipLines(vm!)
const synHeader = lines.find(l => l.text === '[协同技能加成]')
expect(synHeader).toBeDefined()
expect(synHeader?.color).toBe('blue')
expect(synHeader?.font).toBe('fontexocet10')
// Active synergy for Fire Bolt: blue, shows (当前 +X%)
const activeSyn = lines.find(l => l.text.includes('火彈') && l.text.includes('(当前 +'))
expect(activeSyn).toBeDefined()
expect(activeSyn?.color).toBe('blue')
// Inactive synergy for Meteor: gray, does NOT show (当前 +X%)
const inactiveSyn = lines.find(l => l.text.includes('隕石') && !l.text.includes('(当前 +'))
expect(inactiveSyn).toBeDefined()
expect(inactiveSyn?.color).toBe('gray')
})
})
})