feat: render scalable Alta site and group hierarchy

This commit is contained in:
2026-08-20 01:15:01 +00:00
parent c65a813e5f
commit 8d256cd4ab
12 changed files with 614 additions and 164 deletions
+3 -2
View File
@@ -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');
+76
View File
@@ -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);
});
+5
View File
@@ -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', () => {