mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Conflicts with upstream's Street Level provider composition and share-state codec, resolved: - Street View's switch moves from its own share option (`g`) to upstream's provider-switch field (`r`, e.g. `0.r.google-1`); a link without it keeps Street View off. Docs and the browser gate follow. - createApplicationStreetLevel keeps upstream's injectable provider list; its default, once the application supplies its Street Level source, is Mapillary plus Google Street View. Without that source there are none, as upstream intends. - The coverage hint keeps upstream's empty-composition message and joins every switched-on provider's hint otherwise. - CHANGELOG, README and SECURITY keep both sides' entries. Co-Authored-By: Claude Opus 5.5 <[email protected]>
542 lines
17 KiB
JavaScript
542 lines
17 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import {
|
||
presentStreetLevelPanel,
|
||
SINCE_STOPS,
|
||
sinceStopIndex,
|
||
} from './streetLevelPresentation.js';
|
||
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
|
||
|
||
const provider = providerSnapshot;
|
||
|
||
test('an empty composition explains unavailable imagery even while disabled', () => {
|
||
for (const enabled of [false, true]) {
|
||
const view = presentStreetLevelPanel(
|
||
snapshot({
|
||
enabled,
|
||
providers: [],
|
||
coverage: { hint: 'No street-level imagery providers configured' },
|
||
legend: [],
|
||
}),
|
||
);
|
||
assert.equal(view.controlsDisabled, true);
|
||
assert.equal(view.keyRequired, false);
|
||
assert.deepEqual(view.providers, []);
|
||
assert.equal(view.meta, 'No street-level imagery providers configured');
|
||
}
|
||
});
|
||
|
||
function snapshot(overrides = {}) {
|
||
const base = {
|
||
enabled: false,
|
||
keyRequired: false,
|
||
filter: { pano: 'all', sinceDays: 0 },
|
||
providers: [provider()],
|
||
coverage: { loading: false, count: 0, hint: '', error: null },
|
||
legend: [
|
||
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
|
||
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
|
||
],
|
||
sequence: { providerId: null, selectedId: null, images: 0, loading: false },
|
||
street: {
|
||
open: false,
|
||
follow: false,
|
||
loading: false,
|
||
error: null,
|
||
providerId: null,
|
||
providerName: null,
|
||
providerLabel: null,
|
||
imageId: null,
|
||
position: null,
|
||
bearing: null,
|
||
isPano: false,
|
||
capturedAt: null,
|
||
sequenceId: null,
|
||
creator: null,
|
||
externalUrl: null,
|
||
renderMode: 'letterbox',
|
||
},
|
||
};
|
||
return deepMerge(base, overrides);
|
||
}
|
||
|
||
function deepMerge(target, source) {
|
||
const out = { ...target };
|
||
for (const [key, value] of Object.entries(source)) {
|
||
out[key] =
|
||
value &&
|
||
typeof value === 'object' &&
|
||
!Array.isArray(value) &&
|
||
target[key] &&
|
||
typeof target[key] === 'object'
|
||
? deepMerge(target[key], value)
|
||
: value;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
test('a key-gated layer disables every control and flags KEY REQUIRED', () => {
|
||
const view = presentStreetLevelPanel(snapshot({ keyRequired: true }));
|
||
assert.equal(view.controlsDisabled, true);
|
||
assert.equal(view.status.text, 'KEY REQUIRED');
|
||
assert.equal(view.status.tone, 'warn');
|
||
});
|
||
|
||
test('KEY REQUIRED shows no raw error code under the pill', () => {
|
||
const view = presentStreetLevelPanel(
|
||
snapshot({
|
||
enabled: true,
|
||
keyRequired: true,
|
||
coverage: { error: 'no_key' },
|
||
providers: [provider({ keyRequired: true, error: 'no_key' })],
|
||
}),
|
||
);
|
||
assert.equal(view.status.text, 'KEY REQUIRED');
|
||
assert.equal(view.error, null);
|
||
// The chip still says how to add the key.
|
||
assert.match(view.providers[0].title, /^Mapillary: Needs /);
|
||
});
|
||
|
||
test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => {
|
||
const error =
|
||
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
|
||
const view = presentStreetLevelPanel(
|
||
snapshot({
|
||
enabled: true,
|
||
keyRequired: true,
|
||
keyRejected: true,
|
||
coverage: { error },
|
||
providers: [provider({ keyRequired: true, keyRejected: true, error })],
|
||
}),
|
||
);
|
||
assert.equal(view.status.text, 'KEY REJECTED');
|
||
assert.equal(view.status.tone, 'warn');
|
||
assert.equal(view.controlsDisabled, true);
|
||
assert.equal(view.error, error);
|
||
assert.equal(view.providers[0].state, 'error');
|
||
assert.equal(view.providers[0].title, `Mapillary: ${error}`);
|
||
});
|
||
|
||
test('a key problem outranks loading on the pill: KEY REJECTED, then KEY REQUIRED (M65)', () => {
|
||
// A keyless provider can still report loading (its status check, a retry):
|
||
// the pill must say what is wrong, not that something is on its way.
|
||
const loading = { enabled: true, coverage: { loading: true } };
|
||
assert.equal(
|
||
presentStreetLevelPanel(snapshot({ ...loading, keyRequired: true })).status
|
||
.text,
|
||
'KEY REQUIRED',
|
||
);
|
||
assert.equal(
|
||
presentStreetLevelPanel(
|
||
snapshot({ ...loading, keyRequired: true, keyRejected: true }),
|
||
).status.text,
|
||
'KEY REJECTED',
|
||
);
|
||
});
|
||
|
||
test('the header pill is the on/off switch and reads OFF, LOADING or ON', () => {
|
||
assert.deepEqual(presentStreetLevelPanel(snapshot()).status, {
|
||
text: 'OFF',
|
||
tone: '',
|
||
pressed: false,
|
||
title: 'Turn Street Level on',
|
||
});
|
||
assert.deepEqual(
|
||
presentStreetLevelPanel(
|
||
snapshot({ enabled: true, coverage: { loading: true } }),
|
||
).status,
|
||
{
|
||
text: 'LOADING',
|
||
tone: 'busy',
|
||
pressed: true,
|
||
title: 'Turn Street Level off',
|
||
},
|
||
);
|
||
assert.deepEqual(
|
||
presentStreetLevelPanel(snapshot({ enabled: true })).status,
|
||
{ text: 'ON', tone: 'on', pressed: true, title: 'Turn Street Level off' },
|
||
);
|
||
assert.equal(
|
||
presentStreetLevelPanel(snapshot({ enabled: true, keyRequired: true }))
|
||
.status.pressed,
|
||
true,
|
||
'a keyless layer that is on can still be switched off',
|
||
);
|
||
});
|
||
|
||
test('one chip per provider: on, off, loading, and keyless as an error chip', () => {
|
||
const view = presentStreetLevelPanel(
|
||
snapshot({
|
||
enabled: true,
|
||
providers: [
|
||
provider(),
|
||
provider({
|
||
id: 'panoramax',
|
||
name: 'Panoramax',
|
||
label: 'PANORAMAX',
|
||
on: false,
|
||
requiresKeyId: null,
|
||
}),
|
||
provider({
|
||
id: 'kartaview',
|
||
name: 'KartaView',
|
||
label: 'KARTAVIEW',
|
||
loading: true,
|
||
requiresKeyId: null,
|
||
}),
|
||
provider({
|
||
id: 'google-street-view',
|
||
name: 'Google Street View',
|
||
label: 'STREET VIEW',
|
||
keyRequired: true,
|
||
requiresKeyId: 'google-maps',
|
||
}),
|
||
],
|
||
}),
|
||
);
|
||
assert.deepEqual(
|
||
view.providers.map((chip) => [chip.id, chip.active, chip.state, chip.busy]),
|
||
[
|
||
['mapillary', true, 'active', false],
|
||
['panoramax', false, 'idle', false],
|
||
['kartaview', true, 'loading', true],
|
||
['google-street-view', true, 'error', false],
|
||
],
|
||
);
|
||
assert.equal(view.providers[0].label, 'MAPILLARY');
|
||
assert.equal(view.providers[1].title, 'Panoramax imagery off');
|
||
assert.match(
|
||
view.providers[3].title,
|
||
/^Google Street View: Needs GOOGLE_MAPS_API_KEY/,
|
||
);
|
||
assert.ok(view.providers.every((chip) => chip.disabled === false));
|
||
});
|
||
|
||
test('a provider error is explained on its chip', () => {
|
||
const view = presentStreetLevelPanel(
|
||
snapshot({ providers: [provider({ error: 'Tile HTTP 502' })] }),
|
||
);
|
||
assert.equal(view.providers[0].title, 'Mapillary: Tile HTTP 502');
|
||
});
|
||
|
||
test('errors from the viewer or the coverage web surface in one alert', () => {
|
||
assert.equal(presentStreetLevelPanel(snapshot()).error, null);
|
||
assert.equal(
|
||
presentStreetLevelPanel(
|
||
snapshot({ street: { error: 'Image could not be opened' } }),
|
||
).error,
|
||
'Image could not be opened',
|
||
);
|
||
assert.equal(
|
||
presentStreetLevelPanel(snapshot({ coverage: { error: 'Tile HTTP 502' } }))
|
||
.error,
|
||
'Tile HTTP 502',
|
||
);
|
||
});
|
||
|
||
test('chips are dark while the layer is off, so the chip is the layer switch', () => {
|
||
const view = presentStreetLevelPanel(snapshot({ enabled: false }));
|
||
assert.deepEqual(
|
||
view.providers.map((chip) => [chip.id, chip.active, chip.state]),
|
||
[['mapillary', false, 'idle']],
|
||
);
|
||
assert.equal(view.providers[0].title, 'Mapillary imagery off');
|
||
assert.equal(view.enableButton, undefined, 'no separate ON/OFF button');
|
||
const keyless = presentStreetLevelPanel(
|
||
snapshot({ providers: [provider({ keyRequired: true })] }),
|
||
);
|
||
assert.equal(
|
||
keyless.providers[0].state,
|
||
'error',
|
||
'a keyless chip still says why',
|
||
);
|
||
});
|
||
|
||
test('the SINCE slider runs from any date on the left to the last month on the right', () => {
|
||
assert.deepEqual(
|
||
SINCE_STOPS.map((stop) => stop.days),
|
||
[0, 3652, 1826, 1095, 730, 365, 182, 91, 30],
|
||
);
|
||
assert.equal(sinceStopIndex(0), 0);
|
||
assert.equal(sinceStopIndex(365), 5);
|
||
assert.equal(
|
||
sinceStopIndex(400),
|
||
5,
|
||
'an off-stop link lands on the nearest stop',
|
||
);
|
||
assert.equal(sinceStopIndex(-4), 0);
|
||
});
|
||
|
||
test('the SINCE readout names the window and the cut-off date it means today', () => {
|
||
const now = Date.UTC(2026, 8, 25);
|
||
const any = presentStreetLevelPanel(snapshot(), { now });
|
||
assert.deepEqual(any.since, { index: 0, days: 0, label: 'ANY DATE' });
|
||
const year = presentStreetLevelPanel(
|
||
snapshot({ filter: { pano: 'pano', sinceDays: 365 } }),
|
||
{ now },
|
||
);
|
||
assert.deepEqual(year.filter, { pano: 'pano', sinceDays: 365 });
|
||
assert.deepEqual(year.since, {
|
||
index: 5,
|
||
days: 365,
|
||
label: 'LAST YEAR · SINCE 2025-09-25',
|
||
});
|
||
const custom = presentStreetLevelPanel(
|
||
snapshot({ filter: { pano: 'all', sinceDays: 400 } }),
|
||
{ now },
|
||
);
|
||
assert.equal(custom.since.index, 5);
|
||
assert.equal(custom.since.label, 'LAST 400 DAYS · SINCE 2025-08-21');
|
||
});
|
||
|
||
test('legend passes through in the layer’s order', () => {
|
||
const view = presentStreetLevelPanel(snapshot());
|
||
assert.deepEqual(
|
||
view.legend.map((entry) => entry.key),
|
||
['mapillary', 'selected'],
|
||
);
|
||
});
|
||
|
||
test('each provider chip carries its source colour', () => {
|
||
const view = presentStreetLevelPanel(
|
||
snapshot({
|
||
providers: [
|
||
provider(),
|
||
provider({ id: 'panoramax', label: 'PANORAMAX', color: '#a66bff' }),
|
||
],
|
||
}),
|
||
);
|
||
assert.deepEqual(
|
||
view.providers.map((chip) => [chip.id, chip.color]),
|
||
[
|
||
['mapillary', '#05cb63'],
|
||
['panoramax', '#a66bff'],
|
||
],
|
||
);
|
||
});
|
||
|
||
test('the meta line never mixes the visible-sequence count with the selected sequence', () => {
|
||
assert.equal(
|
||
presentStreetLevelPanel(snapshot()).meta,
|
||
'Switch a provider on to draw its coverage.',
|
||
);
|
||
const browsing = presentStreetLevelPanel(
|
||
snapshot({ enabled: true, coverage: { count: 812 } }),
|
||
);
|
||
assert.match(browsing.meta, /^812 sequences in view · click a line/);
|
||
const selected = presentStreetLevelPanel(
|
||
snapshot({
|
||
enabled: true,
|
||
coverage: { count: 812 },
|
||
sequence: { selectedId: 'abc', images: 33 },
|
||
}),
|
||
);
|
||
assert.equal(selected.meta, '33 images in this sequence · Esc clears');
|
||
assert.doesNotMatch(selected.meta, /sequences in view/);
|
||
const hinted = presentStreetLevelPanel(
|
||
snapshot({
|
||
enabled: true,
|
||
coverage: { hint: 'Point the camera at the globe' },
|
||
}),
|
||
);
|
||
assert.equal(hinted.meta, 'Point the camera at the globe');
|
||
});
|
||
|
||
test('viewer caption reads "Image by" left, date right, and links to the provider', () => {
|
||
const view = presentStreetLevelPanel(
|
||
snapshot({
|
||
enabled: true,
|
||
street: {
|
||
open: true,
|
||
providerId: 'mapillary',
|
||
providerName: 'Mapillary',
|
||
providerLabel: 'MAPILLARY',
|
||
imageId: '1814275685699406',
|
||
creator: 'mapfool',
|
||
capturedAt: Date.UTC(2023, 9, 8),
|
||
bearing: 93.4,
|
||
isPano: true,
|
||
externalUrl:
|
||
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
|
||
followAvailable: true,
|
||
},
|
||
}),
|
||
);
|
||
assert.equal(view.viewer.captionLeft, 'Image by mapfool');
|
||
assert.equal(view.viewer.captionRight, '360° · 93° · 2023-10-08');
|
||
assert.equal(
|
||
view.viewer.link,
|
||
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
|
||
);
|
||
assert.equal(view.viewer.linkLabel, 'MAPILLARY ↗');
|
||
assert.equal(view.viewer.follow.disabled, false);
|
||
assert.equal(view.wantsOpen, true);
|
||
});
|
||
|
||
test('an image without a creator name leaves the left caption empty', () => {
|
||
const view = presentStreetLevelPanel(
|
||
snapshot({
|
||
street: { open: true, imageId: '1', capturedAt: Date.UTC(2024, 0, 2) },
|
||
}),
|
||
);
|
||
assert.equal(view.viewer.captionLeft, '');
|
||
assert.equal(view.viewer.captionRight, '2024-01-02');
|
||
assert.equal(view.viewer.link, null);
|
||
assert.equal(view.viewer.linkLabel, '');
|
||
});
|
||
|
||
test('FOLLOW is disabled off Google 3D and says where to switch', () => {
|
||
const open = { open: true, imageId: '1' };
|
||
const off3d = presentStreetLevelPanel(
|
||
snapshot({ street: { ...open, followAvailable: false } }),
|
||
);
|
||
assert.equal(off3d.viewer.follow.disabled, true);
|
||
assert.match(
|
||
off3d.viewer.follow.title,
|
||
/needs the Google 3D map.*MAP SOURCE/,
|
||
);
|
||
const on3d = presentStreetLevelPanel(
|
||
snapshot({ street: { ...open, followAvailable: true } }),
|
||
);
|
||
assert.equal(on3d.viewer.follow.disabled, false);
|
||
assert.match(on3d.viewer.follow.title, /^Camera follows view/);
|
||
const closed = presentStreetLevelPanel(
|
||
snapshot({ street: { open: false, followAvailable: true } }),
|
||
);
|
||
assert.equal(closed.viewer.follow.disabled, true, 'nothing to follow');
|
||
});
|
||
|
||
test('a Street View caption names the place, dates it by month, and offers no FIT/FILL', () => {
|
||
const view = presentStreetLevelPanel(
|
||
snapshot({
|
||
enabled: true,
|
||
street: {
|
||
open: true,
|
||
providerId: 'google',
|
||
providerName: 'Google Street View',
|
||
providerLabel: 'STREET VIEW',
|
||
imageId: 'pano-1',
|
||
title: '1014 10th St',
|
||
creator: 'Google',
|
||
capturedAt: Date.UTC(2023, 0, 1),
|
||
capturedAtPrecision: 'month',
|
||
bearing: 0,
|
||
isPano: true,
|
||
renderModes: false,
|
||
externalUrl:
|
||
'https://www.google.com/maps/@?api=1&map_action=pano&pano=pano-1',
|
||
},
|
||
}),
|
||
);
|
||
assert.equal(view.viewer.captionLeft, '1014 10th St · Image by Google');
|
||
assert.equal(view.viewer.captionRight, '360° · 0° · 2023-01');
|
||
assert.equal(view.viewer.linkLabel, 'STREET VIEW ↗');
|
||
assert.equal(view.viewer.renderModes, false);
|
||
});
|
||
|
||
test('the meta line keeps the sequence count and adds the other providers’ hints', () => {
|
||
const view = presentStreetLevelPanel(
|
||
snapshot({
|
||
enabled: true,
|
||
coverage: {
|
||
loading: false,
|
||
count: 2514,
|
||
hint: 'click a street to open Street View',
|
||
error: null,
|
||
},
|
||
}),
|
||
);
|
||
assert.equal(
|
||
view.meta,
|
||
`${(2514).toLocaleString()} sequences in view · click a line for its photos · click a street to open Street View`,
|
||
);
|
||
});
|
||
|
||
test('OPEN STREET VIEW shows while Street View is on, and waits for street zoom', () => {
|
||
const streetView = provider({
|
||
id: 'google',
|
||
name: 'Google Street View',
|
||
label: 'STREET VIEW',
|
||
groundClick: true,
|
||
requiresKeyId: 'google-maps',
|
||
});
|
||
const view = (overrides) =>
|
||
presentStreetLevelPanel(
|
||
snapshot({
|
||
enabled: true,
|
||
providers: [provider(), streetView],
|
||
...overrides,
|
||
}),
|
||
).nearest;
|
||
const ready = view({ groundClickReady: true });
|
||
assert.deepEqual(ready, {
|
||
visible: true,
|
||
label: 'OPEN STREET VIEW',
|
||
title: 'Open Google Street View at the centre of the view',
|
||
disabled: false,
|
||
providerIds: ['google'],
|
||
});
|
||
const high = view({ groundClickReady: false });
|
||
assert.equal(high.disabled, true);
|
||
assert.equal(
|
||
high.title,
|
||
'Zoom in to a street to open Google Street View here',
|
||
);
|
||
// Hidden with Street View off, without its key, or with the layer off
|
||
// (off Google 3D the provider is not listed at all).
|
||
assert.equal(
|
||
view({ providers: [provider(), { ...streetView, on: false }] }).visible,
|
||
false,
|
||
);
|
||
assert.equal(
|
||
view({ providers: [provider(), { ...streetView, keyRequired: true }] })
|
||
.visible,
|
||
false,
|
||
);
|
||
assert.equal(view({ providers: [provider()] }).visible, false);
|
||
assert.equal(
|
||
presentStreetLevelPanel(snapshot({ providers: [provider(), streetView] }))
|
||
.nearest.visible,
|
||
false,
|
||
);
|
||
});
|
||
|
||
test('KEY REQUIRED points at Street View when it would draw with the Google key', () => {
|
||
const view = presentStreetLevelPanel(
|
||
snapshot({
|
||
enabled: true,
|
||
keyRequired: true,
|
||
providers: [
|
||
provider({ keyRequired: true, configured: false }),
|
||
provider({
|
||
id: 'google',
|
||
name: 'Google Street View',
|
||
label: 'STREET VIEW',
|
||
on: false,
|
||
configured: true,
|
||
requiresKeyId: 'google-maps',
|
||
}),
|
||
],
|
||
keyAlternatives: [
|
||
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
|
||
],
|
||
}),
|
||
);
|
||
const guidance =
|
||
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key';
|
||
assert.equal(view.status.text, 'KEY REQUIRED');
|
||
assert.equal(view.status.title, guidance);
|
||
assert.equal(view.meta, guidance);
|
||
// Off Google 3D Street View is not listed: plain KEY REQUIRED.
|
||
const plain = presentStreetLevelPanel(
|
||
snapshot({
|
||
enabled: true,
|
||
keyRequired: true,
|
||
providers: [provider({ keyRequired: true, configured: false })],
|
||
keyAlternatives: [],
|
||
}),
|
||
);
|
||
assert.equal(plain.status.text, 'KEY REQUIRED');
|
||
assert.notEqual(plain.meta, guidance);
|
||
});
|