545 lines
22 KiB
TypeScript
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')
|
|
})
|
|
})
|
|
})
|