Merge branch 'main' into upstream/traffic-paired-flow-density

This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-11-03 06:20:51 -08:00
commit 8b363d39f6
499 files changed
+28458 -1374

No files matched your search

+65 -1
View File
@@ -10,11 +10,16 @@
// Run with: npm test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { rm } from 'node:fs/promises';
import { gbfsProxy } from '../../../server/providers/gbfs.js';
import { localProviderPlugins } from '../../../server/providers/local.js';
import { createBikeshareSource } from './source.js';
import { createModel } from './model.js';
import { CITY_BY_ID } from './registry.js';
import { makeFixtureRoot } from '../../tooling/fixtureRoot.mjs';
const nominatimStorageDir = await makeFixtureRoot('gev-gbfs-composition-');
test.after(() => rm(nominatimStorageDir, { recursive: true, force: true }));
const AUSTIN = CITY_BY_ID.get('austin-capmetro');
const OSLO = CITY_BY_ID.get('oslo-bysykkel');
@@ -339,7 +344,7 @@ test('the mounted route still refuses a host outside the allowlist', async () =>
* or from the preview half of it — fails here rather than in a browser.
*/
function composedGbfsPlugin() {
const plugin = localProviderPlugins().find(
const plugin = localProviderPlugins({ nominatimStorageDir }).find(
(entry) => entry.name === 'gbfs-proxy',
);
assert.ok(plugin, 'the application must compose a GBFS provider');
@@ -446,3 +451,62 @@ test('unknown GBFS counts stay unknown through the source, model and station col
assert.equal(model.toNonNegativeInteger('3'), 3);
}
});
test('a station without a published capacity re-derives it from every status poll', async () => {
const { createRendering } = await import('./rendering.js');
const { COLOR_YELLOW } = await import('./policy.js');
const model = createModel({});
const state = {
_cityRuntime: new Map(),
_stationRenderMap: new Map(),
_pointCollection: { add: (options) => ({ ...options }) },
};
const rendering = createRendering({
state,
services: { render: { governorRequestRender() {} } },
parts: { model },
});
const info = model.parseStationInformation({
data: {
stations: [
{ station_id: 'open', name: 'Open', lat: 30.26, lon: -97.74 },
{
station_id: 'fixed',
name: 'Fixed',
lat: 30.27,
lon: -97.75,
capacity: 13,
},
],
},
});
rendering.ensureCityPoints('austin', info);
const poll = (stations) =>
rendering.applyStatusToPoints(
'austin',
model.parseStationStatus({ data: { stations } }),
);
const open = state._stationRenderMap.get('austin:open');
const fixed = state._stationRenderMap.get('austin:fixed');
// The first poll has no row for the station: capacity is a placeholder.
poll([
{
station_id: 'fixed',
num_bikes_available: 4,
num_docks_available: 9,
},
]);
poll([
{ station_id: 'open', num_bikes_available: 10, num_docks_available: 20 },
{ station_id: 'fixed', num_bikes_available: 2, num_docks_available: 3 },
]);
assert.equal(open.capacity, 30, 'the first poll fixed the capacity');
assert.equal(open.point.color, COLOR_YELLOW, '10 of 30 is not plenty');
assert.equal(fixed.capacity, 13, 'a published capacity is kept');
poll([
{ station_id: 'open', num_bikes_available: 4, num_docks_available: 2 },
]);
assert.equal(open.capacity, 6);
});
+3 -1
View File
@@ -185,6 +185,7 @@ export function createRendering({
stationName: station.name,
point,
capacity: parts.model.toNonNegativeInteger(station.capacity),
publishedCapacity: parts.model.toNonNegativeInteger(station.capacity),
bikesAvailable: null,
docksAvailable: null,
isInstalled: station.isInstalled,
@@ -245,7 +246,8 @@ export function createRendering({
if (!record) continue;
const status = statusMap.get(record.stationId) || null;
const capacity = resolveCapacity(record.capacity, status);
// Resolve from the published capacity, not last poll's derived value.
const capacity = resolveCapacity(record.publishedCapacity, status);
record.capacity = capacity;
record.bikesAvailable = parts.model.toNonNegativeInteger(
+2 -5
View File
@@ -4,10 +4,7 @@ import {
militaryNamesInView,
nameMilitaryFragment,
} from '../../data/militaryNames.js';
import {
createOpenFreeMapSource,
decodeOpenFreeMapMilitaryTile,
} from '../../sources/openFreeMap.js';
import { createOpenFreeMapSource } from '../../sources/openFreeMap.js';
import {
mergeMilitaryFragments,
militaryNameInView,
@@ -96,7 +93,7 @@ export function createInstallationSource({
loadNames = loadMilitaryNames,
mapTiles = createOpenFreeMapSource({
fetchImpl: tileFetchImpl,
decode: decodeOpenFreeMapMilitaryTile,
selectDecoded: ({ military }) => ({ military }),
}),
} = {}) {
let overpassUnavailable = false;
+3
View File
@@ -11,6 +11,7 @@ import {
MILITARY_TILE_MIN_ZOOM,
} from './source.js';
import {
clearSharedOpenFreeMapTiles,
decodeOpenFreeMapMilitaryTile,
decodeOpenFreeMapTile,
} from '../../sources/openFreeMap.js';
@@ -224,6 +225,7 @@ test('the military-only decoder keeps Camp Mabry and skips road geometry', () =>
});
test('installation API transports never receive vector-tile URLs', async (t) => {
t.after(clearSharedOpenFreeMapTiles);
const calls = [];
const tileFetch = async (url) => {
calls.push(url);
@@ -242,6 +244,7 @@ test('installation API transports never receive vector-tile URLs', async (t) =>
};
t.mock.method(globalThis, 'fetch', tileFetch);
for (const tileFetchImpl of [undefined, tileFetch]) {
clearSharedOpenFreeMapTiles();
const source = createInstallationSource({
tileFetchImpl,
fetchImpl: async (url) => {
+6 -3
View File
@@ -344,14 +344,17 @@ export function createPlayback({ state: layerState, services, parts, source }) {
}
if (!parts.interaction.radioPresentationAllowed())
return Promise.resolve(false);
if (!layerState._selectedId) {
if (layerState._selectedId) {
// Drop a fallback left by a voice step whose playback never started.
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
} else {
const ranked = parts.queries.rankedVisibleStations();
if (!ranked.length) return Promise.resolve(false);
layerState._playFallbackId = ranked[1]?.id || null;
layerState._playFallbackFocus = false;
parts.selection.selectRadioStation(ranked[0].id, {
autoplay: false,
focus: false,
playFallback: { id: ranked[1]?.id || null, focus: false },
});
}
return playSelectedRadio({ origin });
+46 -16
View File
@@ -73,6 +73,16 @@ export function createSelection({
return { filter: layerState._filter, volume: layerState._userVolume };
}
// A paused or failed stream of another station would outlive its selection.
function releaseStaleRadioStream(stationId) {
if (
layerState._audioStationId &&
layerState._audioStationId !== stationId &&
['paused', 'error'].includes(layerState._audioState)
)
parts.playback.stopRadioPlayback();
}
/** Select a station. Playback occurs only when autoplay is explicitly true. */
function selectRadioStation(
@@ -83,11 +93,18 @@ export function createSelection({
origin = 'programmatic',
attemptId = null,
cameraNavigation = null,
playFallback = null,
} = {},
) {
if (!parts.interaction.radioPresentationAllowed()) return false;
const station = layerState._stationById.get(String(id));
if (!station) return false;
if (!autoplay) releaseStaleRadioStream(station.id);
// Set after the release, which clears fallbacks.
if (playFallback) {
layerState._playFallbackId = playFallback.id;
layerState._playFallbackFocus = playFallback.focus;
}
layerState._tuningUnavailableStationId = null;
if (
!parts.navigation.radioCameraNavigationOwnsSelection(
@@ -104,6 +121,7 @@ export function createSelection({
)
parts.tuning.endRadioTuning();
layerState._cancelledTuningPresentationStation = null;
const changed = layerState._selectedId !== station.id;
layerState._selectedId = station.id;
const generation = ++layerState._selectionGeneration;
const sessionGeneration = layerState._sessionGeneration;
@@ -122,6 +140,9 @@ export function createSelection({
if (focus) parts.navigation.focusStation(station);
parts.presentation.emitState();
if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId });
// A user step to another station cancels a playback voice prepared.
else if (origin === 'user' && changed)
parts.presentation.emitPlaybackControl('select', origin);
return true;
}
@@ -133,6 +154,7 @@ export function createSelection({
rotate = false,
stationIds = null,
autoplay = true,
fallback = autoplay,
origin = 'programmatic',
} = {},
) {
@@ -159,25 +181,29 @@ export function createSelection({
current < 0
? 0
: (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length;
layerState._playFallbackId =
ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null;
// Voice defers playback, so its silent step keeps a fallback; silent UI steps do not.
const fallbackId =
fallback && ranked.length > 1
? ranked[(nextIndex + 1) % ranked.length].id
: null;
const rotationCameraState = rotate
? parts.navigation.radioCameraState()
: null;
const rotationNavigation = rotate
? parts.navigation.beginRadioCameraNavigation(rotationCameraState)
: null;
layerState._playFallbackFocus = rotate
? (fallbackStation) => {
parts.navigation.rotateRadioStationIntoView(
fallbackStation,
0.65,
rotationCameraState,
rotationNavigation,
);
return rotationNavigation;
}
: false;
const fallbackFocus =
fallback && rotate
? (fallbackStation) => {
parts.navigation.rotateRadioStationIntoView(
fallbackStation,
0.65,
rotationCameraState,
rotationNavigation,
);
return rotationNavigation;
}
: false;
const station = ranked[nextIndex];
if (rotate)
parts.navigation.rotateRadioStationIntoView(
@@ -191,6 +217,7 @@ export function createSelection({
focus: false,
origin,
cameraNavigation: rotationNavigation,
playFallback: { id: fallbackId, focus: fallbackFocus },
});
}
@@ -217,9 +244,12 @@ export function createSelection({
},
);
if (!ranked.length) return null;
layerState._playFallbackId = ranked[1]?.id || null;
layerState._playFallbackFocus = false;
selectRadioStation(ranked[0].id, { autoplay, focus: false, origin });
selectRadioStation(ranked[0].id, {
autoplay,
focus: false,
origin,
playFallback: { id: ranked[1]?.id || null, focus: false },
});
return ranked[0];
}
+2
View File
@@ -78,6 +78,8 @@ export function createState({ services }) {
layerState._tuningStartStationId = null;
layerState._tuningResumesPlayback = false;
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
layerState._tuningStationById = new Map();
+17 -5
View File
@@ -66,6 +66,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._cancelledTuningPresentationStation = null;
layerState._tuningStartStationId = layerState._selectedId;
layerState._tuningPreviewId = layerState._selectedId;
// Like Previous/Next, the release plays only if a stream was active.
layerState._tuningResumesPlayback = [
'loading',
'buffering',
'playing',
].includes(layerState._audioState);
parts.tuningNoise.installTuningNoise();
parts.playback.pauseRadioPlayback({ origin: 'user' });
parts.tuningNoise.syncTuningNoiseGain();
@@ -179,7 +185,9 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._selectionGeneration += 1;
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
layerState._tuningUnavailableStationId = stationId;
parts.presentation.emitState();
// The drag paused the stream; with no selection, Resume would start the
// nearest station. Stop also publishes the state and cancels a voice handoff.
parts.playback.stopRadioPlayback({ origin });
return Object.freeze({
ok: false,
reason: 'station-unavailable',
@@ -187,9 +195,11 @@ export function createTuning({ state: layerState, services, parts, source }) {
generation,
});
}
const autoplay = layerState._tuningResumesPlayback;
layerState._tuningResumesPlayback = false;
layerState._tuningActive = false;
layerState._tuningStatic = true;
layerState._tuningAwaitingStationId = frozenStation.id;
layerState._tuningStatic = autoplay;
layerState._tuningAwaitingStationId = autoplay ? frozenStation.id : null;
layerState._tuningPreviewId = null;
layerState._tuningStartStationId = null;
layerState._tuningUnavailableStationId = null;
@@ -204,10 +214,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._playFallbackFocus = null;
layerState._playFallbackOrigin = 'programmatic';
layerState._playFallbackAttemptId = null;
parts.tuningNoise.syncTuningNoiseGain();
// Muting alone would leave the noise graph running after a silent release.
if (autoplay) parts.tuningNoise.syncTuningNoiseGain();
else parts.tuningNoise.clearRadioTuningNoise({ emit: false });
parts.presentation.emitState();
const selected = parts.selection.selectRadioStation(frozenStation.id, {
autoplay: true,
autoplay,
focus: false,
origin,
cameraNavigation,
+22 -4
View File
@@ -50,6 +50,11 @@ export function createTuningNoise({
layerState._tuningNoiseGain = null;
}
function suspendTuningNoiseContext() {
const suspended = layerState._tuningNoiseContext?.suspend?.();
if (suspended?.catch) void suspended.catch(() => {});
}
function syncTuningNoiseGain() {
if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return;
const audible = radioTuningStaticShouldPlay({
@@ -79,8 +84,21 @@ export function createTuningNoise({
if (!AudioContextClass) return false;
if (!layerState._tuningNoiseContext)
layerState._tuningNoiseContext = new AudioContextClass();
const resumed = layerState._tuningNoiseContext.resume?.();
if (resumed?.catch) void resumed.catch(() => {});
const context = layerState._tuningNoiseContext;
const resumed = context.resume?.();
// A release before the device starts finds the context still suspended,
// so its suspend() is a no-op; suspend again once resume() settles.
if (resumed?.then)
void resumed.then(
() => {
if (
layerState._tuningNoiseContext === context &&
!layerState._tuningNoiseSource
)
suspendTuningNoiseContext();
},
() => {},
);
if (layerState._tuningNoiseSource) return true;
const frameCount = Math.max(
@@ -120,6 +138,7 @@ export function createTuningNoise({
if (layerState._tuningCameraNavigation)
parts.navigation.invalidateRadioCameraNavigation();
layerState._tuningActive = false;
layerState._tuningResumesPlayback = false;
layerState._tuningStatic = false;
layerState._tuningAwaitingStationId = null;
layerState._tuningPreviewId = null;
@@ -131,8 +150,7 @@ export function createTuningNoise({
parts.rendering.updateSelectionEntity();
syncTuningNoiseGain();
stopTuningNoiseSource();
const suspended = layerState._tuningNoiseContext?.suspend?.();
if (suspended?.catch) void suspended.catch(() => {});
suspendTuningNoiseContext();
if (emit) parts.presentation.emitState();
}
return {
+30 -4
View File
@@ -6,6 +6,21 @@ import {
} from './geometry.js';
import * as Cesium from 'cesium';
const SOURCE_FEATURE_INDEX = '__gevSourceFeatureIndex';
function indexedCollection(features) {
return {
type: 'FeatureCollection',
features: features.map((feature, index) => ({
...feature,
properties: {
...feature.properties,
[SOURCE_FEATURE_INDEX]: index,
},
})),
};
}
export function createIngestion({ state, parts, source }) {
async function load(viewer) {
if (state._loading || state._loaded) return;
@@ -49,7 +64,7 @@ export function createIngestion({ state, parts, source }) {
const landingFeatures = normalizeFeatures(landingJson, 'landing');
const cableDataSource = await Cesium.GeoJsonDataSource.load(
{ type: 'FeatureCollection', features: cableFeatures },
indexedCollection(cableFeatures),
{
clampToGround: true,
stroke: state.cableColor.withAlpha(0.95),
@@ -61,7 +76,7 @@ export function createIngestion({ state, parts, source }) {
);
if (!owns()) return;
const landingDataSource = await Cesium.GeoJsonDataSource.load(
{ type: 'FeatureCollection', features: landingFeatures },
indexedCollection(landingFeatures),
{
clampToGround: true,
stroke: state.landingColor.withAlpha(0.9),
@@ -131,7 +146,12 @@ export function createIngestion({ state, parts, source }) {
state._referenceRecords = [];
state._surfaceRecords = [];
cableEntities.forEach((entity, index) => {
// Cesium expands multipart geometry into several entities and suffixes
// colliding IDs. Its copied properties retain this source index even
// when a provider has no ID, so every segment keeps the right owner.
const cableReferences = new Set();
cableEntities.forEach((entity) => {
const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue();
const feature = cableFeatures[index];
const reference = featureReference(feature);
if (!reference) return;
@@ -146,6 +166,8 @@ export function createIngestion({ state, parts, source }) {
entity,
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
});
if (cableReferences.has(index)) return;
cableReferences.add(index);
parts.rendering.addReferenceStem({
reference,
label: featureLabel(feature),
@@ -155,7 +177,9 @@ export function createIngestion({ state, parts, source }) {
});
});
landingEntities.forEach((entity, index) => {
const landingReferences = new Set();
landingEntities.forEach((entity) => {
const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue();
const feature = landingFeatures[index];
const reference = featureReference(feature);
if (!reference) return;
@@ -170,6 +194,8 @@ export function createIngestion({ state, parts, source }) {
entity,
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
});
if (landingReferences.has(index)) return;
landingReferences.add(index);
parts.rendering.addReferenceStem({
reference,
label: featureLabel(feature),
+191 -48
View File
@@ -154,57 +154,200 @@ for (const targetHeight of [200, 2000]) test(`visible height steps stay pixel-li
assert.equal(state._heightPending, true);
});
for (const outcome of ['timeout', 'abort']) test(`a road pass ${outcome} invalidates queued tiles and terrain publication without a newer generation`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const held = [];
const setTimer = globalThis.setTimeout;
t.mock.method(globalThis, 'setTimeout', (fn, delay, ...args) => delay === 0
? (held.push(fn), held.length) : setTimer(fn, delay, ...args));
let clock = 0;
t.mock.method(performance, 'now', () => clock += 13);
function holdFlowTasks(t) {
const yields = [];
const scheduler = Object.getOwnPropertyDescriptor(globalThis, 'scheduler');
Object.defineProperty(globalThis, 'scheduler', {
configurable: true,
value: { yield: () => new Promise((resolve) => yields.push(resolve)) },
});
t.after(() => {
for (const resolve of yields) resolve();
if (scheduler) Object.defineProperty(globalThis, 'scheduler', scheduler);
else delete globalThis.scheduler;
});
return async () => {
assert.ok(yields.length, 'pass reached a flow task boundary');
yields.shift()();
await flush();
};
}
function flowFixture(source) {
const state = createState({ services: {} });
let options, samples = 0;
const road = { type: 'primary', oneway: 1, coords: [[0, 0], [0.001, 0]],
waypoints: [C.Cartesian3.fromDegrees(0, 0), C.Cartesian3.fromDegrees(0.001, 0)], segmentDist: [111] };
const source = { getStatus: async () => ({ hasKey: false }), requestRoads: (_b, opts) => {
options = opts;
opts.onTile({ roads: [road] });
return new Promise(() => {});
} };
const calls = { samples: 0, selections: 0 };
const road = {
type: 'primary',
oneway: 1,
coords: [
[0, 0],
[0.001, 0],
],
waypoints: [
C.Cartesian3.fromDegrees(0, 0),
C.Cartesian3.fromDegrees(0.001, 0),
],
segmentDist: [111],
};
const paints = [];
Object.assign(state, { _enabled: true, _roadMode: 'osm', _parseRoads: (data) => data.roads,
_viewer: { scene: { globe: { show: true, getHeight: () => { samples++; return 100; } } } } });
Object.assign(state, {
_enabled: true,
_roadMode: 'osm',
_parseRoads: (data) => data.roads,
_viewer: {
scene: {
globe: {
show: true,
getHeight: () => {
calls.samples++;
return 100;
},
},
},
},
});
const parts = {
viewport: { clampBounds: (b) => b, getBoundsCenter: () => ({ lat: 0, lon: 0 }), onCameraChanged() {} },
rendering: { visibleRoadsForAltitude: (roads) => roads, renderRoadsForAltitude: (...args) => paints.push(args) },
model: { allocateRoadDotBudgets: (roads) => roads.map(() => 1), recolorDotsInPlace() {} },
viewport: {
clampBounds: (b) => b,
getBoundsCenter: () => ({ lat: 0, lon: 0 }),
onCameraChanged() {},
},
rendering: {
visibleRoadsForAltitude: (roads) => {
calls.selections++;
return roads;
},
renderRoadsForAltitude: (...args) => paints.push(args),
},
model: {
allocateRoadDotBudgets: (roads) => roads.map(() => 1),
recolorDotsInPlace() {},
},
};
const ctx = { state, source, services: { credits: {} }, parts };
parts.flow = createFlow(ctx);
const ingestion = createIngestion(ctx);
const request = ingestion.loadRoadsForBounds(bounds, 450);
await flush();
assert.equal(held.length, 1);
held.shift()(); await flush();
assert.equal(held.length, 1, 'held at the final station before the real terrain onReady callback');
options.onTile({ roads: [road] });
const generation = state._loadGeneration;
const revision = state._surfaceRevision;
const samplesBefore = samples;
if (outcome === 'timeout') t.mock.timers.tick(12001);
else ingestion.cancelActiveFetch();
await request;
assert.equal(options.signal.aborted, true);
assert.equal(state._loadGeneration, generation);
assert.equal(state._fetching, false);
if (outcome === 'timeout') {
assert.equal(state._activeFetchAbort.signal.aborted, false);
assert.match(state._roadError, /timed out/);
assert.equal(state._retryAttempts, 1);
}
held.shift()(); await flush();
assert.equal(paints.length, 0, 'expired terrain cannot publish');
assert.equal(samples, samplesBefore, 'queued tiles cannot start surface preparation');
assert.equal(state._surfaceRevision, revision, 'queued tiles cannot invalidate another surface pass');
ingestion.cancelActiveFetch();
});
return { state, road, paints, parts, ctx, calls };
}
for (const outcome of ['timeout', 'abort'])
test(`a road pass ${outcome} invalidates queued tiles and terrain publication without a newer generation`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const advanceFlowTask = holdFlowTasks(t);
const held = [];
const setTimer = globalThis.setTimeout;
t.mock.method(globalThis, 'setTimeout', (fn, delay, ...args) =>
delay === 0 ? (held.push(fn), held.length) : setTimer(fn, delay, ...args),
);
let clock = 0;
t.mock.method(performance, 'now', () => (clock += 13));
let options;
const source = {
getStatus: async () => ({ hasKey: false }),
requestRoads: (_b, opts) => {
options = opts;
opts.onTile({ roads: [road] });
return new Promise(() => {});
},
};
const { state, road, paints, ctx, calls } = flowFixture(source);
const ingestion = createIngestion(ctx);
const request = ingestion.loadRoadsForBounds(bounds, 450);
await flush();
await advanceFlowTask();
const samplesBeforeSurface = calls.samples;
await advanceFlowTask();
assert.equal(held.length, 1);
held.shift()();
await flush();
assert.equal(
held.length,
1,
'held at the final station before the real terrain onReady callback',
);
assert.equal(
calls.samples - samplesBeforeSurface,
road.coords.length,
'both terrain stations were sampled before the final hold',
);
options.onTile({ roads: [road] });
const generation = state._loadGeneration;
const revision = state._surfaceRevision;
const samplesBefore = calls.samples;
if (outcome === 'timeout') t.mock.timers.tick(12001);
else ingestion.cancelActiveFetch();
await request;
assert.equal(options.signal.aborted, true);
assert.equal(state._loadGeneration, generation);
assert.equal(state._fetching, false);
if (outcome === 'timeout') {
assert.equal(state._activeFetchAbort.signal.aborted, false);
assert.match(state._roadError, /timed out/);
assert.equal(state._retryAttempts, 1);
}
held.shift()();
await flush();
assert.equal(paints.length, 0, 'expired terrain cannot publish');
assert.equal(
calls.samples,
samplesBefore,
'queued tiles cannot start surface preparation',
);
assert.equal(
state._surfaceRevision,
revision,
'queued tiles cannot invalidate another surface pass',
);
ingestion.cancelActiveFetch();
});
for (const checkpoint of ['none', 'prepare', 'surface'])
test(
checkpoint === 'none'
? 'a current surface pass crosses both task boundaries'
: `a newer surface revision stops stale ${checkpoint} work without aborting the pass`,
async (t) => {
// These cases exercise ownership boundaries, not the surface slice budget.
t.mock.method(performance, 'now', () => 0);
const advanceFlowTask = holdFlowTasks(t);
const { state, road, paints, parts, calls } = flowFixture({});
const controller = new AbortController();
t.after(() => controller.abort());
const generation = state._loadGeneration;
const pass = parts.flow.applyFlowThenRender(
[road],
bounds,
generation,
450,
'Loaded tile',
null,
false,
controller.signal,
);
await flush();
if (checkpoint === 'prepare') state._surfaceRevision++;
await advanceFlowTask();
if (checkpoint === 'prepare') {
assert.equal(
calls.selections,
0,
'stale whole-graph preparation cannot start',
);
} else {
assert.ok(calls.selections > 0, 'the healthy preparation prefix ran');
if (checkpoint === 'surface') state._surfaceRevision++;
await advanceFlowTask();
}
const rendered = await pass;
assert.equal(state._loadGeneration, generation);
assert.equal(controller.signal.aborted, false);
if (checkpoint === 'none') {
assert.equal(rendered, true);
assert.ok(calls.samples > 0);
assert.ok(paints.length > 0);
} else {
assert.equal(rendered, false);
assert.equal(calls.samples, 0, 'superseded work cannot sample terrain');
assert.equal(paints.length, 0, 'superseded work cannot publish');
}
},
);
+7
View File
@@ -12,6 +12,7 @@ import {
roadDotBudget,
} from './policy.js';
import { msg } from '../../i18n/index.js';
import { nextTask } from '../../sources/vectorTiles.js';
export function createFlow({ state: layerState, services, parts, source }) {
const { registerDynamicCredit, TOMTOM_CREDIT } = services.credits;
@@ -253,6 +254,9 @@ export function createFlow({ state: layerState, services, parts, source }) {
parts.rendering.renderRoadsForAltitude([], altitude, label, trace);
return true;
}
// Separate whole-graph preparation from tile completion and publication.
await nextTask();
if (!current()) return false;
const scene = layerState._viewer.scene;
// The enable-time estimate can predate the first local mesh (especially
// after an intercontinental jump). Once previews have measured roads, use
@@ -476,6 +480,9 @@ export function createFlow({ state: layerState, services, parts, source }) {
if (!lastPaint || performance.now() - lastPaint >= 120) paint();
},
};
// The first bounded surface slice needs its own task, not the prefix's tail.
await nextTask();
if (!current()) return false;
let prepared = await prepareRoadSurfaces(
unresolved,
layerState._viewer.scene,
+4
View File
@@ -76,6 +76,10 @@ function setup(t, requestRoads, getStatus = async () => ({ hasKey: false })) {
const tick = async (ms) => {
t.mock.timers.tick(ms);
for (let i = 0; i < 30; i++) await Promise.resolve();
t.mock.timers.tick(0);
for (let i = 0; i < 30; i++) await Promise.resolve();
t.mock.timers.tick(0);
for (let i = 0; i < 30; i++) await Promise.resolve();
};
return { layer, viewer, move, tick };
}
+4 -1
View File
@@ -84,7 +84,10 @@ export function roadRequestError(status, cause) {
export function createTrafficSource({
fetchImpl = (...args) => globalThis.fetch(...args),
tileFetchImpl = (...args) => globalThis.fetch(...args),
mapTiles = createOpenFreeMapSource({ fetchImpl: tileFetchImpl }),
mapTiles = createOpenFreeMapSource({
fetchImpl: tileFetchImpl,
selectDecoded: ({ roads }) => ({ roads }),
}),
} = {}) {
const flow = createFlowTileSource({ fetchImpl });
const api = {
+12 -2
View File
@@ -5,7 +5,10 @@ import { getEventListeners } from 'node:events';
import { createFlowTileSource } from './flowSource.js';
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
import { decodeFlowTile } from './flowDecode.js';
import { decodeOpenFreeMapTile } from '../../sources/openFreeMap.js';
import {
clearSharedOpenFreeMapTiles,
decodeOpenFreeMapTile,
} from '../../sources/openFreeMap.js';
import {
RoadRequestError,
roadRequestError,
@@ -851,6 +854,8 @@ test('failed detail cores retain real coarse roads while successful cores replac
});
test('traffic separates API and public tile transports, including the default tile fetch', async (t) => {
clearSharedOpenFreeMapTiles();
t.after(clearSharedOpenFreeMapTiles);
const publicUrls = [],
apiUrls = [];
const publicFetch = async (url) => {
@@ -886,7 +891,12 @@ test('traffic separates API and public tile transports, including the default ti
(await (await source.requestOsmRoads(bounds)).json()).roads.length,
);
}
assert.ok(publicUrls.length >= 4);
assert.equal(
publicUrls.filter((url) => url.endsWith('.pbf')).length,
1,
'compatible sources reuse the shared decoded public tile',
);
assert.equal(publicUrls.filter((url) => url.endsWith('/planet')).length, 2);
assert.ok(apiUrls.length >= 4);
});