'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); } 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, keys.site); state = reduceSidebar(state, { type: 'KEY', key: 'ArrowRight' }, rows); assert.ok(state.expandedKeys.has(keys.site)); rows = visible(model, state); state = reduceSidebar(state, { type: 'KEY', key: 'ArrowDown' }, rows); 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, keys.cameras.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([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, keys.group); state = reduceSidebar(state, { type: 'KEY', key: 'ArrowLeft' }, rows); assert.equal(state.expandedKeys.has(keys.group), false); state = reduceSidebar(state, { type: 'KEY', key: 'End' }, visible(model, state)); assert.equal(state.activeKey, keys.group); state = reduceSidebar(state, { type: 'KEY', key: ' ' }, visible(model, state)); 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 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: keys.site }); assert.equal(commits.length, 2); 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(); assert.equal(commits.length, 3); assert.notEqual(controller.getSnapshot().rows, modelReference); assert.equal(controller.getSnapshot().rows.length, modelReference.length); }); test('active and selected state survive virtualization and selection is indicated when hidden', () => { 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: keys.cameras.b }); controller.dispatch({ type: 'SET_VIEWPORT', scrollTop: 9999, viewportHeight: 20 }); const snapshot = commits.at(-1); 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({ 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), [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('manual viewport scrolling may virtualize selection until keyboard navigation restores the active row', () => { const model = buildDeviceTree({ sites: [{ id: 'large', name: 'Large' }], groups: [], devices: Array.from({ length: 1500 }, (_, index) => ({ id: `c${String(index).padStart(4, '0')}`, name: `Camera ${String(index).padStart(4, '0')}`, siteId: 'large', })), }); const site = model.sites[0]; const group = site.groups[0]; const firstCamera = group.cameras[0]; const controller = createSidebarController({ model, view: { commit() {} }, rowHeight: 20, overscan: 0, initialState: { expandedKeys: new Set([site.key, group.key]), viewportHeight: 100 }, }); controller.dispatch({ type: 'SET_SELECTION', rowKey: firstCamera.key }); controller.dispatch({ type: 'SET_VIEWPORT', scrollTop: 30000, viewportHeight: 100 }); let snapshot = controller.getSnapshot(); assert.equal(snapshot.state.selectedKey, firstCamera.key); assert.equal(snapshot.state.activeKey, firstCamera.key); assert.equal(snapshot.state.scrollTop, 29940); assert.equal(snapshot.window.rows.some((row) => row.key === firstCamera.key), false); assert.equal(snapshot.rows.hiddenSelected, false); controller.dispatch({ type: 'KEY', key: 'Home' }); snapshot = controller.getSnapshot(); assert.equal(snapshot.state.activeKey, site.key); assert.equal(snapshot.state.scrollTop, 0); assert.equal(snapshot.window.rows.some((row) => row.key === site.key), true); }); 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); });