feat: add scalable device hierarchy model
This commit is contained in:
+304
@@ -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));
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}));
|
||||||
@@ -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)));
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user