mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Persist explicit provider toggles through the layer state coordinator
This commit is contained in:
1 parent
3ba8c6d61b
commit
eacfc1b06a
7 files changed
+152
-23
No files matched your search
+2
-1
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
- Custom Street Level provider switches survive share links and stored state
|
- Custom Street Level provider switches survive share links and stored state
|
||||||
using URL-safe stable provider IDs and a shared boolean-switch codec; existing
|
using URL-safe stable provider IDs and a shared boolean-switch codec; existing
|
||||||
Mapillary links keep their meaning.
|
Mapillary links keep their meaning. Explicit provider toggles reach durable
|
||||||
|
state without adopting unrelated live switch changes.
|
||||||
|
|
||||||
- Directions and Recent Imagery accept replaceable acquisition sources, including
|
- Directions and Recent Imagery accept replaceable acquisition sources, including
|
||||||
route transport, imagery catalog, thumbnails, tile templates and attribution.
|
route transport, imagery catalog, thumbnails, tile templates and attribution.
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ stable. Custom provider switches use the `r` option (`example-1*panoramax-0`),
|
|||||||
with URL-safe separators and a shared layer-neutral boolean-switch codec, sorted by provider ID rather
|
with URL-safe separators and a shared layer-neutral boolean-switch codec, sorted by provider ID rather
|
||||||
than registration order, and round-trip through links and stored state. A receiver
|
than registration order, and round-trip through links and stored state. A receiver
|
||||||
applies only switches for its registered providers; absent custom switches leave
|
applies only switches for its registered providers; absent custom switches leave
|
||||||
current defaults unchanged. The custom switch field is bounded to 256 characters;
|
current defaults unchanged. Explicit provider toggles persist only their requested
|
||||||
|
switch IDs through the state coordinator; unrelated live values remain transient.
|
||||||
|
The custom switch field is bounded to 256 characters;
|
||||||
provider registration rejects compositions that cannot fit rather than truncating
|
provider registration rejects compositions that cannot fit rather than truncating
|
||||||
identities. A malformed or duplicate entry invalidates the complete custom-switch
|
identities. A malformed or duplicate entry invalidates the complete custom-switch
|
||||||
field. Existing whole-link size limits still apply.
|
field. Existing whole-link size limits still apply.
|
||||||
|
|||||||
@@ -45,3 +45,19 @@ export function decodeIdSwitches(value) {
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persist only explicitly requested switch IDs, not unrelated live changes. */
|
||||||
|
export function patchRequestedIdSwitches(previous, applied, requested) {
|
||||||
|
const current = decodeIdSwitches(applied);
|
||||||
|
const next = new Map(
|
||||||
|
[...decodeIdSwitches(previous)].filter(([id]) => current.has(id)),
|
||||||
|
);
|
||||||
|
let changed = false;
|
||||||
|
for (const [id, on] of current) {
|
||||||
|
if (Object.hasOwn(requested, id) && typeof requested[id] === 'boolean') {
|
||||||
|
next.set(id, on);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? encodeIdSwitches(next) : undefined;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { normalizeIdSwitches } from './idSwitches.js';
|
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
|
||||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||||
|
|
||||||
const VALID_DISPOSITIONS = new Set([
|
const VALID_DISPOSITIONS = new Set([
|
||||||
@@ -416,6 +416,7 @@ const OPTION_GROUPS = Object.freeze({
|
|||||||
booleanOption('mapillary', 'm', true),
|
booleanOption('mapillary', 'm', true),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
key: 'providerSwitches',
|
key: 'providerSwitches',
|
||||||
|
patchRequested: patchRequestedIdSwitches,
|
||||||
token: 'r',
|
token: 'r',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
normalize: (value) => normalizeIdSwitches(value, ['mapillary']),
|
normalize: (value) => normalizeIdSwitches(value, ['mapillary']),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
|
||||||
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
||||||
import { expandApplicationHtml } from '../../build/application-html.js';
|
import { expandApplicationHtml } from '../../build/application-html.js';
|
||||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||||
@@ -243,7 +244,11 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
}
|
}
|
||||||
assert.equal(nextLayerStateToken(), '3');
|
assert.equal(nextLayerStateToken(), '3');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '3', bravo: '4' }),
|
nextLayerStateToken({
|
||||||
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
|
alpha: '3',
|
||||||
|
bravo: '4',
|
||||||
|
}),
|
||||||
'5',
|
'5',
|
||||||
);
|
);
|
||||||
const digitsExhausted = {
|
const digitsExhausted = {
|
||||||
@@ -276,7 +281,9 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
[
|
[
|
||||||
...'0123456789',
|
...'0123456789',
|
||||||
...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) =>
|
...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) =>
|
||||||
[...LAYER_STATE_TOKEN_ALPHABET].map((second) => `${first}${second}`),
|
[...LAYER_STATE_TOKEN_ALPHABET].map(
|
||||||
|
(second) => `${first}${second}`,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
].map((token, index) => [`occupied-${index}`, token]),
|
].map((token, index) => [`occupied-${index}`, token]),
|
||||||
),
|
),
|
||||||
@@ -298,33 +305,37 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(
|
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '3', next: '4' },
|
future: '3',
|
||||||
),
|
next: '4',
|
||||||
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
() =>
|
||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||||
future: '00',
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
}),
|
future: '00',
|
||||||
|
}),
|
||||||
/next free token 3/,
|
/next free token 3/,
|
||||||
);
|
);
|
||||||
const beforeLastDigit = { ...digitsExhausted };
|
const beforeLastDigit = { ...digitsExhausted };
|
||||||
delete beforeLastDigit['prior-9'];
|
delete beforeLastDigit['prior-9'];
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(
|
validateLayerStateAllocations(beforeLastDigit, {
|
||||||
beforeLastDigit,
|
...beforeLastDigit,
|
||||||
{ ...beforeLastDigit, futurePair: '00', futureDigit: '9' },
|
futurePair: '00',
|
||||||
),
|
futureDigit: '9',
|
||||||
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(
|
validateLayerStateAllocations(digitsExhausted, {
|
||||||
digitsExhausted,
|
...digitsExhausted,
|
||||||
{ ...digitsExhausted, pairB: '01', pairA: '00' },
|
pairB: '01',
|
||||||
),
|
pairA: '00',
|
||||||
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
@@ -2706,3 +2717,73 @@ test('Street Level: a link without the provider switch keeps Mapillary on', () =
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('explicit provider switches reach durable state without adopting unrelated live switches', () => {
|
||||||
|
const manager = new DataLayerManager({});
|
||||||
|
const providers = new Map([
|
||||||
|
['mapillary', true],
|
||||||
|
['alpha', true],
|
||||||
|
['beta', true],
|
||||||
|
]);
|
||||||
|
const filter = { pano: 'all', sinceDays: 0 };
|
||||||
|
for (const id of REGISTERED_LAYER_IDS) {
|
||||||
|
manager.register(
|
||||||
|
id === 'street-level'
|
||||||
|
? {
|
||||||
|
...fakeLayer(id),
|
||||||
|
getParams: () => encodeParams({ providers, filter }),
|
||||||
|
setParams(params) {
|
||||||
|
const decoded = decodeParams(params, {
|
||||||
|
providerIds: providers.keys(),
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
for (const [id, on] of decoded.providers) providers.set(id, on);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: fakeLayer(id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
manager.finalizeRegistrations(LAYER_STATE_REGISTRY);
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
providers.set('beta', false); // Passive state does not belong to the next action.
|
||||||
|
assert.equal(
|
||||||
|
manager.setLayerParams(
|
||||||
|
'street-level',
|
||||||
|
{ alpha: false },
|
||||||
|
{ origin: 'user' },
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
|
||||||
|
assert.equal(saved.options['street-level'].providerSwitches, 'alpha-0');
|
||||||
|
const shared = decodeLayerStateParams(new URLSearchParams(encode(saved)));
|
||||||
|
const restored = decodeParams(shared.options['street-level'], {
|
||||||
|
providerIds: ['beta', 'alpha'],
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
assert.deepEqual([...restored.providers], [['alpha', false]]);
|
||||||
|
manager.setLayerParams('street-level', { beta: true }, { origin: 'user' });
|
||||||
|
saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
|
||||||
|
assert.equal(
|
||||||
|
saved.options['street-level'].providerSwitches,
|
||||||
|
'alpha-0*beta-1',
|
||||||
|
);
|
||||||
|
manager.setLayerParams(
|
||||||
|
'street-level',
|
||||||
|
{ alpha: true },
|
||||||
|
{ origin: 'local-restore' },
|
||||||
|
);
|
||||||
|
saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
|
||||||
|
assert.equal(
|
||||||
|
saved.options['street-level'].providerSwitches,
|
||||||
|
'alpha-0*beta-1',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
coordinator.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -255,8 +255,22 @@ export class LayerStateCoordinator {
|
|||||||
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
|
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
|
||||||
const implicitTrackingSync =
|
const implicitTrackingSync =
|
||||||
!explicitlyRequested && spec.key === trackingOptionKey;
|
!explicitlyRequested && spec.key === trackingOptionKey;
|
||||||
if (!explicitlyRequested && !implicitTrackingSync) continue;
|
// Packed options can persist only the subkeys owned by this request.
|
||||||
const value = spec.normalize(change.params[spec.key]);
|
const requestedValue =
|
||||||
|
explicitlyRequested || implicitTrackingSync
|
||||||
|
? change.params[spec.key]
|
||||||
|
: spec.patchRequested?.(
|
||||||
|
nextOwnerOptions[spec.key],
|
||||||
|
change.params[spec.key],
|
||||||
|
requestedParams,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!explicitlyRequested &&
|
||||||
|
!implicitTrackingSync &&
|
||||||
|
requestedValue === undefined
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const value = spec.normalize(requestedValue);
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
if (implicitTrackingSync) {
|
if (implicitTrackingSync) {
|
||||||
nextOwnerOptions[spec.key] = null;
|
nextOwnerOptions[spec.key] = null;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { encodeParams, decodeParams } from './params.js';
|
import { encodeParams, decodeParams } from './params.js';
|
||||||
import {
|
import {
|
||||||
normalizeIdSwitches,
|
normalizeIdSwitches,
|
||||||
|
patchRequestedIdSwitches,
|
||||||
decodeIdSwitches,
|
decodeIdSwitches,
|
||||||
encodeIdSwitches,
|
encodeIdSwitches,
|
||||||
} from '../../data/idSwitches.js';
|
} from '../../data/idSwitches.js';
|
||||||
@@ -137,3 +138,16 @@ test('neutral switch codec preserves hyphens and numeric suffixes in IDs', () =>
|
|||||||
for (const [id, on] of switches) assert.equal(decoded.get(id), on);
|
for (const [id, on] of switches) assert.equal(decoded.get(id), on);
|
||||||
assert.equal(decodeIdSwitches('valid-1*broken').size, 0);
|
assert.equal(decodeIdSwitches('valid-1*broken').size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('editing a receiving composition drops unknown IDs before enforcing the share bound', () => {
|
||||||
|
const previous = `${'x'.repeat(254)}-1`;
|
||||||
|
assert.equal(normalizeIdSwitches(previous), previous);
|
||||||
|
assert.equal(
|
||||||
|
patchRequestedIdSwitches(previous, 'alpha-0*beta-0', { alpha: false }),
|
||||||
|
'alpha-0',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
patchRequestedIdSwitches(previous, 'alpha-0', { pano: 'flat' }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user