Files
meshcore-analyzer/test-table-sort.js
Kpa-clawbot 6f3e3535c9 feat: shared table sort utility + packets table sorting (M1, #620) (#638)
## Summary

Implements M1 of the table sorting spec (#620): a shared `TableSort`
utility module and integration with the packets table.

### What's included

**1. `public/table-sort.js` — Shared sort utility (IIFE, no
dependencies)**
- `TableSort.init(tableEl, options)` — attaches click-to-sort on `<th
data-sort-key="...">` elements
- Built-in comparators: text (localeCompare), numeric, date (ISO), dBm
(strips suffix)
- NaN/null values sort last consistently
- Visual: ▲/▼ `<span class="sort-arrow">` appended to active column
header
- Accessibility: `aria-sort="ascending|descending|none"`, keyboard
support (Enter/Space)
- DOM reorder via `appendChild` loop (no innerHTML rebuild)
- `domReorder: false` option for virtual scroll tables (packets)
- `storageKey` option for localStorage persistence
- Custom comparator override per column
- `onSort(column, direction)` callback
- `destroy()` for clean teardown

**2. Packets table integration**
- All columns sortable: region, time, hash, size, HB, type, observer,
path, rpt
- Default sort: time descending (matches existing behavior)
- Uses `domReorder: false` + `onSort` callback to sort the data array,
then re-render via virtual scroll
- Works with both grouped and ungrouped views
- WebSocket updates respect active sort column
- Sort preference persisted in localStorage (`meshcore-packets-sort`)

**3. Tests — 22 unit tests (`test-table-sort.js`)**
- All 4 built-in comparators (text, numeric, date, dBm)
- NaN/null edge cases
- Direction toggle on click
- aria-sort attribute correctness
- Visual indicator (▲/▼) presence and updates
- onSort callback
- domReorder: false behavior
- destroy() cleanup
- Custom comparator override

### Performance

Packets table sorting works at the data array level (single `Array.sort`
call), not DOM level. Virtual scroll then renders only visible rows. No
new DOM nodes are created during sort — it's purely a data reorder +
re-render of the existing visible window. Expected sort time for 30K
packets: ~50-100ms (array sort) + existing virtual scroll render time.

Closes #620 (M1)

Co-authored-by: you <you@example.com>
2026-04-05 15:29:14 -07:00

357 lines
12 KiB
JavaScript

/* test-table-sort.js — Unit tests for TableSort utility */
'use strict';
const { JSDOM } = require('jsdom');
const fs = require('fs');
const path = require('path');
const assert = require('assert');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
passed++;
console.log(`${name}`);
} catch (e) {
failed++;
console.log(`${name}`);
console.log(` ${e.message}`);
}
}
function createDOM(html) {
const dom = new JSDOM(`<!DOCTYPE html><html><body>${html}</body></html>`, {
url: 'http://localhost',
runScripts: 'dangerously'
});
// Load TableSort into this DOM
const script = fs.readFileSync(path.join(__dirname, 'public', 'table-sort.js'), 'utf8');
const el = dom.window.document.createElement('script');
el.textContent = script;
dom.window.document.head.appendChild(el);
return dom;
}
function makeTable(headers, rows) {
// headers: [{key, type?, label}], rows: [[value, ...]]
let html = '<table id="t"><thead><tr>';
for (const h of headers) {
html += `<th data-sort-key="${h.key}"${h.type ? ` data-type="${h.type}"` : ''}>${h.label || h.key}</th>`;
}
html += '</tr></thead><tbody>';
for (const row of rows) {
html += '<tr>';
for (let i = 0; i < row.length; i++) {
const val = row[i];
if (typeof val === 'object' && val !== null) {
html += `<td data-value="${val.dataValue}">${val.text || ''}</td>`;
} else {
html += `<td data-value="${val}">${val}</td>`;
}
}
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
function getColumnValues(dom, colIndex) {
const rows = dom.window.document.querySelectorAll('tbody tr');
return Array.from(rows).map(r => r.cells[colIndex].getAttribute('data-value'));
}
console.log('\nTableSort — comparators');
test('text comparator: basic alphabetical', () => {
const cmp = (() => {
const dom = createDOM('<div></div>');
return dom.window.TableSort.comparators.text;
})();
assert.ok(cmp('apple', 'banana') < 0);
assert.ok(cmp('banana', 'apple') > 0);
assert.strictEqual(cmp('same', 'same'), 0);
});
test('text comparator: null/undefined handling', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.text;
assert.strictEqual(cmp(null, null), 0);
assert.strictEqual(cmp(undefined, undefined), 0);
});
test('numeric comparator: basic numbers', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.numeric;
assert.ok(cmp('1', '2') < 0);
assert.ok(cmp('10', '2') > 0);
assert.strictEqual(cmp('5', '5'), 0);
});
test('numeric comparator: NaN sorts last', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.numeric;
assert.ok(cmp('abc', '5') > 0); // NaN > number (sorts last)
assert.ok(cmp('5', 'abc') < 0);
assert.strictEqual(cmp('abc', 'xyz'), 0); // both NaN
});
test('numeric comparator: negative numbers', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.numeric;
assert.ok(cmp('-10', '-5') < 0);
assert.ok(cmp('-5', '-10') > 0);
});
test('date comparator: ISO dates', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.date;
assert.ok(cmp('2024-01-01T00:00:00Z', '2024-06-01T00:00:00Z') < 0);
assert.ok(cmp('2024-06-01T00:00:00Z', '2024-01-01T00:00:00Z') > 0);
assert.strictEqual(cmp('2024-01-01', '2024-01-01'), 0);
});
test('date comparator: invalid dates sort last', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.date;
assert.ok(cmp('invalid', '2024-01-01') > 0);
assert.ok(cmp('2024-01-01', 'invalid') < 0);
});
test('dBm comparator: strips suffix', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.dbm;
assert.ok(cmp('-120 dBm', '-80 dBm') < 0);
assert.ok(cmp('-80 dBm', '-120 dBm') > 0);
assert.strictEqual(cmp('-95 dBm', '-95 dBm'), 0);
});
test('dBm comparator: works without suffix', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.dbm;
assert.ok(cmp('-120', '-80') < 0);
});
console.log('\nTableSort — DOM sorting');
test('sort ascending by text column', () => {
const html = makeTable(
[{key: 'name'}],
[['Charlie'], ['Alice'], ['Bob']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
const inst = dom.window.TableSort.init(table, { defaultColumn: 'name', defaultDirection: 'asc' });
const vals = getColumnValues(dom, 0);
assert.deepStrictEqual(vals, ['Alice', 'Bob', 'Charlie']);
});
test('sort descending by numeric column', () => {
const html = makeTable(
[{key: 'val', type: 'numeric'}],
[['3'], ['1'], ['2']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'val', defaultDirection: 'desc' });
const vals = getColumnValues(dom, 0);
assert.deepStrictEqual(vals, ['3', '2', '1']);
});
test('click toggles direction', () => {
const html = makeTable(
[{key: 'name'}],
[['B'], ['A'], ['C']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
const inst = dom.window.TableSort.init(table, { defaultColumn: 'name', defaultDirection: 'asc' });
// Initially ascending
assert.deepStrictEqual(getColumnValues(dom, 0), ['A', 'B', 'C']);
// Click same header → descending
const th = dom.window.document.querySelector('th[data-sort-key="name"]');
th.click();
assert.deepStrictEqual(getColumnValues(dom, 0), ['C', 'B', 'A']);
// Click again → ascending
th.click();
assert.deepStrictEqual(getColumnValues(dom, 0), ['A', 'B', 'C']);
});
console.log('\nTableSort — aria-sort attributes');
test('aria-sort set correctly on active column', () => {
const html = makeTable(
[{key: 'a'}, {key: 'b'}],
[['1', 'x'], ['2', 'y']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
const thA = dom.window.document.querySelector('th[data-sort-key="a"]');
const thB = dom.window.document.querySelector('th[data-sort-key="b"]');
assert.strictEqual(thA.getAttribute('aria-sort'), 'ascending');
assert.strictEqual(thB.getAttribute('aria-sort'), 'none');
});
test('aria-sort updates on direction change', () => {
const html = makeTable(
[{key: 'a'}],
[['1'], ['2']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
const th = dom.window.document.querySelector('th[data-sort-key="a"]');
assert.strictEqual(th.getAttribute('aria-sort'), 'ascending');
th.click(); // toggle to desc
assert.strictEqual(th.getAttribute('aria-sort'), 'descending');
});
test('aria-sort updates when switching columns', () => {
const html = makeTable(
[{key: 'a'}, {key: 'b'}],
[['1', 'x'], ['2', 'y']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
const thB = dom.window.document.querySelector('th[data-sort-key="b"]');
thB.click(); // switch to column b
const thA = dom.window.document.querySelector('th[data-sort-key="a"]');
assert.strictEqual(thA.getAttribute('aria-sort'), 'none');
assert.strictEqual(thB.getAttribute('aria-sort'), 'ascending');
});
console.log('\nTableSort — visual indicator');
test('sort arrow shows on active column', () => {
const html = makeTable(
[{key: 'a'}],
[['1'], ['2']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
const arrow = dom.window.document.querySelector('.sort-arrow');
assert.ok(arrow, 'sort arrow should exist');
assert.ok(arrow.textContent.includes('▲'), 'ascending should show ▲');
});
test('sort arrow changes on direction toggle', () => {
const html = makeTable(
[{key: 'a'}],
[['1'], ['2']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
const th = dom.window.document.querySelector('th[data-sort-key="a"]');
th.click(); // desc
const arrow = dom.window.document.querySelector('.sort-arrow');
assert.ok(arrow.textContent.includes('▼'), 'descending should show ▼');
});
console.log('\nTableSort — onSort callback');
test('onSort fires with column and direction', () => {
const html = makeTable(
[{key: 'a'}, {key: 'b'}],
[['1', 'x'], ['2', 'y']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
let called = null;
dom.window.TableSort.init(table, {
domReorder: false,
onSort: function(col, dir) { called = { col, dir }; }
});
const th = dom.window.document.querySelector('th[data-sort-key="a"]');
th.click();
assert.ok(called, 'onSort should fire');
assert.strictEqual(called.col, 'a');
assert.strictEqual(called.dir, 'asc');
});
console.log('\nTableSort — domReorder: false');
test('domReorder: false skips DOM sorting', () => {
const html = makeTable(
[{key: 'name'}],
[['C'], ['A'], ['B']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'name', defaultDirection: 'asc', domReorder: false });
// DOM order should NOT change
const vals = getColumnValues(dom, 0);
assert.deepStrictEqual(vals, ['C', 'A', 'B']);
});
console.log('\nTableSort — destroy');
test('destroy removes event handlers and cleans up', () => {
const html = makeTable(
[{key: 'a'}],
[['2'], ['1']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
const inst = dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
inst.destroy();
const th = dom.window.document.querySelector('th[data-sort-key="a"]');
assert.strictEqual(th.getAttribute('aria-sort'), null, 'aria-sort should be removed');
assert.ok(!th.classList.contains('sort-active'), 'sort-active should be removed');
assert.strictEqual(th.querySelector('.sort-arrow'), null, 'arrow should be removed');
});
console.log('\nTableSort — custom comparators');
test('custom comparator overrides built-in', () => {
const html = makeTable(
[{key: 'val', type: 'numeric'}],
[['3'], ['1'], ['2']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
// Custom: reverse numeric
dom.window.TableSort.init(table, {
defaultColumn: 'val', defaultDirection: 'asc',
comparators: { val: function(a, b) { return Number(b) - Number(a); } }
});
const vals = getColumnValues(dom, 0);
assert.deepStrictEqual(vals, ['3', '2', '1']); // reversed
});
console.log('\nTableSort — date sort with data-type="date"');
test('date column sorts correctly', () => {
const html = makeTable(
[{key: 'ts', type: 'date'}],
[['2024-06-15T10:00:00Z'], ['2024-01-01T00:00:00Z'], ['2024-12-25T23:59:59Z']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'ts', defaultDirection: 'asc' });
const vals = getColumnValues(dom, 0);
assert.deepStrictEqual(vals, ['2024-01-01T00:00:00Z', '2024-06-15T10:00:00Z', '2024-12-25T23:59:59Z']);
});
// Summary
console.log(`\n${passed + failed} tests, ${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);