mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
UiChip is a non-interactive label chip and renders the voice card's chips. The pressable chips and pills are pressed toggles, so the rail card chips, map source chips and city and place pills render through UiButton with pressedClass, keeping their classes, attributes and the classes other code adds. The pills now report aria-pressed.
934 lines
28 KiB
JavaScript
934 lines
28 KiB
JavaScript
// src/data/managerPanel.test.mjs — the manager's layer rows, rendered into a
|
|
// real DOM: toggles keep keyboard focus through transitions, row chips write
|
|
// the params they declare, and legends, info lines and lists follow the layer.
|
|
import '../testSupport/registerDom.mjs';
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { DataLayerManager } from './manager.js';
|
|
import { assertSameNode } from '../testSupport/domAssert.mjs';
|
|
|
|
/** Build a mock layer whose init/update resolve on the next microtask. */
|
|
function makeSlowLayer(id, { updateInterval = 1000 } = {}) {
|
|
const calls = { enable: 0, disable: 0, update: 0, init: 0 };
|
|
return {
|
|
calls,
|
|
module: {
|
|
id,
|
|
name: id,
|
|
icon: '',
|
|
source: 'test',
|
|
updateInterval,
|
|
async init() {
|
|
calls.init++;
|
|
await Promise.resolve();
|
|
},
|
|
enable() {
|
|
calls.enable++;
|
|
},
|
|
disable() {
|
|
calls.disable++;
|
|
},
|
|
async update() {
|
|
calls.update++;
|
|
await Promise.resolve();
|
|
},
|
|
getStats() {
|
|
return { count: 0, lastUpdate: null };
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
/** A layer that declares a two-state chip plus a legend, like satellites. */
|
|
function makeRowControlLayer() {
|
|
let mode = 'core';
|
|
return {
|
|
get mode() {
|
|
return mode;
|
|
},
|
|
module: {
|
|
id: 'satellites',
|
|
name: 'Satellites',
|
|
icon: '',
|
|
source: 'CelesTrak',
|
|
updateInterval: -1,
|
|
async init() {},
|
|
enable() {},
|
|
disable() {},
|
|
async update() {},
|
|
getStats() {
|
|
return { count: 3, lastUpdate: Date.now() };
|
|
},
|
|
setParams(params) {
|
|
if (params.catalog) mode = params.catalog;
|
|
},
|
|
getParams() {
|
|
return { catalog: mode };
|
|
},
|
|
getRowControls() {
|
|
const dense = mode === 'dense';
|
|
return {
|
|
chips: [
|
|
{
|
|
id: 'catalog',
|
|
label: 'DENSE',
|
|
active: dense,
|
|
title: 'toggle the dense catalog',
|
|
params: { catalog: dense ? 'core' : 'dense' },
|
|
},
|
|
],
|
|
legend: [
|
|
{
|
|
klass: 'nav',
|
|
label: 'NAV',
|
|
color: '#4fd8ff',
|
|
blurb: 'GNSS',
|
|
count: 2,
|
|
},
|
|
{
|
|
klass: 'geo',
|
|
label: 'GEO',
|
|
color: '#c89bff',
|
|
blurb: 'belt',
|
|
count: 5,
|
|
},
|
|
],
|
|
};
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function panelHost(t) {
|
|
document.body.replaceChildren();
|
|
const container = document.body.appendChild(document.createElement('div'));
|
|
container.id = 'data-toggles';
|
|
t.after(() => document.body.replaceChildren());
|
|
return container;
|
|
}
|
|
|
|
const row = (container, id) =>
|
|
container.querySelector(`[data-layer-id="${id}"]`);
|
|
const chipsIn = (node) => [...node.querySelectorAll('.data-toggle-chip')];
|
|
const macrotask = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
async function waitFor(predicate, label) {
|
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
if (predicate()) return;
|
|
await macrotask();
|
|
}
|
|
assert.fail(`timed out waiting for ${label}`);
|
|
}
|
|
|
|
test('renders ordinary layer rows without recreating a panel-hidden coordinator', async (t) => {
|
|
const container = panelHost(t);
|
|
const mgr = new DataLayerManager({});
|
|
const ordinary = makeSlowLayer('flights', { updateInterval: -1 });
|
|
const coordinator = makeSlowLayer('military-awareness', {
|
|
updateInterval: -1,
|
|
});
|
|
coordinator.module.showInTogglePanel = false;
|
|
mgr.register(ordinary.module);
|
|
mgr.register(coordinator.module);
|
|
try {
|
|
mgr.buildTogglePanel(container);
|
|
assert.ok(row(container, 'flights'));
|
|
assert.ok(!row(container, 'military-awareness'));
|
|
assert.equal(await mgr.setEnabled('military-awareness', true), true);
|
|
mgr._refreshTogglePanel();
|
|
assert.ok(!row(container, 'military-awareness'));
|
|
assert.equal(
|
|
mgr.getAll().find(({ id }) => id === 'military-awareness').enabled,
|
|
true,
|
|
);
|
|
} finally {
|
|
await mgr.destroyAll();
|
|
}
|
|
});
|
|
|
|
test('keyboard focus survives Data Layer enabling and disabling transitions', async (t) => {
|
|
const container = panelHost(t);
|
|
const mgr = new DataLayerManager({});
|
|
let releaseEnable;
|
|
let releaseDisable;
|
|
const calls = { enable: 0, disable: 0 };
|
|
mgr.register({
|
|
id: 'focus-layer',
|
|
name: 'Focus layer',
|
|
icon: '',
|
|
source: 'Local focus fixture',
|
|
updateInterval: -1,
|
|
init() {},
|
|
enable() {
|
|
calls.enable += 1;
|
|
return new Promise((resolve) => {
|
|
releaseEnable = resolve;
|
|
});
|
|
},
|
|
disable() {
|
|
calls.disable += 1;
|
|
return new Promise((resolve) => {
|
|
releaseDisable = resolve;
|
|
});
|
|
},
|
|
update() {},
|
|
destroy() {},
|
|
getStats() {
|
|
return { count: 1, lastUpdate: Date.now() };
|
|
},
|
|
});
|
|
|
|
try {
|
|
mgr.buildTogglePanel(container);
|
|
const button = row(container, 'focus-layer').querySelector(
|
|
'.data-toggle-btn',
|
|
);
|
|
button.focus();
|
|
|
|
button.click();
|
|
await waitFor(
|
|
() => typeof releaseEnable === 'function',
|
|
'enable transition',
|
|
);
|
|
assertSameNode(document.activeElement, button);
|
|
assert.equal(button.disabled, false, 'ENABLING never uses native disabled');
|
|
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
|
assert.equal(button.getAttribute('aria-busy'), 'true');
|
|
assert.equal(button.textContent, 'ENABLING');
|
|
button.click();
|
|
await macrotask();
|
|
assert.equal(calls.enable, 1, 'repeat activation is inert while enabling');
|
|
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 1);
|
|
|
|
releaseEnable();
|
|
await waitFor(
|
|
() =>
|
|
mgr.isEnabled('focus-layer') &&
|
|
button.getAttribute('aria-busy') === 'false',
|
|
'enabled',
|
|
);
|
|
assertSameNode(document.activeElement, button);
|
|
assertSameNode(
|
|
row(container, 'focus-layer').querySelector('.data-toggle-btn'),
|
|
button,
|
|
'the SAME button node is reused across the transition',
|
|
);
|
|
assert.equal(button.disabled, false);
|
|
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
|
|
|
button.click();
|
|
await waitFor(
|
|
() => typeof releaseDisable === 'function',
|
|
'disable transition',
|
|
);
|
|
assertSameNode(document.activeElement, button);
|
|
assert.equal(
|
|
button.disabled,
|
|
false,
|
|
'DISABLING never uses native disabled',
|
|
);
|
|
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
|
assert.equal(button.getAttribute('aria-busy'), 'true');
|
|
assert.equal(button.textContent, 'DISABLING');
|
|
button.click();
|
|
await macrotask();
|
|
assert.equal(
|
|
calls.disable,
|
|
1,
|
|
'repeat activation is inert while disabling',
|
|
);
|
|
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 2);
|
|
|
|
releaseDisable();
|
|
await waitFor(
|
|
() =>
|
|
!mgr.isEnabled('focus-layer') &&
|
|
button.getAttribute('aria-busy') === 'false',
|
|
'disabled',
|
|
);
|
|
assertSameNode(document.activeElement, button);
|
|
assert.equal(button.disabled, false);
|
|
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
|
} finally {
|
|
releaseEnable?.();
|
|
releaseDisable?.();
|
|
await mgr.destroyAll();
|
|
}
|
|
});
|
|
|
|
test('a failed Data Layer transition clears busy state without losing keyboard focus', async (t) => {
|
|
const container = panelHost(t);
|
|
const originalWarn = console.warn;
|
|
console.warn = () => {};
|
|
const mgr = new DataLayerManager({});
|
|
let rejectEnable;
|
|
let enableCalls = 0;
|
|
mgr.register({
|
|
id: 'focus-failure-layer',
|
|
name: 'Focus failure layer',
|
|
icon: '',
|
|
source: 'Local focus fixture',
|
|
updateInterval: -1,
|
|
init() {},
|
|
enable() {
|
|
enableCalls += 1;
|
|
if (enableCalls > 1) return true;
|
|
return new Promise((resolve, reject) => {
|
|
rejectEnable = reject;
|
|
});
|
|
},
|
|
disable() {
|
|
return true;
|
|
},
|
|
update() {},
|
|
destroy() {},
|
|
getStats() {
|
|
return { count: 0, lastUpdate: null };
|
|
},
|
|
});
|
|
|
|
try {
|
|
mgr.buildTogglePanel(container);
|
|
const button = row(container, 'focus-failure-layer').querySelector(
|
|
'.data-toggle-btn',
|
|
);
|
|
button.focus();
|
|
|
|
button.click();
|
|
await waitFor(
|
|
() => typeof rejectEnable === 'function',
|
|
'enable transition',
|
|
);
|
|
assertSameNode(document.activeElement, button);
|
|
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
|
assert.equal(button.getAttribute('aria-busy'), 'true');
|
|
|
|
rejectEnable(new Error('QA enable rejected'));
|
|
await waitFor(
|
|
() => button.getAttribute('aria-busy') === 'false',
|
|
'failure settlement',
|
|
);
|
|
assertSameNode(
|
|
document.activeElement,
|
|
button,
|
|
'failure settlement preserves focus',
|
|
);
|
|
assert.equal(button.disabled, false);
|
|
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
|
assert.equal(button.textContent, 'OFF');
|
|
assert.deepEqual(mgr.getLayerLifecycleState('focus-failure-layer'), {
|
|
enabled: false,
|
|
lifecycleState: 'disabled',
|
|
uncertain: false,
|
|
});
|
|
|
|
button.click();
|
|
await waitFor(() => mgr.isEnabled('focus-failure-layer'), 'retry');
|
|
assert.equal(enableCalls, 2, 'the settled control accepts a later retry');
|
|
assertSameNode(document.activeElement, button);
|
|
} finally {
|
|
rejectEnable?.(new Error('test cleanup'));
|
|
console.warn = originalWarn;
|
|
await mgr.destroyAll();
|
|
}
|
|
});
|
|
|
|
test('a layer that declares row controls renders its chips and color legend', async (t) => {
|
|
const container = panelHost(t);
|
|
const mgr = new DataLayerManager({});
|
|
const layer = makeRowControlLayer();
|
|
mgr.register(layer.module);
|
|
try {
|
|
mgr.buildTogglePanel(container);
|
|
const controls = row(container, 'satellites').querySelector(
|
|
'.data-toggle-controls',
|
|
);
|
|
assert.ok(controls, 'the row gained a controls block');
|
|
// Disabled layers stay quiet — no chip, no legend.
|
|
assert.equal(controls.hidden, true);
|
|
|
|
assert.equal(await mgr.setEnabled('satellites', true), true);
|
|
mgr._refreshTogglePanel();
|
|
assert.equal(controls.hidden, false);
|
|
|
|
const chips = chipsIn(controls);
|
|
assert.equal(chips.length, 1);
|
|
assert.equal(chips[0].textContent, 'DENSE');
|
|
assert.equal(chips[0].dataset.chipId, 'catalog');
|
|
assert.equal(chips[0].getAttribute('aria-pressed'), 'false');
|
|
assert.equal(chips[0].title, 'toggle the dense catalog');
|
|
|
|
const swatches = [
|
|
...controls.querySelectorAll('.data-toggle-legend-swatch'),
|
|
];
|
|
assert.deepEqual(
|
|
swatches.map((s) => s.style.background),
|
|
['#4fd8ff', '#c89bff'],
|
|
'each legend swatch is painted the exact class color',
|
|
);
|
|
const items = [...controls.querySelectorAll('.data-toggle-legend-item')];
|
|
assert.deepEqual(
|
|
items.map((i) => i.title),
|
|
['GNSS', 'belt'],
|
|
);
|
|
assert.deepEqual(
|
|
items.map((i) => i.textContent),
|
|
['NAV 2', 'GEO 5'],
|
|
);
|
|
} finally {
|
|
await mgr.destroyAll();
|
|
}
|
|
});
|
|
|
|
test('clicking a row chip applies the params it declared and re-renders', async (t) => {
|
|
const container = panelHost(t);
|
|
const mgr = new DataLayerManager({});
|
|
const layer = makeRowControlLayer();
|
|
mgr.register(layer.module);
|
|
try {
|
|
mgr.buildTogglePanel(container);
|
|
assert.equal(await mgr.setEnabled('satellites', true), true);
|
|
const controls = row(container, 'satellites').querySelector(
|
|
'.data-toggle-controls',
|
|
);
|
|
const chip = chipsIn(controls)[0];
|
|
|
|
chip.click();
|
|
assert.equal(layer.mode, 'dense', 'the chip wrote the params it declared');
|
|
// setLayerParams refreshes the panel, so the chip already reflects the flip.
|
|
assert.equal(chip.getAttribute('aria-pressed'), 'true');
|
|
assert.equal(chip.classList.contains('active'), true);
|
|
|
|
chip.click();
|
|
assert.equal(
|
|
layer.mode,
|
|
'core',
|
|
'the chip toggles back rather than latching',
|
|
);
|
|
assert.equal(chip.getAttribute('aria-pressed'), 'false');
|
|
} finally {
|
|
await mgr.destroyAll();
|
|
}
|
|
});
|
|
|
|
test('a click outside a chip is inert, and a throwing layer cannot blank the panel', async (t) => {
|
|
const container = panelHost(t);
|
|
const warn = console.warn;
|
|
console.warn = () => {};
|
|
const mgr = new DataLayerManager({});
|
|
const layer = makeRowControlLayer();
|
|
mgr.register(layer.module);
|
|
try {
|
|
mgr.buildTogglePanel(container);
|
|
assert.equal(await mgr.setEnabled('satellites', true), true);
|
|
const controls = row(container, 'satellites').querySelector(
|
|
'.data-toggle-controls',
|
|
);
|
|
|
|
controls.querySelector('.data-toggle-legend-item').click();
|
|
assert.equal(layer.mode, 'core', 'the legend is not a control');
|
|
controls.click();
|
|
assert.equal(layer.mode, 'core');
|
|
|
|
layer.module.getRowControls = () => {
|
|
throw new Error('boom');
|
|
};
|
|
mgr._refreshTogglePanel();
|
|
assert.equal(
|
|
controls.hidden,
|
|
true,
|
|
'a throwing layer collapses to an empty block',
|
|
);
|
|
assert.ok(row(container, 'satellites'), 'the row itself survives');
|
|
} finally {
|
|
console.warn = warn;
|
|
await mgr.destroyAll();
|
|
}
|
|
});
|
|
|
|
test('keyboard focus on a chip survives the refresh its own click triggers', async (t) => {
|
|
// The panel repaints on EVERY refresh, including the one the chip's own
|
|
// click triggers. Rebuilding the button would blur it every time, so a
|
|
// keyboard user loses their place on activation.
|
|
const container = panelHost(t);
|
|
const mgr = new DataLayerManager({});
|
|
const layer = makeRowControlLayer();
|
|
mgr.register(layer.module);
|
|
try {
|
|
mgr.buildTogglePanel(container);
|
|
assert.equal(await mgr.setEnabled('satellites', true), true);
|
|
const controls = row(container, 'satellites').querySelector(
|
|
'.data-toggle-controls',
|
|
);
|
|
const chip = chipsIn(controls)[0];
|
|
|
|
chip.focus();
|
|
assertSameNode(document.activeElement, chip, 'the chip starts focused');
|
|
|
|
chip.click();
|
|
assertSameNode(
|
|
document.activeElement,
|
|
chip,
|
|
'activating the chip does not blur it',
|
|
);
|
|
assertSameNode(
|
|
chipsIn(controls)[0],
|
|
chip,
|
|
'the SAME button node is reused across the click-driven refresh',
|
|
);
|
|
|
|
mgr._refreshTogglePanel();
|
|
mgr._refreshTogglePanel();
|
|
assertSameNode(
|
|
document.activeElement,
|
|
chip,
|
|
'repeated refreshes never steal focus',
|
|
);
|
|
assert.equal(
|
|
controls.querySelectorAll('.data-toggle-legend-item').length,
|
|
2,
|
|
);
|
|
|
|
// ...and a chip that genuinely goes away is removed from the row.
|
|
layer.module.getRowControls = () => ({ chips: [], legend: [] });
|
|
mgr._refreshTogglePanel();
|
|
assert.equal(chip.isConnected, false);
|
|
} finally {
|
|
await mgr.destroyAll();
|
|
}
|
|
});
|
|
|
|
test('an async layer pushes its own row refresh, and a busy chip refuses clicks', async (t) => {
|
|
const container = panelHost(t);
|
|
const mgr = new DataLayerManager({});
|
|
let settled = false;
|
|
let writes = 0;
|
|
const module = {
|
|
id: 'satellites',
|
|
name: 'Satellites',
|
|
icon: '',
|
|
source: 'CelesTrak',
|
|
updateInterval: -1,
|
|
async init() {},
|
|
enable() {},
|
|
disable() {},
|
|
async update() {},
|
|
getStats() {
|
|
return { count: 1, lastUpdate: Date.now() };
|
|
},
|
|
setParams() {
|
|
writes += 1;
|
|
},
|
|
getRowControls() {
|
|
return {
|
|
chips: [
|
|
{
|
|
id: 'catalog',
|
|
label: settled ? 'DENSE' : 'DENSE ···',
|
|
active: settled,
|
|
busy: !settled,
|
|
disabled: !settled,
|
|
state: settled ? 'active' : 'loading',
|
|
title: 'x',
|
|
params: { catalog: 'core' },
|
|
},
|
|
],
|
|
legend: [],
|
|
};
|
|
},
|
|
setRowControlsListener(fn) {
|
|
module._listener = fn;
|
|
},
|
|
};
|
|
mgr.register(module);
|
|
try {
|
|
mgr.buildTogglePanel(container);
|
|
assert.equal(await mgr.setEnabled('satellites', true), true);
|
|
assert.equal(
|
|
typeof module._listener,
|
|
'function',
|
|
'the manager installed its listener',
|
|
);
|
|
|
|
const controls = row(container, 'satellites').querySelector(
|
|
'.data-toggle-controls',
|
|
);
|
|
const chip = chipsIn(controls)[0];
|
|
assert.equal(chip.textContent, 'DENSE ···');
|
|
assert.equal(chip.disabled, true);
|
|
assert.equal(chip.getAttribute('aria-busy'), 'true');
|
|
assert.equal(
|
|
chip.getAttribute('aria-pressed'),
|
|
'false',
|
|
'busy is never reported as active',
|
|
);
|
|
assert.equal(chip.classList.contains('chip-loading'), true);
|
|
|
|
chip.click();
|
|
assert.equal(writes, 0, 'a disabled chip is inert');
|
|
|
|
// The layer settles and pushes its own refresh — no panel poll involved.
|
|
settled = true;
|
|
module._listener();
|
|
mgr._layerPanel._flushRowControlsRefresh();
|
|
assert.equal(chip.textContent, 'DENSE');
|
|
assert.equal(chip.disabled, false);
|
|
assert.equal(chip.getAttribute('aria-pressed'), 'true');
|
|
assert.equal(chip.getAttribute('aria-busy'), 'false');
|
|
|
|
chip.click();
|
|
assert.equal(writes, 1, 'the settled chip writes again');
|
|
} finally {
|
|
await mgr.destroyAll();
|
|
}
|
|
});
|
|
|
|
test('a layer that surrenders its row controls hides the block entirely', async (t) => {
|
|
// Space Missions borrows the satellite layer with showPoints:false; the row
|
|
// must not advertise a legend for an empty sky or offer a chip whose write
|
|
// the dependency owner will silently revert.
|
|
const container = panelHost(t);
|
|
const mgr = new DataLayerManager({});
|
|
let surrendered = false;
|
|
const layer = makeRowControlLayer();
|
|
const inner = layer.module.getRowControls;
|
|
layer.module.getRowControls = () =>
|
|
surrendered ? { chips: [], legend: [] } : inner();
|
|
mgr.register(layer.module);
|
|
try {
|
|
mgr.buildTogglePanel(container);
|
|
assert.equal(await mgr.setEnabled('satellites', true), true);
|
|
const controls = row(container, 'satellites').querySelector(
|
|
'.data-toggle-controls',
|
|
);
|
|
assert.equal(controls.hidden, false);
|
|
assert.equal(chipsIn(controls).length, 1);
|
|
|
|
surrendered = true;
|
|
mgr._refreshTogglePanel();
|
|
assert.equal(controls.hidden, true);
|
|
assert.equal(
|
|
chipsIn(controls).length,
|
|
0,
|
|
'the chip is removed, not merely hidden behind a style',
|
|
);
|
|
assert.equal(
|
|
controls.querySelectorAll('.data-toggle-legend-item').length,
|
|
0,
|
|
);
|
|
|
|
surrendered = false;
|
|
mgr._refreshTogglePanel();
|
|
assert.equal(
|
|
controls.hidden,
|
|
false,
|
|
'the row returns when the owner releases it',
|
|
);
|
|
assert.equal(chipsIn(controls).length, 1);
|
|
} finally {
|
|
await mgr.destroyAll();
|
|
}
|
|
});
|
|
|
|
test('panel remount releases old listeners and destruction revokes retained controls', async (t) => {
|
|
panelHost(t);
|
|
const manager = new DataLayerManager({});
|
|
const layer = makeSlowLayer('lifecycle-panel', { updateInterval: -1 });
|
|
let rowListener;
|
|
layer.module.getRowControls = () => ({ chips: [] });
|
|
layer.module.setRowControlsListener = (listener) => {
|
|
rowListener = listener;
|
|
};
|
|
manager.register(layer.module);
|
|
try {
|
|
const first = document.body.appendChild(document.createElement('div'));
|
|
manager.buildTogglePanel(first);
|
|
const oldButton = first.querySelector('.data-toggle-btn');
|
|
const second = document.body.appendChild(document.createElement('div'));
|
|
manager.buildTogglePanel(second);
|
|
assert.equal(first.children.length, 0, 'the old container is emptied');
|
|
oldButton.click();
|
|
await macrotask();
|
|
assert.equal(
|
|
layer.calls.enable,
|
|
0,
|
|
'a revoked generation cannot issue actions',
|
|
);
|
|
const currentButton = second.querySelector('.data-toggle-btn');
|
|
assert.ok(currentButton);
|
|
assert.equal(typeof rowListener, 'function');
|
|
await manager.destroyAll();
|
|
assert.equal(second.children.length, 0, 'destruction removes the rows');
|
|
currentButton.click();
|
|
await macrotask();
|
|
assert.equal(
|
|
layer.calls.enable,
|
|
0,
|
|
'a retained control cannot act after destruction',
|
|
);
|
|
assert.equal(rowListener, null);
|
|
} finally {
|
|
await manager.destroyAll();
|
|
}
|
|
});
|
|
|
|
test('a retained chip from a remounted or destroyed panel writes nothing', async (t) => {
|
|
panelHost(t);
|
|
const mgr = new DataLayerManager({});
|
|
const layer = makeRowControlLayer();
|
|
mgr.register(layer.module);
|
|
try {
|
|
const first = document.body.appendChild(document.createElement('div'));
|
|
mgr.buildTogglePanel(first);
|
|
assert.equal(await mgr.setEnabled('satellites', true), true);
|
|
const oldChip = chipsIn(first)[0];
|
|
const second = document.body.appendChild(document.createElement('div'));
|
|
mgr.buildTogglePanel(second);
|
|
oldChip.click();
|
|
assert.equal(
|
|
layer.mode,
|
|
'core',
|
|
'a released generation cannot write params',
|
|
);
|
|
const chip = chipsIn(second)[0];
|
|
mgr._layerPanel.destroy();
|
|
chip.click();
|
|
assert.equal(layer.mode, 'core', 'a destroyed panel cannot write params');
|
|
} finally {
|
|
await mgr.destroyAll();
|
|
}
|
|
});
|
|
|
|
test('a toggle that settles after a remount repaints the new row idle', async (t) => {
|
|
panelHost(t);
|
|
const mgr = new DataLayerManager({});
|
|
let releaseEnable;
|
|
mgr.register({
|
|
id: 'remount-layer',
|
|
name: 'Remount layer',
|
|
icon: '',
|
|
source: 'Local fixture',
|
|
updateInterval: -1,
|
|
init() {},
|
|
enable() {
|
|
return new Promise((resolve) => {
|
|
releaseEnable = resolve;
|
|
});
|
|
},
|
|
disable() {},
|
|
update() {},
|
|
destroy() {},
|
|
getStats() {
|
|
return { count: 1, lastUpdate: Date.now() };
|
|
},
|
|
});
|
|
try {
|
|
const first = document.body.appendChild(document.createElement('div'));
|
|
mgr.buildTogglePanel(first);
|
|
first.querySelector('.data-toggle-btn').click();
|
|
await waitFor(
|
|
() => typeof releaseEnable === 'function',
|
|
'enable transition',
|
|
);
|
|
const second = document.body.appendChild(document.createElement('div'));
|
|
mgr.buildTogglePanel(second);
|
|
releaseEnable();
|
|
await waitFor(() => mgr.isEnabled('remount-layer'), 'enabled');
|
|
await macrotask();
|
|
const button = second.querySelector('.data-toggle-btn');
|
|
assert.equal(button.getAttribute('aria-busy'), 'false');
|
|
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
|
assert.equal(button.textContent, 'ON');
|
|
} finally {
|
|
releaseEnable?.();
|
|
await mgr.destroyAll();
|
|
}
|
|
});
|
|
|
|
test('row action chips use live disabled state and descriptive counts without writing parameters', async (t) => {
|
|
const container = panelHost(t);
|
|
const mgr = new DataLayerManager({});
|
|
const layer = makeRowControlLayer();
|
|
let calls = 0,
|
|
blocked = false;
|
|
layer.module.getStats = () => ({ count: 3, countLabel: '3 nearby' });
|
|
layer.module.getRowControls = () => ({
|
|
chips: [
|
|
{
|
|
id: 'find',
|
|
label: 'SHOW NEAREST',
|
|
disabled: blocked,
|
|
onClick: () => calls++,
|
|
},
|
|
],
|
|
});
|
|
layer.module.setParams = () => {
|
|
throw new Error('navigation must not write persistent params');
|
|
};
|
|
mgr.register(layer.module);
|
|
try {
|
|
mgr.buildTogglePanel(container);
|
|
await mgr.setEnabled('satellites', true);
|
|
const layerRow = row(container, 'satellites');
|
|
assert.equal(layerRow.querySelector('.data-count').textContent, '3 nearby');
|
|
const chip = chipsIn(layerRow)[0];
|
|
chip.click();
|
|
assert.equal(calls, 1);
|
|
blocked = true;
|
|
chip.click();
|
|
assert.equal(calls, 1, 'live disabled descriptor wins before row repaint');
|
|
blocked = false;
|
|
await mgr.setEnabled('satellites', false);
|
|
assert.equal(chip.isConnected, false, 'the off row drops its chips');
|
|
mgr._layerPanel._selectChip('satellites', 'find');
|
|
assert.equal(calls, 1, 'disabled layer cannot navigate');
|
|
} finally {
|
|
await mgr.destroyAll();
|
|
}
|
|
});
|
|
|
|
for (const scheduler of ['animation frame', 'timeout']) {
|
|
test(`row notifications coalesce with ${scheduler} and stop after destroy`, async (t) => {
|
|
const container = panelHost(t);
|
|
const frames = new Map();
|
|
let nextFrame = 0;
|
|
const originalRaf = globalThis.requestAnimationFrame;
|
|
const originalCancel = globalThis.cancelAnimationFrame;
|
|
globalThis.requestAnimationFrame =
|
|
scheduler === 'animation frame'
|
|
? (callback) => {
|
|
frames.set(++nextFrame, callback);
|
|
return nextFrame;
|
|
}
|
|
: undefined;
|
|
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
|
|
const manager = new DataLayerManager({});
|
|
const layer = makeSlowLayer('wind', { updateInterval: -1 });
|
|
let notify;
|
|
layer.module.getRowControls = () => ({ chips: [] });
|
|
layer.module.setRowControlsListener = (listener) => {
|
|
if (listener) notify = listener;
|
|
};
|
|
manager.register(layer.module);
|
|
try {
|
|
manager.buildTogglePanel(container);
|
|
const panel = manager._layerPanel;
|
|
let refreshes = 0;
|
|
const refresh = panel._refreshTogglePanel.bind(panel);
|
|
panel._refreshTogglePanel = () => {
|
|
refreshes++;
|
|
refresh();
|
|
};
|
|
for (let i = 0; i < 10; i++) notify();
|
|
assert.equal(refreshes, 0, 'notifications defer refresh');
|
|
if (scheduler === 'animation frame') assert.equal(frames.size, 1);
|
|
panel._flushRowControlsRefresh();
|
|
assert.equal(refreshes, 1);
|
|
panel._flushRowControlsRefresh();
|
|
await macrotask();
|
|
assert.equal(frames.size, 0, 'flush cancels the pending frame');
|
|
assert.equal(refreshes, 1, 'flush does not leave duplicate work');
|
|
notify();
|
|
notify();
|
|
if (scheduler === 'animation frame') {
|
|
const [id, callback] = frames.entries().next().value;
|
|
frames.delete(id);
|
|
callback();
|
|
} else await macrotask();
|
|
assert.equal(refreshes, 2, 'scheduled callback refreshes once');
|
|
notify();
|
|
panel.destroy();
|
|
assert.equal(frames.size, 0, 'destroy cancels pending work');
|
|
notify();
|
|
panel._flushRowControlsRefresh();
|
|
await macrotask();
|
|
assert.equal(
|
|
refreshes,
|
|
2,
|
|
'retained notifications after destroy are inert',
|
|
);
|
|
} finally {
|
|
await manager.destroyAll();
|
|
globalThis.requestAnimationFrame = originalRaf;
|
|
globalThis.cancelAnimationFrame = originalCancel;
|
|
}
|
|
});
|
|
}
|
|
|
|
test('row info and legends reconcile in place while chips retain focus and order', async (t) => {
|
|
const container = panelHost(t);
|
|
const manager = new DataLayerManager({});
|
|
const layer = makeSlowLayer('wind', { updateInterval: -1 });
|
|
let controls = {
|
|
chips: [{ id: 'one', label: 'One' }],
|
|
legend: [{ label: 'Low', color: '#0000ff', count: 1, blurb: 'Low values' }],
|
|
info: 'Forecast',
|
|
infoTitle: 'Source time',
|
|
};
|
|
layer.module.getRowControls = () => controls;
|
|
manager.register(layer.module);
|
|
try {
|
|
manager.buildTogglePanel(container);
|
|
await manager.setEnabled('wind', true);
|
|
const block = container.querySelector('.data-toggle-controls');
|
|
const chip = block.children[0];
|
|
const info = block.children[2];
|
|
assert.equal(info.className, 'data-toggle-controls-info');
|
|
assert.equal(info.textContent, 'Forecast');
|
|
assert.equal(info.title, 'Source time');
|
|
chip.focus();
|
|
for (const change of [
|
|
{ label: 'High' },
|
|
{ color: '#ff0000' },
|
|
{ count: 2 },
|
|
{ blurb: 'High values' },
|
|
]) {
|
|
controls = structuredClone(controls);
|
|
Object.assign(controls.legend[0], change);
|
|
manager._refreshTogglePanel();
|
|
assertSameNode(block.children[2], info);
|
|
assertSameNode(document.activeElement, chip);
|
|
}
|
|
const legend = block.children[1];
|
|
assert.equal(legend.title, 'High values');
|
|
assert.equal(legend.textContent, 'High 2');
|
|
assert.equal(legend.firstElementChild.style.background, '#ff0000');
|
|
controls.info = '<img onerror=alert(1)>';
|
|
controls.infoTitle = '';
|
|
controls.chips.push({ id: 'two', label: 'Two' });
|
|
manager._refreshTogglePanel();
|
|
assert.deepEqual(
|
|
[...block.children].map((node) =>
|
|
[...node.classList].filter((name) => !name.startsWith('ui-')).join(' '),
|
|
),
|
|
[
|
|
'data-toggle-chip chip-idle',
|
|
'data-toggle-chip chip-idle',
|
|
'data-toggle-legend-item',
|
|
'data-toggle-controls-info',
|
|
],
|
|
);
|
|
assertSameNode(block.children[0], chip);
|
|
assertSameNode(block.children[3], info);
|
|
assert.equal(info.textContent, controls.info);
|
|
assert.equal(info.title, '');
|
|
assert.equal(info.children.length, 0, 'info remains plain text');
|
|
controls = { chips: [], legend: [] };
|
|
manager._refreshTogglePanel();
|
|
assert.equal(block.hidden, true);
|
|
assert.equal(info.hidden, true);
|
|
assert.equal(block.children.length, 1);
|
|
assertSameNode(block.children[0], info);
|
|
controls = {
|
|
info: 'Restored',
|
|
legend: [{ label: 'New', color: '#ffffff' }],
|
|
};
|
|
manager._refreshTogglePanel();
|
|
assertSameNode(block.lastElementChild, info);
|
|
assert.equal(info.hidden, false);
|
|
assert.equal(info.textContent, 'Restored');
|
|
} finally {
|
|
await manager.destroyAll();
|
|
}
|
|
});
|