feat: add scalable device hierarchy model
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
buildDeviceTree,
|
||||
flattenVisibleRows,
|
||||
createSearchRunner,
|
||||
calculateVirtualWindow,
|
||||
} = require('../device-tree');
|
||||
|
||||
function baseFixture() {
|
||||
return {
|
||||
sites: [
|
||||
{ id: 's2', name: 'Zulu' },
|
||||
{ id: 's1', name: 'Alpha', pendingDeletion: true },
|
||||
],
|
||||
groups: [
|
||||
{ id: 'g2', name: 'Doors', parentId: 's2' },
|
||||
{ id: 'g1', name: 'Lobby', parentId: 's1', pendingDeletion: true },
|
||||
],
|
||||
devices: [
|
||||
{ id: 'c4', name: 'No Group', siteId: 's1', type: 'camera' },
|
||||
{ id: 'c2', name: 'Inferred', deviceGroupId: 'g2', model: 'H5A' },
|
||||
{ id: 'c1', name: 'Front', siteId: 's1', deviceGroupId: 'g1', address: '10.0.0.1' },
|
||||
{ id: 'c3', name: 'Conflict', siteId: 's1', deviceGroupId: 'g2' },
|
||||
{ id: 'c5', name: 'Unknown Group', siteId: 's1', deviceGroupId: 'missing-g' },
|
||||
{ id: 'c6', name: 'Unknown Site', siteId: 'missing-s' },
|
||||
{ id: 'c7', name: 'Orphan' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test('builds deterministic hierarchy with explicit exceptional buckets and diagnostics', () => {
|
||||
const model = buildDeviceTree(baseFixture());
|
||||
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.deepEqual(model.sites[3].groups[0].cameras.map((camera) => camera.name), ['Inferred']);
|
||||
|
||||
const conflict = model.cameraByKey.get('camera:c3');
|
||||
assert.equal(conflict.site.id, 's1');
|
||||
assert.equal(conflict.group.name, 'Doors');
|
||||
assert.equal(conflict.hierarchyStatus, 'conflict');
|
||||
assert.equal(model.cameraByKey.get('camera:c2').hierarchyStatus, 'inferred-site');
|
||||
assert.ok(model.diagnostics.some((entry) => entry.code === 'site-group-conflict' && entry.key === 'camera:c3'));
|
||||
});
|
||||
|
||||
test('duplicate and malformed IDs get stable unique keys and diagnostics', () => {
|
||||
const fixture = {
|
||||
sites: [{ id: 's', name: 'A' }, { id: 's', name: 'B' }, { name: 'Bad' }],
|
||||
groups: [{ id: 'g', name: 'G', parentId: 's' }, { id: 'g', name: 'G2', parentId: 's' }],
|
||||
devices: [
|
||||
{ id: 'c', name: 'Same', siteId: 's', deviceGroupId: 'g' },
|
||||
{ id: 'c', name: 'Same', siteId: 's', deviceGroupId: 'g' },
|
||||
{ name: 'Missing', siteId: 's' },
|
||||
],
|
||||
};
|
||||
const first = buildDeviceTree(fixture);
|
||||
const second = buildDeviceTree(fixture);
|
||||
assert.deepEqual([...first.rowByKey.keys()], [...second.rowByKey.keys()]);
|
||||
assert.equal(new Set(first.rowByKey.keys()).size, first.rowByKey.size);
|
||||
assert.ok(first.diagnostics.filter((entry) => entry.code === 'duplicate-id').length >= 3);
|
||||
assert.ok(first.diagnostics.filter((entry) => entry.code === 'malformed-id').length >= 2);
|
||||
});
|
||||
|
||||
test('sites start collapsed; expansion produces levels and complete ARIA metadata', () => {
|
||||
const model = buildDeviceTree(baseFixture());
|
||||
let rows = flattenVisibleRows(model, { expandedKeys: new Set(), selectedKey: 'camera:c1' });
|
||||
assert.equal(rows.length, 4);
|
||||
assert.ok(rows.every((row) => row.kind === 'site' && row.aria.level === 1 && row.aria.expanded === false));
|
||||
assert.ok(rows.every((row) => row.aria.setsize === 4));
|
||||
assert.equal(rows.hiddenSelected, true);
|
||||
|
||||
rows = flattenVisibleRows(model, { expandedKeys: new Set(['site:s1']) });
|
||||
assert.deepEqual(rows.slice(0, 5).map((row) => row.kind), ['site', 'group', 'group', 'group', 'group']);
|
||||
assert.ok(rows.slice(1, 5).every((row) => row.aria.level === 2 && row.aria.expanded === false));
|
||||
|
||||
rows = flattenVisibleRows(model, {
|
||||
expandedKeys: new Set(['site:s1', 'group:s1:g1']),
|
||||
selectedKey: 'camera:c1',
|
||||
});
|
||||
const camera = rows.find((row) => row.key === 'camera:c1');
|
||||
assert.equal(camera.parentKey, 'group:s1:g1');
|
||||
assert.deepEqual(camera.aria, { level: 3, expanded: undefined, selected: true, posinset: 1, setsize: 1 });
|
||||
assert.equal(rows.hiddenSelected, false);
|
||||
});
|
||||
|
||||
test('search covers camera fields and ancestors without mutating saved expansion', async () => {
|
||||
const model = buildDeviceTree(baseFixture());
|
||||
const scheduled = [];
|
||||
const runner = createSearchRunner({ scheduler: (work) => scheduled.push(work), chunkSize: 2 });
|
||||
const saved = new Set(['site:s2']);
|
||||
const promise = runner.search(model, 'alpha lobby 10.0.0.1', { expandedKeys: saved });
|
||||
while (scheduled.length) scheduled.shift()();
|
||||
const result = await promise;
|
||||
assert.equal(result.count, 1);
|
||||
assert.deepEqual([...result.cameraKeys], ['camera:c1']);
|
||||
assert.ok(result.expandedKeys.has('site:s1'));
|
||||
assert.ok(result.expandedKeys.has('group:s1:g1'));
|
||||
assert.deepEqual([...saved], ['site:s2']);
|
||||
|
||||
const ancestorPromise = runner.search(model, 'zulu', { expandedKeys: new Set() });
|
||||
while (scheduled.length) scheduled.shift()();
|
||||
const ancestor = await ancestorPromise;
|
||||
assert.deepEqual([...ancestor.cameraKeys], ['camera:c2']);
|
||||
});
|
||||
|
||||
test('new search cancels stale chunked generation', async () => {
|
||||
const model = buildDeviceTree({
|
||||
sites: [{ id: 's', name: 'Site' }],
|
||||
groups: [],
|
||||
devices: Array.from({ length: 700 }, (_, i) => ({ id: `c${i}`, name: `Camera ${i}`, siteId: 's' })),
|
||||
});
|
||||
const scheduled = [];
|
||||
const runner = createSearchRunner({ scheduler: (work) => scheduled.push(work), chunkSize: 250 });
|
||||
const stale = runner.search(model, 'camera', {});
|
||||
scheduled.shift()();
|
||||
const current = runner.search(model, 'camera 699', {});
|
||||
while (scheduled.length) scheduled.shift()();
|
||||
const [staleResult, currentResult] = await Promise.all([stale, current]);
|
||||
assert.equal(staleResult.cancelled, true);
|
||||
assert.equal(currentResult.cancelled, false);
|
||||
assert.equal(currentResult.count, 1);
|
||||
});
|
||||
|
||||
test('virtual window remains structurally bounded for 5,000 expanded shuffled cameras', () => {
|
||||
const devices = Array.from({ length: 5000 }, (_, i) => ({ id: `id-${i}`, name: `Camera ${String(i).padStart(4, '0')}`, siteId: 's' }));
|
||||
for (let i = devices.length - 1; i > 0; i -= 1) {
|
||||
const j = (i * 48271) % (i + 1);
|
||||
[devices[i], devices[j]] = [devices[j], devices[i]];
|
||||
}
|
||||
const model = buildDeviceTree({ sites: [{ id: 's', name: 'Large' }], groups: [], devices });
|
||||
const rows = flattenVisibleRows(model, { expandedKeys: new Set(['site:s', 'group:s:__ungrouped__']) });
|
||||
const window = calculateVirtualWindow(rows, { scrollTop: 50000, viewportHeight: 320, rowHeight: 28 });
|
||||
assert.ok(window.rows.length >= 40 && window.rows.length <= 50, `mounted ${window.rows.length}`);
|
||||
assert.equal(window.totalHeight, rows.length * 28);
|
||||
assert.equal(window.offsetTop, window.startIndex * 28);
|
||||
});
|
||||
|
||||
test('1,500-site/group fixture has deterministic stable ordering', () => {
|
||||
const sites = Array.from({ length: 1500 }, (_, i) => ({ id: `s-${i}`, name: `Site ${i % 17}` }));
|
||||
const groups = sites.map((site, i) => ({ id: `g-${i}`, name: `Group ${i % 11}`, parentId: site.id }));
|
||||
const model = buildDeviceTree({ sites: sites.reverse(), groups: groups.reverse(), devices: [] });
|
||||
const namesAndIds = model.sites.map((site) => `${site.normalizedName}|${site.id}`);
|
||||
assert.deepEqual(namesAndIds, [...namesAndIds].sort((a, b) => a.localeCompare(b)));
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildDeviceTree, flattenVisibleRows } = require('../device-tree');
|
||||
const { createInitialState, reduceSidebar, createSidebarController } = require('../sidebar-controller');
|
||||
|
||||
function fixture() {
|
||||
const model = buildDeviceTree({
|
||||
sites: [{ id: 's', name: 'Site' }],
|
||||
groups: [{ id: 'g', name: 'Group', parentId: 's' }],
|
||||
devices: [
|
||||
{ id: 'a', name: 'Alpha', siteId: 's', deviceGroupId: 'g' },
|
||||
{ id: 'b', name: 'Beta', siteId: 's', deviceGroupId: 'g' },
|
||||
],
|
||||
});
|
||||
return model;
|
||||
}
|
||||
|
||||
function visible(model, state) {
|
||||
return flattenVisibleRows(model, state);
|
||||
}
|
||||
|
||||
test('reducer expands, navigates, selects, collapses, and preserves hidden selection', () => {
|
||||
const model = fixture();
|
||||
let state = createInitialState();
|
||||
let rows = visible(model, state);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'Home' }, rows);
|
||||
assert.equal(state.activeKey, 'site:s');
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowRight' }, rows);
|
||||
assert.ok(state.expandedKeys.has('site:s'));
|
||||
|
||||
rows = visible(model, state);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowDown' }, rows);
|
||||
assert.equal(state.activeKey, 'group:s:g');
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowRight' }, rows);
|
||||
rows = visible(model, state);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowDown' }, rows);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'Enter' }, rows);
|
||||
assert.equal(state.selectedKey, 'camera:a');
|
||||
|
||||
state = reduceSidebar(state, { type: 'ACTIVATE', rowKey: 'site:s' }, rows);
|
||||
assert.equal(state.selectedKey, 'camera:a');
|
||||
assert.equal(visible(model, state).hiddenSelected, true);
|
||||
});
|
||||
|
||||
test('keyboard Left/Right parent behavior, bounds, Space and Escape', () => {
|
||||
const model = fixture();
|
||||
let state = createInitialState({
|
||||
expandedKeys: new Set(['site:s', 'group:s:g']),
|
||||
activeKey: 'camera:a',
|
||||
searchQuery: 'alpha',
|
||||
});
|
||||
let rows = visible(model, state);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowLeft' }, rows);
|
||||
assert.equal(state.activeKey, 'group:s:g');
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowLeft' }, rows);
|
||||
assert.equal(state.expandedKeys.has('group:s:g'), false);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'End' }, visible(model, state));
|
||||
assert.equal(state.activeKey, 'group:s:g');
|
||||
state = reduceSidebar(state, { type: 'KEY', key: ' ' }, visible(model, state));
|
||||
assert.equal(state.expandedKeys.has('group:s:g'), true);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'Escape' }, visible(model, state));
|
||||
assert.equal(state.searchQuery, '');
|
||||
});
|
||||
|
||||
test('malformed and unknown delegated row keys are no-ops', () => {
|
||||
const state = createInitialState({ activeKey: 'site:s' });
|
||||
const rows = visible(fixture(), state);
|
||||
assert.equal(reduceSidebar(state, { type: 'ACTIVATE', rowKey: '__proto__' }, rows), state);
|
||||
assert.equal(reduceSidebar(state, { type: 'CLICK', rowKey: 'camera:not-there' }, rows), state);
|
||||
});
|
||||
|
||||
test('controller commits exactly once per dispatch through view adapter', () => {
|
||||
const model = fixture();
|
||||
const commits = [];
|
||||
const view = { commit: (snapshot) => commits.push(snapshot) };
|
||||
const controller = createSidebarController({ model, view, rowHeight: 28, overscan: 3 });
|
||||
assert.equal(commits.length, 1);
|
||||
controller.dispatch({ type: 'ACTIVATE', rowKey: 'site:s' });
|
||||
assert.equal(commits.length, 2);
|
||||
assert.equal(commits[1].state.expandedKeys.has('site:s'), true);
|
||||
assert.ok(Array.isArray(commits[1].window.rows));
|
||||
});
|
||||
|
||||
test('active and selected state survive virtualization and selection is indicated when hidden', () => {
|
||||
const model = fixture();
|
||||
const commits = [];
|
||||
const controller = createSidebarController({ model, view: { commit: (value) => commits.push(value) } });
|
||||
controller.dispatch({ type: 'SET_SELECTION', rowKey: 'camera:b' });
|
||||
controller.dispatch({ type: 'SET_VIEWPORT', scrollTop: 9999, viewportHeight: 20 });
|
||||
const snapshot = commits.at(-1);
|
||||
assert.equal(snapshot.state.selectedKey, 'camera:b');
|
||||
assert.equal(snapshot.rows.hiddenSelected, true);
|
||||
});
|
||||
|
||||
test('controller search temporarily expands matches with one completion commit', async () => {
|
||||
const model = fixture();
|
||||
const commits = [];
|
||||
const scheduled = [];
|
||||
const controller = createSidebarController({
|
||||
model,
|
||||
view: { commit: (value) => commits.push(value) },
|
||||
scheduler: (work) => scheduled.push(work),
|
||||
chunkSize: 1,
|
||||
});
|
||||
const pending = controller.search('beta group');
|
||||
while (scheduled.length) scheduled.shift()();
|
||||
const result = await pending;
|
||||
assert.equal(result.count, 1);
|
||||
assert.equal(commits.length, 2);
|
||||
assert.deepEqual(commits.at(-1).rows.map((row) => row.key), ['site:s', 'group:s:g', 'camera:b']);
|
||||
assert.equal(controller.getState().expandedKeys.size, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user