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:
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user