mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
On phones the credit line kept white-space: nowrap, so map plus outline credits ran past the right edge and cut off the Data attribution link. Cesium's credit block is absolutely positioned inside #cesium-credits, so the box needs a real width for the credits to wrap: give it the screen width divided by its 0.8 scale, overriding the dock's width: auto !important the way the phone offset already does. Desktop keeps its one-line rule.
876 lines
30 KiB
JavaScript
876 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/,
|
|
);
|
|
// A long credit line (map plus outline credits) wraps inside the screen:
|
|
// the box needs a real width for Cesium's absolutely positioned credit
|
|
// block, set before the 0.8 scale, so it is divided by it.
|
|
assert.match(idleCredit, /white-space: normal;/);
|
|
assert.match(
|
|
idleCredit,
|
|
/[^-]width: calc\(\(100vw - 20px\) \/ 0\.8\) !important;/,
|
|
);
|
|
assert.match(idleCredit, /max-width: none !important;/);
|
|
assert.match(idleCredit, /scale: 0\.8;/);
|
|
// 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();
|
|
});
|