/** * 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') }) }) })