feat: TR-069 Exchange Viewer - structured MongoDB storage with SSE streaming and sequence diagram UI

- MongoDB storage: cwmp_exchanges collection with indexes (deviceId+timestamp, deviceId+sequenceNumber unique, TTL 24h)
- ExchangeStoreService: CWMP method extraction, per-device queue for ordering, retry logic, message cap (5000/device)
- Exchange API: GET/DELETE/SSE stream endpoints with Last-Event-ID reconnection
- CwmpLoggerMiddleware: dual-write (MongoDB + legacy files) with fire-and-forget
- Frontend: SequenceViewer (CPE↔ACS lifelines, arrows, auto-scroll, virtual scroll) + InspectorPanel (formatted XML, copy, syntax highlighting)
- Tr069Trace page: integrated new viewer with col-6/col-6 layout
- DeviceDetail page: collapsible exchange section when device is traced
- 14 property-based tests, 40+ unit tests, 5 integration tests
- Health check includes index verification
- Config: feature flags, SSE timeouts, retry policy
This commit is contained in:
Kevin L'Hereec committed 2026-06-03 15:52:44 +02:00
1 parent 3fefbf6c40
commit 0c83a4ccb2
50 files changed
+15731 -43

No files matched your search

@@ -0,0 +1,370 @@
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*</g, '>\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('</')) {
indent = Math.max(0, indent - 1)
formatted += ' '.repeat(indent * 2) + line + '\n'
}
// Self-closing tag or processing instruction
else if (line.endsWith('/>') || line.startsWith('<?')) {
formatted += ' '.repeat(indent * 2) + line + '\n'
}
// Opening tag (not self-closing)
else if (line.startsWith('<') && !line.startsWith('</') && line.includes('>')) {
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}</${tag}>`)
.join('')
return `<${root}>${childXml}</${root}>`
})
/**
* 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}</${tag}>`)
.join('')
const body = paramXml
? `<cwmp:${method}>${paramXml}</cwmp:${method}>`
: `<cwmp:${method}/>`
return `<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cwmp="urn:dslforum-org:cwmp-1-0"><soap-env:Header/><soap-env:Body>${body}</soap-env:Body></soap-env:Envelope>`
})
/**
* 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}/><other>text</other></${root}>`
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('</')) {
const selfClosingIndent = selfClosingLine.match(/^( *)/)?.[1]?.length ?? 0
const nextIndent = nextLine.match(/^( *)/)?.[1]?.length ?? 0
expect(nextIndent).toBe(selfClosingIndent)
}
}
}),
{ numRuns: PBT_NUM_RUNS }
)
})
})
describe('Header displays cwmpMethod and direction', () => {
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 }
)
})
})
})
@@ -0,0 +1,599 @@
import { describe, it, expect } from 'vitest'
import fc from 'fast-check'
import {
formatTimestamp,
getArrowClass,
truncateMethod,
needsTruncation,
} from '../../../inertia/helpers/sequence_viewer_helpers.js'
/**
* Property 9: Arrow Direction Correctness
* Validates: Requirements 4.2
*
* For any list of CWMP messages with mixed directions, the SequenceViewer SHALL render
* each message arrow pointing left-to-right for "CPE→ACS" and right-to-left for "ACS→CPE",
* with the overall list ordered by timestamp ascending.
*
* Since this is a component data logic test, we test the pure logic:
* - Direction → CSS class mapping
* - Timestamp ordering of the messages array
*/
const PBT_NUM_RUNS = 100
// --- Types (matching SequenceViewer.vue CwmpExchange interface) ---
interface CwmpExchange {
sequenceNumber: number
timestamp: string
direction: 'CPE→ACS' | 'ACS→CPE'
cwmpMethod: string
sessionId: string
rawXml: string
}
// --- Pure logic functions ---
/**
* Checks if an array of messages is ordered by timestamp ascending.
* The SequenceViewer displays messages in array order, and the API returns them
* ordered by timestamp ascending.
*/
function isTimestampAscending(messages: CwmpExchange[]): boolean {
for (let i = 1; i < messages.length; i++) {
if (messages[i].timestamp < messages[i - 1].timestamp) {
return false
}
}
return true
}
// --- Arbitraries ---
/**
* Generates a random direction value.
*/
const arbDirection = fc.constantFrom<'CPE→ACS' | 'ACS→CPE'>('CPE→ACS', 'ACS→CPE')
/**
* Generates a random ISO 8601 timestamp within a reasonable range.
*/
const arbTimestamp = fc
.integer({
min: new Date('2024-01-01T00:00:00.000Z').getTime(),
max: new Date('2025-12-31T23:59:59.999Z').getTime(),
})
.map((ms) => new Date(ms).toISOString())
/**
* Generates a random CWMP method name.
*/
const arbCwmpMethod = fc.constantFrom(
'Inform',
'GetParameterValues',
'GetParameterValuesResponse',
'SetParameterValues',
'SetParameterValuesResponse',
'AddObject',
'AddObjectResponse',
'DeleteObject',
'DeleteObjectResponse',
'Download',
'DownloadResponse',
'Reboot',
'RebootResponse',
'FactoryReset',
'Fault',
'Empty',
'Unknown'
)
/**
* Generates a single CwmpExchange with the given sequence number and a sorted-friendly timestamp.
*/
function arbExchange(seqNum: number): fc.Arbitrary<CwmpExchange> {
return fc
.record({
timestamp: arbTimestamp,
direction: arbDirection,
cwmpMethod: arbCwmpMethod,
sessionId: fc.string({ minLength: 5, maxLength: 30 }),
rawXml: fc.string({ minLength: 0, maxLength: 100 }),
})
.map(({ timestamp, direction, cwmpMethod, sessionId, rawXml }) => ({
sequenceNumber: seqNum,
timestamp,
direction,
cwmpMethod,
sessionId,
rawXml,
}))
}
/**
* Generates a list of CwmpExchange messages sorted by timestamp ascending
* (simulating what the API returns and how SequenceViewer receives them).
*/
const arbSortedExchangeList = fc
.array(
fc.record({
timestamp: arbTimestamp,
direction: arbDirection,
cwmpMethod: arbCwmpMethod,
sessionId: fc.string({ minLength: 5, maxLength: 30 }),
rawXml: fc.string({ minLength: 0, maxLength: 100 }),
}),
{ minLength: 1, maxLength: 50 }
)
.map((items) => {
// Sort by timestamp ascending to simulate API-ordered data
items.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
return items.map((item, index) => ({
...item,
sequenceNumber: index + 1,
}))
})
/**
* Generates a list with ALL messages having the same direction.
*/
function arbUniformDirectionList(direction: 'CPE→ACS' | 'ACS→CPE') {
return fc
.array(
fc.record({
timestamp: arbTimestamp,
cwmpMethod: arbCwmpMethod,
sessionId: fc.string({ minLength: 5, maxLength: 30 }),
rawXml: fc.string({ minLength: 0, maxLength: 100 }),
}),
{ minLength: 1, maxLength: 30 }
)
.map((items) => {
items.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
return items.map((item, index) => ({
...item,
direction,
sequenceNumber: index + 1,
}))
})
}
// --- Property Tests ---
describe('Property 9: Arrow Direction Correctness', () => {
it('CPE→ACS messages always get "arrow-right" class (left-to-right arrow)', () => {
fc.assert(
fc.property(arbSortedExchangeList, (messages) => {
for (const message of messages) {
if (message.direction === 'CPE→ACS') {
expect(getArrowClass(message.direction)).toBe('arrow-right')
}
}
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('ACS→CPE messages always get "arrow-left" class (right-to-left arrow)', () => {
fc.assert(
fc.property(arbSortedExchangeList, (messages) => {
for (const message of messages) {
if (message.direction === 'ACS→CPE') {
expect(getArrowClass(message.direction)).toBe('arrow-left')
}
}
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('arrow class is exclusively determined by direction — no other field affects it', () => {
fc.assert(
fc.property(arbSortedExchangeList, (messages) => {
for (const message of messages) {
const arrowClass = getArrowClass(message.direction)
// The arrow class is a bijection: exactly two valid outputs
expect(['arrow-right', 'arrow-left']).toContain(arrowClass)
// And each direction maps to exactly one class
if (message.direction === 'CPE→ACS') {
expect(arrowClass).toBe('arrow-right')
} else {
expect(arrowClass).toBe('arrow-left')
}
}
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('messages with mixed directions are displayed in timestamp-ascending order', () => {
fc.assert(
fc.property(arbSortedExchangeList, (messages) => {
// The API-sorted list must be in timestamp ascending order
expect(isTimestampAscending(messages)).toBe(true)
// Each message still gets the correct arrow class regardless of position
for (const message of messages) {
const expectedClass =
message.direction === 'CPE→ACS' ? 'arrow-right' : 'arrow-left'
expect(getArrowClass(message.direction)).toBe(expectedClass)
}
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('all-CPE→ACS lists: every arrow is "arrow-right" and order is maintained', () => {
fc.assert(
fc.property(arbUniformDirectionList('CPE→ACS'), (messages) => {
expect(isTimestampAscending(messages)).toBe(true)
for (const message of messages) {
expect(getArrowClass(message.direction)).toBe('arrow-right')
}
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('all-ACS→CPE lists: every arrow is "arrow-left" and order is maintained', () => {
fc.assert(
fc.property(arbUniformDirectionList('ACS→CPE'), (messages) => {
expect(isTimestampAscending(messages)).toBe(true)
for (const message of messages) {
expect(getArrowClass(message.direction)).toBe('arrow-left')
}
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('single message: correct arrow class regardless of direction', () => {
fc.assert(
fc.property(arbDirection, arbTimestamp, (direction, timestamp) => {
const message: CwmpExchange = {
sequenceNumber: 1,
timestamp,
direction,
cwmpMethod: 'Inform',
sessionId: 'test-session',
rawXml: '<xml/>',
}
const expectedClass =
direction === 'CPE→ACS' ? 'arrow-right' : 'arrow-left'
expect(getArrowClass(message.direction)).toBe(expectedClass)
// Single-element list is trivially sorted
expect(isTimestampAscending([message])).toBe(true)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('alternating directions preserve timestamp order and correct arrow assignment', () => {
// Generate an alternating direction list
const arbAlternatingList = fc
.array(arbTimestamp, { minLength: 2, maxLength: 40 })
.map((timestamps) => {
const sorted = [...timestamps].sort()
return sorted.map((ts, i) => ({
sequenceNumber: i + 1,
timestamp: ts,
direction: (i % 2 === 0 ? 'CPE→ACS' : 'ACS→CPE') as 'CPE→ACS' | 'ACS→CPE',
cwmpMethod: 'Inform',
sessionId: 'session-alt',
rawXml: '',
}))
})
fc.assert(
fc.property(arbAlternatingList, (messages) => {
expect(isTimestampAscending(messages)).toBe(true)
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (i % 2 === 0) {
expect(msg.direction).toBe('CPE→ACS')
expect(getArrowClass(msg.direction)).toBe('arrow-right')
} else {
expect(msg.direction).toBe('ACS→CPE')
expect(getArrowClass(msg.direction)).toBe('arrow-left')
}
}
}),
{ numRuns: PBT_NUM_RUNS }
)
})
})
/**
* Property 10: Method Label Truncation
* Validates: Requirements 4.3
*
* For any cwmpMethod name, if its length exceeds 40 characters, the displayed label
* SHALL be truncated to 40 characters; if length ≤ 40, the full name SHALL be displayed.
*/
describe('Property 10: Method Label Truncation', () => {
it('strings longer than 40 characters are truncated to exactly 40 chars plus ellipsis', () => {
fc.assert(
fc.property(
fc.string({ minLength: 41, maxLength: 200 }),
(method) => {
const result = truncateMethod(method)
// The visible portion (before ellipsis) must be exactly the first 40 chars of the original
expect(result.substring(0, 40)).toBe(method.substring(0, 40))
// The result ends with the ellipsis character
expect(result.endsWith('…')).toBe(true)
// Total length is 41 (40 chars + 1 ellipsis char '…')
expect(result.length).toBe(41)
}
),
{ numRuns: PBT_NUM_RUNS }
)
})
it('strings of 40 characters or fewer are displayed in full without truncation', () => {
fc.assert(
fc.property(
fc.string({ minLength: 0, maxLength: 40 }),
(method) => {
const result = truncateMethod(method)
// The full original string is returned unchanged
expect(result).toBe(method)
expect(result.length).toBe(method.length)
}
),
{ numRuns: PBT_NUM_RUNS }
)
})
it('needsTruncation returns true if and only if method length exceeds 40', () => {
fc.assert(
fc.property(
fc.string({ minLength: 0, maxLength: 200 }),
(method) => {
const result = needsTruncation(method)
expect(result).toBe(method.length > 40)
}
),
{ numRuns: PBT_NUM_RUNS }
)
})
it('truncation preserves the first 40 characters of the original string', () => {
fc.assert(
fc.property(
fc.string({ minLength: 41, maxLength: 300 }),
(method) => {
const result = truncateMethod(method)
// The truncated output starts with the first 40 chars of the input
expect(result.substring(0, 40)).toBe(method.substring(0, 40))
}
),
{ numRuns: PBT_NUM_RUNS }
)
})
it('exactly 40 characters are NOT truncated', () => {
fc.assert(
fc.property(
fc.string({ minLength: 40, maxLength: 40 }),
(method) => {
const result = truncateMethod(method)
expect(result).toBe(method)
expect(result.length).toBe(40)
expect(needsTruncation(method)).toBe(false)
}
),
{ numRuns: PBT_NUM_RUNS }
)
})
it('exactly 41 characters ARE truncated', () => {
fc.assert(
fc.property(
fc.string({ minLength: 41, maxLength: 41 }),
(method) => {
const result = truncateMethod(method)
expect(result.length).toBe(41)
expect(result.endsWith('…')).toBe(true)
expect(needsTruncation(method)).toBe(true)
// The first 40 chars match
expect(result.substring(0, 40)).toBe(method.substring(0, 40))
}
),
{ numRuns: PBT_NUM_RUNS }
)
})
it('empty string is not truncated', () => {
const result = truncateMethod('')
expect(result).toBe('')
expect(needsTruncation('')).toBe(false)
})
it('unicode characters in method names respect character-length truncation', () => {
// Generate strings with multi-byte unicode characters (BMP range)
const unicodeMethodArb = fc
.array(fc.integer({ min: 0x0100, max: 0xFFFF }), { minLength: 41, maxLength: 100 })
.map((codePoints) => String.fromCharCode(...codePoints))
fc.assert(
fc.property(unicodeMethodArb, (method) => {
const result = truncateMethod(method)
// Truncation is based on JS string length (char count), not byte count
expect(result.length).toBe(41)
expect(result.endsWith('…')).toBe(true)
expect(needsTruncation(method)).toBe(true)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
})
/**
* Property 11: Timestamp Formatting
* Validates: Requirements 4.4
*
* For any valid ISO 8601 timestamp, the formatted display SHALL match the pattern
* `HH:mm:ss.SSS` (hours:minutes:seconds.milliseconds).
*/
describe('Property 11: Timestamp Formatting', () => {
// ─── Arbitraries ─────────────────────────────────────────────────────────────
/**
* Arbitrary that generates valid ISO 8601 timestamps as strings.
* Uses integer milliseconds to avoid invalid Date issues.
*/
const validIsoTimestamp = fc
.integer({
min: 0, // 1970-01-01T00:00:00.000Z
max: 4102444799999, // 2099-12-31T23:59:59.999Z
})
.map((ms) => new Date(ms).toISOString())
/**
* Arbitrary that generates ISO 8601 timestamps with timezone offsets.
* E.g., "2025-06-15T14:30:00.123+05:30"
*/
const isoTimestampWithOffset = fc
.record({
ms: fc.integer({ min: 0, max: 4102444799999 }),
offsetHours: fc.integer({ min: -12, max: 14 }),
offsetMinutes: fc.constantFrom(0, 30, 45),
})
.map(({ ms, offsetHours, offsetMinutes }) => {
const date = new Date(ms)
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
const day = String(date.getUTCDate()).padStart(2, '0')
const hours = String(date.getUTCHours()).padStart(2, '0')
const minutes = String(date.getUTCMinutes()).padStart(2, '0')
const seconds = String(date.getUTCSeconds()).padStart(2, '0')
const millis = String(date.getUTCMilliseconds()).padStart(3, '0')
const sign = offsetHours >= 0 ? '+' : '-'
const absHours = String(Math.abs(offsetHours)).padStart(2, '0')
const absMinutes = String(offsetMinutes).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${millis}${sign}${absHours}:${absMinutes}`
})
// ─── Pattern regex ───────────────────────────────────────────────────────────
const TIMESTAMP_PATTERN = /^\d{2}:\d{2}:\d{2}\.\d{3}$/
// ─── Property Tests ──────────────────────────────────────────────────────────
it('should format any valid ISO 8601 timestamp to HH:mm:ss.SSS pattern', () => {
fc.assert(
fc.property(validIsoTimestamp, (isoStr) => {
const result = formatTimestamp(isoStr)
expect(result).toMatch(TIMESTAMP_PATTERN)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('should produce correct UTC time components from ISO timestamp', () => {
fc.assert(
fc.property(validIsoTimestamp, (isoStr) => {
const result = formatTimestamp(isoStr)
const date = new Date(isoStr)
const expectedHours = String(date.getUTCHours()).padStart(2, '0')
const expectedMinutes = String(date.getUTCMinutes()).padStart(2, '0')
const expectedSeconds = String(date.getUTCSeconds()).padStart(2, '0')
const expectedMillis = String(date.getUTCMilliseconds()).padStart(3, '0')
const expected = `${expectedHours}:${expectedMinutes}:${expectedSeconds}.${expectedMillis}`
expect(result).toBe(expected)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('should format timestamps with timezone offsets to HH:mm:ss.SSS pattern', () => {
fc.assert(
fc.property(isoTimestampWithOffset, (isoStr) => {
const result = formatTimestamp(isoStr)
expect(result).toMatch(TIMESTAMP_PATTERN)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('should produce valid hour values (00-23)', () => {
fc.assert(
fc.property(validIsoTimestamp, (isoStr) => {
const result = formatTimestamp(isoStr)
const hours = parseInt(result.substring(0, 2), 10)
expect(hours).toBeGreaterThanOrEqual(0)
expect(hours).toBeLessThanOrEqual(23)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('should produce valid minute values (00-59)', () => {
fc.assert(
fc.property(validIsoTimestamp, (isoStr) => {
const result = formatTimestamp(isoStr)
const minutes = parseInt(result.substring(3, 5), 10)
expect(minutes).toBeGreaterThanOrEqual(0)
expect(minutes).toBeLessThanOrEqual(59)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('should produce valid second values (00-59)', () => {
fc.assert(
fc.property(validIsoTimestamp, (isoStr) => {
const result = formatTimestamp(isoStr)
const seconds = parseInt(result.substring(6, 8), 10)
expect(seconds).toBeGreaterThanOrEqual(0)
expect(seconds).toBeLessThanOrEqual(59)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('should produce valid millisecond values (000-999)', () => {
fc.assert(
fc.property(validIsoTimestamp, (isoStr) => {
const result = formatTimestamp(isoStr)
const millis = parseInt(result.substring(9, 12), 10)
expect(millis).toBeGreaterThanOrEqual(0)
expect(millis).toBeLessThanOrEqual(999)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
// ─── Edge cases ──────────────────────────────────────────────────────────────
it('should format midnight correctly', () => {
const result = formatTimestamp('2025-01-01T00:00:00.000Z')
expect(result).toBe('00:00:00.000')
})
it('should format noon correctly', () => {
const result = formatTimestamp('2025-01-01T12:00:00.000Z')
expect(result).toBe('12:00:00.000')
})
it('should format end-of-day correctly', () => {
const result = formatTimestamp('2025-01-01T23:59:59.999Z')
expect(result).toBe('23:59:59.999')
})
it('should handle millisecond precision correctly', () => {
const result = formatTimestamp('2025-06-15T10:30:00.001Z')
expect(result).toBe('10:30:00.001')
})
it('should handle zero milliseconds correctly', () => {
const result = formatTimestamp('2025-06-15T10:30:45.000Z')
expect(result).toBe('10:30:45.000')
})
})
@@ -0,0 +1,201 @@
import { describe, it, expect } from 'vitest'
/**
* Unit tests for the virtual scrolling logic in SequenceViewer.vue.
* Tests the pure computation functions extracted from the component's computed properties.
*
* Virtual scrolling parameters:
* - ITEM_HEIGHT = 60px
* - BUFFER_SIZE = 5 items
* - VIRTUAL_SCROLL_THRESHOLD = 200 messages
*/
const ITEM_HEIGHT = 60
const BUFFER_SIZE = 5
const VIRTUAL_SCROLL_THRESHOLD = 200
// --- Pure logic functions (mirror of component computed properties) ---
function useVirtualScroll(messageCount: number): boolean {
return messageCount > VIRTUAL_SCROLL_THRESHOLD
}
function totalHeight(messageCount: number): number {
return messageCount * ITEM_HEIGHT
}
function visibleStart(scrollTop: number, messageCount: number): number {
if (!useVirtualScroll(messageCount)) return 0
const start = Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER_SIZE
return Math.max(0, start)
}
function visibleEnd(
scrollTop: number,
containerHeight: number,
messageCount: number
): number {
if (!useVirtualScroll(messageCount)) return messageCount
const visibleCount = Math.ceil(containerHeight / ITEM_HEIGHT)
const end = Math.floor(scrollTop / ITEM_HEIGHT) + visibleCount + BUFFER_SIZE
return Math.min(messageCount, end)
}
function offsetY(scrollTop: number, messageCount: number): number {
return visibleStart(scrollTop, messageCount) * ITEM_HEIGHT
}
describe('Virtual Scrolling Logic', () => {
describe('useVirtualScroll threshold', () => {
it('should NOT use virtual scroll for 200 messages or fewer', () => {
expect(useVirtualScroll(0)).toBe(false)
expect(useVirtualScroll(100)).toBe(false)
expect(useVirtualScroll(200)).toBe(false)
})
it('should use virtual scroll for more than 200 messages', () => {
expect(useVirtualScroll(201)).toBe(true)
expect(useVirtualScroll(500)).toBe(true)
expect(useVirtualScroll(5000)).toBe(true)
})
})
describe('totalHeight', () => {
it('should compute total height as messageCount * ITEM_HEIGHT', () => {
expect(totalHeight(0)).toBe(0)
expect(totalHeight(1)).toBe(60)
expect(totalHeight(100)).toBe(6000)
expect(totalHeight(1000)).toBe(60000)
expect(totalHeight(5000)).toBe(300000)
})
})
describe('visibleStart', () => {
it('should return 0 when virtual scroll is disabled (≤200 messages)', () => {
expect(visibleStart(0, 100)).toBe(0)
expect(visibleStart(1000, 200)).toBe(0)
})
it('should return 0 when scrolled to top with virtual scroll enabled', () => {
expect(visibleStart(0, 500)).toBe(0)
})
it('should include buffer items before the visible area', () => {
// scrollTop = 600 means first visible item is at index 10
// With buffer of 5, visibleStart = 10 - 5 = 5
expect(visibleStart(600, 500)).toBe(5)
})
it('should clamp to 0 when buffer would go negative', () => {
// scrollTop = 120 means first visible item is at index 2
// With buffer of 5, visibleStart = 2 - 5 = -3 → clamped to 0
expect(visibleStart(120, 500)).toBe(0)
})
it('should compute correctly for large scroll positions', () => {
// scrollTop = 30000 means first visible item is at index 500
// With buffer of 5, visibleStart = 500 - 5 = 495
expect(visibleStart(30000, 1000)).toBe(495)
})
})
describe('visibleEnd', () => {
it('should return messageCount when virtual scroll is disabled', () => {
expect(visibleEnd(0, 500, 100)).toBe(100)
expect(visibleEnd(0, 500, 200)).toBe(200)
})
it('should compute visible end based on container height and buffer', () => {
// containerHeight = 500, so visibleCount = ceil(500/60) = 9
// scrollTop = 0, so first visible = 0
// end = 0 + 9 + 5 = 14
expect(visibleEnd(0, 500, 500)).toBe(14)
})
it('should clamp to messageCount when end exceeds total', () => {
// With few messages relative to viewport
// scrollTop = 0, containerHeight = 500, visibleCount = 9
// end = 0 + 9 + 5 = 14, but messageCount = 201
// For scrollTop near the end:
// scrollTop = 12000 → first visible = 200, end = 200 + 9 + 5 = 214 → clamped to 201
expect(visibleEnd(12000, 500, 201)).toBe(201)
})
it('should compute correctly for mid-scroll positions', () => {
// scrollTop = 3000, containerHeight = 500
// first visible = floor(3000/60) = 50
// visibleCount = ceil(500/60) = 9
// end = 50 + 9 + 5 = 64
expect(visibleEnd(3000, 500, 1000)).toBe(64)
})
})
describe('offsetY', () => {
it('should return 0 when virtual scroll is disabled', () => {
expect(offsetY(0, 100)).toBe(0)
expect(offsetY(5000, 200)).toBe(0)
})
it('should return 0 when scrolled to top', () => {
expect(offsetY(0, 500)).toBe(0)
})
it('should compute offset based on visibleStart', () => {
// visibleStart(600, 500) = 5, so offsetY = 5 * 60 = 300
expect(offsetY(600, 500)).toBe(300)
})
it('should compute correctly for large scroll positions', () => {
// visibleStart(30000, 1000) = 495, so offsetY = 495 * 60 = 29700
expect(offsetY(30000, 1000)).toBe(29700)
})
})
describe('rendered item count', () => {
it('should render only a subset of items when virtual scrolling is active', () => {
const messageCount = 1000
const containerH = 500
// For any scroll position, rendered items = visibleEnd - visibleStart
const scrollPos = 3000
const start = visibleStart(scrollPos, messageCount)
const end = visibleEnd(scrollPos, containerH, messageCount)
const renderedCount = end - start
// visibleCount = ceil(500/60) = 9, plus 2*buffer = 10 → max ~19 items rendered
// Way less than the 1000 total messages
expect(renderedCount).toBeLessThan(30)
expect(renderedCount).toBeGreaterThan(0)
})
it('should render all items when below threshold', () => {
const messageCount = 150
const containerH = 500
const scrollPos = 0
const start = visibleStart(scrollPos, messageCount)
const end = visibleEnd(scrollPos, containerH, messageCount)
expect(start).toBe(0)
expect(end).toBe(messageCount)
})
it('should maintain render performance target (≤100ms render budget)', () => {
// With 5000 messages, virtual scroll renders only ~19 items max
// At 60px height, a 500px container shows ~8 items + 2*5 buffer = 18 items
// 18 items at worst case should render well under 100ms
const messageCount = 5000
const containerH = 500
for (let scrollPos = 0; scrollPos < totalHeight(messageCount); scrollPos += 6000) {
const start = visibleStart(scrollPos, messageCount)
const end = visibleEnd(scrollPos, containerH, messageCount)
const renderedCount = end - start
// Never render more than visibleCount + 2*buffer items
const maxRendered = Math.ceil(containerH / ITEM_HEIGHT) + 2 * BUFFER_SIZE
expect(renderedCount).toBeLessThanOrEqual(maxRendered)
}
})
})
})