mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
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.
434 lines
14 KiB
JavaScript
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();
|
|
}
|
|
});
|