feat: render scalable Alta site and group hierarchy
This commit is contained in:
@@ -13,11 +13,11 @@ function baseFixture() {
|
||||
return {
|
||||
sites: [
|
||||
{ id: 's2', name: 'Zulu' },
|
||||
{ id: 's1', name: 'Alpha', pendingDeletion: true },
|
||||
{ id: 's1', name: 'Alpha', pendingDeletionStart: '2026-08-19T12:00:00Z' },
|
||||
],
|
||||
groups: [
|
||||
{ id: 'g2', name: 'Doors', parentId: 's2' },
|
||||
{ id: 'g1', name: 'Lobby', parentId: 's1', pendingDeletion: true },
|
||||
{ id: 'g1', name: 'Lobby', parentId: 's1', pendingDeletionStart: '2026-08-19T12:00:00Z' },
|
||||
],
|
||||
devices: [
|
||||
{ id: 'c4', name: 'No Group', siteId: 's1', type: 'camera' },
|
||||
@@ -36,6 +36,7 @@ test('builds deterministic hierarchy with explicit exceptional buckets and diagn
|
||||
assert.deepEqual(model.sites.map((site) => site.name), ['Alpha', 'Orphaned cameras', 'Unknown site: missing-s', 'Zulu']);
|
||||
assert.equal(model.sites[0].pendingDeletion, true);
|
||||
assert.deepEqual(model.sites[0].groups.map((group) => group.name), ['Doors', 'Lobby', 'Ungrouped', 'Unknown group: missing-g']);
|
||||
assert.equal(model.sites[0].groups.find((group) => group.id === 'g1').pendingDeletion, true);
|
||||
assert.deepEqual(model.sites[3].groups[0].cameras.map((camera) => camera.name), ['Inferred']);
|
||||
|
||||
const conflict = model.cameraByKey.get('camera:c3');
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { describeVirtualRows } = require('../sidebar-view');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const read = (name) => fs.readFileSync(path.join(ROOT, name), 'utf8');
|
||||
|
||||
function syntheticSnapshot(count) {
|
||||
const rows = Array.from({ length: count }, (_, index) => ({
|
||||
key: `camera:00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
|
||||
id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
|
||||
kind: 'camera', name: `Camera ${index}`, ariaLevel: 3, ariaSelected: index === 4,
|
||||
ariaPosInSet: index + 1, ariaSetSize: count,
|
||||
}));
|
||||
const startIndex = Math.max(0, Math.floor(count / 2) - 20);
|
||||
return {
|
||||
state: { activeKey: rows[startIndex].key, selectedKey: rows[4].key },
|
||||
rows: Object.assign(rows, { hiddenSelected: true }),
|
||||
window: { rows: rows.slice(startIndex, startIndex + 60), startIndex, offsetTop: startIndex * 30, totalHeight: count * 30 },
|
||||
};
|
||||
}
|
||||
|
||||
test('renderer loads hierarchy foundations in order and exposes an accessible virtual tree', () => {
|
||||
const html = read('index.html');
|
||||
const treeIndex = html.indexOf('device-tree.js');
|
||||
const controllerIndex = html.indexOf('sidebar-controller.js');
|
||||
const viewIndex = html.indexOf('sidebar-view.js');
|
||||
const rendererIndex = html.indexOf('renderer.js');
|
||||
assert.ok(treeIndex >= 0 && treeIndex < controllerIndex && controllerIndex < viewIndex && viewIndex < rendererIndex);
|
||||
assert.match(html, /id="deviceList"[^>]*role="tree"[^>]*tabindex="0"[^>]*aria-activedescendant/);
|
||||
assert.match(html, /id="deviceResults"[^>]*role="status"[^>]*aria-live="polite"/);
|
||||
});
|
||||
|
||||
test('renderer uses one hierarchy request and no parallel flat discovery', () => {
|
||||
const renderer = read('renderer.js');
|
||||
assert.match(renderer, /electronAPI\.getDeviceHierarchy\(\)/);
|
||||
assert.doesNotMatch(renderer, /electronAPI\.getDevices\(|electronAPI\.getDeviceSites\(|Promise\.all\(\s*\[\s*window\.electronAPI\.get/);
|
||||
assert.doesNotMatch(renderer, /\ballDevices\b|\ballSites\b|\bcollapsedSites\b|groupDevicesBySite/);
|
||||
assert.match(renderer, /loadGeneration/);
|
||||
assert.match(renderer, /generation\s*!==\s*loadGeneration/);
|
||||
});
|
||||
|
||||
test('renderer uses delegated tree events, throttled scroll, and debounced search', () => {
|
||||
const renderer = read('renderer.js');
|
||||
assert.equal((renderer.match(/deviceList\.addEventListener\('click'/g) || []).length, 1);
|
||||
assert.equal((renderer.match(/deviceList\.addEventListener\('keydown'/g) || []).length, 1);
|
||||
assert.equal((renderer.match(/deviceList\.addEventListener\('scroll'/g) || []).length, 1);
|
||||
assert.match(renderer, /requestAnimationFrame/);
|
||||
assert.match(renderer, /setTimeout\([^,]+,\s*125\)/s);
|
||||
});
|
||||
|
||||
test('virtual row descriptors stay bounded for 1,500 and 5,000 visible rows', () => {
|
||||
for (const count of [1_500, 5_000]) {
|
||||
const descriptors = describeVirtualRows(syntheticSnapshot(count), { activeDeviceIds: new Set() });
|
||||
assert.ok(descriptors.length <= 80, `${count} rows mounted ${descriptors.length} descriptors`);
|
||||
assert.equal(descriptors[0].role, 'treeitem');
|
||||
assert.equal(descriptors[0].aria.level, 3);
|
||||
assert.match(descriptors[0].domId, /^apt-tree-row-/);
|
||||
}
|
||||
});
|
||||
|
||||
test('view descriptors preserve selection and refresh proxy/status classes without rebuilding rows', () => {
|
||||
const snapshot = syntheticSnapshot(1_500);
|
||||
const selectedId = snapshot.rows[4].id;
|
||||
const before = describeVirtualRows(snapshot, { activeDeviceIds: new Set() });
|
||||
const camera = before.find((row) => row.kind === 'camera');
|
||||
const after = describeVirtualRows(snapshot, { activeDeviceIds: new Set([camera.deviceId]), isOnline: () => true });
|
||||
assert.ok(after.find((row) => row.key === camera.key).classes.includes('proxy-active'));
|
||||
assert.ok(after.find((row) => row.key === camera.key).classes.includes('online'));
|
||||
assert.equal(snapshot.rows[4].id, selectedId);
|
||||
assert.equal(snapshot.rows.hiddenSelected, true);
|
||||
});
|
||||
@@ -81,6 +81,11 @@ test('controller commits exactly once per dispatch through view adapter', () =>
|
||||
assert.equal(commits.length, 2);
|
||||
assert.equal(commits[1].state.expandedKeys.has('site:s'), true);
|
||||
assert.ok(Array.isArray(commits[1].window.rows));
|
||||
const modelReference = controller.getSnapshot().rows;
|
||||
controller.refresh();
|
||||
assert.equal(commits.length, 3);
|
||||
assert.notEqual(controller.getSnapshot().rows, modelReference);
|
||||
assert.equal(controller.getSnapshot().rows.length, modelReference.length);
|
||||
});
|
||||
|
||||
test('active and selected state survive virtualization and selection is indicated when hidden', () => {
|
||||
|
||||
Reference in New Issue
Block a user