fix: harden hierarchy keys and virtual navigation

This commit is contained in:
2026-08-20 01:26:00 +00:00
parent 8d256cd4ab
commit 3b5e2acb75
4 changed files with 255 additions and 56 deletions
+76 -16
View File
@@ -33,18 +33,19 @@ function baseFixture() {
test('builds deterministic hierarchy with explicit exceptional buckets and diagnostics', () => {
const model = buildDeviceTree(baseFixture());
const camera = (id) => [...model.cameraByKey.values()].find((entry) => entry.canonicalId === id);
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');
const conflict = 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'));
assert.equal(camera('c2').hierarchyStatus, 'inferred-site');
assert.ok(model.diagnostics.some((entry) => entry.code === 'site-group-conflict' && entry.key === conflict.key));
});
test('duplicate and malformed IDs get stable unique keys and diagnostics', () => {
@@ -67,44 +68,49 @@ test('duplicate and malformed IDs get stable unique keys and diagnostics', () =>
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' });
const site = model.sites.find((entry) => entry.canonicalId === 's1' && !entry.synthetic);
const group = site.groups.find((entry) => entry.canonicalId === 'g1' && !entry.synthetic);
const selected = [...model.cameraByKey.values()].find((entry) => entry.canonicalId === 'c1');
let rows = flattenVisibleRows(model, { expandedKeys: new Set(), selectedKey: selected.key });
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']) });
rows = flattenVisibleRows(model, { expandedKeys: new Set([site.key]) });
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',
expandedKeys: new Set([site.key, group.key]),
selectedKey: selected.key,
});
const camera = rows.find((row) => row.key === 'camera:c1');
assert.equal(camera.parentKey, 'group:s1:g1');
const camera = rows.find((row) => row.key === selected.key);
assert.equal(camera.parentKey, group.key);
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 site = (id) => model.sites.find((entry) => entry.canonicalId === id && !entry.synthetic);
const camera = (id) => [...model.cameraByKey.values()].find((entry) => entry.canonicalId === id);
const scheduled = [];
const runner = createSearchRunner({ scheduler: (work) => scheduled.push(work), chunkSize: 2 });
const saved = new Set(['site:s2']);
const saved = new Set([site('s2').key]);
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']);
assert.deepEqual([...result.cameraKeys], [camera('c1').key]);
assert.ok(result.expandedKeys.has(site('s1').key));
assert.ok(result.expandedKeys.has(camera('c1').group.key));
assert.deepEqual([...saved], [site('s2').key]);
const ancestorPromise = runner.search(model, 'zulu', { expandedKeys: new Set() });
while (scheduled.length) scheduled.shift()();
const ancestor = await ancestorPromise;
assert.deepEqual([...ancestor.cameraKeys], ['camera:c2']);
assert.deepEqual([...ancestor.cameraKeys], [camera('c2').key]);
});
test('new search cancels stale chunked generation', async () => {
@@ -132,7 +138,9 @@ test('virtual window remains structurally bounded for 5,000 expanded shuffled ca
[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 rows = flattenVisibleRows(model, {
expandedKeys: new Set([model.sites[0].key, model.sites[0].groups[0].key]),
});
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);
@@ -146,3 +154,55 @@ test('1,500-site/group fixture has deterministic stable ordering', () => {
const namesAndIds = model.sites.map((site) => `${site.normalizedName}|${site.id}`);
assert.deepEqual(namesAndIds, [...namesAndIds].sort((a, b) => a.localeCompare(b)));
});
test('tenant and synthetic namespaces stay distinct for reserved and ambiguous IDs', () => {
const model = buildDeviceTree({
sites: [
{ id: '__orphaned__', name: 'Tenant Reserved Site' },
{ id: '__unknown_site__:missing', name: 'Tenant Unknown-shaped Site' },
{ id: 'a', name: 'A' },
{ id: 'a:b', name: 'A colon B' },
{ id: 'percent%:site', name: 'Percent Site' },
],
groups: [
{ id: '__ungrouped__', name: 'Tenant Reserved Group', parentId: '__orphaned__' },
{ id: 'b:c', name: 'Tuple One', parentId: 'a' },
{ id: 'c', name: 'Tuple Two', parentId: 'a:b' },
{ id: 'g:%', name: 'Escaped Group', parentId: 'percent%:site' },
],
devices: [
{ id: 'tenant-group', siteId: '__orphaned__', deviceGroupId: '__ungrouped__' },
{ id: 'synthetic-group', siteId: '__orphaned__' },
{ id: 'orphan' },
{ id: 'unknown-site', siteId: 'missing' },
{ id: 'tuple-one', siteId: 'a', deviceGroupId: 'b:c' },
{ id: 'tuple-two', siteId: 'a:b', deviceGroupId: 'c' },
{ id: 'escaped', siteId: 'percent%:site', deviceGroupId: 'g:%' },
],
});
const tenantReserved = model.sites.find((site) => !site.synthetic && site.id === '__orphaned__');
const orphanBucket = model.sites.find((site) => site.synthetic && site.hierarchyStatus === 'orphan');
const unknownBucket = model.sites.find((site) => site.synthetic && site.hierarchyStatus === 'unknown-site');
assert.ok(tenantReserved && orphanBucket && unknownBucket);
assert.notEqual(tenantReserved.key, orphanBucket.key);
const camera = (id) => [...model.cameraByKey.values()].find((entry) => entry.canonicalId === id);
assert.notEqual(camera('tenant-group').group.key, camera('synthetic-group').group.key);
assert.notEqual(camera('tuple-one').group.key, camera('tuple-two').group.key);
assert.equal(model.rowByKey.size, model.sites.length +
model.sites.reduce((count, site) => count + site.groups.length, 0) + model.cameraCount);
assert.ok([...model.rowByKey.keys()].every((key) => !/\s/.test(key)));
});
test('virtual window clamps an obsolete scroll offset after rows shrink', () => {
const rows = [{ key: 'only-row' }];
const window = calculateVirtualWindow(rows, {
scrollTop: 10000,
viewportHeight: 320,
rowHeight: 28,
overscan: 0,
});
assert.deepEqual(window.rows, rows);
assert.equal(window.startIndex, 0);
assert.equal(window.endIndex, 1);
});
+101 -19
View File
@@ -21,65 +21,76 @@ function visible(model, state) {
return flattenVisibleRows(model, state);
}
function fixtureKeys(model) {
const site = model.sites[0];
const group = site.groups[0];
const cameras = Object.fromEntries(group.cameras.map((camera) => [camera.canonicalId, camera.key]));
return { site: site.key, group: group.key, cameras };
}
test('reducer expands, navigates, selects, collapses, and preserves hidden selection', () => {
const model = fixture();
const keys = fixtureKeys(model);
let state = createInitialState();
let rows = visible(model, state);
state = reduceSidebar(state, { type: 'KEY', key: 'Home' }, rows);
assert.equal(state.activeKey, 'site:s');
assert.equal(state.activeKey, keys.site);
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowRight' }, rows);
assert.ok(state.expandedKeys.has('site:s'));
assert.ok(state.expandedKeys.has(keys.site));
rows = visible(model, state);
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowDown' }, rows);
assert.equal(state.activeKey, 'group:s:g');
assert.equal(state.activeKey, keys.group);
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');
assert.equal(state.selectedKey, keys.cameras.a);
state = reduceSidebar(state, { type: 'ACTIVATE', rowKey: 'site:s' }, rows);
assert.equal(state.selectedKey, 'camera:a');
state = reduceSidebar(state, { type: 'ACTIVATE', rowKey: keys.site }, rows);
assert.equal(state.selectedKey, keys.cameras.a);
assert.equal(visible(model, state).hiddenSelected, true);
});
test('keyboard Left/Right parent behavior, bounds, Space and Escape', () => {
const model = fixture();
const keys = fixtureKeys(model);
let state = createInitialState({
expandedKeys: new Set(['site:s', 'group:s:g']),
activeKey: 'camera:a',
expandedKeys: new Set([keys.site, keys.group]),
activeKey: keys.cameras.a,
searchQuery: 'alpha',
});
let rows = visible(model, state);
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowLeft' }, rows);
assert.equal(state.activeKey, 'group:s:g');
assert.equal(state.activeKey, keys.group);
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowLeft' }, rows);
assert.equal(state.expandedKeys.has('group:s:g'), false);
assert.equal(state.expandedKeys.has(keys.group), false);
state = reduceSidebar(state, { type: 'KEY', key: 'End' }, visible(model, state));
assert.equal(state.activeKey, 'group:s:g');
assert.equal(state.activeKey, keys.group);
state = reduceSidebar(state, { type: 'KEY', key: ' ' }, visible(model, state));
assert.equal(state.expandedKeys.has('group:s:g'), true);
assert.equal(state.expandedKeys.has(keys.group), 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);
const model = fixture();
const state = createInitialState({ activeKey: fixtureKeys(model).site });
const rows = visible(model, 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 keys = fixtureKeys(model);
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' });
controller.dispatch({ type: 'ACTIVATE', rowKey: keys.site });
assert.equal(commits.length, 2);
assert.equal(commits[1].state.expandedKeys.has('site:s'), true);
assert.equal(commits[1].state.expandedKeys.has(keys.site), true);
assert.ok(Array.isArray(commits[1].window.rows));
const modelReference = controller.getSnapshot().rows;
controller.refresh();
@@ -90,17 +101,19 @@ test('controller commits exactly once per dispatch through view adapter', () =>
test('active and selected state survive virtualization and selection is indicated when hidden', () => {
const model = fixture();
const keys = fixtureKeys(model);
const commits = [];
const controller = createSidebarController({ model, view: { commit: (value) => commits.push(value) } });
controller.dispatch({ type: 'SET_SELECTION', rowKey: 'camera:b' });
controller.dispatch({ type: 'SET_SELECTION', rowKey: keys.cameras.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.state.selectedKey, keys.cameras.b);
assert.equal(snapshot.rows.hiddenSelected, true);
});
test('controller search temporarily expands matches with one completion commit', async () => {
const model = fixture();
const keys = fixtureKeys(model);
const commits = [];
const scheduled = [];
const controller = createSidebarController({
@@ -114,6 +127,75 @@ test('controller search temporarily expands matches with one completion commit',
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.deepEqual(commits.at(-1).rows.map((row) => row.key), [keys.site, keys.group, keys.cameras.b]);
assert.equal(controller.getState().expandedKeys.size, 0);
});
test('first arrow press chooses the directional edge when no row is active', () => {
const model = buildDeviceTree({
sites: [{ id: 'b', name: 'B' }, { id: 'a', name: 'A' }, { id: 'c', name: 'C' }],
});
const rows = visible(model, createInitialState());
assert.equal(reduceSidebar(createInitialState(), { type: 'KEY', key: 'ArrowDown' }, rows).activeKey, rows[0].key);
assert.equal(reduceSidebar(createInitialState(), { type: 'KEY', key: 'ArrowUp' }, rows).activeKey, rows.at(-1).key);
});
test('collapse clamps stale scroll and keyboard navigation scrolls active rows into the virtual window', () => {
const model = buildDeviceTree({
sites: [{ id: 'large', name: 'Large' }],
groups: [],
devices: Array.from({ length: 500 }, (_, index) => ({ id: `c${index}`, name: `Camera ${index}`, siteId: 'large' })),
});
const site = model.sites[0];
const group = site.groups[0];
const controller = createSidebarController({
model,
view: { commit() {} },
rowHeight: 20,
overscan: 0,
initialState: { expandedKeys: new Set([site.key, group.key]), scrollTop: 10000, viewportHeight: 100 },
});
controller.dispatch({ type: 'ACTIVATE', rowKey: site.key });
let snapshot = controller.getSnapshot();
assert.deepEqual(snapshot.window.rows.map((row) => row.key), [site.key]);
assert.equal(snapshot.state.scrollTop, 0);
controller.dispatch({ type: 'ACTIVATE', rowKey: site.key });
controller.dispatch({ type: 'SET_ACTIVE', rowKey: site.key });
controller.dispatch({ type: 'KEY', key: 'End' });
snapshot = controller.getSnapshot();
assert.equal(snapshot.window.rows.some((row) => row.key === snapshot.state.activeKey), true);
assert.ok(snapshot.state.scrollTop > 0);
});
test('search expansion controls rendered temporary state and restores saved expansion', async () => {
const model = fixture();
const scheduled = [];
const site = model.sites[0];
const group = site.groups[0];
const controller = createSidebarController({
model,
view: { commit() {} },
scheduler: (work) => scheduled.push(work),
initialState: { expandedKeys: new Set([site.key]) },
});
const pending = controller.search('alpha');
while (scheduled.length) scheduled.shift()();
await pending;
controller.dispatch({ type: 'SET_ACTIVE', rowKey: group.key });
controller.dispatch({ type: 'KEY', key: 'ArrowLeft' });
assert.equal(controller.getState().searchExpandedKeys.has(group.key), false);
assert.equal(controller.getSnapshot().rows.some((row) => row.kind === 'camera'), false);
assert.deepEqual(controller.getState().expandedKeys, new Set([site.key]));
controller.dispatch({ type: 'KEY', key: 'ArrowRight' });
assert.equal(controller.getState().searchExpandedKeys.has(group.key), true);
assert.equal(controller.getSnapshot().rows.some((row) => row.kind === 'camera'), true);
assert.deepEqual(controller.getState().expandedKeys, new Set([site.key]));
await controller.search('');
assert.deepEqual(controller.getState().expandedKeys, new Set([site.key]));
assert.equal(controller.getState().searchExpandedKeys, null);
});