Files
adam-acs/tests/unit/components/sequence_viewer.property.spec.ts
T
Kevin L'Hereec 0c83a4ccb2 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
2026-06-03 15:52:44 +02:00

600 lines
19 KiB
TypeScript

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