diff --git a/public/index.html b/public/index.html index ef48ec91..57884be3 100644 --- a/public/index.html +++ b/public/index.html @@ -22,9 +22,9 @@ - - - + + + @@ -85,30 +85,30 @@
- - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/packets.js b/public/packets.js index 27d79531..40401834 100644 --- a/public/packets.js +++ b/public/packets.js @@ -37,6 +37,19 @@ const PANEL_WIDTH_KEY = 'meshcore-panel-width'; const PANEL_CLOSE_HTML = ''; + // --- Virtual scroll state --- + const VSCROLL_ROW_HEIGHT = 36; // estimated row height in px + const VSCROLL_BUFFER = 30; // extra rows above/below viewport + let _displayPackets = []; // filtered packets for current view + let _displayGrouped = false; // whether _displayPackets is in grouped mode + let _rowCounts = []; // per-entry DOM row counts (1 for flat, 1+children for expanded groups) + let _cumulativeOffsetsCache = null; // cached cumulative offsets, invalidated on _rowCounts change + let _lastVisibleStart = -1; // last rendered start index (for dirty checking) + let _lastVisibleEnd = -1; // last rendered end index (for dirty checking) + let _vsScrollHandler = null; // scroll listener reference + let _wsRenderTimer = null; // debounce timer for WS-triggered renders + let _observerFilterSet = null; // cached Set from filters.observer, hoisted above loops (#427) + function closeDetailPanel() { var panel = document.getElementById('pktRight'); if (panel) { @@ -396,7 +409,9 @@ packets = filtered.concat(packets); } totalCount += filtered.length; - renderTableRows(); + // Debounce WS-triggered renders to avoid rapid full rebuilds + clearTimeout(_wsRenderTimer); + _wsRenderTimer = setTimeout(function () { renderTableRows(); }, 200); }); }); } @@ -404,6 +419,14 @@ function destroy() { if (wsHandler) offWS(wsHandler); wsHandler = null; + detachVScrollListener(); + clearTimeout(_wsRenderTimer); + _displayPackets = []; + _rowCounts = []; + _cumulativeOffsetsCache = null; + _observerFilterSet = null; + _lastVisibleStart = -1; + _lastVisibleEnd = -1; if (_docActionHandler) { document.removeEventListener('click', _docActionHandler); _docActionHandler = null; } if (_docMenuCloseHandler) { document.removeEventListener('click', _docMenuCloseHandler); _docMenuCloseHandler = null; } if (_docColMenuCloseHandler) { document.removeEventListener('click', _docColMenuCloseHandler); _docColMenuCloseHandler = null; } @@ -988,6 +1011,234 @@ makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths'); } + // Build HTML for a single grouped packet row + function buildGroupRowHtml(p) { + const isExpanded = expandedHashes.has(p.hash); + let headerObserverId = p.observer_id; + let headerPathJson = p.path_json; + if (_observerFilterSet && p._children?.length) { + const match = p._children.find(c => _observerFilterSet.has(String(c.observer_id))); + if (match) { + headerObserverId = match.observer_id; + headerPathJson = match.path_json; + } + } + const groupRegion = headerObserverId ? (observers.find(o => o.id === headerObserverId)?.iata || '') : ''; + let groupPath = []; + try { groupPath = JSON.parse(headerPathJson || '[]'); } catch {} + const groupPathStr = renderPath(groupPath, headerObserverId); + const groupTypeName = payloadTypeName(p.payload_type); + const groupTypeClass = payloadTypeColor(p.payload_type); + const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0; + const groupHashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1; + const isSingle = p.count <= 1; + let html = ` + ${isSingle ? '' : (isExpanded ? '▼' : '▶')} + ${groupRegion ? `${groupRegion}` : '—'} + ${renderTimestampCell(p.latest)} + ${truncate(p.hash || '—', 8)} + ${groupSize ? groupSize + 'B' : '—'} + ${groupHashBytes} + ${p.payload_type != null ? `${groupTypeName}${transportBadge(p.route_type)}` : '—'} + ${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')} + ${groupPathStr} + ${p.observation_count > 1 ? '👁 ' + p.observation_count + '' : (isSingle ? '' : p.count)} + ${getDetailPreview((() => { try { return JSON.parse(p.decoded_json || '{}'); } catch { return {}; } })())} + `; + if (isExpanded && p._children) { + let visibleChildren = p._children; + if (_observerFilterSet) { + visibleChildren = visibleChildren.filter(c => _observerFilterSet.has(String(c.observer_id))); + } + for (const c of visibleChildren) { + const typeName = payloadTypeName(c.payload_type); + const typeClass = payloadTypeColor(c.payload_type); + const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0; + const childHashBytes = ((parseInt(c.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1; + const childRegion = c.observer_id ? (observers.find(o => o.id === c.observer_id)?.iata || '') : ''; + let childPath = []; + try { childPath = JSON.parse(c.path_json || '[]'); } catch {} + const childPathStr = renderPath(childPath, c.observer_id); + html += ` + ${childRegion ? `${childRegion}` : '—'} + ${renderTimestampCell(c.timestamp)} + ${truncate(c.hash || '', 8)} + ${size}B + ${childHashBytes} + ${typeName}${transportBadge(c.route_type)} + ${truncate(obsName(c.observer_id), 16)} + ${childPathStr} + + ${getDetailPreview((() => { try { return JSON.parse(c.decoded_json || '{}'); } catch { return {}; } })())} + `; + } + } + return html; + } + + // Build HTML for a single flat (ungrouped) packet row + function buildFlatRowHtml(p) { + let decoded, pathHops = []; + try { decoded = JSON.parse(p.decoded_json || '{}'); } catch {} + try { pathHops = JSON.parse(p.path_json || '[]'); } catch {} + const region = p.observer_id ? (observers.find(o => o.id === p.observer_id)?.iata || '') : ''; + const typeName = payloadTypeName(p.payload_type); + const typeClass = payloadTypeColor(p.payload_type); + const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0; + const hashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1; + const pathStr = renderPath(pathHops, p.observer_id); + const detail = getDetailPreview(decoded); + return ` + ${region ? `${region}` : '—'} + ${renderTimestampCell(p.timestamp)} + ${truncate(p.hash || String(p.id), 8)} + ${size}B + ${hashBytes} + ${typeName}${transportBadge(p.route_type)} + ${truncate(obsName(p.observer_id), 16)} + ${pathStr} + + ${detail} + `; + } + + // Compute the number of DOM rows a single entry produces. + // Used by both row counting and renderVisibleRows to avoid divergence (#424). + function _getRowCount(p) { + if (!_displayGrouped) return 1; + if (!expandedHashes.has(p.hash) || !p._children) return 1; + let childCount = p._children.length; + if (_observerFilterSet) { + childCount = p._children.filter(c => _observerFilterSet.has(String(c.observer_id))).length; + } + return 1 + childCount; + } + + // Get the column count from the thead (dynamic, avoids hardcoded colspan — #426) + function _getColCount() { + const thead = document.querySelector('#pktLeft thead tr'); + return thead ? thead.children.length : 11; + } + + // Compute cumulative DOM row offsets from per-entry row counts. + // Returns array where cumulativeOffsets[i] = total rows before entry i. + function _cumulativeRowOffsets() { + if (_cumulativeOffsetsCache) return _cumulativeOffsetsCache; + const offsets = new Array(_rowCounts.length + 1); + offsets[0] = 0; + for (let i = 0; i < _rowCounts.length; i++) { + offsets[i + 1] = offsets[i] + _rowCounts[i]; + } + _cumulativeOffsetsCache = offsets; + return offsets; + return offsets; + } + + function renderVisibleRows() { + const tbody = document.getElementById('pktBody'); + if (!tbody || !_displayPackets.length) return; + + const scrollContainer = document.getElementById('pktLeft'); + if (!scrollContainer) return; + + // Compute total DOM rows accounting for expanded groups + const offsets = _cumulativeRowOffsets(); + const totalDomRows = offsets[offsets.length - 1]; + const totalHeight = totalDomRows * VSCROLL_ROW_HEIGHT; + const colCount = _getColCount(); + + // Get or create spacer elements + let topSpacer = document.getElementById('vscroll-top'); + let bottomSpacer = document.getElementById('vscroll-bottom'); + if (!topSpacer) { + topSpacer = document.createElement('tr'); + topSpacer.id = 'vscroll-top'; + topSpacer.innerHTML = ''; + } + if (!bottomSpacer) { + bottomSpacer = document.createElement('tr'); + bottomSpacer.id = 'vscroll-bottom'; + bottomSpacer.innerHTML = ''; + } + + // Calculate visible range based on scroll position + const scrollTop = scrollContainer.scrollTop; + const viewportHeight = scrollContainer.clientHeight; + // Account for thead height (~40px) + const theadHeight = 40; + const adjustedScrollTop = Math.max(0, scrollTop - theadHeight); + + // Find the first entry whose cumulative row offset covers the scroll position + const firstDomRow = Math.floor(adjustedScrollTop / VSCROLL_ROW_HEIGHT); + const visibleDomCount = Math.ceil(viewportHeight / VSCROLL_ROW_HEIGHT); + + // Binary search for entry index containing firstDomRow + let lo = 0, hi = _displayPackets.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (offsets[mid + 1] <= firstDomRow) lo = mid + 1; + else hi = mid; + } + const firstEntry = lo; + + // Find entry index covering last visible DOM row + const lastDomRow = firstDomRow + visibleDomCount; + lo = firstEntry; hi = _displayPackets.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (offsets[mid + 1] <= lastDomRow) lo = mid + 1; + else hi = mid; + } + const lastEntry = Math.min(lo + 1, _displayPackets.length); + + const startIdx = Math.max(0, firstEntry - VSCROLL_BUFFER); + const endIdx = Math.min(_displayPackets.length, lastEntry + VSCROLL_BUFFER); + + // Skip DOM rebuild if visible range hasn't changed + if (startIdx === _lastVisibleStart && endIdx === _lastVisibleEnd) return; + _lastVisibleStart = startIdx; + _lastVisibleEnd = endIdx; + + // Compute padding using cumulative row counts + const topPad = offsets[startIdx] * VSCROLL_ROW_HEIGHT; + const bottomPad = (totalDomRows - offsets[endIdx]) * VSCROLL_ROW_HEIGHT; + + topSpacer.firstChild.style.height = topPad + 'px'; + bottomSpacer.firstChild.style.height = bottomPad + 'px'; + + // LAZY ROW GENERATION: only build HTML for the visible slice (#422) + const builder = _displayGrouped ? buildGroupRowHtml : buildFlatRowHtml; + const visibleSlice = _displayPackets.slice(startIdx, endIdx); + const visibleHtml = visibleSlice.map(p => builder(p)).join(''); + tbody.innerHTML = ''; + tbody.appendChild(topSpacer); + tbody.insertAdjacentHTML('beforeend', visibleHtml); + tbody.appendChild(bottomSpacer); + } + + // Attach/detach scroll listener for virtual scrolling + function attachVScrollListener() { + const scrollContainer = document.getElementById('pktLeft'); + if (!scrollContainer) return; + if (_vsScrollHandler) return; // already attached + let scrollRaf = null; + _vsScrollHandler = function () { + if (scrollRaf) return; + scrollRaf = requestAnimationFrame(function () { + scrollRaf = null; + renderVisibleRows(); + }); + }; + scrollContainer.addEventListener('scroll', _vsScrollHandler, { passive: true }); + } + + function detachVScrollListener() { + if (!_vsScrollHandler) return; + const scrollContainer = document.getElementById('pktLeft'); + if (scrollContainer) scrollContainer.removeEventListener('scroll', _vsScrollHandler); + _vsScrollHandler = null; + } + async function renderTableRows() { const tbody = document.getElementById('pktBody'); if (!tbody) return; @@ -1040,108 +1291,31 @@ if (countEl) countEl.textContent = `(${displayPackets.length})`; if (!displayPackets.length) { - tbody.innerHTML = '' + (filters.myNodes ? 'No packets from your claimed/favorited nodes' : 'No packets found') + ''; + _displayPackets = []; + _rowCounts = []; + _cumulativeOffsetsCache = null; + _observerFilterSet = null; + _lastVisibleStart = -1; + _lastVisibleEnd = -1; + detachVScrollListener(); + const colCount = _getColCount(); + tbody.innerHTML = '' + (filters.myNodes ? 'No packets from your claimed/favorited nodes' : 'No packets found') + ''; return; } - if (groupByHash) { - let html = ''; - for (const p of displayPackets) { - const isExpanded = expandedHashes.has(p.hash); - // When observer filter is active, use first matching child's data for header - let headerObserverId = p.observer_id; - let headerPathJson = p.path_json; - if (filters.observer && p._children?.length) { - const obsIds = new Set(filters.observer.split(',')); - const match = p._children.find(c => obsIds.has(String(c.observer_id))); - if (match) { - headerObserverId = match.observer_id; - headerPathJson = match.path_json; - } - } - const groupRegion = headerObserverId ? (observers.find(o => o.id === headerObserverId)?.iata || '') : ''; - let groupPath = []; - try { groupPath = JSON.parse(headerPathJson || '[]'); } catch {} - const groupPathStr = renderPath(groupPath, headerObserverId); - const groupTypeName = payloadTypeName(p.payload_type); - const groupTypeClass = payloadTypeColor(p.payload_type); - const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0; - const groupHashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1; - const isSingle = p.count <= 1; - html += ` - ${isSingle ? '' : (isExpanded ? '▼' : '▶')} - ${groupRegion ? `${groupRegion}` : '—'} - ${renderTimestampCell(p.latest)} - ${truncate(p.hash || '—', 8)} - ${groupSize ? groupSize + 'B' : '—'} - ${groupHashBytes} - ${p.payload_type != null ? `${groupTypeName}${transportBadge(p.route_type)}` : '—'} - ${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')} - ${groupPathStr} - ${p.observation_count > 1 ? '👁 ' + p.observation_count + '' : (isSingle ? '' : p.count)} - ${getDetailPreview((() => { try { return JSON.parse(p.decoded_json || '{}'); } catch { return {}; } })())} - `; - // Child rows (loaded async when expanded) - if (isExpanded && p._children) { - let visibleChildren = p._children; - // Filter children by selected observers - if (filters.observer) { - const obsSet = new Set(filters.observer.split(',')); - visibleChildren = visibleChildren.filter(c => obsSet.has(String(c.observer_id))); - } - for (const c of visibleChildren) { - const typeName = payloadTypeName(c.payload_type); - const typeClass = payloadTypeColor(c.payload_type); - const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0; - const childHashBytes = ((parseInt(c.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1; - const childRegion = c.observer_id ? (observers.find(o => o.id === c.observer_id)?.iata || '') : ''; - let childPath = []; - try { childPath = JSON.parse(c.path_json || '[]'); } catch {} - const childPathStr = renderPath(childPath, c.observer_id); - html += ` - ${childRegion ? `${childRegion}` : '—'} - ${renderTimestampCell(c.timestamp)} - ${truncate(c.hash || '', 8)} - ${size}B - ${childHashBytes} - ${typeName}${transportBadge(c.route_type)} - ${truncate(obsName(c.observer_id), 16)} - ${childPathStr} - - ${getDetailPreview((() => { try { return JSON.parse(c.decoded_json); } catch { return {}; } })())} - `; - } - } - } - tbody.innerHTML = html; - return; - } + // Lazy virtual scroll: store display packets and row counts, but do NOT + // pre-generate HTML strings. HTML is built on-demand in renderVisibleRows() + // for only the visible slice + buffer (#422). + _lastVisibleStart = -1; + _lastVisibleEnd = -1; + _displayPackets = displayPackets; + _displayGrouped = groupByHash; + _observerFilterSet = filters.observer ? new Set(filters.observer.split(',')) : null; + _rowCounts = displayPackets.map(p => _getRowCount(p)); + _cumulativeOffsetsCache = null; - tbody.innerHTML = displayPackets.map(p => { - let decoded, pathHops = []; - try { decoded = JSON.parse(p.decoded_json); } catch {} - try { pathHops = JSON.parse(p.path_json || '[]'); } catch {} - - const region = p.observer_id ? (observers.find(o => o.id === p.observer_id)?.iata || '') : ''; - const typeName = payloadTypeName(p.payload_type); - const typeClass = payloadTypeColor(p.payload_type); - const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0; - const hashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1; - const pathStr = renderPath(pathHops, p.observer_id); const detail = getDetailPreview(decoded); - - return ` - ${region ? `${region}` : '—'} - ${renderTimestampCell(p.timestamp)} - ${truncate(p.hash || String(p.id), 8)} - ${size}B - ${hashBytes} - ${typeName}${transportBadge(p.route_type)} - ${truncate(obsName(p.observer_id), 16)} - ${pathStr} - - ${detail} - `; - }).join(''); + attachVScrollListener(); + renderVisibleRows(); } function getDetailPreview(decoded) { diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 3450fd78..b72c16fb 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -2540,11 +2540,146 @@ console.log('\n=== packets.js: savedTimeWindowMin defaults ==='); test('handles null/empty decoded_json gracefully', () => { const result = filterMyNodes(testPackets, ['abc123']); - // Should not throw, null decoded_json packets are skipped assert.strictEqual(result.length, 2); }); } +// ===== Packets page: virtual scroll infrastructure ===== +{ + console.log('\nPackets page — virtual scroll:'); + const packetsSource = fs.readFileSync('public/packets.js', 'utf8'); + + // --- Behavioral tests using extracted logic --- + + // Extract _cumulativeRowOffsets logic for testing + function cumulativeRowOffsets(rowCounts) { + const offsets = new Array(rowCounts.length + 1); + offsets[0] = 0; + for (let i = 0; i < rowCounts.length; i++) { + offsets[i + 1] = offsets[i] + rowCounts[i]; + } + return offsets; + } + + // Extract _getRowCount logic for testing (#424 — single source of truth) + function getRowCount(p, grouped, expandedHashes, observerFilterSet) { + if (!grouped) return 1; + if (!expandedHashes.has(p.hash) || !p._children) return 1; + let childCount = p._children.length; + if (observerFilterSet) { + childCount = p._children.filter(c => observerFilterSet.has(String(c.observer_id))).length; + } + return 1 + childCount; + } + + test('cumulativeRowOffsets computes correct offsets for flat rows', () => { + const counts = [1, 1, 1, 1, 1]; + const offsets = cumulativeRowOffsets(counts); + assert.deepStrictEqual(offsets, [0, 1, 2, 3, 4, 5]); + }); + + test('cumulativeRowOffsets handles expanded groups with multiple rows', () => { + const counts = [1, 4, 1]; + const offsets = cumulativeRowOffsets(counts); + assert.deepStrictEqual(offsets, [0, 1, 5, 6]); + assert.strictEqual(offsets[offsets.length - 1], 6); + }); + + test('total scroll height accounts for expanded group rows', () => { + const VSCROLL_ROW_HEIGHT = 36; + const counts = [1, 4, 1, 4, 1]; + const offsets = cumulativeRowOffsets(counts); + const totalDomRows = offsets[offsets.length - 1]; + assert.strictEqual(totalDomRows, 11); + assert.strictEqual(totalDomRows * VSCROLL_ROW_HEIGHT, 396); + }); + + test('scroll height with all collapsed equals entries * row height', () => { + const VSCROLL_ROW_HEIGHT = 36; + const counts = [1, 1, 1, 1, 1]; + const offsets = cumulativeRowOffsets(counts); + const totalDomRows = offsets[offsets.length - 1]; + assert.strictEqual(totalDomRows * VSCROLL_ROW_HEIGHT, 5 * VSCROLL_ROW_HEIGHT); + }); + + // --- Behavioral tests for _getRowCount (#424, #428 — test logic, not source strings) --- + + test('getRowCount returns 1 for flat (ungrouped) mode', () => { + const p = { hash: 'abc', _children: [{observer_id: '1'}, {observer_id: '2'}] }; + assert.strictEqual(getRowCount(p, false, new Set(), null), 1); + }); + + test('getRowCount returns 1 for collapsed group', () => { + const p = { hash: 'abc', _children: [{observer_id: '1'}, {observer_id: '2'}] }; + assert.strictEqual(getRowCount(p, true, new Set(), null), 1); + }); + + test('getRowCount returns 1+children for expanded group', () => { + const p = { hash: 'abc', _children: [{observer_id: '1'}, {observer_id: '2'}, {observer_id: '3'}] }; + const expanded = new Set(['abc']); + assert.strictEqual(getRowCount(p, true, expanded, null), 4); + }); + + test('getRowCount filters children by observer set', () => { + const p = { hash: 'abc', _children: [{observer_id: '1'}, {observer_id: '2'}, {observer_id: '3'}] }; + const expanded = new Set(['abc']); + const obsFilter = new Set(['1', '3']); + assert.strictEqual(getRowCount(p, true, expanded, obsFilter), 3); + }); + + test('getRowCount returns 1 for expanded group with no _children', () => { + const p = { hash: 'abc' }; + const expanded = new Set(['abc']); + assert.strictEqual(getRowCount(p, true, expanded, null), 1); + }); + + test('renderVisibleRows uses cumulative offsets not flat entry count', () => { + assert.ok(packetsSource.includes('_cumulativeRowOffsets'), + 'renderVisibleRows should use cumulative row offsets'); + assert.ok(!packetsSource.includes('const totalRows = _displayPackets.length'), + 'should NOT use flat array length for total row count'); + }); + + test('renderVisibleRows skips DOM rebuild when range unchanged', () => { + assert.ok(packetsSource.includes('startIdx === _lastVisibleStart && endIdx === _lastVisibleEnd'), + 'should skip rebuild when range is unchanged'); + }); + + test('lazy row generation — HTML built only for visible slice', () => { + assert.ok(!packetsSource.includes('_lastRenderedRows'), + 'should NOT have pre-built row HTML cache'); + assert.ok(packetsSource.includes('_displayPackets.slice(startIdx, endIdx)'), + 'should slice display packets for visible range'); + assert.ok(packetsSource.includes('visibleSlice.map(p => builder(p))'), + 'should build HTML lazily per visible packet'); + }); + + test('observer filter Set is hoisted, not recreated per-packet', () => { + assert.ok(packetsSource.includes('_observerFilterSet = filters.observer ? new Set(filters.observer.split'), + 'observer filter Set should be created once in renderTableRows'); + assert.ok(packetsSource.includes('_observerFilterSet.has(String(c.observer_id))'), + 'buildGroupRowHtml should use hoisted _observerFilterSet'); + }); + + test('buildFlatRowHtml has null-safe decoded_json', () => { + const flatBuilderMatch = packetsSource.match(/function buildFlatRowHtml[\s\S]*?(?=\n function )/); + assert.ok(flatBuilderMatch, 'buildFlatRowHtml should exist'); + assert.ok(flatBuilderMatch[0].includes("p.decoded_json || '{}'"), + 'buildFlatRowHtml should have null-safe decoded_json fallback'); + }); + + test('destroy cleans up virtual scroll state', () => { + assert.ok(packetsSource.includes('detachVScrollListener'), + 'destroy should detach virtual scroll listener'); + assert.ok(packetsSource.includes("_displayPackets = []"), + 'destroy should reset display packets'); + assert.ok(packetsSource.includes("_rowCounts = []"), + 'destroy should reset row counts'); + assert.ok(packetsSource.includes("_lastVisibleStart = -1"), + 'destroy should reset visible start'); + }); +} + // ===== SUMMARY ===== Promise.allSettled(pendingTests).then(() => { console.log(`\n${'═'.repeat(40)}`);