Files
gods-eye-view/src/ui/mobileShell.test.mjs
T
Sameh Khamis 8d454bf7f1 fix: improve mobile contact selection and context drawers (#1338)
Improve coarse-pointer contact selection, reveal tracked aircraft through Contacts, support downward drawer dismissal, and enlarge the mobile logo.
2026-10-23 15:05:23 -07:00

866 lines
30 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { StyleManager } from './applicationShell.js';
import { ContextControls } from './contextControls.js';
import {
MOBILE_SHEET_PANELS,
MOBILE_TABS,
initMobileShell,
nextSheet,
sheetForPanelId,
} from './mobileShell.js';
function element(tag, document) {
const node = new EventTarget();
const attributes = new Map();
const classes = new Set();
Object.assign(node, {
tagName: tag.toUpperCase(),
children: [],
dataset: {},
hidden: false,
textContent: '',
className: '',
parent: null,
scrollTop: 0,
parentElement: null,
classList: {
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
contains: (name) => classes.has(name),
},
style: {
values: new Map(),
setProperty(name, value) {
this.values.set(name, value);
},
removeProperty(name) {
this.values.delete(name);
},
},
setAttribute: (key, value) => attributes.set(key, String(value)),
getAttribute: (key) => attributes.get(key) ?? null,
removeAttribute: (key) => attributes.delete(key),
append(...children) {
for (const child of children) {
child.parent = node;
child.parentElement = node;
node.children.push(child);
if (child.id) document.ids.set(child.id, child);
}
},
remove() {
const siblings = node.parent?.children;
siblings?.splice(siblings.indexOf(node), 1);
document.ids.delete(node.id);
},
contains(other) {
for (let current = other; current; current = current.parent)
if (current === node) return true;
return false;
},
closest(selector) {
for (let current = node; current; current = current.parent) {
if (
selector.startsWith('button, a') &&
(['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'].includes(
current.tagName,
) ||
['slider', 'button'].includes(current.getAttribute('role')) ||
(current.getAttribute('contenteditable') !== null &&
current.getAttribute('contenteditable') !== 'false'))
)
return current;
if (selector === '[data-mobile-sheet]' && current.dataset.mobileSheet)
return current;
}
return null;
},
querySelectorAll(selector) {
const found = [];
const matches = (child) =>
selector === '[data-mobile-sheet]'
? Boolean(child.dataset.mobileSheet)
: child.tagName === 'BUTTON';
const walk = (parent) => {
for (const child of parent.children) {
if (matches(child)) found.push(child);
walk(child);
}
};
walk(node);
return found;
},
getBoundingClientRect: () => ({ top: 0, height: 0 }),
getClientRects: () => (node.hidden ? [] : [{}]),
matches: (selector) =>
selector === '.panel-collapse-btn' &&
node.className.split(' ').includes('panel-collapse-btn'),
focus() {
document.activeElement = node;
},
blur() {
if (document.activeElement === node)
document.activeElement = document.body;
},
});
return node;
}
const PANEL_IDS = [...new Set(Object.values(MOBILE_SHEET_PANELS).flat())];
function fixture({
mobile = true,
expanded = [],
onTabOpened = () => {},
} = {}) {
const document = new EventTarget();
document.ids = new Map();
document.createElement = (tag) => element(tag, document);
document.getElementById = (id) => document.ids.get(id) ?? null;
const viewport = element('meta', document);
viewport.setAttribute('content', 'width=device-width, initial-scale=1.0');
document.querySelector = (selector) =>
selector === 'meta[name="viewport"]'
? viewport
: selector === 'dialog[open]' && document.openDialog
? {}
: null;
document.body = element('body', document);
document.activeElement = document.body;
const search = element('input', document);
search.id = 'location-search';
document.body.append(search);
const rails = {};
for (const id of ['left-panel-stack', 'right-context-rail', 'command-dock']) {
rails[id] = element('div', document);
rails[id].id = id;
document.body.append(rails[id]);
}
for (const id of PANEL_IDS) {
const panel = element('section', document);
panel.id = id;
if (!expanded.includes(id)) panel.classList.add('collapsed');
// A hidden tray toggle and a collapse control first, like the panels'
// own chrome in a sheet.
const toggle = element('button', document);
toggle.hidden = true;
const collapse = element('button', document);
collapse.className = 'panel-collapse-btn';
panel.append(toggle, collapse, element('button', document));
const root =
id === 'data-panel'
? 'left-panel-stack'
: ['location-bar', 'control-panel'].includes(id)
? 'command-dock'
: 'right-context-rail';
rails[root].append(panel);
}
const media = new EventTarget();
media.matches = mobile;
const calls = [];
const frames = [];
let closedCount = 0;
let observed = null;
class FakeMutationObserver {
constructor(callback) {
observed = callback;
}
observe() {}
disconnect() {
observed = null;
}
}
const shell = initMobileShell({
documentRef: document,
onTabOpened,
matchMedia: () => media,
MutationObserverImpl: FakeMutationObserver,
ResizeObserverImpl: null,
requestFrame: (callback) => frames.push(callback),
onSheetsClosed: () => {
closedCount += 1;
},
setPanelCollapsed: (panelId, collapsed, options) => {
calls.push({ panelId, collapsed, ...options });
const panel = document.getElementById(panelId);
if (collapsed) panel.classList.add('collapsed');
else panel.classList.remove('collapsed');
},
});
const tab = (sheet) =>
document
.getElementById('mobile-tab-bar')
.children.find((button) => button.dataset.mobileSheet === sheet);
const press = (sheet) => {
const event = new Event('click', { cancelable: true });
Object.defineProperty(event, 'target', { value: tab(sheet) });
document.getElementById('mobile-tab-bar').dispatchEvent(event);
};
const resize = (matches) => {
media.matches = matches;
const event = new Event('change');
event.matches = matches;
media.dispatchEvent(event);
};
const setBodyClass = (name) => {
document.body.classList.add(name);
observed?.();
};
const collapsed = (id) =>
document.getElementById(id).classList.contains('collapsed');
const runFrames = () => frames.splice(0).forEach((callback) => callback());
return {
document,
shell,
calls,
runFrames,
closedCount: () => closedCount,
press,
tab,
resize,
viewport,
search,
setBodyClass,
collapsed,
};
}
test('every tab names a sheet and every sheet panel maps back to its sheet', () => {
assert.deepEqual(
MOBILE_TABS.map(({ sheet }) => sheet),
Object.keys(MOBILE_SHEET_PANELS),
);
for (const [sheet, panels] of Object.entries(MOBILE_SHEET_PANELS))
for (const panelId of panels) assert.equal(sheetForPanelId(panelId), sheet);
assert.equal(sheetForPanelId('unknown-panel'), null);
});
test('pressing a tab opens its sheet, pressing it again closes it', () => {
assert.equal(nextSheet(null, 'layers'), 'layers');
assert.equal(nextSheet('layers', 'layers'), null);
assert.equal(nextSheet('layers', 'more'), 'more');
assert.equal(nextSheet('layers', 'nonsense'), null);
assert.equal(nextSheet(null, 'constructor'), null);
});
test('one sheet at a time, presented with aria-expanded and a backdrop', () => {
const { document, shell, press, tab } = fixture();
assert.equal(document.body.classList.contains('mobile-shell'), true);
press('layers');
assert.equal(shell.getSheet(), 'layers');
assert.equal(document.body.getAttribute('data-mobile-sheet'), 'layers');
assert.equal(tab('layers').getAttribute('aria-expanded'), 'true');
assert.equal(document.getElementById('mobile-sheet-backdrop').hidden, false);
press('more');
assert.equal(shell.getSheet(), 'more');
assert.equal(tab('layers').getAttribute('aria-expanded'), 'false');
assert.equal(tab('more').getAttribute('aria-expanded'), 'true');
press('more');
assert.equal(shell.getSheet(), null);
assert.equal(document.body.getAttribute('data-mobile-sheet'), null);
assert.equal(document.getElementById('mobile-sheet-backdrop').hidden, true);
});
test('sheets borrow panels and put them back exactly as they were', () => {
// The user left Data Layers and Presets open on desktop.
const kept = ['data-panel', 'control-panel'];
const { calls, press, collapsed } = fixture({ expanded: kept });
press('cameras');
assert.equal(collapsed('cctv-panel'), false);
assert.equal(collapsed('street-level-panel'), false);
press('search');
assert.equal(collapsed('cctv-panel'), true, 'Cameras put back on switch');
assert.equal(collapsed('location-bar'), false);
press('search');
for (const id of PANEL_IDS)
assert.equal(collapsed(id), !kept.includes(id), `${id} is back as it was`);
// Nothing a sheet does is the user's choice, persisted or shared.
assert.ok(calls.length > 0);
for (const call of calls) {
assert.equal(call.explicit, undefined, `${call.panelId} is not explicit`);
assert.equal(call.persist, false);
assert.equal(call.syncShare, false);
}
// Already-open panels are not touched.
assert.ok(!calls.some(({ panelId }) => kept.includes(panelId)));
});
test('a panel the user changes inside a sheet stays as they left it', () => {
const { press, collapsed, document } = fixture();
press('cameras');
// The user collapses CCTV inside the sheet (their own, persisted choice).
document.getElementById('cctv-panel').classList.add('collapsed');
press('cameras');
assert.equal(collapsed('cctv-panel'), true, 'their collapse is kept');
assert.equal(
collapsed('street-level-panel'),
true,
'the borrowed one is put back',
);
// Expanding one in a sheet is kept too, and later sheets respect it.
press('layers');
document.getElementById('cctv-panel').classList.remove('collapsed');
press('more');
press('more');
assert.equal(collapsed('cctv-panel'), false);
});
test('focus moves into the sheet and returns to the tab on close', () => {
const { document, press, search, tab } = fixture();
press('search');
assert.equal(document.activeElement, search);
press('search');
assert.equal(document.activeElement, tab('search'));
press('layers');
const dataPanel = document.getElementById('data-panel');
// The first visible control that is not the panel's own collapse button.
assert.equal(document.activeElement, dataPanel.children[2]);
const escape = new Event('keydown', { cancelable: true });
escape.key = 'Escape';
document.dispatchEvent(escape);
assert.equal(escape.defaultPrevented, true);
assert.equal(document.activeElement, tab('layers'));
});
test('the share link keeps the state from before the sheets', () => {
const { shell, press, document, closedCount } = fixture({
expanded: ['street-level-panel'],
});
assert.equal(shell.sharedCollapsed('data-panel'), undefined);
press('layers');
// Data Layers is open only because the sheet borrowed it.
assert.equal(shell.sharedCollapsed('data-panel'), true);
assert.equal(shell.sharedCollapsed('street-level-panel'), false);
assert.equal(shell.sharedCollapsed('unrelated-panel'), undefined);
// A user change inside the sheet is theirs to share.
document.getElementById('cctv-panel').classList.remove('collapsed');
assert.equal(shell.sharedCollapsed('cctv-panel'), false);
press('more');
assert.equal(closedCount(), 0, 'switching sheets is not closing');
press('more');
assert.equal(closedCount(), 1, 'closing refreshes the share link once');
assert.equal(shell.sharedCollapsed('data-panel'), undefined);
});
test('Escape closes the sheet only when nothing else took it', () => {
const { document, shell, press } = fixture();
const escape = () => {
const event = new Event('keydown', { cancelable: true, bubbles: true });
event.key = 'Escape';
return event;
};
press('layers');
// A field cancelling its edit (or a lightbox closing) handles it first.
const handled = escape();
handled.preventDefault();
document.dispatchEvent(handled);
assert.equal(shell.getSheet(), 'layers');
// An open dialog keeps Escape for its native close.
document.openDialog = true;
document.dispatchEvent(escape());
assert.equal(shell.getSheet(), 'layers');
document.openDialog = false;
const plain = escape();
document.dispatchEvent(plain);
assert.equal(shell.getSheet(), null);
assert.equal(plain.defaultPrevented, true);
});
test('share state and restores follow the preference, not layout collapse', () => {
const { shell, press, collapsed, document } = fixture();
// Layout collapsed Street Level only for space; the user left it open.
const street = document.getElementById('street-level-panel');
street.classList.add('layout-auto-collapsed');
press('cameras');
assert.equal(shell.sharedCollapsed('street-level-panel'), false);
press('cameras');
assert.equal(
collapsed('street-level-panel'),
false,
'not turned into a real collapse',
);
});
test('layout settling after a sheet opens is part of opening', () => {
const { press, runFrames, collapsed, document } = fixture({
expanded: ['street-level-panel'],
});
press('cameras');
// A rail layout pass on the next frame collapses Street Level.
document.getElementById('street-level-panel').classList.add('collapsed');
runFrames();
press('cameras');
assert.equal(collapsed('street-level-panel'), false, 'put back as it was');
});
test('the backdrop closes the open sheet', () => {
const { document, shell, press } = fixture();
press('context');
document
.getElementById('mobile-sheet-backdrop')
.dispatchEvent(new Event('click'));
assert.equal(shell.getSheet(), null);
});
test('surfaces that own the screen close sheets; scene playback does not', () => {
for (const mode of ['cockpit-mode', 'recording-mode', 'ui-clean-view']) {
const { shell, press, setBodyClass, collapsed } = fixture();
press('layers');
setBodyClass(mode);
assert.equal(shell.getSheet(), null, `${mode} closes the sheet`);
assert.equal(collapsed('data-panel'), true, `${mode} puts panels back`);
press('layers');
assert.equal(shell.getSheet(), null, `${mode} keeps sheets closed`);
}
// Scene STOP lives in the Scenes panel of the Layers sheet.
const playback = fixture();
playback.setBodyClass('scene-playback-mode');
playback.press('layers');
assert.equal(playback.shell.getSheet(), 'layers');
});
test('desktop widths never see the shell; crossing to desktop restores it', () => {
const desktop = fixture({ mobile: false });
assert.equal(desktop.document.body.classList.contains('mobile-shell'), false);
desktop.press('layers');
assert.equal(desktop.shell.getSheet(), null);
assert.equal(
desktop.viewport.getAttribute('content'),
'width=device-width, initial-scale=1.0',
);
const phone = fixture({ expanded: ['street-level-panel'] });
assert.match(phone.viewport.getAttribute('content'), /viewport-fit=cover$/);
phone.press('cameras');
phone.resize(false);
assert.equal(phone.shell.getSheet(), null);
assert.equal(phone.document.body.classList.contains('mobile-shell'), false);
assert.equal(phone.collapsed('cctv-panel'), true);
assert.equal(phone.collapsed('street-level-panel'), false);
assert.equal(
phone.viewport.getAttribute('content'),
'width=device-width, initial-scale=1.0',
);
});
test('destroy puts panels back and removes the shell', () => {
const { document, shell, press, viewport, collapsed } = fixture();
press('layers');
shell.destroy();
assert.equal(collapsed('data-panel'), true);
assert.equal(document.getElementById('mobile-tab-bar'), null);
assert.equal(document.getElementById('mobile-sheet-backdrop'), null);
assert.equal(document.body.classList.contains('mobile-shell'), false);
assert.equal(document.body.getAttribute('data-mobile-sheet'), null);
assert.equal(document.body.style.values.size, 0);
assert.equal(
viewport.getAttribute('content'),
'width=device-width, initial-scale=1.0',
);
});
test('the phone shell keeps the required attribution visible', () => {
// Whitespace-normalised, so formatter line wrapping cannot hide a rule.
const css = readFileSync(
new URL('./styles/mobile-shell.css', import.meta.url),
'utf8',
)
.replace(/\s+/g, ' ')
.replace(/\( /g, '(')
.replace(/ \)/g, ')');
/** The declaration block of the rule whose selector list ends with `selector`. */
const rule = (selector) => {
const end = css.indexOf(`${selector} {`);
assert.notEqual(end, -1, `missing rule ending in ${selector}`);
const open = css.indexOf('{', end);
return css.slice(open, css.indexOf('}', open));
};
const lift = (block) =>
Number(
/bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px\)/.exec(block)?.[1],
);
const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]);
const idleCredit = rule(
'body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits',
);
const dock = rule('body.mobile-shell #command-dock');
const voice = rule('body.mobile-shell #command-dock #gev-voice-control');
const voiceHeightPx = Number(/height: ([\d.]+)rem/.exec(voice)[1]) * 16;
// Idle: the credit line clears the voice bar, which clears the tab bar.
assert.ok(lift(idleCredit) > lift(dock) + voiceHeightPx);
// It must beat the dock's :has() placements, so the offset is !important.
assert.match(idleCredit, /\) !important;/);
assert.doesNotMatch(
idleCredit,
/display:\s*none|visibility:\s*hidden|opacity:\s*0/,
);
// Open sheet: above everything the sheet covers, drawn above the sheets.
const sheetCredit = rule(
'body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits',
);
assert.match(
sheetCredit,
/bottom: calc\(var\(--mobile-sheet-cover, 0px\) \+ 6px\) !important;/,
);
const openDock = rule('body.mobile-shell[data-mobile-sheet] #command-dock');
const railSheet = rule(
"body.mobile-shell[data-mobile-sheet='more'] #right-context-rail",
);
assert.ok(z(sheetCredit) > z(openDock));
assert.ok(z(sheetCredit) > z(railSheet));
});
function touchEvent(
document,
type,
target,
{ x = 10, y = 10, count = 1, id = 1, cancelable = true } = {},
) {
const event = new Event(type, { cancelable });
const touch = { identifier: id, clientX: x, clientY: y };
Object.defineProperty(event, 'target', { value: target });
event.touches =
type === 'touchend' || type === 'touchcancel'
? []
: Array.from({ length: count }, (_, i) => ({
...touch,
identifier: id + i,
}));
event.changedTouches = [touch];
document.dispatchEvent(event);
return event;
}
test('downward sheet pull closes through borrowed-panel lifecycle and returns tab focus', () => {
for (const sheet of Object.keys(MOBILE_SHEET_PANELS)) {
const phone = fixture();
const { document, shell, press, tab } = phone;
press(sheet);
const target = document.getElementById(MOBILE_SHEET_PANELS[sheet][0]);
touchEvent(document, 'touchstart', target);
const move = touchEvent(document, 'touchmove', target, { y: 90 });
assert.equal(move.defaultPrevented, true);
assert.equal(shell.getSheet(), sheet, 'waits until finger lifts');
touchEvent(document, 'touchend', target, { y: 90 });
assert.equal(shell.getSheet(), null);
assert.equal(phone.closedCount(), 1);
assert.equal(document.activeElement, tab(sheet));
for (const id of MOBILE_SHEET_PANELS[sheet])
assert.equal(phone.collapsed(id), true);
assert.ok(
phone.calls.every(
(call) => call.persist === false && call.syncShare === false,
),
);
shell.destroy();
}
});
test('content scroll stays native, including a scroll that reaches the top mid-gesture', () => {
for (const nested of [false, true]) {
const { document, shell, press } = fixture();
press('layers');
const panel = document.getElementById('data-panel');
const scroller = nested
? panel
: document.getElementById('left-panel-stack');
scroller.scrollTop = 40;
touchEvent(document, 'touchstart', panel);
scroller.scrollTop = 0;
assert.equal(
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
false,
);
touchEvent(document, 'touchend', panel, { y: 120 });
assert.equal(shell.getSheet(), 'layers');
shell.destroy();
}
});
test('taps, short pulls, upward and horizontal gestures never dismiss', () => {
for (const path of [
[],
[{ x: 12, y: 14 }],
[{ y: 40 }],
[{ y: -30 }, { y: 120 }],
[{ x: 60, y: 12 }, { y: 120 }],
]) {
const { document, shell, press } = fixture();
press('layers');
const panel = document.getElementById('data-panel');
touchEvent(document, 'touchstart', panel);
for (const point of path) touchEvent(document, 'touchmove', panel, point);
touchEvent(document, 'touchend', panel, path.at(-1) ?? {});
assert.equal(shell.getSheet(), 'layers');
shell.destroy();
}
});
test('native controls and custom sliders keep touch gestures', () => {
for (const tag of [
'button',
'a',
'input',
'select',
'textarea',
'label',
'slider',
'editable',
]) {
const { document, shell, press } = fixture();
press('more');
const control = document.createElement(
['slider', 'editable'].includes(tag) ? 'div' : tag,
);
if (tag === 'slider') control.setAttribute('role', 'slider');
if (tag === 'editable') control.setAttribute('contenteditable', 'true');
const child = document.createElement('span');
control.append(child);
document.getElementById('control-panel').append(control);
touchEvent(document, 'touchstart', child);
assert.equal(
touchEvent(document, 'touchmove', child, { y: 120 }).defaultPrevented,
false,
);
touchEvent(document, 'touchend', child, { y: 120 });
assert.equal(shell.getSheet(), 'more');
shell.destroy();
}
});
test('multitouch, cancellation, sheet changes and desktop transitions abandon the gesture', () => {
for (const cancel of [
'multitouch',
'cancel',
'sheet',
'desktop',
'noncancelable',
]) {
const { document, shell, press, resize } = fixture();
press('layers');
const panel = document.getElementById('data-panel');
touchEvent(document, 'touchstart', panel);
if (cancel === 'multitouch')
touchEvent(document, 'touchstart', document.body, { count: 2 });
if (cancel === 'cancel') touchEvent(document, 'touchcancel', panel);
if (cancel === 'sheet') press('context');
if (cancel === 'desktop') {
resize(false);
resize(true);
press('layers');
}
if (cancel === 'noncancelable')
touchEvent(document, 'touchmove', panel, { y: 30, cancelable: false });
assert.equal(
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
false,
);
touchEvent(document, 'touchend', panel, { y: 120 });
assert.equal(shell.getSheet(), cancel === 'sheet' ? 'context' : 'layers');
shell.destroy();
}
});
test('touches outside the active sheet and destroyed listeners cannot dismiss or claim a gesture', () => {
const { document, shell, press } = fixture();
press('layers');
const hiddenPanel = document.getElementById('global-context-panel');
touchEvent(document, 'touchstart', hiddenPanel);
assert.equal(
touchEvent(document, 'touchmove', hiddenPanel, { y: 120 }).defaultPrevented,
false,
);
touchEvent(document, 'touchend', hiddenPanel, { y: 120 });
assert.equal(shell.getSheet(), 'layers');
const panel = document.getElementById('data-panel');
touchEvent(document, 'touchstart', panel);
shell.destroy();
assert.equal(
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
false,
);
touchEvent(document, 'touchend', panel, { y: 120 });
assert.equal(shell.getSheet(), null);
});
test('the tab-open callback reports only a successful manual opening', () => {
const opened = [];
const phone = fixture({ onTabOpened: (sheet) => opened.push(sheet) });
phone.shell.setSheet('context');
assert.deepEqual(opened, []);
phone.press('context');
assert.deepEqual(opened, [], 'closing is not a new context request');
phone.press('context');
assert.deepEqual(opened, ['context']);
phone.setBodyClass('cockpit-mode');
phone.press('context');
assert.deepEqual(
opened,
['context'],
'blocked sheets cannot activate a mode',
);
phone.shell.destroy();
});
test('manual Context opens the existing transaction without disabling the tracked aircraft', async () => {
const calls = [];
const tracked = { layerId: 'flights', icao24: 'abc123', callsign: 'EJA225' };
const viewer = { trackedEntity: { gevTrackedId: 'flights:abc123' } };
const entity = viewer.trackedEntity;
const enabled = new Set(['flights', 'weather']);
const controls = new ContextControls({
elements: {},
installations: {},
actions: {
getCockpit: () => null,
claimVisualAuthority: () => calls.push('claim'),
syncDetection() {},
scheduleLayout() {},
showToast() {},
},
});
controls.connect({
layers: new Map(
['flights', 'weather', 'military-awareness'].map((id) => [id, {}]),
),
getEnabledLayerIds: () => [...enabled],
getLayerParams: () => ({}),
isEffectivelyEnabled: (id) => enabled.has(id),
isEnabled: (id) => enabled.has(id),
setLayerParams: (id, params) => calls.push({ id, params }),
setEnabled: async (id, value) => {
calls.push({ id, value });
if (!value) enabled.delete(id);
if (id === 'flights' && !value) viewer.trackedEntity = null;
return true;
},
_setEnabledWithIntent: (id, value) => {
calls.push({ id, value });
enabled.add(id);
return { intentEpoch: 1, promise: Promise.resolve(true) };
},
});
const shell = {
cockpitView: {
readAircraftInfo: () => (viewer.trackedEntity ? tracked : null),
},
getAircraftTrackingTarget: StyleManager.prototype.getAircraftTrackingTarget,
getContextModeState: () => controls.getContextModeState(),
setContextMode: (mode) => controls.setContextMode(mode),
_showToast: () => assert.fail('successful entry must not report a failure'),
};
try {
assert.equal(
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
true,
);
assert.equal(controls.getContextModeState().mode, 'flights');
assert.equal(viewer.trackedEntity, entity);
assert.deepEqual(shell.getAircraftTrackingTarget(), {
layerId: 'flights',
id: 'abc123',
});
assert.ok(enabled.has('flights'));
assert.ok(
calls.some(
(call) => call.id === 'military-awareness' && call.value === true,
),
);
assert.ok(!calls.some((call) => call.id === 'flights'));
const count = calls.length;
assert.equal(
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
false,
);
assert.equal(
calls.length,
count,
'an already active Contacts session is not restarted',
);
} finally {
controls.stop();
controls.disconnect();
}
});
test('manual Context respects Space Missions, busy transitions, missing tracks and disposal', async () => {
for (const state of [
{ mode: 'space-missions' },
{ mode: 'flights' },
{ changing: true },
{ entering: 'space-missions' },
{ noTrack: true },
{ disposed: true },
{ sheet: 'layers' },
]) {
const shell = {
_disposed: state.disposed,
getContextModeState: () => state,
getAircraftTrackingTarget: () =>
state.noTrack ? null : { layerId: 'flights', id: 'abc123' },
setContextMode: () =>
assert.fail('guarded tab must not mutate Context or tracking'),
};
assert.equal(
await StyleManager.prototype._onMobileTabOpened.call(
shell,
state.sheet || 'context',
),
false,
);
}
});
test('failed mobile Contacts entry uses the existing notice and cancelled/disposed entries stay quiet', async () => {
for (const outcome of ['failure', 'throw', 'cancelled', 'disposed']) {
const notices = [];
const shell = {
getContextModeState: () => ({ mode: null }),
getAircraftTrackingTarget: () => ({ layerId: 'flights', id: 'abc123' }),
setContextMode: async () => {
if (outcome === 'throw') throw new Error('source unavailable');
if (outcome === 'disposed') shell._disposed = true;
return { ok: false, cancelled: outcome === 'cancelled' };
},
_showToast: (message) => notices.push(message),
};
assert.equal(
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
false,
);
assert.equal(
notices.length,
['failure', 'throw'].includes(outcome) ? 1 : 0,
);
}
});
test('a slow downward pull is claimed before Android native scroll takes ownership', () => {
const { document, shell, press } = fixture();
press('layers');
const panel = document.getElementById('data-panel');
touchEvent(document, 'touchstart', panel, { y: 100 });
assert.equal(
touchEvent(document, 'touchmove', panel, { y: 102 }).defaultPrevented,
false,
'tap jitter is left native',
);
assert.equal(
touchEvent(document, 'touchmove', panel, { y: 109.3 }).defaultPrevented,
true,
'cancel the first directional move while Android still allows it',
);
assert.equal(
touchEvent(document, 'touchmove', panel, { y: 131 }).defaultPrevented,
true,
);
touchEvent(document, 'touchend', panel, { y: 175 });
assert.equal(shell.getSheet(), null);
shell.destroy();
});