// src/ui/awarenessPanel.test.mjs — the Contacts panel renders what the
// Contacts layer publishes (`subscribePanel()`), 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 { useContextStore } from './stores/context.js';
import AwarenessPanel from './views/panels/AwarenessPanel.vue';
function fixture(t) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(AwarenessPanel).use(pinia);
app.mount(host);
t.after(() => app.unmount());
const store = useContextStore(pinia);
const calls = [];
store.connectAwareness({
navigatePrevious: (options) => calls.push(['previous', options]),
focusCurrent: (options) => calls.push(['focus', options]),
navigateNext: (options) => calls.push(['next', options]),
focusTarget: (layer, id, options) =>
calls.push([`target:${layer}:${id}`, options]),
});
const panel = () => document.getElementById('military-awareness-panel');
const publish = async (view) => {
store.publishAwareness(view);
await nextTick();
};
return { panel, publish, calls };
}
const target = (layer, id, label, extra = {}) => ({
key: `target:${layer}:${id}`,
layer,
targetId: id,
label,
distance: '4.6 km',
ariaLabel: `Focus ${label}`,
names: null,
...extra,
});
const results = ({ nearest = [], previousDisabled = true } = {}) => ({
kind: 'results',
subject: 'N123AB · 250 km FLIGHT / VESSEL WINDOW',
subjectNames: null,
navigation: {
label: 'Global Context navigation',
previous: {
text: 'PREVIOUS',
title: 'Previous',
disabled: previousDisabled,
},
focus: { text: 'FOCUS' },
next: { text: 'NEXT', title: 'Next', disabled: false },
},
rows: [
{
id: 'flights',
relationship: 'observed',
label: 'Flights',
count: '2',
detail: 'ADS-B · CURRENT VIEWPORT ONLY · nearest',
nearest,
},
],
note: 'Open-source mapped/observed context.',
});
test('before the layer publishes, the panel shows its initial markup', (t) => {
const { panel } = fixture(t);
const standby = panel().querySelector('.military-awareness-standby');
assert.equal(
standby.querySelector('strong').textContent,
'CONTACTS CONTEXT OFF',
);
// Rendered through t(), so a later null view is translated too.
assert.equal(
standby.querySelector('strong').hasAttribute('data-i18n'),
false,
);
});
test('a standby view replaces the initial copy', async (t) => {
const { panel, publish } = fixture(t);
await publish({
kind: 'standby',
title: 'CONTEXT READY',
text: 'SELECT A FLIGHT',
});
const standby = panel().querySelector('.military-awareness-standby');
assert.equal(standby.querySelector('strong').textContent, 'CONTEXT READY');
assert.equal(standby.querySelector('span').textContent, 'SELECT A FLIGHT');
await publish({ kind: 'empty' });
assert.equal(panel().children.length, 0);
});
test('results render the rows, controls and names as text, and buttons call the layer', async (t) => {
const { panel, publish, calls } = fixture(t);
const view = results({
nearest: [
target('flights', 'abc123', 'ALPHA', {
names: {
summary: 'Named areas (2)',
names: ['Area one', ''],
},
}),
{
key: 'unavailable:1',
label: '—',
distance: '9 km',
ariaLabel: 'Unavailable',
},
],
});
await publish(view);
const root = panel();
assert.equal(
root.querySelector('.military-awareness-subject').textContent,
view.subject,
);
const row = root.querySelector('.military-awareness-row');
assert.equal(row.className, 'military-awareness-row observed');
assert.equal(row.querySelector('b').getAttribute('aria-live'), 'polite');
assert.equal(row.querySelector('b').textContent, '2');
assert.equal(row.querySelector('small').textContent, view.rows[0].detail);
const alpha = row.querySelector('button[data-awareness-id="abc123"]');
assert.equal(alpha.dataset.awarenessLayer, 'flights');
assert.equal(alpha.getAttribute('aria-label'), 'Focus ALPHA');
assert.equal(alpha.textContent, 'ALPHA 4.6 km');
const names = row.querySelector('.military-awareness-names div');
assert.equal(
names.innerHTML.replace(//g, ''),
'Area one
<Area two>',
);
const unavailable = row.querySelector(
'span.military-awareness-target.unavailable',
);
assert.equal(unavailable.getAttribute('aria-label'), 'Unavailable');
const [previous, focus, next] = root.querySelectorAll(
'[data-awareness-action]',
);
assert.equal(previous.disabled, true);
assert.equal(next.title, 'Next');
focus.click();
next.click();
alpha.click();
const user = { origin: 'user' };
assert.deepEqual(calls, [
['focus', user],
['next', user],
['target:flights:abc123', user],
]);
const note = root.querySelector('[data-awareness-focus-continuation]');
assert.equal(note.tagName, 'P');
assert.equal(note.getAttribute('tabindex'), '-1');
});
test('a live repaint keeps a focused contact by identity and never retargets another', async (t) => {
const { panel, publish } = fixture(t);
const alpha = target('flights', 'abc123', 'ALPHA');
const bravo = target('flights', 'def456', 'BRAVO');
await publish(results({ nearest: [alpha, bravo] }));
const alphaButton = panel().querySelector('[data-awareness-id="abc123"]');
alphaButton.focus();
await publish(results({ nearest: [bravo, alpha] }));
assert.equal(
document.activeElement === alphaButton,
true,
'identity wins when rows reorder',
);
const charlie = target('ais-live-vessels', '789', 'CHARLIE');
await publish(results({ nearest: [charlie] }));
const note = panel().querySelector('[data-awareness-focus-continuation]');
assert.equal(
document.activeElement === note,
true,
'a departed contact continues at the note',
);
await publish(results({ nearest: [bravo] }));
assert.equal(
document.activeElement === note,
true,
'later repaints keep the continuation point',
);
});
test('a reorder that drops focus from a moved contact restores the same contact', async (t) => {
const { panel, publish } = fixture(t);
const alpha = target('flights', 'abc123', 'ALPHA');
const bravo = target('flights', 'def456', 'BRAVO');
await publish(results({ nearest: [alpha, bravo] }));
// Browsers drop focus from a node that is moved; happy-dom does not, so
// moving the focused node sends focus outside the panel here.
const sink = document.body.appendChild(document.createElement('button'));
const list = panel().querySelector('ul');
const insertBefore = list.insertBefore.bind(list);
list.insertBefore = (node, ref) => {
const moved = node.contains(document.activeElement);
const result = insertBefore(node, ref);
if (moved) sink.focus();
return result;
};
const focusNode = (id) =>
panel().querySelector(`[data-awareness-id="${id}"]`);
focusNode('def456').focus();
await publish(results({ nearest: [bravo, alpha] }));
focusNode('abc123').focus();
await publish(results({ nearest: [alpha, bravo] }));
assert.equal(
document.activeElement === focusNode('abc123'),
true,
'the same contact keeps focus, not another one or the note',
);
});
test('a repaint that disables the focused control continues at the note', async (t) => {
const { panel, publish } = fixture(t);
await publish(results({ previousDisabled: false }));
const previous = panel().querySelector('[data-awareness-action="previous"]');
previous.focus();
await publish(results({ previousDisabled: true }));
assert.equal(previous.disabled, true);
const note = panel().querySelector('[data-awareness-focus-continuation]');
assert.equal(document.activeElement === note, true);
});
test('a repaint that leaves focus outside the panel does not pull it back', async (t) => {
const { panel, publish } = fixture(t);
const outside = document.body.appendChild(document.createElement('button'));
await publish(results({ nearest: [target('flights', 'abc123', 'ALPHA')] }));
outside.focus();
await publish(results({ nearest: [] }));
assert.equal(document.activeElement === outside, true);
assert.equal(
panel().querySelector('ul'),
null,
'an empty cohort renders no list',
);
});