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 { 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: '', } 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') }) })