239 lines
10 KiB
JavaScript
239 lines
10 KiB
JavaScript
'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);
|
|
});
|