fix: harden hierarchy keys and virtual navigation
This commit is contained in:
+57
-16
@@ -16,6 +16,11 @@
|
|||||||
return text(value).normalize('NFKD').toLocaleLowerCase();
|
return text(value).normalize('NFKD').toLocaleLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function keyPart(value) {
|
||||||
|
const encoded = encodeURIComponent(String(value));
|
||||||
|
return `${encoded.length}:${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
function compareNodes(left, right) {
|
function compareNodes(left, right) {
|
||||||
const nameOrder = left.normalizedName === right.normalizedName
|
const nameOrder = left.normalizedName === right.normalizedName
|
||||||
? 0
|
? 0
|
||||||
@@ -41,16 +46,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeSite(id, name, ordinal, extra) {
|
function makeSite(id, name, ordinal, extra) {
|
||||||
|
const syntheticType = extra && extra.syntheticType;
|
||||||
return Object.assign({
|
return Object.assign({
|
||||||
kind: 'site', id, canonicalId: id, name, normalizedName: normalized(name), ordinal,
|
kind: 'site', id, canonicalId: id, name, normalizedName: normalized(name), ordinal,
|
||||||
key: `site:${id}`, pendingDeletion: false, groups: [], synthetic: false,
|
key: syntheticType
|
||||||
|
? `site:s:${keyPart(syntheticType)}:${keyPart(id)}`
|
||||||
|
: `site:t:${keyPart(id)}`,
|
||||||
|
pendingDeletion: false, groups: [], synthetic: false,
|
||||||
}, extra);
|
}, extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeGroup(site, id, name, ordinal, extra) {
|
function makeGroup(site, id, name, ordinal, extra) {
|
||||||
|
const syntheticType = extra && extra.syntheticType;
|
||||||
return Object.assign({
|
return Object.assign({
|
||||||
kind: 'group', id, canonicalId: id, name, normalizedName: normalized(name), ordinal,
|
kind: 'group', id, canonicalId: id, name, normalizedName: normalized(name), ordinal,
|
||||||
key: `group:${site.id}:${id}`, parentKey: site.key, site, cameras: [],
|
key: syntheticType
|
||||||
|
? `group:s:${keyPart(syntheticType)}:${keyPart(site.key)}:${keyPart(id)}`
|
||||||
|
: `group:t:${keyPart(site.key)}:${keyPart(id)}`,
|
||||||
|
parentKey: site.key, site, cameras: [],
|
||||||
pendingDeletion: false, synthetic: false,
|
pendingDeletion: false, synthetic: false,
|
||||||
}, extra);
|
}, extra);
|
||||||
}
|
}
|
||||||
@@ -68,8 +81,28 @@
|
|||||||
const groupOccurrences = new Map();
|
const groupOccurrences = new Map();
|
||||||
const siteOccurrences = new Map();
|
const siteOccurrences = new Map();
|
||||||
const cameraOccurrences = new Map();
|
const cameraOccurrences = new Map();
|
||||||
|
const syntheticSites = new Map();
|
||||||
let syntheticOrdinal = sourceSites.length + sourceGroups.length + sourceDevices.length;
|
let syntheticOrdinal = sourceSites.length + sourceGroups.length + sourceDevices.length;
|
||||||
|
|
||||||
|
function registerRow(node) {
|
||||||
|
if (!rowByKey.has(node.key)) {
|
||||||
|
rowByKey.set(node.key, node);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const collidedKey = node.key;
|
||||||
|
let collisionOrdinal = 2;
|
||||||
|
do {
|
||||||
|
node.key = `${collidedKey}:collision:${collisionOrdinal}`;
|
||||||
|
collisionOrdinal += 1;
|
||||||
|
} while (rowByKey.has(node.key));
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'row-key-collision', key: collidedKey, resolvedKey: node.key,
|
||||||
|
kind: node.kind, id: node.canonicalId,
|
||||||
|
});
|
||||||
|
rowByKey.set(node.key, node);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
sourceSites.forEach((item, ordinal) => {
|
sourceSites.forEach((item, ordinal) => {
|
||||||
const safe = item && typeof item === 'object' ? item : {};
|
const safe = item && typeof item === 'object' ? item : {};
|
||||||
const identity = createIdentity('site', safe, ordinal, siteOccurrences, diagnostics);
|
const identity = createIdentity('site', safe, ordinal, siteOccurrences, diagnostics);
|
||||||
@@ -79,19 +112,24 @@
|
|||||||
source: safe,
|
source: safe,
|
||||||
});
|
});
|
||||||
sites.push(site);
|
sites.push(site);
|
||||||
rowByKey.set(site.key, site);
|
registerRow(site);
|
||||||
if (identity.rawId && !siteByRawId.has(identity.rawId)) siteByRawId.set(identity.rawId, site);
|
if (identity.rawId && !siteByRawId.has(identity.rawId)) siteByRawId.set(identity.rawId, site);
|
||||||
});
|
});
|
||||||
|
|
||||||
function ensureUnknownSite(rawSiteId) {
|
function ensureUnknownSite(rawSiteId) {
|
||||||
const labelId = text(rawSiteId);
|
const labelId = text(rawSiteId);
|
||||||
const id = labelId ? `${SPECIAL.UNKNOWN_SITE_PREFIX}${labelId}` : SPECIAL.ORPHAN_SITE;
|
const id = labelId ? `${SPECIAL.UNKNOWN_SITE_PREFIX}${labelId}` : SPECIAL.ORPHAN_SITE;
|
||||||
let site = rowByKey.get(`site:${id}`);
|
const syntheticType = labelId ? 'unknown-site' : 'orphan';
|
||||||
|
const identity = `${syntheticType}:${keyPart(labelId)}`;
|
||||||
|
let site = syntheticSites.get(identity);
|
||||||
if (!site) {
|
if (!site) {
|
||||||
const name = labelId ? `Unknown site: ${labelId}` : 'Orphaned cameras';
|
const name = labelId ? `Unknown site: ${labelId}` : 'Orphaned cameras';
|
||||||
site = makeSite(id, name, syntheticOrdinal++, { synthetic: true, hierarchyStatus: labelId ? 'unknown-site' : 'orphan' });
|
site = makeSite(id, name, syntheticOrdinal++, {
|
||||||
|
synthetic: true, syntheticType, hierarchyStatus: syntheticType,
|
||||||
|
});
|
||||||
sites.push(site);
|
sites.push(site);
|
||||||
rowByKey.set(site.key, site);
|
registerRow(site);
|
||||||
|
syntheticSites.set(identity, site);
|
||||||
}
|
}
|
||||||
return site;
|
return site;
|
||||||
}
|
}
|
||||||
@@ -109,23 +147,24 @@
|
|||||||
source: safe,
|
source: safe,
|
||||||
});
|
});
|
||||||
site.groups.push(group);
|
site.groups.push(group);
|
||||||
rowByKey.set(group.key, group);
|
registerRow(group);
|
||||||
if (identity.rawId && !groupByRawId.has(identity.rawId)) groupByRawId.set(identity.rawId, group);
|
if (identity.rawId && !groupByRawId.has(identity.rawId)) groupByRawId.set(identity.rawId, group);
|
||||||
});
|
});
|
||||||
|
|
||||||
function ensureGroup(site, id, name, status, template) {
|
function ensureGroup(site, id, name, status, template) {
|
||||||
const key = `group:${site.id}:${id}`;
|
const syntheticType = status === 'conflict' ? 'conflict' : status;
|
||||||
|
const key = `group:s:${keyPart(syntheticType)}:${keyPart(site.key)}:${keyPart(id)}`;
|
||||||
let group = rowByKey.get(key);
|
let group = rowByKey.get(key);
|
||||||
if (!group) {
|
if (!group) {
|
||||||
group = makeGroup(site, id, name, syntheticOrdinal++, {
|
group = makeGroup(site, id, name, syntheticOrdinal++, {
|
||||||
synthetic: true,
|
synthetic: true, syntheticType,
|
||||||
hierarchyStatus: status,
|
hierarchyStatus: status,
|
||||||
canonicalId: template ? template.canonicalId : id,
|
canonicalId: template ? template.canonicalId : id,
|
||||||
pendingDeletion: template ? template.pendingDeletion : false,
|
pendingDeletion: template ? template.pendingDeletion : false,
|
||||||
source: template ? template.source : undefined,
|
source: template ? template.source : undefined,
|
||||||
});
|
});
|
||||||
site.groups.push(group);
|
site.groups.push(group);
|
||||||
rowByKey.set(group.key, group);
|
registerRow(group);
|
||||||
}
|
}
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
@@ -133,6 +172,7 @@
|
|||||||
sourceDevices.forEach((item, ordinal) => {
|
sourceDevices.forEach((item, ordinal) => {
|
||||||
const safe = item && typeof item === 'object' ? item : {};
|
const safe = item && typeof item === 'object' ? item : {};
|
||||||
const identity = createIdentity('camera', safe, ordinal, cameraOccurrences, diagnostics);
|
const identity = createIdentity('camera', safe, ordinal, cameraOccurrences, diagnostics);
|
||||||
|
const cameraKey = `camera:t:${keyPart(identity.uniqueId)}`;
|
||||||
const directSiteId = text(safe.siteId);
|
const directSiteId = text(safe.siteId);
|
||||||
const requestedGroupId = text(safe.deviceGroupId);
|
const requestedGroupId = text(safe.deviceGroupId);
|
||||||
const knownGroup = requestedGroupId ? groupByRawId.get(requestedGroupId) : undefined;
|
const knownGroup = requestedGroupId ? groupByRawId.get(requestedGroupId) : undefined;
|
||||||
@@ -156,11 +196,11 @@
|
|||||||
} else if (!knownGroup) {
|
} else if (!knownGroup) {
|
||||||
group = ensureGroup(site, `${SPECIAL.UNKNOWN_GROUP_PREFIX}${requestedGroupId}`, `Unknown group: ${requestedGroupId}`, 'unknown-group');
|
group = ensureGroup(site, `${SPECIAL.UNKNOWN_GROUP_PREFIX}${requestedGroupId}`, `Unknown group: ${requestedGroupId}`, 'unknown-group');
|
||||||
if (hierarchyStatus !== 'unknown-site') hierarchyStatus = 'unknown-group';
|
if (hierarchyStatus !== 'unknown-site') hierarchyStatus = 'unknown-group';
|
||||||
diagnostics.push({ code: 'unknown-group', key: `camera:${identity.uniqueId}`, id: requestedGroupId });
|
diagnostics.push({ code: 'unknown-group', key: cameraKey, id: requestedGroupId });
|
||||||
} else if (knownGroup.site !== site) {
|
} else if (knownGroup.site !== site) {
|
||||||
group = ensureGroup(site, knownGroup.id, knownGroup.name, 'conflict', knownGroup);
|
group = ensureGroup(site, knownGroup.id, knownGroup.name, 'conflict', knownGroup);
|
||||||
hierarchyStatus = 'conflict';
|
hierarchyStatus = 'conflict';
|
||||||
diagnostics.push({ code: 'site-group-conflict', key: `camera:${identity.uniqueId}`, siteId: directSiteId, groupId: requestedGroupId, groupSiteId: knownGroup.site.canonicalId });
|
diagnostics.push({ code: 'site-group-conflict', key: cameraKey, siteId: directSiteId, groupId: requestedGroupId, groupSiteId: knownGroup.site.canonicalId });
|
||||||
} else {
|
} else {
|
||||||
group = knownGroup;
|
group = knownGroup;
|
||||||
}
|
}
|
||||||
@@ -168,15 +208,15 @@
|
|||||||
const name = text(safe.name) || identity.canonicalId;
|
const name = text(safe.name) || identity.canonicalId;
|
||||||
const camera = {
|
const camera = {
|
||||||
kind: 'camera', id: identity.uniqueId, canonicalId: identity.canonicalId,
|
kind: 'camera', id: identity.uniqueId, canonicalId: identity.canonicalId,
|
||||||
key: `camera:${identity.uniqueId}`, name, normalizedName: normalized(name), ordinal,
|
key: cameraKey, name, normalizedName: normalized(name), ordinal,
|
||||||
parentKey: group.key, site, group, hierarchyStatus, source: safe,
|
parentKey: group.key, site, group, hierarchyStatus, source: safe,
|
||||||
};
|
};
|
||||||
camera.searchCorpus = normalized([
|
camera.searchCorpus = normalized([
|
||||||
name, identity.canonicalId, safe.model, safe.type, safe.address, site.name, group.name,
|
name, identity.canonicalId, safe.model, safe.type, safe.address, site.name, group.name,
|
||||||
].map(text).join(' '));
|
].map(text).join(' '));
|
||||||
group.cameras.push(camera);
|
group.cameras.push(camera);
|
||||||
|
registerRow(camera);
|
||||||
cameraByKey.set(camera.key, camera);
|
cameraByKey.set(camera.key, camera);
|
||||||
rowByKey.set(camera.key, camera);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
sites.forEach((site) => {
|
sites.forEach((site) => {
|
||||||
@@ -238,15 +278,16 @@
|
|||||||
function calculateVirtualWindow(rows, options = {}) {
|
function calculateVirtualWindow(rows, options = {}) {
|
||||||
const rowHeight = Number.isFinite(options.rowHeight) && options.rowHeight > 0 ? options.rowHeight : 28;
|
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 viewportHeight = Number.isFinite(options.viewportHeight) && options.viewportHeight >= 0 ? options.viewportHeight : 0;
|
||||||
const scrollTop = Math.max(0, Number.isFinite(options.scrollTop) ? options.scrollTop : 0);
|
const requestedScrollTop = Math.max(0, Number.isFinite(options.scrollTop) ? options.scrollTop : 0);
|
||||||
const overscan = Math.max(0, Number.isInteger(options.overscan) ? options.overscan : 16);
|
const overscan = Math.max(0, Number.isInteger(options.overscan) ? options.overscan : 16);
|
||||||
|
const scrollTop = Math.min(requestedScrollTop, Math.max(0, (rows.length * rowHeight) - viewportHeight));
|
||||||
const visibleStart = Math.floor(scrollTop / rowHeight);
|
const visibleStart = Math.floor(scrollTop / rowHeight);
|
||||||
const visibleEnd = Math.ceil((scrollTop + viewportHeight) / rowHeight);
|
const visibleEnd = Math.ceil((scrollTop + viewportHeight) / rowHeight);
|
||||||
const startIndex = Math.max(0, Math.min(rows.length, visibleStart - overscan));
|
const startIndex = Math.max(0, Math.min(rows.length, visibleStart - overscan));
|
||||||
const endIndex = Math.max(startIndex, Math.min(rows.length, visibleEnd + overscan));
|
const endIndex = Math.max(startIndex, Math.min(rows.length, visibleEnd + overscan));
|
||||||
return {
|
return {
|
||||||
rows: rows.slice(startIndex, endIndex), startIndex, endIndex,
|
rows: rows.slice(startIndex, endIndex), startIndex, endIndex,
|
||||||
offsetTop: startIndex * rowHeight, totalHeight: rows.length * rowHeight,
|
offsetTop: startIndex * rowHeight, totalHeight: rows.length * rowHeight, scrollTop,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-5
@@ -35,13 +35,20 @@
|
|||||||
return rows.find((row) => row && row.key === key) || null;
|
return rows.find((row) => row && row.key === key) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expansionState(state) {
|
||||||
|
return state.searchQuery && state.searchExpandedKeys instanceof Set
|
||||||
|
? { field: 'searchExpandedKeys', keys: state.searchExpandedKeys }
|
||||||
|
: { field: 'expandedKeys', keys: state.expandedKeys };
|
||||||
|
}
|
||||||
|
|
||||||
function changeExpansion(state, row, force) {
|
function changeExpansion(state, row, force) {
|
||||||
if (!row || row.kind === 'camera') return state;
|
if (!row || row.kind === 'camera') return state;
|
||||||
const expandedKeys = copySet(state.expandedKeys);
|
const current = expansionState(state);
|
||||||
|
const expandedKeys = copySet(current.keys);
|
||||||
const shouldExpand = force === undefined ? !expandedKeys.has(row.key) : force;
|
const shouldExpand = force === undefined ? !expandedKeys.has(row.key) : force;
|
||||||
if (shouldExpand) expandedKeys.add(row.key);
|
if (shouldExpand) expandedKeys.add(row.key);
|
||||||
else expandedKeys.delete(row.key);
|
else expandedKeys.delete(row.key);
|
||||||
return Object.assign({}, state, { expandedKeys, activeKey: row.key });
|
return Object.assign({}, state, { [current.field]: expandedKeys, activeKey: row.key });
|
||||||
}
|
}
|
||||||
|
|
||||||
function activate(state, row) {
|
function activate(state, row) {
|
||||||
@@ -101,8 +108,13 @@
|
|||||||
}
|
}
|
||||||
if (!rows.length) return state;
|
if (!rows.length) return state;
|
||||||
let index = rows.findIndex((row) => row.key === state.activeKey);
|
let index = rows.findIndex((row) => row.key === state.activeKey);
|
||||||
if (index < 0) index = 0;
|
if (index < 0) {
|
||||||
|
if (action.key === 'ArrowDown' || action.key === 'Home') return moveActive(state, rows, 0);
|
||||||
|
if (action.key === 'ArrowUp' || action.key === 'End') return moveActive(state, rows, rows.length - 1);
|
||||||
|
index = 0;
|
||||||
|
}
|
||||||
const row = rows[index];
|
const row = rows[index];
|
||||||
|
const expandedKeys = expansionState(state).keys;
|
||||||
switch (action.key) {
|
switch (action.key) {
|
||||||
case 'ArrowDown': return moveActive(state, rows, index + 1);
|
case 'ArrowDown': return moveActive(state, rows, index + 1);
|
||||||
case 'ArrowUp': return moveActive(state, rows, index - 1);
|
case 'ArrowUp': return moveActive(state, rows, index - 1);
|
||||||
@@ -110,12 +122,12 @@
|
|||||||
case 'End': return moveActive(state, rows, rows.length - 1);
|
case 'End': return moveActive(state, rows, rows.length - 1);
|
||||||
case 'ArrowRight': {
|
case 'ArrowRight': {
|
||||||
if (row.kind === 'camera') return state;
|
if (row.kind === 'camera') return state;
|
||||||
if (!state.expandedKeys.has(row.key)) return changeExpansion(state, row, true);
|
if (!expandedKeys.has(row.key)) return changeExpansion(state, row, true);
|
||||||
const child = rows[index + 1];
|
const child = rows[index + 1];
|
||||||
return child && child.parentKey === row.key ? Object.assign({}, state, { activeKey: child.key }) : state;
|
return child && child.parentKey === row.key ? Object.assign({}, state, { activeKey: child.key }) : state;
|
||||||
}
|
}
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
if (row.kind !== 'camera' && state.expandedKeys.has(row.key)) return changeExpansion(state, row, false);
|
if (row.kind !== 'camera' && expandedKeys.has(row.key)) return changeExpansion(state, row, false);
|
||||||
return row.parentKey && validKey(row.parentKey) ? Object.assign({}, state, { activeKey: row.parentKey }) : state;
|
return row.parentKey && validKey(row.parentKey) ? Object.assign({}, state, { activeKey: row.parentKey }) : state;
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
case ' ':
|
case ' ':
|
||||||
@@ -150,6 +162,10 @@
|
|||||||
matchCameraKeys: state.searchQuery ? state.searchCameraKeys : null,
|
matchCameraKeys: state.searchQuery ? state.searchCameraKeys : null,
|
||||||
});
|
});
|
||||||
const activeIndex = rows.findIndex((row) => row.key === state.activeKey);
|
const activeIndex = rows.findIndex((row) => row.key === state.activeKey);
|
||||||
|
const maximumScrollTop = Math.max(0, (rows.length * rowHeight) - state.viewportHeight);
|
||||||
|
if (state.scrollTop > maximumScrollTop) {
|
||||||
|
state = Object.assign({}, state, { scrollTop: maximumScrollTop });
|
||||||
|
}
|
||||||
if (activeIndex >= 0 && state.viewportHeight > 0) {
|
if (activeIndex >= 0 && state.viewportHeight > 0) {
|
||||||
const activeTop = activeIndex * rowHeight;
|
const activeTop = activeIndex * rowHeight;
|
||||||
const activeBottom = activeTop + rowHeight;
|
const activeBottom = activeTop + rowHeight;
|
||||||
|
|||||||
+76
-16
@@ -33,18 +33,19 @@ function baseFixture() {
|
|||||||
|
|
||||||
test('builds deterministic hierarchy with explicit exceptional buckets and diagnostics', () => {
|
test('builds deterministic hierarchy with explicit exceptional buckets and diagnostics', () => {
|
||||||
const model = buildDeviceTree(baseFixture());
|
const model = buildDeviceTree(baseFixture());
|
||||||
|
const camera = (id) => [...model.cameraByKey.values()].find((entry) => entry.canonicalId === id);
|
||||||
assert.deepEqual(model.sites.map((site) => site.name), ['Alpha', 'Orphaned cameras', 'Unknown site: missing-s', 'Zulu']);
|
assert.deepEqual(model.sites.map((site) => site.name), ['Alpha', 'Orphaned cameras', 'Unknown site: missing-s', 'Zulu']);
|
||||||
assert.equal(model.sites[0].pendingDeletion, true);
|
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[0].groups.map((group) => group.name), ['Doors', 'Lobby', 'Ungrouped', 'Unknown group: missing-g']);
|
||||||
assert.equal(model.sites[0].groups.find((group) => group.id === 'g1').pendingDeletion, true);
|
assert.equal(model.sites[0].groups.find((group) => group.id === 'g1').pendingDeletion, true);
|
||||||
assert.deepEqual(model.sites[3].groups[0].cameras.map((camera) => camera.name), ['Inferred']);
|
assert.deepEqual(model.sites[3].groups[0].cameras.map((camera) => camera.name), ['Inferred']);
|
||||||
|
|
||||||
const conflict = model.cameraByKey.get('camera:c3');
|
const conflict = camera('c3');
|
||||||
assert.equal(conflict.site.id, 's1');
|
assert.equal(conflict.site.id, 's1');
|
||||||
assert.equal(conflict.group.name, 'Doors');
|
assert.equal(conflict.group.name, 'Doors');
|
||||||
assert.equal(conflict.hierarchyStatus, 'conflict');
|
assert.equal(conflict.hierarchyStatus, 'conflict');
|
||||||
assert.equal(model.cameraByKey.get('camera:c2').hierarchyStatus, 'inferred-site');
|
assert.equal(camera('c2').hierarchyStatus, 'inferred-site');
|
||||||
assert.ok(model.diagnostics.some((entry) => entry.code === 'site-group-conflict' && entry.key === 'camera:c3'));
|
assert.ok(model.diagnostics.some((entry) => entry.code === 'site-group-conflict' && entry.key === conflict.key));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('duplicate and malformed IDs get stable unique keys and diagnostics', () => {
|
test('duplicate and malformed IDs get stable unique keys and diagnostics', () => {
|
||||||
@@ -67,44 +68,49 @@ test('duplicate and malformed IDs get stable unique keys and diagnostics', () =>
|
|||||||
|
|
||||||
test('sites start collapsed; expansion produces levels and complete ARIA metadata', () => {
|
test('sites start collapsed; expansion produces levels and complete ARIA metadata', () => {
|
||||||
const model = buildDeviceTree(baseFixture());
|
const model = buildDeviceTree(baseFixture());
|
||||||
let rows = flattenVisibleRows(model, { expandedKeys: new Set(), selectedKey: 'camera:c1' });
|
const site = model.sites.find((entry) => entry.canonicalId === 's1' && !entry.synthetic);
|
||||||
|
const group = site.groups.find((entry) => entry.canonicalId === 'g1' && !entry.synthetic);
|
||||||
|
const selected = [...model.cameraByKey.values()].find((entry) => entry.canonicalId === 'c1');
|
||||||
|
let rows = flattenVisibleRows(model, { expandedKeys: new Set(), selectedKey: selected.key });
|
||||||
assert.equal(rows.length, 4);
|
assert.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.kind === 'site' && row.aria.level === 1 && row.aria.expanded === false));
|
||||||
assert.ok(rows.every((row) => row.aria.setsize === 4));
|
assert.ok(rows.every((row) => row.aria.setsize === 4));
|
||||||
assert.equal(rows.hiddenSelected, true);
|
assert.equal(rows.hiddenSelected, true);
|
||||||
|
|
||||||
rows = flattenVisibleRows(model, { expandedKeys: new Set(['site:s1']) });
|
rows = flattenVisibleRows(model, { expandedKeys: new Set([site.key]) });
|
||||||
assert.deepEqual(rows.slice(0, 5).map((row) => row.kind), ['site', 'group', 'group', 'group', 'group']);
|
assert.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));
|
assert.ok(rows.slice(1, 5).every((row) => row.aria.level === 2 && row.aria.expanded === false));
|
||||||
|
|
||||||
rows = flattenVisibleRows(model, {
|
rows = flattenVisibleRows(model, {
|
||||||
expandedKeys: new Set(['site:s1', 'group:s1:g1']),
|
expandedKeys: new Set([site.key, group.key]),
|
||||||
selectedKey: 'camera:c1',
|
selectedKey: selected.key,
|
||||||
});
|
});
|
||||||
const camera = rows.find((row) => row.key === 'camera:c1');
|
const camera = rows.find((row) => row.key === selected.key);
|
||||||
assert.equal(camera.parentKey, 'group:s1:g1');
|
assert.equal(camera.parentKey, group.key);
|
||||||
assert.deepEqual(camera.aria, { level: 3, expanded: undefined, selected: true, posinset: 1, setsize: 1 });
|
assert.deepEqual(camera.aria, { level: 3, expanded: undefined, selected: true, posinset: 1, setsize: 1 });
|
||||||
assert.equal(rows.hiddenSelected, false);
|
assert.equal(rows.hiddenSelected, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('search covers camera fields and ancestors without mutating saved expansion', async () => {
|
test('search covers camera fields and ancestors without mutating saved expansion', async () => {
|
||||||
const model = buildDeviceTree(baseFixture());
|
const model = buildDeviceTree(baseFixture());
|
||||||
|
const site = (id) => model.sites.find((entry) => entry.canonicalId === id && !entry.synthetic);
|
||||||
|
const camera = (id) => [...model.cameraByKey.values()].find((entry) => entry.canonicalId === id);
|
||||||
const scheduled = [];
|
const scheduled = [];
|
||||||
const runner = createSearchRunner({ scheduler: (work) => scheduled.push(work), chunkSize: 2 });
|
const runner = createSearchRunner({ scheduler: (work) => scheduled.push(work), chunkSize: 2 });
|
||||||
const saved = new Set(['site:s2']);
|
const saved = new Set([site('s2').key]);
|
||||||
const promise = runner.search(model, 'alpha lobby 10.0.0.1', { expandedKeys: saved });
|
const promise = runner.search(model, 'alpha lobby 10.0.0.1', { expandedKeys: saved });
|
||||||
while (scheduled.length) scheduled.shift()();
|
while (scheduled.length) scheduled.shift()();
|
||||||
const result = await promise;
|
const result = await promise;
|
||||||
assert.equal(result.count, 1);
|
assert.equal(result.count, 1);
|
||||||
assert.deepEqual([...result.cameraKeys], ['camera:c1']);
|
assert.deepEqual([...result.cameraKeys], [camera('c1').key]);
|
||||||
assert.ok(result.expandedKeys.has('site:s1'));
|
assert.ok(result.expandedKeys.has(site('s1').key));
|
||||||
assert.ok(result.expandedKeys.has('group:s1:g1'));
|
assert.ok(result.expandedKeys.has(camera('c1').group.key));
|
||||||
assert.deepEqual([...saved], ['site:s2']);
|
assert.deepEqual([...saved], [site('s2').key]);
|
||||||
|
|
||||||
const ancestorPromise = runner.search(model, 'zulu', { expandedKeys: new Set() });
|
const ancestorPromise = runner.search(model, 'zulu', { expandedKeys: new Set() });
|
||||||
while (scheduled.length) scheduled.shift()();
|
while (scheduled.length) scheduled.shift()();
|
||||||
const ancestor = await ancestorPromise;
|
const ancestor = await ancestorPromise;
|
||||||
assert.deepEqual([...ancestor.cameraKeys], ['camera:c2']);
|
assert.deepEqual([...ancestor.cameraKeys], [camera('c2').key]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('new search cancels stale chunked generation', async () => {
|
test('new search cancels stale chunked generation', async () => {
|
||||||
@@ -132,7 +138,9 @@ test('virtual window remains structurally bounded for 5,000 expanded shuffled ca
|
|||||||
[devices[i], devices[j]] = [devices[j], devices[i]];
|
[devices[i], devices[j]] = [devices[j], devices[i]];
|
||||||
}
|
}
|
||||||
const model = buildDeviceTree({ sites: [{ id: 's', name: 'Large' }], groups: [], devices });
|
const model = buildDeviceTree({ sites: [{ id: 's', name: 'Large' }], groups: [], devices });
|
||||||
const rows = flattenVisibleRows(model, { expandedKeys: new Set(['site:s', 'group:s:__ungrouped__']) });
|
const rows = flattenVisibleRows(model, {
|
||||||
|
expandedKeys: new Set([model.sites[0].key, model.sites[0].groups[0].key]),
|
||||||
|
});
|
||||||
const window = calculateVirtualWindow(rows, { scrollTop: 50000, viewportHeight: 320, rowHeight: 28 });
|
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.ok(window.rows.length >= 40 && window.rows.length <= 50, `mounted ${window.rows.length}`);
|
||||||
assert.equal(window.totalHeight, rows.length * 28);
|
assert.equal(window.totalHeight, rows.length * 28);
|
||||||
@@ -146,3 +154,55 @@ test('1,500-site/group fixture has deterministic stable ordering', () => {
|
|||||||
const namesAndIds = model.sites.map((site) => `${site.normalizedName}|${site.id}`);
|
const namesAndIds = model.sites.map((site) => `${site.normalizedName}|${site.id}`);
|
||||||
assert.deepEqual(namesAndIds, [...namesAndIds].sort((a, b) => a.localeCompare(b)));
|
assert.deepEqual(namesAndIds, [...namesAndIds].sort((a, b) => a.localeCompare(b)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tenant and synthetic namespaces stay distinct for reserved and ambiguous IDs', () => {
|
||||||
|
const model = buildDeviceTree({
|
||||||
|
sites: [
|
||||||
|
{ id: '__orphaned__', name: 'Tenant Reserved Site' },
|
||||||
|
{ id: '__unknown_site__:missing', name: 'Tenant Unknown-shaped Site' },
|
||||||
|
{ id: 'a', name: 'A' },
|
||||||
|
{ id: 'a:b', name: 'A colon B' },
|
||||||
|
{ id: 'percent%:site', name: 'Percent Site' },
|
||||||
|
],
|
||||||
|
groups: [
|
||||||
|
{ id: '__ungrouped__', name: 'Tenant Reserved Group', parentId: '__orphaned__' },
|
||||||
|
{ id: 'b:c', name: 'Tuple One', parentId: 'a' },
|
||||||
|
{ id: 'c', name: 'Tuple Two', parentId: 'a:b' },
|
||||||
|
{ id: 'g:%', name: 'Escaped Group', parentId: 'percent%:site' },
|
||||||
|
],
|
||||||
|
devices: [
|
||||||
|
{ id: 'tenant-group', siteId: '__orphaned__', deviceGroupId: '__ungrouped__' },
|
||||||
|
{ id: 'synthetic-group', siteId: '__orphaned__' },
|
||||||
|
{ id: 'orphan' },
|
||||||
|
{ id: 'unknown-site', siteId: 'missing' },
|
||||||
|
{ id: 'tuple-one', siteId: 'a', deviceGroupId: 'b:c' },
|
||||||
|
{ id: 'tuple-two', siteId: 'a:b', deviceGroupId: 'c' },
|
||||||
|
{ id: 'escaped', siteId: 'percent%:site', deviceGroupId: 'g:%' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tenantReserved = model.sites.find((site) => !site.synthetic && site.id === '__orphaned__');
|
||||||
|
const orphanBucket = model.sites.find((site) => site.synthetic && site.hierarchyStatus === 'orphan');
|
||||||
|
const unknownBucket = model.sites.find((site) => site.synthetic && site.hierarchyStatus === 'unknown-site');
|
||||||
|
assert.ok(tenantReserved && orphanBucket && unknownBucket);
|
||||||
|
assert.notEqual(tenantReserved.key, orphanBucket.key);
|
||||||
|
const camera = (id) => [...model.cameraByKey.values()].find((entry) => entry.canonicalId === id);
|
||||||
|
assert.notEqual(camera('tenant-group').group.key, camera('synthetic-group').group.key);
|
||||||
|
assert.notEqual(camera('tuple-one').group.key, camera('tuple-two').group.key);
|
||||||
|
assert.equal(model.rowByKey.size, model.sites.length +
|
||||||
|
model.sites.reduce((count, site) => count + site.groups.length, 0) + model.cameraCount);
|
||||||
|
assert.ok([...model.rowByKey.keys()].every((key) => !/\s/.test(key)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('virtual window clamps an obsolete scroll offset after rows shrink', () => {
|
||||||
|
const rows = [{ key: 'only-row' }];
|
||||||
|
const window = calculateVirtualWindow(rows, {
|
||||||
|
scrollTop: 10000,
|
||||||
|
viewportHeight: 320,
|
||||||
|
rowHeight: 28,
|
||||||
|
overscan: 0,
|
||||||
|
});
|
||||||
|
assert.deepEqual(window.rows, rows);
|
||||||
|
assert.equal(window.startIndex, 0);
|
||||||
|
assert.equal(window.endIndex, 1);
|
||||||
|
});
|
||||||
|
|||||||
+101
-19
@@ -21,65 +21,76 @@ function visible(model, state) {
|
|||||||
return flattenVisibleRows(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', () => {
|
test('reducer expands, navigates, selects, collapses, and preserves hidden selection', () => {
|
||||||
const model = fixture();
|
const model = fixture();
|
||||||
|
const keys = fixtureKeys(model);
|
||||||
let state = createInitialState();
|
let state = createInitialState();
|
||||||
let rows = visible(model, state);
|
let rows = visible(model, state);
|
||||||
state = reduceSidebar(state, { type: 'KEY', key: 'Home' }, rows);
|
state = reduceSidebar(state, { type: 'KEY', key: 'Home' }, rows);
|
||||||
assert.equal(state.activeKey, 'site:s');
|
assert.equal(state.activeKey, keys.site);
|
||||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowRight' }, rows);
|
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowRight' }, rows);
|
||||||
assert.ok(state.expandedKeys.has('site:s'));
|
assert.ok(state.expandedKeys.has(keys.site));
|
||||||
|
|
||||||
rows = visible(model, state);
|
rows = visible(model, state);
|
||||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowDown' }, rows);
|
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowDown' }, rows);
|
||||||
assert.equal(state.activeKey, 'group:s:g');
|
assert.equal(state.activeKey, keys.group);
|
||||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowRight' }, rows);
|
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowRight' }, rows);
|
||||||
rows = visible(model, state);
|
rows = visible(model, state);
|
||||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowDown' }, rows);
|
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowDown' }, rows);
|
||||||
state = reduceSidebar(state, { type: 'KEY', key: 'Enter' }, rows);
|
state = reduceSidebar(state, { type: 'KEY', key: 'Enter' }, rows);
|
||||||
assert.equal(state.selectedKey, 'camera:a');
|
assert.equal(state.selectedKey, keys.cameras.a);
|
||||||
|
|
||||||
state = reduceSidebar(state, { type: 'ACTIVATE', rowKey: 'site:s' }, rows);
|
state = reduceSidebar(state, { type: 'ACTIVATE', rowKey: keys.site }, rows);
|
||||||
assert.equal(state.selectedKey, 'camera:a');
|
assert.equal(state.selectedKey, keys.cameras.a);
|
||||||
assert.equal(visible(model, state).hiddenSelected, true);
|
assert.equal(visible(model, state).hiddenSelected, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keyboard Left/Right parent behavior, bounds, Space and Escape', () => {
|
test('keyboard Left/Right parent behavior, bounds, Space and Escape', () => {
|
||||||
const model = fixture();
|
const model = fixture();
|
||||||
|
const keys = fixtureKeys(model);
|
||||||
let state = createInitialState({
|
let state = createInitialState({
|
||||||
expandedKeys: new Set(['site:s', 'group:s:g']),
|
expandedKeys: new Set([keys.site, keys.group]),
|
||||||
activeKey: 'camera:a',
|
activeKey: keys.cameras.a,
|
||||||
searchQuery: 'alpha',
|
searchQuery: 'alpha',
|
||||||
});
|
});
|
||||||
let rows = visible(model, state);
|
let rows = visible(model, state);
|
||||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowLeft' }, rows);
|
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowLeft' }, rows);
|
||||||
assert.equal(state.activeKey, 'group:s:g');
|
assert.equal(state.activeKey, keys.group);
|
||||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowLeft' }, rows);
|
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowLeft' }, rows);
|
||||||
assert.equal(state.expandedKeys.has('group:s:g'), false);
|
assert.equal(state.expandedKeys.has(keys.group), false);
|
||||||
state = reduceSidebar(state, { type: 'KEY', key: 'End' }, visible(model, state));
|
state = reduceSidebar(state, { type: 'KEY', key: 'End' }, visible(model, state));
|
||||||
assert.equal(state.activeKey, 'group:s:g');
|
assert.equal(state.activeKey, keys.group);
|
||||||
state = reduceSidebar(state, { type: 'KEY', key: ' ' }, visible(model, state));
|
state = reduceSidebar(state, { type: 'KEY', key: ' ' }, visible(model, state));
|
||||||
assert.equal(state.expandedKeys.has('group:s:g'), true);
|
assert.equal(state.expandedKeys.has(keys.group), true);
|
||||||
state = reduceSidebar(state, { type: 'KEY', key: 'Escape' }, visible(model, state));
|
state = reduceSidebar(state, { type: 'KEY', key: 'Escape' }, visible(model, state));
|
||||||
assert.equal(state.searchQuery, '');
|
assert.equal(state.searchQuery, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('malformed and unknown delegated row keys are no-ops', () => {
|
test('malformed and unknown delegated row keys are no-ops', () => {
|
||||||
const state = createInitialState({ activeKey: 'site:s' });
|
const model = fixture();
|
||||||
const rows = visible(fixture(), state);
|
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: 'ACTIVATE', rowKey: '__proto__' }, rows), state);
|
||||||
assert.equal(reduceSidebar(state, { type: 'CLICK', rowKey: 'camera:not-there' }, rows), state);
|
assert.equal(reduceSidebar(state, { type: 'CLICK', rowKey: 'camera:not-there' }, rows), state);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('controller commits exactly once per dispatch through view adapter', () => {
|
test('controller commits exactly once per dispatch through view adapter', () => {
|
||||||
const model = fixture();
|
const model = fixture();
|
||||||
|
const keys = fixtureKeys(model);
|
||||||
const commits = [];
|
const commits = [];
|
||||||
const view = { commit: (snapshot) => commits.push(snapshot) };
|
const view = { commit: (snapshot) => commits.push(snapshot) };
|
||||||
const controller = createSidebarController({ model, view, rowHeight: 28, overscan: 3 });
|
const controller = createSidebarController({ model, view, rowHeight: 28, overscan: 3 });
|
||||||
assert.equal(commits.length, 1);
|
assert.equal(commits.length, 1);
|
||||||
controller.dispatch({ type: 'ACTIVATE', rowKey: 'site:s' });
|
controller.dispatch({ type: 'ACTIVATE', rowKey: keys.site });
|
||||||
assert.equal(commits.length, 2);
|
assert.equal(commits.length, 2);
|
||||||
assert.equal(commits[1].state.expandedKeys.has('site:s'), true);
|
assert.equal(commits[1].state.expandedKeys.has(keys.site), true);
|
||||||
assert.ok(Array.isArray(commits[1].window.rows));
|
assert.ok(Array.isArray(commits[1].window.rows));
|
||||||
const modelReference = controller.getSnapshot().rows;
|
const modelReference = controller.getSnapshot().rows;
|
||||||
controller.refresh();
|
controller.refresh();
|
||||||
@@ -90,17 +101,19 @@ test('controller commits exactly once per dispatch through view adapter', () =>
|
|||||||
|
|
||||||
test('active and selected state survive virtualization and selection is indicated when hidden', () => {
|
test('active and selected state survive virtualization and selection is indicated when hidden', () => {
|
||||||
const model = fixture();
|
const model = fixture();
|
||||||
|
const keys = fixtureKeys(model);
|
||||||
const commits = [];
|
const commits = [];
|
||||||
const controller = createSidebarController({ model, view: { commit: (value) => commits.push(value) } });
|
const controller = createSidebarController({ model, view: { commit: (value) => commits.push(value) } });
|
||||||
controller.dispatch({ type: 'SET_SELECTION', rowKey: 'camera:b' });
|
controller.dispatch({ type: 'SET_SELECTION', rowKey: keys.cameras.b });
|
||||||
controller.dispatch({ type: 'SET_VIEWPORT', scrollTop: 9999, viewportHeight: 20 });
|
controller.dispatch({ type: 'SET_VIEWPORT', scrollTop: 9999, viewportHeight: 20 });
|
||||||
const snapshot = commits.at(-1);
|
const snapshot = commits.at(-1);
|
||||||
assert.equal(snapshot.state.selectedKey, 'camera:b');
|
assert.equal(snapshot.state.selectedKey, keys.cameras.b);
|
||||||
assert.equal(snapshot.rows.hiddenSelected, true);
|
assert.equal(snapshot.rows.hiddenSelected, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('controller search temporarily expands matches with one completion commit', async () => {
|
test('controller search temporarily expands matches with one completion commit', async () => {
|
||||||
const model = fixture();
|
const model = fixture();
|
||||||
|
const keys = fixtureKeys(model);
|
||||||
const commits = [];
|
const commits = [];
|
||||||
const scheduled = [];
|
const scheduled = [];
|
||||||
const controller = createSidebarController({
|
const controller = createSidebarController({
|
||||||
@@ -114,6 +127,75 @@ test('controller search temporarily expands matches with one completion commit',
|
|||||||
const result = await pending;
|
const result = await pending;
|
||||||
assert.equal(result.count, 1);
|
assert.equal(result.count, 1);
|
||||||
assert.equal(commits.length, 2);
|
assert.equal(commits.length, 2);
|
||||||
assert.deepEqual(commits.at(-1).rows.map((row) => row.key), ['site:s', 'group:s:g', 'camera:b']);
|
assert.deepEqual(commits.at(-1).rows.map((row) => row.key), [keys.site, keys.group, keys.cameras.b]);
|
||||||
assert.equal(controller.getState().expandedKeys.size, 0);
|
assert.equal(controller.getState().expandedKeys.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('first arrow press chooses the directional edge when no row is active', () => {
|
||||||
|
const model = buildDeviceTree({
|
||||||
|
sites: [{ id: 'b', name: 'B' }, { id: 'a', name: 'A' }, { id: 'c', name: 'C' }],
|
||||||
|
});
|
||||||
|
const rows = visible(model, createInitialState());
|
||||||
|
assert.equal(reduceSidebar(createInitialState(), { type: 'KEY', key: 'ArrowDown' }, rows).activeKey, rows[0].key);
|
||||||
|
assert.equal(reduceSidebar(createInitialState(), { type: 'KEY', key: 'ArrowUp' }, rows).activeKey, rows.at(-1).key);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collapse clamps stale scroll and keyboard navigation scrolls active rows into the virtual window', () => {
|
||||||
|
const model = buildDeviceTree({
|
||||||
|
sites: [{ id: 'large', name: 'Large' }],
|
||||||
|
groups: [],
|
||||||
|
devices: Array.from({ length: 500 }, (_, index) => ({ id: `c${index}`, name: `Camera ${index}`, siteId: 'large' })),
|
||||||
|
});
|
||||||
|
const site = model.sites[0];
|
||||||
|
const group = site.groups[0];
|
||||||
|
const controller = createSidebarController({
|
||||||
|
model,
|
||||||
|
view: { commit() {} },
|
||||||
|
rowHeight: 20,
|
||||||
|
overscan: 0,
|
||||||
|
initialState: { expandedKeys: new Set([site.key, group.key]), scrollTop: 10000, viewportHeight: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.dispatch({ type: 'ACTIVATE', rowKey: site.key });
|
||||||
|
let snapshot = controller.getSnapshot();
|
||||||
|
assert.deepEqual(snapshot.window.rows.map((row) => row.key), [site.key]);
|
||||||
|
assert.equal(snapshot.state.scrollTop, 0);
|
||||||
|
|
||||||
|
controller.dispatch({ type: 'ACTIVATE', rowKey: site.key });
|
||||||
|
controller.dispatch({ type: 'SET_ACTIVE', rowKey: site.key });
|
||||||
|
controller.dispatch({ type: 'KEY', key: 'End' });
|
||||||
|
snapshot = controller.getSnapshot();
|
||||||
|
assert.equal(snapshot.window.rows.some((row) => row.key === snapshot.state.activeKey), true);
|
||||||
|
assert.ok(snapshot.state.scrollTop > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search expansion controls rendered temporary state and restores saved expansion', async () => {
|
||||||
|
const model = fixture();
|
||||||
|
const scheduled = [];
|
||||||
|
const site = model.sites[0];
|
||||||
|
const group = site.groups[0];
|
||||||
|
const controller = createSidebarController({
|
||||||
|
model,
|
||||||
|
view: { commit() {} },
|
||||||
|
scheduler: (work) => scheduled.push(work),
|
||||||
|
initialState: { expandedKeys: new Set([site.key]) },
|
||||||
|
});
|
||||||
|
const pending = controller.search('alpha');
|
||||||
|
while (scheduled.length) scheduled.shift()();
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
controller.dispatch({ type: 'SET_ACTIVE', rowKey: group.key });
|
||||||
|
controller.dispatch({ type: 'KEY', key: 'ArrowLeft' });
|
||||||
|
assert.equal(controller.getState().searchExpandedKeys.has(group.key), false);
|
||||||
|
assert.equal(controller.getSnapshot().rows.some((row) => row.kind === 'camera'), false);
|
||||||
|
assert.deepEqual(controller.getState().expandedKeys, new Set([site.key]));
|
||||||
|
|
||||||
|
controller.dispatch({ type: 'KEY', key: 'ArrowRight' });
|
||||||
|
assert.equal(controller.getState().searchExpandedKeys.has(group.key), true);
|
||||||
|
assert.equal(controller.getSnapshot().rows.some((row) => row.kind === 'camera'), true);
|
||||||
|
assert.deepEqual(controller.getState().expandedKeys, new Set([site.key]));
|
||||||
|
|
||||||
|
await controller.search('');
|
||||||
|
assert.deepEqual(controller.getState().expandedKeys, new Set([site.key]));
|
||||||
|
assert.equal(controller.getState().searchExpandedKeys, null);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user