From c65a813e5f256d62ecc600caaf91a6b6273af43b Mon Sep 17 00:00:00 2001 From: PageZ948 Date: Thu, 20 Aug 2026 01:03:48 +0000 Subject: [PATCH] feat: add scalable device hierarchy model --- device-tree.js | 304 ++++++++++++++++++++++++++++++++ sidebar-controller.js | 202 +++++++++++++++++++++ test/device-tree.test.js | 147 +++++++++++++++ test/sidebar-controller.test.js | 114 ++++++++++++ 4 files changed, 767 insertions(+) create mode 100644 device-tree.js create mode 100644 sidebar-controller.js create mode 100644 test/device-tree.test.js create mode 100644 test/sidebar-controller.test.js diff --git a/device-tree.js b/device-tree.js new file mode 100644 index 0000000..aa3f26e --- /dev/null +++ b/device-tree.js @@ -0,0 +1,304 @@ +'use strict'; + +(function exposeDeviceTree(globalScope) { + const SPECIAL = Object.freeze({ + ORPHAN_SITE: '__orphaned__', + UNGROUPED: '__ungrouped__', + UNKNOWN_GROUP_PREFIX: '__unknown_group__:', + UNKNOWN_SITE_PREFIX: '__unknown_site__:', + }); + + function text(value) { + return value === null || value === undefined ? '' : String(value).trim(); + } + + function normalized(value) { + return text(value).normalize('NFKD').toLocaleLowerCase(); + } + + function compareNodes(left, right) { + const nameOrder = left.normalizedName === right.normalizedName + ? 0 + : (left.normalizedName < right.normalizedName ? -1 : 1); + const idOrder = left.canonicalId === right.canonicalId + ? 0 + : (left.canonicalId < right.canonicalId ? -1 : 1); + return nameOrder || idOrder || left.ordinal - right.ordinal; + } + + function createIdentity(kind, item, ordinal, occurrences, diagnostics) { + const rawId = text(item && item.id); + const canonicalId = rawId || `__invalid_${kind}_${ordinal}`; + const count = (occurrences.get(canonicalId) || 0) + 1; + occurrences.set(canonicalId, count); + if (!rawId) diagnostics.push({ code: 'malformed-id', kind, ordinal, id: rawId }); + if (count > 1) diagnostics.push({ code: 'duplicate-id', kind, ordinal, id: canonicalId }); + return { + rawId, + canonicalId, + uniqueId: count === 1 ? canonicalId : `${canonicalId}~${count}`, + }; + } + + function makeSite(id, name, ordinal, extra) { + return Object.assign({ + kind: 'site', id, canonicalId: id, name, normalizedName: normalized(name), ordinal, + key: `site:${id}`, pendingDeletion: false, groups: [], synthetic: false, + }, extra); + } + + function makeGroup(site, id, name, ordinal, extra) { + return Object.assign({ + kind: 'group', id, canonicalId: id, name, normalizedName: normalized(name), ordinal, + key: `group:${site.id}:${id}`, parentKey: site.key, site, cameras: [], + pendingDeletion: false, synthetic: false, + }, extra); + } + + function buildDeviceTree(payload = {}) { + const diagnostics = []; + const sourceSites = Array.isArray(payload.sites) ? payload.sites : []; + const sourceGroups = Array.isArray(payload.groups) ? payload.groups : []; + const sourceDevices = Array.isArray(payload.devices) ? payload.devices : []; + const rowByKey = new Map(); + const cameraByKey = new Map(); + const sites = []; + const siteByRawId = new Map(); + const groupByRawId = new Map(); + const groupOccurrences = new Map(); + const siteOccurrences = new Map(); + const cameraOccurrences = new Map(); + let syntheticOrdinal = sourceSites.length + sourceGroups.length + sourceDevices.length; + + sourceSites.forEach((item, ordinal) => { + const safe = item && typeof item === 'object' ? item : {}; + const identity = createIdentity('site', safe, ordinal, siteOccurrences, diagnostics); + const site = makeSite(identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, { + canonicalId: identity.canonicalId, + pendingDeletion: safe.pendingDeletion === true, + source: safe, + }); + sites.push(site); + rowByKey.set(site.key, 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}`); + if (!site) { + const name = labelId ? `Unknown site: ${labelId}` : 'Orphaned cameras'; + site = makeSite(id, name, syntheticOrdinal++, { synthetic: true, hierarchyStatus: labelId ? 'unknown-site' : 'orphan' }); + sites.push(site); + rowByKey.set(site.key, site); + } + return site; + } + + sourceGroups.forEach((item, ordinal) => { + const safe = item && typeof item === 'object' ? item : {}; + const identity = createIdentity('group', safe, ordinal, groupOccurrences, diagnostics); + const parentId = text(safe.parentId); + const site = siteByRawId.get(parentId) || ensureUnknownSite(parentId); + if (!siteByRawId.has(parentId)) diagnostics.push({ code: parentId ? 'unknown-parent-site' : 'malformed-parent-site', key: `group:${site.id}:${identity.uniqueId}`, id: parentId }); + const group = makeGroup(site, identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, { + canonicalId: identity.canonicalId, + rawParentId: parentId, + pendingDeletion: safe.pendingDeletion === true, + source: safe, + }); + site.groups.push(group); + rowByKey.set(group.key, 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}`; + let group = rowByKey.get(key); + if (!group) { + group = makeGroup(site, id, name, syntheticOrdinal++, { + synthetic: true, + 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); + } + return group; + } + + sourceDevices.forEach((item, ordinal) => { + const safe = item && typeof item === 'object' ? item : {}; + const identity = createIdentity('camera', safe, ordinal, cameraOccurrences, diagnostics); + const directSiteId = text(safe.siteId); + const requestedGroupId = text(safe.deviceGroupId); + const knownGroup = requestedGroupId ? groupByRawId.get(requestedGroupId) : undefined; + let site; + let hierarchyStatus = text(safe.hierarchyStatus) || 'assigned'; + + if (directSiteId) { + site = siteByRawId.get(directSiteId) || ensureUnknownSite(directSiteId); + if (!siteByRawId.has(directSiteId)) hierarchyStatus = 'unknown-site'; + } else if (knownGroup) { + site = knownGroup.site; + hierarchyStatus = 'inferred-site'; + } else { + site = ensureUnknownSite(''); + hierarchyStatus = requestedGroupId ? 'unknown-group' : 'orphan'; + } + + let group; + if (!requestedGroupId) { + group = ensureGroup(site, SPECIAL.UNGROUPED, 'Ungrouped', 'ungrouped'); + } 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 }); + } 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 }); + } else { + group = knownGroup; + } + + 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, + 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); + cameraByKey.set(camera.key, camera); + rowByKey.set(camera.key, camera); + }); + + sites.forEach((site) => { + site.groups.sort(compareNodes); + site.groups.forEach((group) => group.cameras.sort(compareNodes)); + }); + sites.sort(compareNodes); + + return Object.freeze({ sites, rowByKey, cameraByKey, diagnostics, cameraCount: cameraByKey.size }); + } + + function rowDescriptor(node, level, expanded, selectedKey, position, size) { + const aria = { + level, + expanded: node.kind === 'camera' ? undefined : expanded, + selected: node.key === selectedKey, + posinset: position, + setsize: size, + }; + return { + key: node.key, parentKey: node.parentKey || null, kind: node.kind, id: node.id, + name: node.name, pendingDeletion: node.pendingDeletion === true, + hierarchyStatus: node.hierarchyStatus, aria, + ariaLevel: aria.level, ariaExpanded: aria.expanded, ariaSelected: aria.selected, + ariaPosInSet: aria.posinset, ariaSetSize: aria.setsize, + }; + } + + function flattenVisibleRows(model, options = {}) { + const expandedKeys = options.expandedKeys instanceof Set ? options.expandedKeys : new Set(); + const selectedKey = typeof options.selectedKey === 'string' ? options.selectedKey : null; + const matches = options.matchCameraKeys instanceof Set ? options.matchCameraKeys : null; + const rows = []; + const includedGroups = (site) => site.groups.filter((group) => !matches || group.cameras.some((camera) => matches.has(camera.key))); + const includedSites = model.sites.filter((site) => !matches || includedGroups(site).length > 0); + + includedSites.forEach((site, siteIndex) => { + const siteExpanded = expandedKeys.has(site.key); + rows.push(rowDescriptor(site, 1, siteExpanded, selectedKey, siteIndex + 1, includedSites.length)); + if (!siteExpanded) return; + const groups = includedGroups(site); + groups.forEach((group, groupIndex) => { + const groupExpanded = expandedKeys.has(group.key); + rows.push(rowDescriptor(group, 2, groupExpanded, selectedKey, groupIndex + 1, groups.length)); + if (!groupExpanded) return; + const cameras = matches ? group.cameras.filter((camera) => matches.has(camera.key)) : group.cameras; + cameras.forEach((camera, cameraIndex) => { + rows.push(rowDescriptor(camera, 3, undefined, selectedKey, cameraIndex + 1, cameras.length)); + }); + }); + }); + Object.defineProperty(rows, 'hiddenSelected', { + value: Boolean(selectedKey && model.rowByKey.has(selectedKey) && !rows.some((row) => row.key === selectedKey)), + enumerable: true, + }); + return rows; + } + + 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 overscan = Math.max(0, Number.isInteger(options.overscan) ? options.overscan : 16); + 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, + }; + } + + function createSearchRunner(options = {}) { + const scheduler = typeof options.scheduler === 'function' + ? options.scheduler + : (work) => setTimeout(work, 0); + const chunkSize = Number.isInteger(options.chunkSize) && options.chunkSize > 0 ? options.chunkSize : 250; + let generation = 0; + + function search(model, query, searchOptions = {}) { + const myGeneration = ++generation; + const terms = normalized(query).split(/\s+/).filter(Boolean); + const cameras = [...model.cameraByKey.values()]; + const cameraKeys = new Set(); + const expandedKeys = new Set(searchOptions.expandedKeys instanceof Set ? searchOptions.expandedKeys : []); + let index = 0; + + return new Promise((resolve) => { + function finish(cancelled) { + resolve(Object.freeze({ generation: myGeneration, cancelled, count: cameraKeys.size, cameraKeys, expandedKeys })); + } + function work() { + if (myGeneration !== generation) return finish(true); + const end = Math.min(cameras.length, index + chunkSize); + for (; index < end; index += 1) { + const camera = cameras[index]; + if (terms.every((term) => camera.searchCorpus.includes(term))) { + cameraKeys.add(camera.key); + expandedKeys.add(camera.site.key); + expandedKeys.add(camera.group.key); + } + } + if (index < cameras.length) scheduler(work); + else finish(false); + } + scheduler(work); + }); + } + + return Object.freeze({ search, cancel() { generation += 1; }, get generation() { return generation; } }); + } + + const api = Object.freeze({ + SPECIAL, + buildDeviceTree, + buildDeviceTreeModel: buildDeviceTree, + flattenVisibleRows, + flattenRows: flattenVisibleRows, + createSearchRunner, + calculateVirtualWindow, + }); + if (typeof module !== 'undefined' && module.exports) module.exports = api; + if (globalScope) globalScope.AptDeviceTree = api; +}(typeof window !== 'undefined' ? window : undefined)); diff --git a/sidebar-controller.js b/sidebar-controller.js new file mode 100644 index 0000000..fb1ec83 --- /dev/null +++ b/sidebar-controller.js @@ -0,0 +1,202 @@ +'use strict'; + +(function defineSidebarController(root, factory) { + const treeApi = typeof module !== 'undefined' && module.exports + ? require('./device-tree') + : root && root.AptDeviceTree; + const api = factory(treeApi); + if (typeof module !== 'undefined' && module.exports) module.exports = api; + if (root) root.AptSidebarController = api; +}(typeof window !== 'undefined' ? window : undefined, function sidebarFactory(treeApi) { + function validKey(key) { + return typeof key === 'string' && /^(site|group|camera):[^\s]+$/.test(key); + } + + function copySet(value) { + return value instanceof Set ? new Set(value) : new Set(); + } + + function createInitialState(initial = {}) { + return { + expandedKeys: copySet(initial.expandedKeys), + activeKey: validKey(initial.activeKey) ? initial.activeKey : null, + selectedKey: validKey(initial.selectedKey) ? initial.selectedKey : null, + searchQuery: typeof initial.searchQuery === 'string' ? initial.searchQuery : '', + searchCameraKeys: initial.searchCameraKeys instanceof Set ? new Set(initial.searchCameraKeys) : null, + searchExpandedKeys: initial.searchExpandedKeys instanceof Set ? new Set(initial.searchExpandedKeys) : null, + searchGeneration: Number.isInteger(initial.searchGeneration) ? initial.searchGeneration : 0, + scrollTop: Number.isFinite(initial.scrollTop) ? Math.max(0, initial.scrollTop) : 0, + viewportHeight: Number.isFinite(initial.viewportHeight) ? Math.max(0, initial.viewportHeight) : 320, + }; + } + + function findRow(rows, key) { + if (!validKey(key) || !Array.isArray(rows)) return null; + return rows.find((row) => row && row.key === key) || null; + } + + function changeExpansion(state, row, force) { + if (!row || row.kind === 'camera') return state; + const expandedKeys = copySet(state.expandedKeys); + 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 }); + } + + function activate(state, row) { + if (!row) return state; + if (row.kind === 'camera') return Object.assign({}, state, { activeKey: row.key, selectedKey: row.key }); + return changeExpansion(state, row); + } + + function moveActive(state, rows, targetIndex) { + if (!rows.length) return state; + const index = Math.max(0, Math.min(rows.length - 1, targetIndex)); + return Object.assign({}, state, { activeKey: rows[index].key }); + } + + function reduceSidebar(state, action, rows = []) { + if (!state || !action || typeof action.type !== 'string') return state; + if (action.type === 'ACTIVATE' || action.type === 'CLICK') { + return activate(state, findRow(rows, action.rowKey)); + } + if (action.type === 'SET_SELECTION') { + if (!validKey(action.rowKey) || !action.rowKey.startsWith('camera:') || + (!action.allowHidden && !findRow(rows, action.rowKey))) return state; + return Object.assign({}, state, { selectedKey: action.rowKey, activeKey: action.rowKey }); + } + if (action.type === 'SET_ACTIVE') { + const row = findRow(rows, action.rowKey); + return row ? Object.assign({}, state, { activeKey: row.key }) : state; + } + if (action.type === 'SET_VIEWPORT') { + const scrollTop = Number.isFinite(action.scrollTop) ? Math.max(0, action.scrollTop) : state.scrollTop; + const viewportHeight = Number.isFinite(action.viewportHeight) ? Math.max(0, action.viewportHeight) : state.viewportHeight; + if (scrollTop === state.scrollTop && viewportHeight === state.viewportHeight) return state; + return Object.assign({}, state, { scrollTop, viewportHeight }); + } + if (action.type === 'SET_SEARCH_QUERY') { + const searchQuery = typeof action.query === 'string' ? action.query : ''; + return Object.assign({}, state, { + searchQuery, + searchCameraKeys: searchQuery ? state.searchCameraKeys : null, + searchExpandedKeys: searchQuery ? state.searchExpandedKeys : null, + }); + } + if (action.type === 'SET_SEARCH_RESULTS') { + if (!Number.isInteger(action.generation) || action.generation < state.searchGeneration) return state; + return Object.assign({}, state, { + searchQuery: typeof action.query === 'string' ? action.query : state.searchQuery, + searchCameraKeys: action.cameraKeys instanceof Set ? new Set(action.cameraKeys) : null, + searchExpandedKeys: action.expandedKeys instanceof Set ? new Set(action.expandedKeys) : null, + searchGeneration: action.generation, + }); + } + if (action.type !== 'KEY' || typeof action.key !== 'string') return state; + + if (action.key === 'Escape') { + if (!state.searchQuery && !state.searchCameraKeys) return state; + return Object.assign({}, state, { searchQuery: '', searchCameraKeys: null, searchExpandedKeys: null }); + } + if (!rows.length) return state; + let index = rows.findIndex((row) => row.key === state.activeKey); + if (index < 0) index = 0; + const row = rows[index]; + switch (action.key) { + case 'ArrowDown': return moveActive(state, rows, index + 1); + case 'ArrowUp': return moveActive(state, rows, index - 1); + case 'Home': return moveActive(state, rows, 0); + 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); + 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); + return row.parentKey && validKey(row.parentKey) ? Object.assign({}, state, { activeKey: row.parentKey }) : state; + case 'Enter': + case ' ': + case 'Spacebar': return activate(state, row); + default: return state; + } + } + + function createSidebarController(options = {}) { + if (!treeApi || typeof treeApi.flattenVisibleRows !== 'function' || typeof treeApi.calculateVirtualWindow !== 'function') { + throw new Error('AptDeviceTree must be loaded before AptSidebarController.'); + } + if (!options.model || !options.view || typeof options.view.commit !== 'function') { + throw new TypeError('Sidebar controller requires a model and view.commit adapter.'); + } + const model = options.model; + const view = options.view; + const rowHeight = Number.isFinite(options.rowHeight) ? options.rowHeight : 28; + const overscan = Number.isInteger(options.overscan) ? options.overscan : 16; + const searchRunner = options.searchRunner || treeApi.createSearchRunner({ scheduler: options.scheduler, chunkSize: options.chunkSize }); + let state = createInitialState(options.initialState); + let lastSnapshot; + + function effectiveExpandedKeys() { + return state.searchQuery && state.searchExpandedKeys ? state.searchExpandedKeys : state.expandedKeys; + } + + function commit() { + const rows = treeApi.flattenVisibleRows(model, { + expandedKeys: effectiveExpandedKeys(), + selectedKey: state.selectedKey, + matchCameraKeys: state.searchQuery ? state.searchCameraKeys : null, + }); + const window = treeApi.calculateVirtualWindow(rows, { + scrollTop: state.scrollTop, viewportHeight: state.viewportHeight, rowHeight, overscan, + }); + lastSnapshot = Object.freeze({ state, rows, window }); + view.commit(lastSnapshot); + return lastSnapshot; + } + + function dispatch(action) { + const visibleRows = lastSnapshot ? lastSnapshot.rows : []; + let safeAction = action; + if (action && action.type === 'SET_SELECTION') { + if (!model.cameraByKey.has(action.rowKey)) return lastSnapshot; + safeAction = Object.assign({}, action, { allowHidden: true }); + } + const next = reduceSidebar(state, safeAction, visibleRows); + if (next === state) return lastSnapshot; + state = next; + return commit(); + } + + async function search(query) { + const normalizedQuery = typeof query === 'string' ? query : ''; + if (!normalizedQuery.trim()) { + searchRunner.cancel(); + return dispatch({ type: 'SET_SEARCH_QUERY', query: '' }); + } + state = reduceSidebar(state, { type: 'SET_SEARCH_QUERY', query: normalizedQuery }, lastSnapshot ? lastSnapshot.rows : []); + const result = await searchRunner.search(model, normalizedQuery, { expandedKeys: state.expandedKeys }); + if (result.cancelled) return result; + dispatch({ + type: 'SET_SEARCH_RESULTS', query: normalizedQuery, generation: result.generation, + cameraKeys: result.cameraKeys, expandedKeys: result.expandedKeys, + }); + return result; + } + + commit(); + return Object.freeze({ + dispatch, search, getState: () => state, getSnapshot: () => lastSnapshot, + destroy() { searchRunner.cancel(); }, + }); + } + + return Object.freeze({ + createInitialState, + reduceSidebar, + sidebarReducer: reduceSidebar, + createSidebarController, + }); +})); diff --git a/test/device-tree.test.js b/test/device-tree.test.js new file mode 100644 index 0000000..81d14ed --- /dev/null +++ b/test/device-tree.test.js @@ -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))); +}); diff --git a/test/sidebar-controller.test.js b/test/sidebar-controller.test.js new file mode 100644 index 0000000..1fcda70 --- /dev/null +++ b/test/sidebar-controller.test.js @@ -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); +});