Files
gods-eye-view/src/ui/layerPanel.test.mjs
T
Said Turksever 4ebb287d36 Merge main into the Utilities panel, rebuilt on the Vue panel rail
main moved the panels to Vue views and a one-open-panel rail, and removed
the two templates this branch edited. This merge carries the branch onto
that architecture; it adds no behaviour of its own beyond the port.

- The Utilities panel is a RailPanel in LayerPanelsView with its own rail
  item (handyman). LayerPanel renders the Directions rows into its list with
  LayerRows, and falls back to Data Layers without it.
- Draw leaves DisplayPanel for DrawControls, DrawMarkList and DrawSwatches,
  which render what the draw tool publishes to the display store and call
  its actions, as GeoImport does. drawMarks.js words the rows; the DOM-built
  drawMarkList.js is gone. A row's fly-to is an immediate navigation like
  Import's ('drawing' gets its Cockpit refusal), the name field keeps a
  draft through v-field-value, and the colours are one radio group with a
  single tab stop.
- UiSegmented draws an option's icon, named by its label, for the shape
  buttons.
- The catalogs take main's languages with this branch's strings, Arabic
  included, and the tests follow the views to their new homes.
2026-11-02 16:43:29 +00:00

434 lines
14 KiB
JavaScript

import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
test('panel presentation places Transit between Street Traffic and Bike Share in Movement', () => {
const source = readFileSync(
new URL('./layerPanel.js', import.meta.url),
'utf8',
);
const declarations = source.slice(
source.indexOf('const PANEL_GROUPS ='),
source.indexOf('const PANEL_POSITIONS ='),
);
const order = JSON.parse(
runInNewContext(
`const msg = (text) => text;\n${declarations}\nJSON.stringify(PANEL_ORDER)`,
),
);
assert.deepEqual(
order.filter(({ label }) => label === 'Movement').map(({ id }) => id),
[
'satellites',
'flights',
'military',
'local-adsb',
'ais-live-vessels',
'traffic',
'transit',
'bikeshare',
],
);
assert.equal(order.filter(({ id }) => id === 'transit').length, 1);
});
test('partial feed controls distinguish incomplete records from stale data and outages', async () => {
const { LayerPanel, layerFeedState, presentLayerToggle } =
await import('./layerPanel.js');
const layer = {
id: 'ais-live-vessels',
name: 'Live Vessels',
source: 'AISStream',
enabled: true,
stats: {
partial: true,
stale: false,
count: 2,
acceptedRowCount: 2,
rawRowCount: 3,
lastUpdate: Date.now(),
},
};
const panel = LayerPanel.prototype;
let toggle = presentLayerToggle(layer);
assert.equal(toggle.text, 'PARTIAL');
assert.equal(toggle.feedState, 'partial');
assert.equal(toggle.classes['feed-partial'], true);
assert.equal(toggle.classes['feed-stale'], false);
assert.match(toggle.ariaLabel, /PARTIAL/);
assert.match(
panel._buildMetaText(layer),
/^PARTIAL · AISStream · 2 of 3 records accepted · /,
);
assert.match(
panel._buildMetaText({
...layer,
stats: { ...layer.stats, rawRowCount: 2 },
}),
/incomplete snapshot/,
);
assert.equal(layerFeedState({ ...layer.stats, stale: true }), 'stale');
assert.equal(
layerFeedState({ ...layer.stats, error: 'Connection lost' }),
'degraded',
);
assert.equal(
layerFeedState({ ...layer.stats, status: 'unavailable' }),
'unavailable',
);
assert.equal(layerFeedState({ ...layer.stats, loading: true }), 'loading');
layer.stats = { ...layer.stats, partial: false };
toggle = presentLayerToggle(layer);
assert.equal(toggle.text, 'ON');
assert.equal(toggle.classes['feed-partial'], false);
layer.enabled = false;
assert.equal(presentLayerToggle(layer).text, 'OFF');
});
test('a stale row dates cached data by its oldest tile, not the last load', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const panel = LayerPanel.prototype;
const layer = {
id: 'traffic',
name: 'Street Traffic',
source: 'TomTom',
enabled: true,
stats: {
mode: 'live',
stale: true,
count: 4,
lastUpdate: Date.now(),
oldestTileAt: Date.now() - 3 * 3600_000,
},
};
assert.match(panel._buildMetaText(layer), /^STALE · TomTom · 3h ago$/);
delete layer.stats.oldestTileAt;
assert.match(panel._buildMetaText(layer), /^STALE · TomTom · just now$/);
});
test('readout rows contain only toggles and metadata; ordinary rows retain controls', async () => {
const { LayerPanel } = await import('./layerPanel.js');
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
const find = (id) => container.querySelector(`[data-layer-id="${id}"]`);
const ids = [
'wind',
'weather-radar',
'weather-satellite',
'weather-lightning',
'weather-cyclones',
'other',
];
let refresh;
const layers = ids.map((id) => ({
id,
name: id,
icon: '*',
source: 'Source',
enabled: true,
showInTogglePanel: true,
stats: {},
}));
const calls = [];
const panel = new LayerPanel({
getLayers: () => layers,
isEnabled: () => true,
setEnabled: (...args) => calls.push(args),
setLayerParams: (...args) => calls.push(args),
hasRowControls: () => true,
subscribeRowControls: (_id, callback) => {
refresh = callback;
},
getRowControls: (id) => ({
readout: id !== 'other',
summary: { status: 'Loading' },
info: 'Detailed time',
chips: [{ id: 'config', label: 'Soft', params: { opacity: 'light' } }],
legend: [{ label: 'Rain', color: '#abcdef' }],
list: {
items: [{ id: 'storm', text: 'Storm', params: { focus: true } }],
},
}),
});
try {
panel.mount(container);
for (const id of ids.slice(0, -1)) {
const row = find(id);
assert.equal(row.children.length, 2);
assert.ok(row.querySelector('.data-name'));
assert.ok(row.querySelector('.data-toggle-meta'));
for (const cls of [
'data-toggle-chip',
'data-count',
'data-toggle-controls',
'data-toggle-controls-status',
'data-row-list',
])
assert.ok(!row.querySelector(`.${cls}`), cls);
row.querySelector('.data-toggle-btn').click();
}
assert.deepEqual(
calls.map(([id]) => id),
ids.slice(0, -1),
);
const ordinary = find('other');
assert.equal(
ordinary.querySelector('.data-toggle-controls-info').textContent,
'Detailed time',
);
assert.ok(ordinary.querySelector('.data-toggle-legend-item'));
ordinary.querySelector('.data-toggle-chip').click();
assert.deepEqual(calls.at(-1), [
'other',
{ opacity: 'light' },
{ origin: 'user' },
]);
assert.equal(typeof refresh, 'function');
} finally {
panel.destroy();
document.body.replaceChildren();
}
});
test('the Recent Imagery readout mounts in its rail body like the weather readout and is rebuilt or released with the panel', async () => {
const { LayerPanel } = await import('./layerPanel.js');
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
const body = document.body.appendChild(document.createElement('div'));
body.id = 'recent-imagery-panel-body';
const mounted = [];
const factory = (container) => {
const readout = {
container,
destroyed: false,
destroy() {
this.destroyed = true;
},
};
mounted.push(readout);
return readout;
};
const panel = new LayerPanel({
getLayers: () => [],
isEnabled: () => false,
setEnabled() {},
setLayerParams() {},
hasRowControls: () => false,
subscribeRowControls() {},
getRowControls: () => null,
});
try {
panel.attachRecentImagery(factory);
assert.equal(mounted.length, 0, 'no body before the panel mounts');
panel.mount(container);
assert.equal(mounted.length, 1);
assert.ok(mounted[0].container === body);
panel.mount(container);
assert.equal(mounted[0].destroyed, true, 'a remount rebuilds it');
assert.equal(mounted.length, 2);
panel.attachRecentImagery(null);
assert.equal(mounted[1].destroyed, true);
panel.attachRecentImagery(factory);
assert.equal(mounted.length, 3);
panel.destroy();
assert.equal(mounted[2].destroyed, true);
panel.attachRecentImagery(factory);
assert.equal(mounted.length, 3, 'a destroyed panel mounts nothing');
} finally {
panel.destroy();
document.body.replaceChildren();
}
});
test('an omitted source is visibly unavailable and cannot be toggled on', async () => {
const { presentLayerToggle } = await import('./layerPanel.js');
const layer = {
id: 'flights',
name: 'Flights',
enabled: false,
stats: {
sourceUnavailable: true,
status: 'unavailable',
error: 'Data source not configured: flights',
},
};
const toggle = presentLayerToggle(layer);
assert.equal(toggle.text, 'UNAVAILABLE');
assert.equal(toggle.ariaDisabled, true);
assert.match(toggle.title, /not configured/);
assert.equal(
presentLayerToggle({ ...layer, enabled: true }).ariaDisabled,
false,
'an active layer can always be switched off',
);
});
test('row text keeps a hyphenated code whole and never starts a line with the separator', async () => {
const { rowTextRuns } = await import('./layerRowText.js');
const text = (runs) => runs.map((run) => run.text).join('');
const kept = (runs) => runs.filter((run) => run.keep).map((run) => run.text);
const name = rowTextRuns('Локальный ADS-B');
assert.equal(text(name), 'Локальный ADS-B');
assert.deepEqual(kept(name), ['ADS-B']);
const meta = rowTextRuns('UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED');
assert.equal(text(meta), 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED');
assert.deepEqual(kept(meta), [' ·', ' ·', ' ·']);
assert.ok(
meta.every((run) => !run.text.startsWith('·')),
'the space before each dot is held to the piece it closes',
);
assert.equal(rowTextRuns('Satellites'), null, 'plain text needs no runs');
const compound = rowTextRuns('Kartierte ALPR-Kameras · ADS-B-Flüge');
assert.equal(text(compound), 'Kartierte ALPR-Kameras · ADS-B-Flüge');
assert.deepEqual(
kept(compound),
[' ·', 'ADS-B'],
'a compound may break at its hyphen; the code inside it stays whole',
);
const long = rowTextRuns('see https-proxy-endpoint-config-x');
assert.deepEqual(
kept(long || []),
[],
'a long hyphenated run may still wrap',
);
});
test('the unavailable chip may abbreviate, but its accessible name keeps the full state', async () => {
const { presentLayerToggle } = await import('./layerPanel.js');
const toggle = presentLayerToggle({
id: 'local-firms',
name: 'FIRMS Active Fires',
enabled: false,
stats: { sourceUnavailable: true, error: 'KEY REQUIRED' },
});
assert.equal(toggle.text, 'UNAVAILABLE');
assert.equal(toggle.feedState, 'unavailable');
assert.match(toggle.ariaLabel, /^Active Fires: UNAVAILABLE\./);
});
test('the Weather readout receives enabled air-quality alongside weather layers', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const layers = ['wind', 'air-quality', 'other'].map((id) => ({
id,
icon: '*',
enabled: true,
}));
layers.push({ id: 'weather-radar', icon: '*', enabled: false });
const panel = new LayerPanel({
getLayers: () => layers,
getRowControls: (id) => ({ readout: true, summary: { label: id } }),
});
let entries = null;
panel._weatherPanel = {
update: (next) => {
entries = next;
},
};
panel._refreshWeatherPanel();
assert.deepEqual(
entries.map(({ id, summary }) => [id, summary.label]),
[
['wind', 'wind'],
['air-quality', 'air-quality'],
],
);
});
// Directions renders in the Utilities panel's list; Data Layers keeps the rest.
async function utilitiesFixture({ withUtilities = true } = {}) {
const { LayerPanel } = await import('./layerPanel.js');
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
container.id = 'data-toggles';
const utilities = document.createElement('div');
utilities.id = 'utilities-toggles';
if (withUtilities) document.body.append(utilities);
const layers = ['radio', 'directions', 'flights'].map((id) => ({
id,
name: id,
icon: '*',
source: 'S',
enabled: false,
showInTogglePanel: true,
stats: {},
}));
const panel = new LayerPanel({
getLayers: () => layers,
isEnabled: (id) => layers.find((layer) => layer.id === id).enabled,
setEnabled() {},
setLayerParams() {},
getRowControls: () => null,
hasRowControls: () => false,
subscribeRowControls: () => () => {},
});
panel.mount(container);
const headings = (root) =>
[...root.querySelectorAll('.data-layer-group-heading')].map((node) =>
node.textContent.trim(),
);
const rowIds = (root) =>
[...root.querySelectorAll('[data-layer-id]')].map(
(node) => node.dataset.layerId,
);
const restore = () => {
panel.destroy();
document.body.replaceChildren();
};
return { container, utilities, panel, layers, headings, rowIds, restore };
}
test('Directions renders in the Utilities list without a heading, and Radio under Audio', async () => {
const { container, utilities, panel, headings, rowIds, restore } =
await utilitiesFixture();
try {
assert.deepEqual(rowIds(utilities), ['directions']);
assert.deepEqual(headings(utilities), []);
assert.deepEqual(rowIds(container), ['flights', 'radio']);
assert.deepEqual(headings(container), ['Movement', 'Audio']);
panel.mount(container);
assert.deepEqual(
rowIds(utilities),
['directions'],
'a remount replaces it',
);
panel.destroy();
assert.deepEqual(rowIds(utilities), [], 'destroy releases both lists');
} finally {
restore();
}
});
test('a refresh updates the Directions row in the Utilities list', async () => {
const { utilities, panel, layers, restore } = await utilitiesFixture();
try {
const toggle = () =>
utilities.querySelector('[data-layer-id="directions"] .data-toggle-btn');
assert.ok(!toggle().classList.contains('active'));
layers.find(({ id }) => id === 'directions').enabled = true;
panel._refreshTogglePanel();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.ok(toggle().classList.contains('active'));
} finally {
restore();
}
});
test('without the Utilities list, Directions stays in Data Layers under Utilities', async () => {
const { container, headings, rowIds, restore } = await utilitiesFixture({
withUtilities: false,
});
try {
assert.deepEqual(rowIds(container), ['flights', 'directions', 'radio']);
assert.deepEqual(headings(container), ['Movement', 'Utilities', 'Audio']);
} finally {
restore();
}
});