diff --git a/device-tree.js b/device-tree.js index 05abce2..abbdbd9 100644 --- a/device-tree.js +++ b/device-tree.js @@ -16,6 +16,11 @@ return text(value).normalize('NFKD').toLocaleLowerCase(); } + function keyPart(value) { + const encoded = encodeURIComponent(String(value)); + return `${encoded.length}:${encoded}`; + } + function compareNodes(left, right) { const nameOrder = left.normalizedName === right.normalizedName ? 0 @@ -41,16 +46,24 @@ } function makeSite(id, name, ordinal, extra) { + const syntheticType = extra && extra.syntheticType; return Object.assign({ kind: 'site', id, canonicalId: id, name, normalizedName: normalized(name), ordinal, - key: `site:${id}`, pendingDeletion: false, groups: [], synthetic: false, + key: syntheticType + ? `site:s:${keyPart(syntheticType)}:${keyPart(id)}` + : `site:t:${keyPart(id)}`, + pendingDeletion: false, groups: [], synthetic: false, }, extra); } function makeGroup(site, id, name, ordinal, extra) { + const syntheticType = extra && extra.syntheticType; return Object.assign({ kind: 'group', id, canonicalId: id, name, normalizedName: normalized(name), ordinal, - key: `group:${site.id}:${id}`, parentKey: site.key, site, cameras: [], + key: syntheticType + ? `group:s:${keyPart(syntheticType)}:${keyPart(site.key)}:${keyPart(id)}` + : `group:t:${keyPart(site.key)}:${keyPart(id)}`, + parentKey: site.key, site, cameras: [], pendingDeletion: false, synthetic: false, }, extra); } @@ -68,8 +81,28 @@ const groupOccurrences = new Map(); const siteOccurrences = new Map(); const cameraOccurrences = new Map(); + const syntheticSites = new Map(); let syntheticOrdinal = sourceSites.length + sourceGroups.length + sourceDevices.length; + function registerRow(node) { + if (!rowByKey.has(node.key)) { + rowByKey.set(node.key, node); + return true; + } + const collidedKey = node.key; + let collisionOrdinal = 2; + do { + node.key = `${collidedKey}:collision:${collisionOrdinal}`; + collisionOrdinal += 1; + } while (rowByKey.has(node.key)); + diagnostics.push({ + code: 'row-key-collision', key: collidedKey, resolvedKey: node.key, + kind: node.kind, id: node.canonicalId, + }); + rowByKey.set(node.key, node); + return false; + } + sourceSites.forEach((item, ordinal) => { const safe = item && typeof item === 'object' ? item : {}; const identity = createIdentity('site', safe, ordinal, siteOccurrences, diagnostics); @@ -79,19 +112,24 @@ source: safe, }); sites.push(site); - rowByKey.set(site.key, site); + registerRow(site); if (identity.rawId && !siteByRawId.has(identity.rawId)) siteByRawId.set(identity.rawId, site); }); function ensureUnknownSite(rawSiteId) { const labelId = text(rawSiteId); const id = labelId ? `${SPECIAL.UNKNOWN_SITE_PREFIX}${labelId}` : SPECIAL.ORPHAN_SITE; - let site = rowByKey.get(`site:${id}`); + const syntheticType = labelId ? 'unknown-site' : 'orphan'; + const identity = `${syntheticType}:${keyPart(labelId)}`; + let site = syntheticSites.get(identity); if (!site) { const name = labelId ? `Unknown site: ${labelId}` : 'Orphaned cameras'; - site = makeSite(id, name, syntheticOrdinal++, { synthetic: true, hierarchyStatus: labelId ? 'unknown-site' : 'orphan' }); + site = makeSite(id, name, syntheticOrdinal++, { + synthetic: true, syntheticType, hierarchyStatus: syntheticType, + }); sites.push(site); - rowByKey.set(site.key, site); + registerRow(site); + syntheticSites.set(identity, site); } return site; } @@ -109,23 +147,24 @@ source: safe, }); site.groups.push(group); - rowByKey.set(group.key, group); + registerRow(group); if (identity.rawId && !groupByRawId.has(identity.rawId)) groupByRawId.set(identity.rawId, group); }); function ensureGroup(site, id, name, status, template) { - const key = `group:${site.id}:${id}`; + const syntheticType = status === 'conflict' ? 'conflict' : status; + const key = `group:s:${keyPart(syntheticType)}:${keyPart(site.key)}:${keyPart(id)}`; let group = rowByKey.get(key); if (!group) { group = makeGroup(site, id, name, syntheticOrdinal++, { - synthetic: true, + synthetic: true, syntheticType, hierarchyStatus: status, canonicalId: template ? template.canonicalId : id, pendingDeletion: template ? template.pendingDeletion : false, source: template ? template.source : undefined, }); site.groups.push(group); - rowByKey.set(group.key, group); + registerRow(group); } return group; } @@ -133,6 +172,7 @@ sourceDevices.forEach((item, ordinal) => { const safe = item && typeof item === 'object' ? item : {}; const identity = createIdentity('camera', safe, ordinal, cameraOccurrences, diagnostics); + const cameraKey = `camera:t:${keyPart(identity.uniqueId)}`; const directSiteId = text(safe.siteId); const requestedGroupId = text(safe.deviceGroupId); const knownGroup = requestedGroupId ? groupByRawId.get(requestedGroupId) : undefined; @@ -156,11 +196,11 @@ } else if (!knownGroup) { group = ensureGroup(site, `${SPECIAL.UNKNOWN_GROUP_PREFIX}${requestedGroupId}`, `Unknown group: ${requestedGroupId}`, 'unknown-group'); if (hierarchyStatus !== 'unknown-site') hierarchyStatus = 'unknown-group'; - diagnostics.push({ code: 'unknown-group', key: `camera:${identity.uniqueId}`, id: requestedGroupId }); + diagnostics.push({ code: 'unknown-group', key: cameraKey, id: requestedGroupId }); } else if (knownGroup.site !== site) { group = ensureGroup(site, knownGroup.id, knownGroup.name, 'conflict', knownGroup); hierarchyStatus = 'conflict'; - diagnostics.push({ code: 'site-group-conflict', key: `camera:${identity.uniqueId}`, siteId: directSiteId, groupId: requestedGroupId, groupSiteId: knownGroup.site.canonicalId }); + diagnostics.push({ code: 'site-group-conflict', key: cameraKey, siteId: directSiteId, groupId: requestedGroupId, groupSiteId: knownGroup.site.canonicalId }); } else { group = knownGroup; } @@ -168,15 +208,15 @@ const name = text(safe.name) || identity.canonicalId; const camera = { kind: 'camera', id: identity.uniqueId, canonicalId: identity.canonicalId, - key: `camera:${identity.uniqueId}`, name, normalizedName: normalized(name), ordinal, + key: cameraKey, name, normalizedName: normalized(name), ordinal, parentKey: group.key, site, group, hierarchyStatus, source: safe, }; camera.searchCorpus = normalized([ name, identity.canonicalId, safe.model, safe.type, safe.address, site.name, group.name, ].map(text).join(' ')); group.cameras.push(camera); + registerRow(camera); cameraByKey.set(camera.key, camera); - rowByKey.set(camera.key, camera); }); sites.forEach((site) => { @@ -238,15 +278,16 @@ function calculateVirtualWindow(rows, options = {}) { const rowHeight = Number.isFinite(options.rowHeight) && options.rowHeight > 0 ? options.rowHeight : 28; const viewportHeight = Number.isFinite(options.viewportHeight) && options.viewportHeight >= 0 ? options.viewportHeight : 0; - const scrollTop = Math.max(0, Number.isFinite(options.scrollTop) ? options.scrollTop : 0); + const requestedScrollTop = Math.max(0, Number.isFinite(options.scrollTop) ? options.scrollTop : 0); const overscan = Math.max(0, Number.isInteger(options.overscan) ? options.overscan : 16); + const scrollTop = Math.min(requestedScrollTop, Math.max(0, (rows.length * rowHeight) - viewportHeight)); const visibleStart = Math.floor(scrollTop / rowHeight); const visibleEnd = Math.ceil((scrollTop + viewportHeight) / rowHeight); const startIndex = Math.max(0, Math.min(rows.length, visibleStart - overscan)); const endIndex = Math.max(startIndex, Math.min(rows.length, visibleEnd + overscan)); return { rows: rows.slice(startIndex, endIndex), startIndex, endIndex, - offsetTop: startIndex * rowHeight, totalHeight: rows.length * rowHeight, + offsetTop: startIndex * rowHeight, totalHeight: rows.length * rowHeight, scrollTop, }; } diff --git a/sidebar-controller.js b/sidebar-controller.js index 6219a3b..ec802e0 100644 --- a/sidebar-controller.js +++ b/sidebar-controller.js @@ -35,13 +35,20 @@ return rows.find((row) => row && row.key === key) || null; } + function expansionState(state) { + return state.searchQuery && state.searchExpandedKeys instanceof Set + ? { field: 'searchExpandedKeys', keys: state.searchExpandedKeys } + : { field: 'expandedKeys', keys: state.expandedKeys }; + } + function changeExpansion(state, row, force) { if (!row || row.kind === 'camera') return state; - const expandedKeys = copySet(state.expandedKeys); + const current = expansionState(state); + const expandedKeys = copySet(current.keys); const shouldExpand = force === undefined ? !expandedKeys.has(row.key) : force; if (shouldExpand) expandedKeys.add(row.key); else expandedKeys.delete(row.key); - return Object.assign({}, state, { expandedKeys, activeKey: row.key }); + return Object.assign({}, state, { [current.field]: expandedKeys, activeKey: row.key }); } function activate(state, row) { @@ -101,8 +108,13 @@ } if (!rows.length) return state; let index = rows.findIndex((row) => row.key === state.activeKey); - if (index < 0) index = 0; + if (index < 0) { + if (action.key === 'ArrowDown' || action.key === 'Home') return moveActive(state, rows, 0); + if (action.key === 'ArrowUp' || action.key === 'End') return moveActive(state, rows, rows.length - 1); + index = 0; + } const row = rows[index]; + const expandedKeys = expansionState(state).keys; switch (action.key) { case 'ArrowDown': return moveActive(state, rows, index + 1); case 'ArrowUp': return moveActive(state, rows, index - 1); @@ -110,12 +122,12 @@ case 'End': return moveActive(state, rows, rows.length - 1); case 'ArrowRight': { if (row.kind === 'camera') return state; - if (!state.expandedKeys.has(row.key)) return changeExpansion(state, row, true); + if (!expandedKeys.has(row.key)) return changeExpansion(state, row, true); const child = rows[index + 1]; return child && child.parentKey === row.key ? Object.assign({}, state, { activeKey: child.key }) : state; } case 'ArrowLeft': - if (row.kind !== 'camera' && state.expandedKeys.has(row.key)) return changeExpansion(state, row, false); + if (row.kind !== 'camera' && expandedKeys.has(row.key)) return changeExpansion(state, row, false); return row.parentKey && validKey(row.parentKey) ? Object.assign({}, state, { activeKey: row.parentKey }) : state; case 'Enter': case ' ': @@ -150,6 +162,10 @@ matchCameraKeys: state.searchQuery ? state.searchCameraKeys : null, }); const activeIndex = rows.findIndex((row) => row.key === state.activeKey); + const maximumScrollTop = Math.max(0, (rows.length * rowHeight) - state.viewportHeight); + if (state.scrollTop > maximumScrollTop) { + state = Object.assign({}, state, { scrollTop: maximumScrollTop }); + } if (activeIndex >= 0 && state.viewportHeight > 0) { const activeTop = activeIndex * rowHeight; const activeBottom = activeTop + rowHeight; diff --git a/test/device-tree.test.js b/test/device-tree.test.js index f850d1f..39b950e 100644 --- a/test/device-tree.test.js +++ b/test/device-tree.test.js @@ -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); +}); diff --git a/test/sidebar-controller.test.js b/test/sidebar-controller.test.js index 893d2cc..f60c88c 100644 --- a/test/sidebar-controller.test.js +++ b/test/sidebar-controller.test.js @@ -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); +});