Files
gods-eye-view/src/ui/cockpitContext.test.mjs
T
German Khokhlov 99831ea19b Keep STALE on Contacts while a poll is in flight
A poll that returned the same stale snapshot dropped STALE from the
Contacts cohort until the next answer, because the in-flight loading flag
outranked stale and the source revision ignored the provider name. A
source that has answered now reads its status without the loading flag;
one that has never answered (AIS first connect) still reads as loading,
so it shows no STALE before any snapshot exists.
2026-11-04 16:34:39 +04:00

325 lines
12 KiB
JavaScript

// src/ui/cockpitContext.test.mjs — the Cockpit Contact card renders what the
// Cockpit controller presents, over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { setLocale, t as translate } from '../i18n/index.js';
import {
publishContextView,
showContextCard,
updateContext,
} from './cockpitContext.js';
import { createContextState } from './cockpitContextState.js';
import { syncWeatherToggle } from './cockpitInstruments.js';
import { setContextCollapsed } from './cockpitLayout.js';
import { useCockpitStore } from './stores/cockpit.js';
import CockpitContext from './views/panels/CockpitContext.vue';
function fixture(t, snapshot = null) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(CockpitContext).use(pinia);
app.mount(host);
t.after(() => app.unmount());
const store = useCockpitStore(pinia);
const $ = (selector) => document.querySelector(selector);
const signals = [];
const layouts = [];
// The Contact presenter needs only this much of the controller.
const owner = {
context: $('#cockpit-context'),
presence: { context: false },
contextView: createContextState(),
contextLayoutStamp: null,
publishContext: (view) => store.publishContext(view),
publishContextView,
publishPresenceView: () => store.publishPresence({ ...owner.presence }),
showContextCard,
pushCockpitSignal: (...args) => signals.push(args),
updateCockpitSignals() {},
scheduleContextLayout: () => layouts.push(true),
services: {
militaryAwarenessLayer: { getContextSnapshot: () => owner.snapshot },
formatAwarenessLabel: (item) => item?.id ?? '—',
},
snapshot,
};
const text = (selector) => $(selector).textContent.trim();
return { $, text, owner, signals, layouts };
}
const INFO = {
layerId: 'flights',
icao24: 'abc123',
latitude: 30,
longitude: -97,
};
const snapshot = (overrides = {}) => ({
subject: { layerId: 'military', id: 'm1', label: 'RCH123' },
radiusM: 250_000,
evaluatedAt: Date.UTC(2026, 9, 31, 12, 34, 56),
navigation: { canPrevious: false, canNext: true },
cohorts: [
{
id: 'flights',
label: 'Flights',
count: 12,
relationship: 'OBSERVED',
nearest: [{ id: 'N1', distanceM: 4200 }],
},
{
id: 'military',
label: 'Military',
count: null,
relationship: 'UNKNOWN',
nearest: [],
},
],
...overrides,
});
test('before the controller presents, the card shows the state it starts in', (t) => {
const { $, text } = fixture(t);
const card = $('#cockpit-context');
assert.equal(card.hidden, true);
assert.equal(card.hasAttribute('data-state'), false);
assert.equal(text('#cockpit-context-subject'), 'CONTACTS · 250 KM');
assert.equal($('#cockpit-context-previous').disabled, false);
assert.equal($('#cockpit-context-next').disabled, false);
assert.deepEqual(
[...document.querySelectorAll('[data-context-cohort] strong')].map(
(count) => count.textContent,
),
['—', '—', '—', '—'],
);
// No provider is named before a snapshot reports one.
assert.deepEqual(
[...document.querySelectorAll('[data-context-cohort] small')].map(
(source) => source.textContent,
),
['—', '—', '—', '—'],
);
assert.equal(text('#cockpit-context-nearest-label'), 'NO AVAILABLE EXAMPLE');
assert.equal(
$('#cockpit-context-nearest-label').hasAttribute('aria-label'),
false,
);
assert.equal($('#cockpit-context-direction').getAttribute('style'), null);
assert.equal(
text('#cockpit-context-uncertainty'),
'AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR',
);
assert.equal(text('#cockpit-context-updated'), '--:--:--Z');
const weather = $('#cockpit-weather-toggle');
assert.equal(weather.getAttribute('aria-pressed'), 'false');
assert.equal(
weather.getAttribute('aria-label'),
'Enable cockpit weather effects',
);
assert.equal(weather.title, 'Enable cockpit weather effects');
assert.equal(text('#cockpit-weather-state'), 'OFF');
});
test('a snapshot fills the card and keeps classes other code sets', async (t) => {
const { $, text, owner, layouts } = fixture(t, snapshot());
const flights = $('[data-context-cohort="flights"]');
flights.classList.add('voice-highlight');
updateContext.call(owner, INFO, 0);
await nextTick();
const card = $('#cockpit-context');
assert.equal(card.hidden, false);
assert.equal(card.dataset.state, 'uncertain');
assert.equal(owner.presence.context, true);
assert.match(
text('#cockpit-context-subject'),
/^RCH123 · 250\u00a0KM AIR\/SEA WINDOW$/,
);
assert.equal($('#cockpit-context-previous').disabled, true);
assert.equal($('#cockpit-context-next').disabled, false);
assert.equal(flights.querySelector('strong').textContent, '12');
assert.equal(flights.classList.contains('unknown'), false);
assert.equal(flights.classList.contains('voice-highlight'), true);
const military = $('[data-context-cohort="military"]');
assert.equal(military.querySelector('strong').textContent, '?');
assert.equal(military.classList.contains('unknown'), true);
assert.equal(text('#cockpit-context-nearest-label'), 'FLIGHTS · N1');
assert.equal(
$('#cockpit-context-nearest-label').getAttribute('aria-label'),
'FLIGHTS · N1',
);
assert.match(text('#cockpit-context-distance'), /^4\.2/);
// The tracked aircraft is not the subject: the nose-relative half dashes.
assert.equal(text('#cockpit-context-bearing'), 'BRG —');
const direction = $('#cockpit-context-direction');
assert.equal(direction.style.transform, 'rotate(0deg)');
assert.equal(direction.classList.contains('unknown'), true);
assert.equal(direction.classList.contains('material-symbols-outlined'), true);
assert.equal(
text('#cockpit-context-uncertainty'),
'1 INPUT UNKNOWN · NOT AN ALL-CLEAR',
);
assert.equal(text('#cockpit-context-updated'), '12:34:56Z');
assert.equal(layouts.length, 1);
});
test('each count names the provider its snapshot reports, with its feed state', async (t) => {
const reported = snapshot();
reported.cohorts[0].source = 'adsb.lol';
reported.cohorts[0].sourceStatus = 'fallback';
reported.cohorts[1].source = 'Plane Finder';
reported.cohorts.push(
{
id: 'ais-live-vessels',
label: 'AIS vessels',
source: 'Datalastic',
sourceStatus: 'stale',
count: 3,
relationship: 'OBSERVED',
nearest: [],
},
{
id: 'military-installations',
label: 'Mapped installations',
source: 'OpenStreetMap',
sourceStatus: null,
count: 0,
relationship: 'MAPPED',
nearest: [],
},
);
const { $, owner } = fixture(t, reported);
updateContext.call(owner, INFO, 0);
await nextTick();
const source = (id) => $(`[data-context-cohort="${id}"] small`);
assert.equal(source('flights').textContent, 'adsb.lol · FALLBACK');
assert.equal(source('military').textContent, 'Plane Finder');
assert.equal(source('ais-live-vessels').textContent, 'Datalastic · STALE');
assert.equal(source('military-installations').textContent, 'OpenStreetMap');
// The tile ellipsizes; the full name stays readable on hover.
assert.equal(source('flights').title, 'adsb.lol · FALLBACK');
});
test('a lost contact holds its readout behind the CONTACT LOST cue', async (t) => {
const { $, text, owner, layouts } = fixture(t, snapshot());
updateContext.call(owner, INFO, 0);
await nextTick();
const readout = () =>
[
'#cockpit-context-nearest-label',
'#cockpit-context-distance',
'#cockpit-context-bearing',
'#cockpit-context-updated',
].map(text);
const held = readout();
owner.snapshot = snapshot({
subjectPresent: false,
navigation: { canPrevious: true, canNext: false },
cohorts: [{ id: 'flights', label: 'Flights', count: 1, nearest: [] }],
});
updateContext.call(owner, INFO, 0);
updateContext.call(owner, INFO, 0);
await nextTick();
assert.equal($('#cockpit-context').dataset.state, 'lost');
assert.equal(
text('#cockpit-context-uncertainty'),
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR',
);
// Navigation follows the lost snapshot; the readout holds.
assert.deepEqual(readout(), held);
assert.equal($('#cockpit-context-previous').disabled, false);
assert.equal($('#cockpit-context-next').disabled, true);
assert.equal($('[data-context-cohort="flights"] strong').textContent, '12');
// Layout reruns once on the way in, not every update.
assert.equal(layouts.length, 2);
});
test('without a snapshot the card stands by, hidden', async (t) => {
const { $, owner, signals } = fixture(t, snapshot());
updateContext.call(owner, INFO, 0);
owner.snapshot = null;
updateContext.call(owner, INFO, 0);
await nextTick();
assert.equal($('#cockpit-context').hidden, true);
assert.equal(owner.presence.context, false);
assert.equal(signals.at(-1)[2], 'CONTEXT STANDBY');
});
test('the weather toggle renders its state and the action it offers', async (t) => {
const { $, text, owner } = fixture(t);
syncWeatherToggle.call(owner, true);
await nextTick();
const weather = $('#cockpit-weather-toggle');
assert.equal(weather.getAttribute('aria-pressed'), 'true');
assert.equal(
weather.getAttribute('aria-label'),
'Disable cockpit weather effects',
);
assert.equal(weather.title, 'Disable cockpit weather effects');
assert.equal(text('#cockpit-weather-state'), 'ON');
assert.equal(owner.contextView.weather, true);
});
test('re-renders keep the collapse the controller presents and leave it the layout', async (t) => {
const { $, owner } = fixture(t, snapshot());
updateContext.call(owner, INFO, 0);
const card = $('#cockpit-context');
owner.presence.contextCollapsed = true;
owner.publishPresenceView();
card.dataset.layoutMode = 'compact-bottom';
owner.snapshot = snapshot({ evaluatedAt: Date.UTC(2026, 9, 31, 12, 35) });
updateContext.call(owner, INFO, 0);
await nextTick();
assert.equal(card.hidden, false);
assert.equal(card.dataset.collapsed, 'true');
assert.equal(card.dataset.layoutMode, 'compact-bottom');
const toggle = $('#cockpit-context-toggle');
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
assert.equal(toggle.getAttribute('aria-label'), 'Expand Contact panel');
assert.equal(toggle.title, 'Expand contact panel');
assert.equal(toggle.textContent.trim(), 'chevron_right');
});
test('the card is labelled in the UI language', async (t) => {
t.after(() => setLocale('en'));
setLocale('ru');
const { $, text, owner } = fixture(t);
const subject = translate('CONTACTS · 250 KM');
assert.notEqual(subject, 'CONTACTS · 250 KM');
assert.equal(text('#cockpit-context-subject'), subject);
assert.equal(text('#cockpit-weather-state'), translate('OFF'));
syncWeatherToggle.call(owner, true);
await nextTick();
assert.equal(text('#cockpit-weather-state'), translate('ON'));
assert.equal(
$('#cockpit-weather-toggle').getAttribute('aria-label'),
translate('Disable cockpit weather effects'),
);
});
test('collapsing the card publishes once per change', async (t) => {
const { $, owner } = fixture(t);
let published = 0;
const publish = owner.publishPresenceView;
Object.assign(owner, {
contextCollapsed: false,
presence: { context: true, contextCollapsed: false },
publishPresenceView: () => {
published += 1;
publish();
},
});
setContextCollapsed.call(owner, true);
setContextCollapsed.call(owner, true);
assert.equal(published, 1);
await nextTick();
assert.equal($('#cockpit-context').dataset.collapsed, 'true');
assert.equal(
$('#cockpit-context-toggle').getAttribute('aria-expanded'),
'false',
);
});