import { describe, it, expect } from 'vitest' import fc from 'fast-check' // --- Pure logic extracted from InspectorPanel.vue --- /** * Formats XML with 2-space indentation (same logic as InspectorPanel.vue prettyPrintXml). */ function prettyPrintXml(xml: string): string { let formatted = '' let indent = 0 const lines = xml .replace(/>\s*\n<') .replace(/\n\s*\n/g, '\n') .split('\n') for (const rawLine of lines) { const line = rawLine.trim() if (!line) continue // Closing tag if (line.startsWith('') || line.startsWith('')) { formatted += ' '.repeat(indent * 2) + line + '\n' // Only increase indent if this is a pure opening tag (not self-closing and not containing content + close) const tagContent = line.replace(/<[^>]*>/g, '').trim() const closeTagMatch = line.match(/<\/[^>]+>/) if (!closeTagMatch && !line.endsWith('/>') && tagContent === '') { indent++ } } else { formatted += ' '.repeat(indent * 2) + line + '\n' } } return formatted.trim() } /** * Formats XML content with graceful fallback (same logic as InspectorPanel.vue formatXml). */ function formatXml(xml: string): string { if (!xml || xml.trim() === '') return '' try { return prettyPrintXml(xml) } catch { return xml } } /** * Generates the header text for the Inspector Panel (same logic as InspectorPanel.vue headerText computed). */ function generateHeaderText(cwmpMethod: string, direction: string): string { return `${cwmpMethod} — ${direction}` } // --- Arbitraries --- const PBT_NUM_RUNS = 100 const arbDirection = fc.constantFrom('CPE→ACS' as const, 'ACS→CPE' as const) const arbCwmpMethod = fc.constantFrom( 'Inform', 'InformResponse', 'GetParameterValues', 'GetParameterValuesResponse', 'SetParameterValues', 'SetParameterValuesResponse', 'AddObject', 'AddObjectResponse', 'DeleteObject', 'DeleteObjectResponse', 'Download', 'DownloadResponse', 'Upload', 'Reboot', 'FactoryReset', 'Fault', 'Empty', 'Unknown' ) /** * Generate a random XML tag name (valid XML element name: letters, digits, hyphens, dots, underscores, colons). */ const arbTagName = fc .tuple( fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')), fc.array( fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789-_:.'.split('')), { minLength: 1, maxLength: 15 } ) ) .map(([first, rest]) => first + rest.join('')) /** * Generate safe text content (no XML special chars). */ const arbSafeText = fc .string({ minLength: 1, maxLength: 30 }) .map((s) => s.replace(/[<>&"']/g, 'x')) /** * Generate a simple nested XML structure with 1-3 levels of nesting. * Ensures valid well-formed XML that the prettyPrintXml can format. */ const arbSimpleXml = fc .tuple( arbTagName, fc.array( fc.tuple(arbTagName, arbSafeText), { minLength: 1, maxLength: 5 } ) ) .map(([root, children]) => { const childXml = children .map(([tag, text]) => `<${tag}>${text}`) .join('') return `<${root}>${childXml}` }) /** * Generate a SOAP envelope-like XML structure with cwmp namespace (realistic test data). */ const arbSoapXml = fc .tuple( arbCwmpMethod, fc.array( fc.tuple(arbTagName, arbSafeText), { minLength: 0, maxLength: 4 } ) ) .map(([method, params]) => { const paramXml = params .map(([tag, text]) => `<${tag}>${text}`) .join('') const body = paramXml ? `${paramXml}` : `` return `${body}` }) /** * Arbitrary for non-empty rawXml content (either simple or SOAP-like). */ const arbNonEmptyXml = fc.oneof(arbSimpleXml, arbSoapXml) /** * Generate a random CwmpExchange object with non-empty rawXml. */ const arbCwmpExchange = fc.record({ deviceId: fc .array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')), { minLength: 5, maxLength: 20, }) .map((chars) => chars.join('')), direction: arbDirection, timestamp: fc .integer({ min: 1577836800000, max: 1924905600000 }) .map((ms) => new Date(ms).toISOString()), sessionId: fc .tuple( fc.ipV4(), fc.integer({ min: 1024, max: 65535 }), fc.ipV4(), fc.integer({ min: 1, max: 65535 }) ) .map(([ip1, port1, ip2, port2]) => `${ip1}:${port1}->${ip2}:${port2}`), cwmpMethod: arbCwmpMethod, rawXml: arbNonEmptyXml, sequenceNumber: fc.integer({ min: 1, max: 100_000 }), }) // ─── Property Tests ────────────────────────────────────────────────────────── /** * Property 12: Inspector Panel Display on Selection * Validates: Requirements 5.1, 5.2 * * For any selected CWMP message with non-empty rawXml, the Inspector Panel SHALL * display the XML formatted with 2-space indentation AND a header showing the * cwmpMethod name and direction ("CPE→ACS" or "ACS→CPE"). */ describe('Property 12: Inspector Panel Display on Selection', () => { describe('Formatted XML uses 2-space indentation', () => { it('all indented lines in formatted XML start with multiples of 2 spaces', () => { fc.assert( fc.property(arbCwmpExchange, (exchange) => { const formatted = formatXml(exchange.rawXml) // The formatted output should not be empty for non-empty XML expect(formatted.length).toBeGreaterThan(0) // Every line should start with 0 or more groups of exactly 2 spaces const lines = formatted.split('\n') for (const line of lines) { if (line.trim() === '') continue // Count leading spaces const leadingSpaces = line.match(/^( *)/)?.[1]?.length ?? 0 // Leading spaces must be a multiple of 2 (0, 2, 4, 6, ...) expect(leadingSpaces % 2).toBe(0) } }), { numRuns: PBT_NUM_RUNS } ) }) it('formatted XML preserves all original tag content (no data loss)', () => { fc.assert( fc.property(arbCwmpExchange, (exchange) => { const formatted = formatXml(exchange.rawXml) // Strip all whitespace from both to compare content const originalNoWs = exchange.rawXml.replace(/\s+/g, '') const formattedNoWs = formatted.replace(/\s+/g, '') expect(formattedNoWs).toBe(originalNoWs) }), { numRuns: PBT_NUM_RUNS } ) }) it('nested XML produces increasing indentation levels', () => { fc.assert( fc.property(arbSoapXml, (xml) => { const formatted = formatXml(xml) const lines = formatted.split('\n').filter((l) => l.trim() !== '') // For multi-line XML, at least some lines should have indentation > 0 if (lines.length > 2) { const maxIndent = Math.max( ...lines.map((l) => (l.match(/^( *)/)?.[1]?.length ?? 0)) ) // Nested XML should produce at least 2-space indentation somewhere expect(maxIndent).toBeGreaterThanOrEqual(2) } }), { numRuns: PBT_NUM_RUNS } ) }) it('self-closing tags do not increase indentation for following lines', () => { fc.assert( fc.property(arbTagName, arbTagName, (root, child) => { // Build XML with a self-closing tag followed by a normal element const xml = `<${root}><${child}/>text` const formatted = formatXml(xml) const lines = formatted.split('\n').filter((l) => l.trim() !== '') // The self-closing tag and the next element should be at the same indentation level if (lines.length >= 3) { const selfClosingLine = lines.find((l) => l.trim().endsWith('/>')) const nextLine = selfClosingLine ? lines[lines.indexOf(selfClosingLine) + 1] : undefined if (selfClosingLine && nextLine && !nextLine.trim().startsWith(' { it('header text contains both cwmpMethod and direction for any exchange', () => { fc.assert( fc.property(arbCwmpExchange, (exchange) => { const header = generateHeaderText(exchange.cwmpMethod, exchange.direction) // Header must include the cwmpMethod expect(header).toContain(exchange.cwmpMethod) // Header must include the direction expect(header).toContain(exchange.direction) }), { numRuns: PBT_NUM_RUNS } ) }) it('header text follows format "cwmpMethod — direction"', () => { fc.assert( fc.property(arbCwmpMethod, arbDirection, (method, direction) => { const header = generateHeaderText(method, direction) // Exact format: "method — direction" expect(header).toBe(`${method} — ${direction}`) }), { numRuns: PBT_NUM_RUNS } ) }) it('header always contains the em dash separator', () => { fc.assert( fc.property(arbCwmpExchange, (exchange) => { const header = generateHeaderText(exchange.cwmpMethod, exchange.direction) // The em dash separator must be present expect(header).toContain(' — ') }), { numRuns: PBT_NUM_RUNS } ) }) it('header direction is always one of the two valid values', () => { fc.assert( fc.property(arbCwmpExchange, (exchange) => { const header = generateHeaderText(exchange.cwmpMethod, exchange.direction) // Direction must be one of the two valid directions const containsCpeToAcs = header.includes('CPE→ACS') const containsAcsToCpe = header.includes('ACS→CPE') expect(containsCpeToAcs || containsAcsToCpe).toBe(true) }), { numRuns: PBT_NUM_RUNS } ) }) }) describe('Combined: formatted XML + header for random exchanges', () => { it('for any exchange with non-empty rawXml, both formatted output and header are correctly generated', () => { fc.assert( fc.property(arbCwmpExchange, (exchange) => { // --- Verify formatted XML --- const formatted = formatXml(exchange.rawXml) // Formatted output is non-empty expect(formatted.length).toBeGreaterThan(0) // All lines use 2-space indentation multiples const lines = formatted.split('\n') for (const line of lines) { if (line.trim() === '') continue const leadingSpaces = line.match(/^( *)/)?.[1]?.length ?? 0 expect(leadingSpaces % 2).toBe(0) } // --- Verify header --- const header = generateHeaderText(exchange.cwmpMethod, exchange.direction) // Header includes cwmpMethod expect(header).toContain(exchange.cwmpMethod) // Header includes direction expect(header).toContain(exchange.direction) }), { numRuns: PBT_NUM_RUNS } ) }) }) })