- 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
600 lines
19 KiB
TypeScript
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')
|
|
})
|
|
})
|