mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
# Conflicts: # docs/CURRENT-STATE.md # docs/TRANSLATORS.md # scripts/package-boundaries.json # src/i18n/i18n.test.mjs # src/i18n/index.js # src/i18n/locale.js # src/i18n/same-as-english.json # src/layers/cctv/presentation.js # src/ui/localePicker.test.mjs
951 lines
30 KiB
JavaScript
951 lines
30 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import * as Cesium from 'cesium';
|
||
import {
|
||
DEFAULT_DIRECTIONS_MODE,
|
||
DIRECTIONS_MODES,
|
||
DIRECTIONS_POINTER_OWNER,
|
||
DIRECTIONS_ROUTE_COLOR,
|
||
createDirectionsStepOverlayEntry,
|
||
directionsRequestUrl,
|
||
directionsRowControls,
|
||
directionsStats,
|
||
directionsStepCopy,
|
||
directionsStepList,
|
||
normalizeDirectionsParams,
|
||
normalizeRoutePayload,
|
||
stepAnchorDelayMs,
|
||
stepIndexAtDistance,
|
||
stepMarkerHeightM,
|
||
stepMarkerIndices,
|
||
pointerBlockedMessage,
|
||
POINTER_TOOL_EXITS,
|
||
STEP_ANCHOR_DEADLINE_MS,
|
||
STEP_ANCHOR_RETRY_MS,
|
||
STEP_ANCHOR_SLOW_RETRY_MS,
|
||
STEP_ANCHOR_FAST_ATTEMPTS,
|
||
createApplicationDirectionsLayer,
|
||
} from './directions.js';
|
||
import { GROUND_FLOOR_LIFT_M } from './groundFloor.js';
|
||
import { instructionFor } from './routeSteps.js';
|
||
import { setLocale } from '../i18n/index.js';
|
||
import {
|
||
directionsInstruction,
|
||
routeDistanceText,
|
||
routeDurationText,
|
||
} from '../layers/directions/index.js';
|
||
import {
|
||
claimPointer,
|
||
isPointerFree,
|
||
pointerOwner,
|
||
releasePointer,
|
||
resetPointerOwnership,
|
||
} from './inputOwnership.js';
|
||
|
||
/** Take the pointer as some other tool, and hand back its release. */
|
||
function claimAs(owner) {
|
||
const lease = claimPointer(owner);
|
||
assert.ok(lease, `${owner} should have taken the pointer`);
|
||
return () => releasePointer(lease);
|
||
}
|
||
import { LAYER_STATE_REGISTRY } from './layerState.js';
|
||
import { SPRITE_LAYER_ORDER } from './spriteOrder.js';
|
||
|
||
/** The instance under test; each ownership fixture builds a fresh one. */
|
||
let directionsLayer = createApplicationDirectionsLayer();
|
||
|
||
const idle = {
|
||
enabled: true,
|
||
mode: 'car',
|
||
armed: null,
|
||
a: null,
|
||
b: null,
|
||
status: 'idle',
|
||
error: null,
|
||
route: null,
|
||
lastUpdate: null,
|
||
};
|
||
const A = { lat: 60.1699, lon: 24.9384 };
|
||
const B = { lat: 60.2055, lon: 24.6559 };
|
||
const route = {
|
||
distanceM: 21479,
|
||
durationS: 1478,
|
||
geometry: [
|
||
[24.9384, 60.1699],
|
||
[24.6559, 60.2055],
|
||
],
|
||
steps: [
|
||
{
|
||
index: 0,
|
||
instruction: 'Head out on Kaivokatu',
|
||
distanceM: 8,
|
||
durationS: 1,
|
||
lat: 60.1699,
|
||
lon: 24.9384,
|
||
},
|
||
{
|
||
index: 1,
|
||
instruction: 'Turn right onto Kansakoulukatu',
|
||
distanceM: 236,
|
||
durationS: 39,
|
||
lat: 60.1683,
|
||
lon: 24.9349,
|
||
},
|
||
{
|
||
index: 2,
|
||
instruction: 'Arrive at your destination',
|
||
distanceM: 0,
|
||
durationS: 0,
|
||
lat: 60.2055,
|
||
lon: 24.6559,
|
||
},
|
||
],
|
||
mode: 'car',
|
||
};
|
||
|
||
test('layer module declares the manager contract, params, and row controls', () => {
|
||
assert.equal(directionsLayer.id, 'directions');
|
||
assert.equal(directionsLayer.updateInterval, 0);
|
||
for (const method of [
|
||
'init',
|
||
'enable',
|
||
'disable',
|
||
'update',
|
||
'getStats',
|
||
'destroy',
|
||
'setParams',
|
||
'getParams',
|
||
'getRowControls',
|
||
'setRowControlsListener',
|
||
'attachDataManager',
|
||
]) {
|
||
assert.equal(
|
||
typeof directionsLayer[method],
|
||
'function',
|
||
`${method} is implemented`,
|
||
);
|
||
}
|
||
assert.deepEqual(directionsLayer.getParams(), {
|
||
mode: DEFAULT_DIRECTIONS_MODE,
|
||
});
|
||
const entry = LAYER_STATE_REGISTRY.find((row) => row.id === 'directions');
|
||
assert.ok(entry, 'directions has a share-link token');
|
||
assert.equal(
|
||
LAYER_STATE_REGISTRY.filter((row) => row.token === entry.token).length,
|
||
1,
|
||
);
|
||
assert.ok(SPRITE_LAYER_ORDER.includes('directions'));
|
||
});
|
||
|
||
test('params: unknown modes are rejected, commands and arm state are normalized', () => {
|
||
assert.deepEqual(normalizeDirectionsParams({ mode: 'FOOT' }), {
|
||
mode: 'foot',
|
||
});
|
||
assert.equal(normalizeDirectionsParams({ mode: 'rocket' }), null);
|
||
assert.deepEqual(normalizeDirectionsParams({ arm: 'a' }), { arm: 'a' });
|
||
assert.deepEqual(normalizeDirectionsParams({ arm: 'zzz' }), { arm: null });
|
||
assert.deepEqual(
|
||
normalizeDirectionsParams({ swap: true, fly: true, clear: true }),
|
||
{ swap: true, fly: true, clear: true },
|
||
);
|
||
assert.deepEqual(normalizeDirectionsParams({ swap: 'yes' }), {});
|
||
assert.deepEqual(normalizeDirectionsParams(), {});
|
||
// The live module refuses an unknown mode without touching state.
|
||
assert.equal(directionsLayer.setParams({ mode: 'rocket' }), false);
|
||
assert.deepEqual(directionsLayer.getParams(), {
|
||
mode: DEFAULT_DIRECTIONS_MODE,
|
||
});
|
||
});
|
||
|
||
test('row chips: modes, arming, and command availability follow the state', () => {
|
||
const { chips } = directionsRowControls(idle);
|
||
const ids = chips.map((c) => c.id);
|
||
assert.deepEqual(ids, [
|
||
'mode-car',
|
||
'mode-foot',
|
||
'mode-bike',
|
||
'set-a',
|
||
'set-b',
|
||
'swap',
|
||
'fly',
|
||
'clear',
|
||
]);
|
||
assert.equal(chips.find((c) => c.id === 'mode-car').active, true);
|
||
assert.equal(chips.find((c) => c.id === 'set-a').label, 'SET A');
|
||
assert.deepEqual(chips.find((c) => c.id === 'set-a').params, { arm: 'a' });
|
||
assert.equal(chips.find((c) => c.id === 'fly').disabled, true);
|
||
assert.equal(chips.find((c) => c.id === 'swap').disabled, true);
|
||
assert.equal(chips.find((c) => c.id === 'clear').disabled, true);
|
||
|
||
const armed = directionsRowControls({ ...idle, armed: 'a' }).chips;
|
||
assert.equal(armed.find((c) => c.id === 'set-a').label, 'CLICK MAP');
|
||
assert.deepEqual(
|
||
armed.find((c) => c.id === 'set-a').params,
|
||
{ arm: null },
|
||
'clicking again cancels',
|
||
);
|
||
|
||
const ready = directionsRowControls({
|
||
...idle,
|
||
a: A,
|
||
b: B,
|
||
status: 'ready',
|
||
route,
|
||
}).chips;
|
||
assert.equal(ready.find((c) => c.id === 'set-a').label, 'A ✓');
|
||
assert.equal(ready.find((c) => c.id === 'fly').disabled, false);
|
||
assert.equal(ready.find((c) => c.id === 'swap').disabled, false);
|
||
assert.equal(ready.find((c) => c.id === 'clear').disabled, false);
|
||
|
||
const routing = directionsRowControls({
|
||
...idle,
|
||
a: A,
|
||
b: B,
|
||
status: 'routing',
|
||
}).chips;
|
||
assert.equal(routing.find((c) => c.id === 'fly').busy, true);
|
||
for (const mode of Object.keys(DIRECTIONS_MODES))
|
||
assert.ok(ids.includes(`mode-${mode}`));
|
||
});
|
||
|
||
test('stats guide the user, show progress, and summarize a route honestly', () => {
|
||
assert.equal(directionsStats(idle).coverage, 'SET A, then click the globe');
|
||
assert.equal(
|
||
directionsStats({ ...idle, armed: 'b' }).coverage,
|
||
'Click the globe to place B',
|
||
);
|
||
assert.equal(
|
||
directionsStats({ ...idle, a: A }).coverage,
|
||
'SET B, then click the globe',
|
||
);
|
||
const routing = directionsStats({ ...idle, a: A, b: B, status: 'routing' });
|
||
assert.equal(routing.loading, true);
|
||
const ready = directionsStats({
|
||
...idle,
|
||
a: A,
|
||
b: B,
|
||
status: 'ready',
|
||
route,
|
||
lastUpdate: 5,
|
||
});
|
||
assert.equal(ready.count, 3);
|
||
assert.equal(ready.coverage, '21\u00a0km · 25\u00a0min · Drive');
|
||
assert.equal(
|
||
ready.loadingLabel,
|
||
'21\u00a0km · 25\u00a0min · Drive',
|
||
'the summary is the row detail line',
|
||
);
|
||
assert.equal(ready.loading, undefined);
|
||
assert.equal(
|
||
directionsStats(idle).loadingLabel,
|
||
'SET A, then click the globe',
|
||
);
|
||
const failed = directionsStats({
|
||
...idle,
|
||
a: A,
|
||
b: B,
|
||
status: 'error',
|
||
error: 'No route found between A and B',
|
||
});
|
||
assert.equal(failed.error, 'No route found between A and B');
|
||
assert.equal(failed.count, 0);
|
||
assert.equal(directionsLayer.getStats().count, 0);
|
||
});
|
||
|
||
test('step cards name the maneuver, its leg, and what comes next', () => {
|
||
const copy = directionsStepCopy(route.steps, 1);
|
||
assert.equal(copy.title, 'Turn right onto Kansakoulukatu');
|
||
assert.deepEqual(copy.details, [
|
||
'Step 2 of 3 · then 240\u00a0m · 39\u00a0s',
|
||
'Then: Arrive at your destination',
|
||
]);
|
||
const last = directionsStepCopy(route.steps, 2);
|
||
assert.deepEqual(last.details, ['Step 3 of 3']);
|
||
const position = Cesium.Cartesian3.fromDegrees(24.9349, 60.1683, 2);
|
||
const card = createDirectionsStepOverlayEntry(1, position, copy);
|
||
assert.equal(card.accent, DIRECTIONS_ROUTE_COLOR);
|
||
assert.equal(card.id, 'directions-step-1');
|
||
assert.equal(createDirectionsStepOverlayEntry(null, position, copy), null);
|
||
});
|
||
|
||
test('the proxy request carries both endpoints, the profile, and asks for steps', () => {
|
||
const url = directionsRequestUrl('foot', A, B);
|
||
assert.match(
|
||
url,
|
||
/^\/api\/route\?profile=foot&coords=24\.938400%2C60\.169900%3B24\.655900%2C60\.205500&steps=1$/,
|
||
);
|
||
});
|
||
|
||
test('route payloads are validated; a failed route never becomes a straight line', () => {
|
||
const ok = normalizeRoutePayload(
|
||
{
|
||
ok: true,
|
||
distanceM: 21479,
|
||
durationS: 1478,
|
||
geometry: route.geometry,
|
||
steps: route.steps,
|
||
},
|
||
'car',
|
||
);
|
||
assert.equal(ok.distanceM, 21479);
|
||
assert.equal(ok.steps.length, 3);
|
||
assert.equal(ok.mode, 'car');
|
||
assert.equal(
|
||
normalizeRoutePayload({ ok: false, error: 'no route found' }, 'car'),
|
||
null,
|
||
);
|
||
assert.equal(
|
||
normalizeRoutePayload({ ok: true, geometry: [[1, 1]] }, 'car'),
|
||
null,
|
||
);
|
||
assert.equal(
|
||
normalizeRoutePayload(
|
||
{
|
||
ok: true,
|
||
geometry: [
|
||
[999, 1],
|
||
[1, 1],
|
||
],
|
||
},
|
||
'car',
|
||
),
|
||
null,
|
||
);
|
||
const noSteps = normalizeRoutePayload(
|
||
{ ok: true, geometry: route.geometry },
|
||
'bike',
|
||
);
|
||
assert.deepEqual(noSteps.steps, []);
|
||
assert.equal(normalizeRoutePayload(null, 'car'), null);
|
||
});
|
||
|
||
test('maneuver dots skip departure and arrival and ride the shared ground floor', () => {
|
||
assert.deepEqual(stepMarkerIndices(route.steps), [1]);
|
||
assert.deepEqual(stepMarkerIndices([]), []);
|
||
assert.deepEqual(stepMarkerIndices(null), []);
|
||
assert.deepEqual(
|
||
stepMarkerIndices([{}, {}, {}, {}, {}]),
|
||
[1, 2, 3],
|
||
'every decision between the endpoints gets a dot',
|
||
);
|
||
// Denver: ground is ~1.6 km ellipsoidal. A dot fixed near zero would sit a
|
||
// kilometre and a half under the junction it marks.
|
||
const lift = GROUND_FLOOR_LIFT_M;
|
||
assert.equal(stepMarkerHeightM(1609.3, lift), 1609.3 + lift);
|
||
assert.equal(stepMarkerHeightM(0, lift), lift);
|
||
assert.equal(
|
||
stepMarkerHeightM(null, lift),
|
||
null,
|
||
'a cold cell has no honest height',
|
||
);
|
||
assert.equal(stepMarkerHeightM(undefined, lift), null);
|
||
assert.equal(stepMarkerHeightM(Number.NaN, lift), null);
|
||
});
|
||
|
||
test('the turn-by-turn list is ordered, labelled, and highlights the flown step', () => {
|
||
assert.equal(directionsStepList({ route: null }), null);
|
||
assert.equal(directionsStepList({ route: { steps: [] } }), null);
|
||
const list = directionsStepList({
|
||
route,
|
||
selectedStep: null,
|
||
flightStep: null,
|
||
});
|
||
assert.deepEqual(
|
||
list.items.map((item) => item.ordinal),
|
||
[1, 2, 3],
|
||
'items are in route order',
|
||
);
|
||
assert.deepEqual(
|
||
list.items.map((item) => item.text),
|
||
route.steps.map((step) => step.instruction),
|
||
);
|
||
assert.deepEqual(
|
||
list.items.map((item) => item.lead),
|
||
['8\u00a0m', '240\u00a0m', '0\u00a0m'],
|
||
);
|
||
assert.deepEqual(
|
||
list.items.map((item) => item.params),
|
||
[{ step: 0 }, { step: 1 }, { step: 2 }],
|
||
);
|
||
assert.equal(
|
||
list.items.some((item) => item.active),
|
||
false,
|
||
);
|
||
|
||
const selected = directionsStepList({
|
||
route,
|
||
selectedStep: 2,
|
||
flightStep: null,
|
||
});
|
||
assert.deepEqual(
|
||
selected.items.map((item) => item.active),
|
||
[false, false, true],
|
||
);
|
||
assert.deepEqual(
|
||
selected.items.map((item) => item.current),
|
||
[false, false, false],
|
||
);
|
||
|
||
// A running FLY owns the highlight; the clicked step yields to it.
|
||
const flying = directionsStepList({ route, selectedStep: 2, flightStep: 1 });
|
||
assert.deepEqual(
|
||
flying.items.map((item) => item.active),
|
||
[false, true, false],
|
||
);
|
||
assert.deepEqual(
|
||
flying.items.map((item) => item.current),
|
||
[false, true, false],
|
||
);
|
||
|
||
const rowList = directionsRowControls({
|
||
...idle,
|
||
a: A,
|
||
b: B,
|
||
status: 'ready',
|
||
route,
|
||
}).list;
|
||
assert.equal(rowList.items.length, 3);
|
||
assert.equal(rowList.ariaLabel, 'Turn-by-turn directions');
|
||
assert.equal(directionsRowControls(idle).list, null, 'no route, no list');
|
||
});
|
||
|
||
test('the flown step follows distance travelled, never running off either end', () => {
|
||
const steps = [{ distanceM: 100 }, { distanceM: 250 }, { distanceM: 0 }];
|
||
assert.equal(stepIndexAtDistance(steps, 0), 0);
|
||
assert.equal(stepIndexAtDistance(steps, 99), 0);
|
||
assert.equal(stepIndexAtDistance(steps, 100), 1);
|
||
assert.equal(stepIndexAtDistance(steps, 349), 1);
|
||
assert.equal(stepIndexAtDistance(steps, 350), 2, 'the arrival step is last');
|
||
assert.equal(stepIndexAtDistance(steps, 99999), 2);
|
||
assert.equal(stepIndexAtDistance(steps, -5), 0);
|
||
assert.equal(stepIndexAtDistance(steps, Number.NaN), 0);
|
||
assert.equal(stepIndexAtDistance([], 10), null);
|
||
assert.equal(stepIndexAtDistance(null, 10), null);
|
||
});
|
||
|
||
test('the FLY chip reports a flight in progress', () => {
|
||
const flying = directionsRowControls({
|
||
...idle,
|
||
a: A,
|
||
b: B,
|
||
status: 'ready',
|
||
route,
|
||
flightStep: 1,
|
||
}).chips.find((chip) => chip.id === 'fly');
|
||
assert.equal(flying.label, 'FLYING');
|
||
assert.equal(flying.busy, true);
|
||
assert.equal(flying.active, true);
|
||
});
|
||
|
||
test('a blocked pointer is reported instead of silently doing nothing', () => {
|
||
const blocked = directionsStats({ ...idle, pointerBlocked: true });
|
||
assert.match(blocked.error, /Another map tool is using clicks/);
|
||
assert.equal(blocked.count, 0);
|
||
});
|
||
|
||
test('a step selection can be requested and cleared through params', () => {
|
||
assert.deepEqual(normalizeDirectionsParams({ step: 3 }), { step: 3 });
|
||
assert.deepEqual(normalizeDirectionsParams({ step: 0 }), { step: 0 });
|
||
assert.deepEqual(normalizeDirectionsParams({ step: null }), { step: null });
|
||
assert.deepEqual(normalizeDirectionsParams({ step: -1 }), { step: null });
|
||
assert.deepEqual(normalizeDirectionsParams({ step: 'two' }), { step: null });
|
||
});
|
||
|
||
/**
|
||
* Drive the live layer module with the overlay host stubbed out. No viewer is
|
||
* needed for the arming paths: they are pure state plus the pointer claim.
|
||
*/
|
||
function ownershipFixture(t) {
|
||
directionsLayer = createApplicationDirectionsLayer();
|
||
directionsLayer._setOverlayHostForTest({
|
||
setEntries() {},
|
||
setVisible() {},
|
||
clearSource() {},
|
||
});
|
||
resetPointerOwnership();
|
||
t.after(() => {
|
||
directionsLayer.setParams({ clear: true });
|
||
directionsLayer._setOverlayHostForTest(null);
|
||
resetPointerOwnership();
|
||
});
|
||
}
|
||
|
||
test('placing an endpoint takes the pointer, and every exit path gives it back', (t) => {
|
||
ownershipFixture(t);
|
||
|
||
assert.equal(isPointerFree(), true);
|
||
assert.equal(directionsLayer.setParams({ arm: 'a' }), true);
|
||
assert.equal(
|
||
pointerOwner(),
|
||
DIRECTIONS_POINTER_OWNER,
|
||
'arming claims the pointer',
|
||
);
|
||
assert.equal(
|
||
directionsLayer.getRowControls().chips.find((c) => c.id === 'set-a').active,
|
||
true,
|
||
);
|
||
|
||
// Re-arming for the other endpoint keeps the same claim; it never stacks.
|
||
directionsLayer.setParams({ arm: 'b' });
|
||
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
|
||
|
||
// Cancel (clicking the armed chip again).
|
||
directionsLayer.setParams({ arm: null });
|
||
assert.equal(isPointerFree(), true, 'cancelling releases');
|
||
assert.equal(
|
||
directionsLayer.getRowControls().chips.find((c) => c.id === 'set-b').active,
|
||
false,
|
||
);
|
||
|
||
// CLEAR while armed.
|
||
directionsLayer.setParams({ arm: 'a' });
|
||
directionsLayer.setParams({ clear: true });
|
||
assert.equal(isPointerFree(), true, 'CLEAR releases');
|
||
|
||
// disable() while armed.
|
||
directionsLayer.setParams({ arm: 'a' });
|
||
directionsLayer.disable(null);
|
||
assert.equal(isPointerFree(), true, 'disabling the layer releases');
|
||
|
||
// destroy() while armed, on a layer that was never enabled.
|
||
directionsLayer.setParams({ arm: 'b' });
|
||
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
|
||
directionsLayer.destroy({ scene: { primitives: { remove() {} } } });
|
||
assert.equal(isPointerFree(), true, 'destroy releases');
|
||
});
|
||
|
||
test('arming is refused, and said so, while another tool holds the pointer', (t) => {
|
||
ownershipFixture(t);
|
||
|
||
const releaseDraw = claimAs('draw');
|
||
assert.equal(
|
||
directionsLayer.setParams({ arm: 'a' }),
|
||
true,
|
||
'the write is accepted',
|
||
);
|
||
assert.equal(
|
||
pointerOwner(),
|
||
'draw',
|
||
"the other tool's claim is never stolen",
|
||
);
|
||
const chips = directionsLayer.getRowControls().chips;
|
||
assert.equal(
|
||
chips.find((chip) => chip.id === 'set-a').active,
|
||
false,
|
||
'nothing is armed',
|
||
);
|
||
assert.match(
|
||
directionsLayer.getStats().error,
|
||
/Another map tool is using clicks/,
|
||
'the row says why the click did nothing',
|
||
);
|
||
|
||
// Once the other tool lets go, arming works and the complaint clears.
|
||
assert.equal(releaseDraw(), true);
|
||
directionsLayer.setParams({ arm: 'a' });
|
||
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
|
||
assert.equal(directionsLayer.getStats().error, null);
|
||
});
|
||
|
||
test('a release names this layer, so it cannot free a successor tool claim', (t) => {
|
||
ownershipFixture(t);
|
||
|
||
directionsLayer.setParams({ arm: 'a' });
|
||
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
|
||
// A superseding teardown: the layer is disabled, then another tool claims.
|
||
directionsLayer.disable(null);
|
||
const releaseDraw = claimAs('draw');
|
||
// A late second teardown of ours must not free the draw tool's claim.
|
||
directionsLayer.disable(null);
|
||
directionsLayer.setParams({ clear: true });
|
||
assert.equal(pointerOwner(), 'draw');
|
||
releaseDraw();
|
||
});
|
||
|
||
test('the pointer claim outlives the click that consumed it', async (t) => {
|
||
ownershipFixture(t);
|
||
// Every layer binds its own handler to the same canvas and they all run
|
||
// inside ONE browser event. Releasing the claim the moment the endpoint is
|
||
// placed hands the rest of that same click to every ambient handler
|
||
// registered after this one — which is how placing A on top of a bikeshare
|
||
// station also deselected the station.
|
||
directionsLayer.setParams({ arm: 'a' });
|
||
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
|
||
|
||
assert.equal(
|
||
directionsLayer.placeEndpoint('a', { lat: 37.7955, lon: -122.3937 }),
|
||
true,
|
||
);
|
||
// Still inside the dispatch: an ambient handler that runs after this one
|
||
// asks the same question the real ones ask, and must be told to stand down.
|
||
assert.equal(
|
||
isPointerFree(),
|
||
false,
|
||
'a later handler in the same click yields',
|
||
);
|
||
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
|
||
// A microtask checkpoint runs BETWEEN two DOM listeners, so the release must
|
||
// not be on one.
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
assert.equal(
|
||
isPointerFree(),
|
||
false,
|
||
'a microtask is not the end of the click',
|
||
);
|
||
|
||
// The click is over; the pointer goes back.
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.equal(
|
||
isPointerFree(),
|
||
true,
|
||
'the claim is returned once the click ends',
|
||
);
|
||
|
||
// A bad placement request changes nothing.
|
||
directionsLayer.setParams({ arm: 'b' });
|
||
assert.equal(directionsLayer.placeEndpoint('c', { lat: 1, lon: 1 }), false);
|
||
assert.equal(
|
||
directionsLayer.placeEndpoint('b', { lat: Number.NaN, lon: 1 }),
|
||
false,
|
||
);
|
||
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER, 'still armed');
|
||
});
|
||
|
||
test('a teardown during the pending release still frees the pointer at once', async (t) => {
|
||
ownershipFixture(t);
|
||
directionsLayer.setParams({ arm: 'a' });
|
||
directionsLayer.placeEndpoint('a', { lat: 37.7955, lon: -122.3937 });
|
||
assert.equal(isPointerFree(), false);
|
||
// Disabling mid-click must not wait for a timer that may never be reached.
|
||
directionsLayer.disable(null);
|
||
assert.equal(isPointerFree(), true);
|
||
// And the pending timer cannot then release someone else's later claim.
|
||
const releaseDraw = claimAs('draw');
|
||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||
assert.equal(pointerOwner(), 'draw');
|
||
releaseDraw();
|
||
});
|
||
|
||
test('a cold ground cell is re-read for as long as the terrain proxy can take', () => {
|
||
// The proxy behind the shared floor can take tens of seconds on a cold
|
||
// cache. Twelve quick retries covered four seconds of that.
|
||
assert.ok(
|
||
STEP_ANCHOR_DEADLINE_MS >= 60_000,
|
||
`the budget must outlast a slow terrain round trip, is ${STEP_ANCHOR_DEADLINE_MS} ms`,
|
||
);
|
||
assert.equal(stepAnchorDelayMs(0, 0), STEP_ANCHOR_RETRY_MS);
|
||
assert.equal(
|
||
stepAnchorDelayMs(STEP_ANCHOR_FAST_ATTEMPTS - 1, 1000),
|
||
STEP_ANCHOR_RETRY_MS,
|
||
);
|
||
assert.equal(
|
||
stepAnchorDelayMs(STEP_ANCHOR_FAST_ATTEMPTS, 2000),
|
||
STEP_ANCHOR_SLOW_RETRY_MS,
|
||
'the re-reads back off once the quick ones have not settled it',
|
||
);
|
||
// The last wait never overshoots the budget, and past it there is no wait.
|
||
assert.equal(stepAnchorDelayMs(50, STEP_ANCHOR_DEADLINE_MS - 100), 100);
|
||
assert.equal(stepAnchorDelayMs(50, STEP_ANCHOR_DEADLINE_MS), null);
|
||
assert.equal(stepAnchorDelayMs(50, STEP_ANCHOR_DEADLINE_MS + 1), null);
|
||
assert.equal(stepAnchorDelayMs(0, Number.NaN), null);
|
||
});
|
||
|
||
test('a route cut off at the step cap says so instead of ending at a random turn', () => {
|
||
const cut = { ...route, stepsTruncated: true };
|
||
const list = directionsStepList({ route: cut });
|
||
assert.equal(list.items.length, route.steps.length + 1);
|
||
const last = list.items.at(-1);
|
||
assert.match(last.text, /Only the first 3 turns of this route are shown/);
|
||
assert.equal(last.disabled, true, 'the note is not an action');
|
||
assert.equal(last.params, undefined, 'and carries no command');
|
||
assert.match(
|
||
directionsStats({ ...idle, a: A, b: B, status: 'ready', route: cut })
|
||
.coverage,
|
||
/first 3 turns$/,
|
||
);
|
||
// A complete route says nothing extra.
|
||
assert.equal(directionsStepList({ route }).items.length, route.steps.length);
|
||
assert.equal(
|
||
normalizeRoutePayload(
|
||
{ ok: true, geometry: route.geometry, steps: route.steps },
|
||
'car',
|
||
).stepsTruncated,
|
||
false,
|
||
);
|
||
assert.equal(
|
||
normalizeRoutePayload(
|
||
{
|
||
ok: true,
|
||
geometry: route.geometry,
|
||
steps: route.steps,
|
||
stepsTruncated: true,
|
||
},
|
||
'car',
|
||
).stepsTruncated,
|
||
true,
|
||
);
|
||
});
|
||
|
||
test('re-arming reuses the lease this layer already holds', (t) => {
|
||
ownershipFixture(t);
|
||
// A claim never stacks, not even under the same name — two live instances of
|
||
// one tool are two owners. So arming B after arming A must not ask for a
|
||
// second claim it cannot get.
|
||
assert.equal(directionsLayer.setParams({ arm: 'a' }), true);
|
||
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
|
||
assert.equal(directionsLayer.setParams({ arm: 'b' }), true);
|
||
assert.equal(
|
||
pointerOwner(),
|
||
DIRECTIONS_POINTER_OWNER,
|
||
'the layer still holds the pointer after re-arming',
|
||
);
|
||
const chips = directionsLayer.getRowControls().chips;
|
||
assert.equal(chips.find((chip) => chip.id === 'set-b').active, true);
|
||
assert.equal(
|
||
directionsLayer.getStats().error,
|
||
null,
|
||
'and nothing is refused',
|
||
);
|
||
});
|
||
|
||
test('a stale lease cannot free the claim a later arming holds', async (t) => {
|
||
ownershipFixture(t);
|
||
directionsLayer.setParams({ arm: 'a' });
|
||
directionsLayer.placeEndpoint('a', { lat: 37.7955, lon: -122.3937 });
|
||
// The release for that click is pending. Arming again before it fires must
|
||
// keep the pointer, not lose it to the timer a moment later.
|
||
directionsLayer.setParams({ arm: 'b' });
|
||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||
assert.equal(
|
||
pointerOwner(),
|
||
DIRECTIONS_POINTER_OWNER,
|
||
'the pending release must not disarm the new arming',
|
||
);
|
||
});
|
||
|
||
test('a refused arming names the tool holding the pointer and how to leave it', () => {
|
||
// Owner field test: he pressed SET A while Draw was open, nothing happened,
|
||
// and he concluded Directions was broken. Silence was the bug.
|
||
assert.equal(
|
||
pointerBlockedMessage('draw', 'a'),
|
||
'Draw is active — press Escape twice to leave Draw, then set A',
|
||
);
|
||
assert.equal(
|
||
pointerBlockedMessage('draw', 'b'),
|
||
'Draw is active — press Escape twice to leave Draw, then set B',
|
||
);
|
||
// A tool nobody has written an exit for still gets a usable sentence.
|
||
assert.equal(
|
||
pointerBlockedMessage('measure', 'a'),
|
||
'measure is active — turn measure off, then set A',
|
||
);
|
||
// A registered owner without an exit is named by its tool, not its id.
|
||
assert.equal(
|
||
pointerBlockedMessage('recent-imagery-box', 'a'),
|
||
'Recent imagery is active — turn Recent imagery off, then set A',
|
||
);
|
||
// Nothing to say when nothing is in the way, or when we are the holder.
|
||
assert.equal(pointerBlockedMessage(null, 'a'), null);
|
||
assert.equal(pointerBlockedMessage('', 'a'), null);
|
||
assert.equal(pointerBlockedMessage(' ', 'a'), null);
|
||
assert.equal(pointerBlockedMessage(DIRECTIONS_POINTER_OWNER, 'a'), null);
|
||
// Every exit the table knows names its tool and says how to leave it.
|
||
for (const [id, exit] of Object.entries(POINTER_TOOL_EXITS)) {
|
||
assert.match(
|
||
pointerBlockedMessage(id, 'a'),
|
||
new RegExp(`^${exit.name} is active — `),
|
||
);
|
||
assert.match(pointerBlockedMessage(id, 'a'), /, then set A$/);
|
||
}
|
||
});
|
||
|
||
test('the refusal reaches the app toast, and a successful arming says nothing', (t) => {
|
||
ownershipFixture(t);
|
||
const toasts = [];
|
||
directionsLayer.attachShellServices({
|
||
showToast: (text) => toasts.push(text),
|
||
});
|
||
t.after(() => directionsLayer.attachShellServices(null));
|
||
|
||
const releaseDraw = claimAs('draw');
|
||
directionsLayer.setParams({ arm: 'a' });
|
||
assert.deepEqual(toasts, [
|
||
'Draw is active — press Escape twice to leave Draw, then set A',
|
||
]);
|
||
// The row status the operator can also read is still set.
|
||
assert.match(
|
||
directionsLayer.getStats().error,
|
||
/Another map tool is using clicks/,
|
||
);
|
||
|
||
// Once Draw lets go, arming works and says nothing at all.
|
||
releaseDraw();
|
||
directionsLayer.setParams({ arm: 'a' });
|
||
assert.equal(toasts.length, 1, 'a successful arming is silent');
|
||
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
|
||
});
|
||
|
||
test('maneuvers are phrased on the page, in English word for word as the proxy does', () => {
|
||
const types = [
|
||
'depart',
|
||
'arrive',
|
||
'turn',
|
||
'new name',
|
||
'continue',
|
||
'end of road',
|
||
'fork',
|
||
'merge',
|
||
'on ramp',
|
||
'off ramp',
|
||
'roundabout',
|
||
'rotary',
|
||
'roundabout turn',
|
||
'exit roundabout',
|
||
'exit rotary',
|
||
'use lane',
|
||
'notification',
|
||
'something new',
|
||
];
|
||
const modifiers = [
|
||
null,
|
||
'left',
|
||
'right',
|
||
'slight left',
|
||
'slight right',
|
||
'sharp left',
|
||
'sharp right',
|
||
'straight',
|
||
'uturn',
|
||
];
|
||
const roads = [
|
||
{ name: '', ref: null },
|
||
{ name: 'Congress Avenue', ref: null },
|
||
{ name: 'Mannerheimintie', ref: 'E12' },
|
||
{ name: '', ref: 'I 35' },
|
||
];
|
||
for (const type of types)
|
||
for (const modifier of modifiers)
|
||
for (const road of roads)
|
||
for (const exit of [null, 1, 2, 3, 4, 11, 12, 13, 21, 22]) {
|
||
const step = { type, modifier, exit, ...road };
|
||
assert.equal(
|
||
directionsInstruction(step),
|
||
instructionFor(step),
|
||
JSON.stringify(step),
|
||
);
|
||
}
|
||
// An older payload without a maneuver type keeps the proxy's sentence.
|
||
assert.equal(
|
||
directionsInstruction({ instruction: 'Head out on Kaivokatu' }),
|
||
'Head out on Kaivokatu',
|
||
);
|
||
});
|
||
|
||
test('route numbers follow the page language and never break from their unit', (t) => {
|
||
t.after(() => setLocale('en'));
|
||
assert.equal(routeDistanceText(8.4), '8\u00a0m');
|
||
assert.equal(routeDistanceText(236), '240\u00a0m');
|
||
assert.equal(routeDistanceText(994), '990\u00a0m');
|
||
assert.equal(routeDistanceText(996), '1.0\u00a0km');
|
||
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
|
||
assert.equal(routeDurationText(39), '39\u00a0s');
|
||
assert.equal(routeDurationText(59.4), '59\u00a0s');
|
||
assert.equal(routeDurationText(59.6), '1\u00a0min');
|
||
assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
|
||
assert.equal(routeDurationText(7200), '2\u00a0h');
|
||
assert.equal(routeDistanceText(-1), '');
|
||
assert.equal(routeDurationText(Number.NaN), '');
|
||
|
||
setLocale('ru');
|
||
assert.equal(routeDistanceText(1500), '1,5\u00a0км');
|
||
assert.equal(routeDurationText(240), '4\u00a0мин');
|
||
assert.equal(
|
||
directionsInstruction({
|
||
type: 'turn',
|
||
modifier: 'right',
|
||
name: 'East 7th Street',
|
||
}),
|
||
'Поверните направо на East 7th Street',
|
||
);
|
||
assert.equal(
|
||
directionsInstruction({ type: 'roundabout', exit: 2, name: '' }),
|
||
'На кольце сверните на 2-й съезд',
|
||
);
|
||
const list = directionsStepList({ route });
|
||
assert.equal(list.items[1].lead, '240\u00a0м');
|
||
|
||
// Japanese unit words follow the number: 1時間5分, but 1.5 km.
|
||
setLocale('ja');
|
||
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||
assert.equal(routeDurationText(39), '39秒');
|
||
assert.equal(routeDurationText(3900), '1時間5分');
|
||
assert.equal(routeDurationText(7200), '2時間');
|
||
});
|
||
|
||
test('ramp and lane wording stays localized after choosing the maneuver form', (t) => {
|
||
t.after(() => setLocale('en'));
|
||
const cases = [
|
||
[
|
||
'ru',
|
||
'Въезжайте по съезду плавно налево на I-95',
|
||
'Сверните на съезд резко направо',
|
||
'Займите левую полосу',
|
||
],
|
||
[
|
||
'es',
|
||
'Toma la rampa ligeramente a la izquierda hacia I-95',
|
||
'Toma la salida bruscamente a la derecha',
|
||
'Toma el carril izquierdo',
|
||
],
|
||
[
|
||
'ja',
|
||
'ランプを斜め左に進んでI-95に入る',
|
||
'出口を急角度で右に出る',
|
||
'左車線を通行',
|
||
],
|
||
[
|
||
'zh-CN',
|
||
'向左前方驶入匝道,进入 I-95',
|
||
'从出口向右后方驶出',
|
||
'使用左转车道',
|
||
],
|
||
];
|
||
for (const [locale, ramp, exit, lane] of cases) {
|
||
setLocale(locale);
|
||
assert.equal(
|
||
directionsInstruction({
|
||
type: 'on ramp',
|
||
modifier: 'slight left',
|
||
name: 'I-95',
|
||
}),
|
||
ramp,
|
||
);
|
||
assert.equal(
|
||
directionsInstruction({ type: 'off ramp', modifier: 'sharp right' }),
|
||
exit,
|
||
);
|
||
assert.equal(
|
||
directionsInstruction({ type: 'use lane', modifier: 'sharp left' }),
|
||
lane,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('the reroute tooltip keeps German mode nouns capitalized', (t) => {
|
||
t.after(() => setLocale('en'));
|
||
const title = (id) =>
|
||
directionsRowControls(idle).chips.find((chip) => chip.id === id).title;
|
||
assert.equal(title('mode-foot'), 'Walk — reroute for walk');
|
||
setLocale('de');
|
||
assert.equal(
|
||
title('mode-foot'),
|
||
'Zu Fuß – Route im Modus „Zu Fuß“ neu berechnen',
|
||
);
|
||
assert.equal(
|
||
title('mode-bike'),
|
||
'Fahrrad – Route im Modus „Fahrrad“ neu berechnen',
|
||
);
|
||
});
|