Merge branch 'main' into feat/tools-area-breakdowns

This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-10-16 17:09:08 -07:00
commit 2a6ba1180d
20 files changed
+608 -88

No files matched your search

+48 -1
View File
@@ -726,7 +726,15 @@ const LABEL_ICAO = 'ae1fa4';
const LABEL_POSITION = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668);
/** Seed one civil contact through the production refresh seam. */
function seedLabelContact({ callsign, registration, tracked = false }) {
function seedLabelContact({
callsign,
registration,
tracked = false,
// Per-test metadata overrides, spread last so a test can set a field to
// `undefined` to model "the feed never sent it" — a default parameter
// could not express that, it would just re-supply the default.
meta: metaOverrides = {},
}) {
_setTrackedFlightRefreshStateForTest({
icao24: LABEL_ICAO,
entity: null,
@@ -752,6 +760,7 @@ function seedLabelContact({ callsign, registration, tracked = false }) {
turnRateDps: 0,
rawLat: 30.21,
rawLon: -97.71,
...metaOverrides,
},
});
}
@@ -825,6 +834,44 @@ test('civil label chain: the cockpit descriptor exposes a trimmed registration',
assert.equal(flightsLayer.getTrackedInfo()?.registration, null);
});
test('the tracked descriptor publishes the reported vertical rate', () => {
// `mapAnalystRecord` has always published `verticalRateMps`; the tracked
// seam did not, so a consumer that needs climb/descent (a flight-phase
// classifier, the cockpit) had to reach past getTrackedInfo() into the
// raw record. Both seams now answer with the same field name and unit.
seedLabelContact({
callsign: 'SWA696',
registration: 'N123AB',
tracked: true,
meta: { verticalRate: -6.4 },
});
assert.equal(flightsLayer.getTrackedInfo()?.verticalRateMps, -6.4);
assert.equal(
mapAnalystRecord(LABEL_ICAO, { verticalRate: -6.4 }).verticalRateMps,
-6.4,
'the analyst seam and the tracked seam must agree',
);
});
test('an unreported vertical rate stays null, never a level-flight zero', () => {
// Absent is not level. `stickyNumber` seeds this field with null, so a
// contact that has never transmitted a rate must not read as 0 m/s — a
// phase classifier would call that cruise while the aircraft climbs.
for (const missing of [undefined, null, Number.NaN]) {
seedLabelContact({
callsign: 'SWA696',
registration: 'N123AB',
tracked: true,
meta: { verticalRate: missing },
});
assert.equal(
flightsLayer.getTrackedInfo()?.verticalRateMps,
null,
`vertical rate ${String(missing)} must publish as null`,
);
}
});
test('civil label chain: identity stays icao24 while the label moves', () => {
for (const fixture of [
{ callsign: 'SWA696', registration: 'N123AB' },
+45
View File
@@ -834,3 +834,48 @@ test('two contacts matching at the same strength resolve deterministically', ()
'and the same query resolves the same way every time',
);
});
test('the military tracked descriptor publishes the reported vertical rate', () => {
// Shape parity with the civil layer's getTrackedInfo(). The two layers store
// the rate under different record keys (`verticalRateMps` here,
// `verticalRate` there), so `resolveTrackedAircraftInfo` only hands back one
// shape if BOTH descriptors map it — a consumer that reads the field off the
// civil seam and finds it missing on the military one would silently treat
// every military contact as level flight.
const seedRate = (verticalRateMps) =>
_setTrackedMilitaryRefreshStateForTest({
icao24: 'ae7f01',
entity: { gevLabelModel: { title: 'TEST', details: [] } },
billboard: {
position: Cesium.Cartesian3.fromDegrees(-97.0, 31.0, 8_000),
color: Cesium.Color.WHITE,
show: true,
},
billboardCollection: { show: true, remove() {} },
viewer: { camera: { positionCartographic: null }, scene: {} },
meta: {
callsign: 'RCH512',
rawLat: 31,
rawLon: -97,
altitudeFt: 26_000,
speedMps: 220,
track: 95,
onGround: false,
verticalRateMps,
},
});
seedRate(12.7);
assert.equal(militaryFlightsLayer.getTrackedInfo()?.verticalRateMps, 12.7);
// Absent is not level: a contact that has never transmitted a rate must read
// as null, not as a 0 m/s a phase classifier would call cruise.
for (const missing of [undefined, null, Number.NaN]) {
seedRate(missing);
assert.equal(
militaryFlightsLayer.getTrackedInfo()?.verticalRateMps,
null,
`vertical rate ${String(missing)} must publish as null`,
);
}
});
+13 -2
View File
@@ -3,6 +3,12 @@ import {
FRAME_ENDPOINT,
MEDIA_ENDPOINT,
} from './sourcePolicy.js';
const FRAME_SOURCE_KINDS = new Set([
'upstream-image',
'hls-pull',
'streetview',
'synthetic',
]);
function safeNumber(value, fallback = NaN) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
@@ -51,7 +57,7 @@ export function createCctvSource({
},
getFrameUrl: frameUrlFor,
getMediaUrl: mediaUrlFor,
/** Read one current frame through the registered frame endpoint. */
/** Read a registered frame and its provenance, including fallback frames. */
async getFrame(camera, { signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(frameUrlFor(camera), {
@@ -62,7 +68,12 @@ export function createCctvSource({
const bytes = new Uint8Array(await response.arrayBuffer());
signal?.throwIfAborted();
const type = response.headers.get('content-type') || '';
return { contentType: type.split(';')[0].trim().toLowerCase(), bytes };
const sourceKind = response.headers.get('x-cctv-source');
return {
contentType: type.split(';')[0].trim().toLowerCase(),
bytes,
sourceKind: FRAME_SOURCE_KINDS.has(sourceKind) ? sourceKind : null,
};
},
};
}
+23
View File
@@ -173,3 +173,26 @@ test('frames are read through the registered frame endpoint', async () => {
});
await assert.rejects(failing.getFrame(camera), /Camera frame HTTP 502/);
});
test('frame provenance admits the proxy kinds and leaves missing or unknown headers unknown', async () => {
for (const kind of [
'upstream-image',
'hls-pull',
'streetview',
'synthetic',
'other',
null,
]) {
const source = createCctvSource({
fetchImpl: async () =>
new Response(new Uint8Array([1]), {
headers: {
'Content-Type': 'image/png',
...(kind ? { 'X-CCTV-Source': kind } : {}),
},
}),
});
const frame = await source.getFrame(camera);
assert.equal(frame.sourceKind, kind === 'other' ? null : kind);
}
});
+11 -2
View File
@@ -114,7 +114,7 @@ export function createQueries({
* Build a public descriptor for one aircraft using its best current position
* (dead-reckoned when history exists, billboard position otherwise).
* @param {string} icao24 - ICAO 24-bit transponder address.
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
* Descriptor with a cloned position, or null if the aircraft is unknown.
*/
@@ -164,6 +164,15 @@ export function createQueries({
onGround: info?.onGround === true,
velocityMps: displayed.speedMps,
track: displayed.trackDeg,
// Climb/descent rate, the one kinematic field `mapAnalystRecord` already
// published (as `verticalRateMps`) but this descriptor did not, so a
// consumer reading the tracked seam had to reach back into `_flightData`
// for it. Reported, never derived: `stickyNumber` in records.js keeps the
// last value across a blank poll, and an absent rate stays null rather
// than becoming a level-flight 0 the aircraft never transmitted.
verticalRateMps: Number.isFinite(info?.verticalRate)
? info.verticalRate
: null,
stale: Boolean(
flightState.records.missingPolls.get(icao24) ||
flightState.feed._backoff,
@@ -856,7 +865,7 @@ export function createQueries({
/**
* Describe the currently tracked aircraft at its dead-reckoned position.
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
* Tracked aircraft info, or null when nothing is tracked.
*/
getTrackedInfo() {
+9 -2
View File
@@ -88,7 +88,7 @@ export function createQueries({
* Build a public descriptor for one aircraft using its best current position
* (dead-reckoned when history exists, billboard position otherwise).
* @param {string} icao24 - ICAO hex identifier of the aircraft.
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
* Descriptor with a cloned position, or null if the aircraft is unknown.
*/
@@ -131,6 +131,13 @@ export function createQueries({
onGround: info?.onGround === true,
velocityMps: displayed.speedMps,
track: displayed.trackDeg,
// Same field, same name, same null-when-absent rule as the civil
// descriptor — the two layers store the record under different keys
// (`verticalRateMps` here, `verticalRate` there), so only the mapping
// makes `resolveTrackedAircraftInfo` hand back one shape for both.
verticalRateMps: Number.isFinite(info?.verticalRateMps)
? info.verticalRateMps
: null,
stale: Boolean(
flightState.records.missingPolls.get(icao24) ||
flightState.feed._backoff,
@@ -777,7 +784,7 @@ export function createQueries({
/**
* Describe the currently tracked aircraft at its dead-reckoned position.
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
* Tracked aircraft info, or null when nothing is tracked.
*/
getTrackedInfo() {
+50 -39
View File
@@ -189,20 +189,22 @@ export class SceneDirector {
...this._presentation,
hasRun: !!this._lastRunJson,
}));
this._bootstrapLegacyShotPacks();
// Upgrade only already-installed packs; unrelated Scenes are untouched.
for (const scene of this._project.scenes) {
for (const marker of [...(scene.appliedShotPacks || [])]) {
const recipe = getSceneAppendRecipeById(marker.id);
if (
recipe?.expansionFromVersion &&
marker.version > 0 &&
marker.version <= recipe.expansionFromVersion
) {
this.appendShotPack(scene.id, marker.id, {
render: false,
announce: false,
});
if (!this._storageReadError) {
this._bootstrapLegacyShotPacks();
// Upgrade only already-installed packs; unrelated Scenes are untouched.
for (const scene of this._project.scenes) {
for (const marker of [...(scene.appliedShotPacks || [])]) {
const recipe = getSceneAppendRecipeById(marker.id);
if (
recipe?.expansionFromVersion &&
marker.version > 0 &&
marker.version <= recipe.expansionFromVersion
) {
this.appendShotPack(scene.id, marker.id, {
render: false,
announce: false,
});
}
}
}
}
@@ -324,13 +326,14 @@ export class SceneDirector {
'Scene not saved — existing saved project could not be read. Export edits or import a valid file.',
),
);
return;
return false;
}
this._project.updatedAt = new Date().toISOString();
try {
const payload = JSON.stringify(this._project);
parseSceneDocument(payload);
localStorage.setItem(STORAGE_KEY, payload);
return true;
} catch (e) {
// Private browsing / block-all-cookies / quota-exceeded throws here. The
// in-memory project stays usable this session, but persistence failed —
@@ -351,6 +354,7 @@ export class SceneDirector {
})
: undefined,
);
return false;
}
}
@@ -923,12 +927,12 @@ export class SceneDirector {
}
this._selectedSceneId = scene.id;
this._selectedShotId = appendedShots[0]?.id || this._selectedShotId;
this._saveProject();
const saved = this._saveProject();
if (render) {
this._renderSceneSelect();
this._renderShotList();
}
if (announce) {
if (announce && saved) {
this._updateStatus(
marker
? tn(
@@ -984,14 +988,16 @@ export class SceneDirector {
scene.shots.push(shot);
this._selectedShotId = shot.id;
this._saveProject();
const saved = this._saveProject();
this._shotOutcome('shot-captured', scene, shot);
this._updateStatus(
t('Captured: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
if (saved) {
this._updateStatus(
t('Captured: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
}
}
/**
@@ -1017,14 +1023,16 @@ export class SceneDirector {
shot.visual = this.styleManager.getVisualState();
shot.layers = this._captureLayerStates();
this._saveProject();
const saved = this._saveProject();
this._shotOutcome('shot-updated', scene, shot);
this._updateStatus(
t('Updated: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
if (saved) {
this._updateStatus(
t('Updated: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
}
}
/**
@@ -1755,13 +1763,13 @@ export class SceneDirector {
) {
if (this._running) return { started: false, reason: 'already-running' };
let queue = this._buildPlaybackQueue(
sceneId || this._selectedSceneId || this._project.scenes[0]?.id,
{ single },
);
const startSceneId =
sceneId || this._selectedSceneId || this._project.scenes[0]?.id;
let queue = this._buildPlaybackQueue(startSceneId, { single });
if (afterShotId !== null) {
const index = queue.findIndex(
({ scene, shot }) => scene.id === sceneId && shot.id === afterShotId,
({ scene, shot }) =>
scene.id === startSceneId && shot.id === afterShotId,
);
if (index < 0) return { started: false, reason: 'shot-not-found' };
queue = queue.slice(index + 1);
@@ -1834,7 +1842,7 @@ export class SceneDirector {
title: 'Editable Scene Run',
startedAt: new Date().toISOString(),
estimatedDurationSec,
scenesRun: queue.length,
scenesRun: new Set(queue.map(({ scene }) => scene.id)).size,
events: [],
};
@@ -1842,6 +1850,7 @@ export class SceneDirector {
this._logEvent('scene_run_start', { count: queue.length });
this._setPlaybackKeyboardEnabled(true);
let runResult = { started: true, shots: queue.length };
try {
await playSceneQueue(queue, {
token,
@@ -1860,9 +1869,11 @@ export class SceneDirector {
this._logEvent('scene_run_error', {
message: error.message || 'unknown error',
});
runResult = { started: false, reason: 'run-failed' };
} finally {
this._finishRun();
}
return runResult;
}
/**
@@ -2090,9 +2101,9 @@ export class SceneDirector {
this._selectedShotId =
selection?.shotId || project.scenes[0]?.shots[0]?.id || null;
this._loadedSceneId = null;
this._saveProject();
const saved = this._saveProject();
this._publish({ type: 'project-imported', project });
this._updateStatus(t('Imported {file}', { file: file.name }));
if (saved) this._updateStatus(t('Imported {file}', { file: file.name }));
return true;
} catch (error) {
if (
+80 -6
View File
@@ -601,8 +601,12 @@ const PROJECT_FIXTURE = {
}],
};
/** Stub the browser globals the director touches, headlessly. */
function installSceneRuntime(project = PROJECT_FIXTURE) {
/**
* Stub the browser globals the director touches, headlessly. `storage` replaces
* the seeded entries (key -> raw string); setting `localStorage.full` makes
* every write throw a quota error.
*/
function installSceneRuntime(project = PROJECT_FIXTURE, { storage } = {}) {
const originalDocument = globalThis.document;
const originalLocalStorage = globalThis.localStorage;
const noopClassList = { add() {}, remove() {}, toggle() {}, contains: () => false };
@@ -614,10 +618,17 @@ function installSceneRuntime(project = PROJECT_FIXTURE) {
removeEventListener() {},
body: { classList: noopClassList, appendChild() {} },
};
const stored = new Map([['godsEyeView.sceneProject.v2', JSON.stringify(project)]]);
const stored = new Map(storage
? Object.entries(storage)
: [['godsEyeView.sceneProject.v2', JSON.stringify(project)]]);
globalThis.localStorage = {
stored,
full: false,
getItem: (key) => stored.get(key) ?? null,
setItem: (key, value) => stored.set(key, value),
setItem(key, value) {
if (this.full) throw new DOMException('quota exceeded', 'QuotaExceededError');
stored.set(key, value);
},
removeItem: (key) => stored.delete(key),
};
@@ -718,7 +729,8 @@ async function settle(turns = 8) {
/** Build a director over doubles, with the fixture project loaded. */
function makeDirector(options = {}) {
const restore = installSceneRuntime(options.project);
const restore = installSceneRuntime(options.project, { storage: options.storage });
const storage = globalThis.localStorage;
const viewer = fakeViewer();
const styleManager = fakeStyleManager(options.style);
const dataManager = fakeDataManager(options.data);
@@ -728,7 +740,7 @@ function makeDirector(options = {}) {
// Telemetry is only accumulated during a run; observable-failure assertions
// need the accumulator without driving a whole run.
director._activeRun = { events: [] };
return { director, viewer, styleManager, dataManager, restore };
return { director, viewer, styleManager, dataManager, storage, restore };
}
test('only Play Shot or a scene run grants transient media authority; LOAD, Stop and replacement revoke it', async () => {
@@ -1584,3 +1596,65 @@ test('camera refusal starts no authored frame or playback clock', async () => {
assert.deepEqual(viewer.flights, []);
} finally { restore(); }
});
test('an unreadable saved project does not overwrite its recovery checkpoint (#971)', async () => {
const unreadable = '{"version":99,"scenes":[]}';
const warn = console.warn;
console.warn = () => {};
const { director, storage, restore } = makeDirector({ storage: {
'godsEyeView.sceneProject.v2': unreadable,
'godsEyeView.sceneProject.checkpoint.v1': 'EARLIER-CHECKPOINT',
} });
try {
assert.equal(storage.stored.get('godsEyeView.sceneProject.v2'), unreadable);
assert.equal(storage.stored.get('godsEyeView.sceneProject.checkpoint.v1'), 'EARLIER-CHECKPOINT');
} finally { console.warn = warn; await director.destroy(); restore(); }
});
test('a capture, update or import that cannot be saved keeps the "Scene not saved" status (#971)', async () => {
const warn = console.warn;
console.warn = () => {};
const { director, storage, restore } = makeDirector();
let status = '';
director.subscribe(({ state }) => { status = state.status; });
try {
storage.full = true;
director._selectedSceneId = 'scene-1';
director._selectedShotId = 'shot-a';
director.captureShot();
assert.match(status, /^Scene not saved/, 'failed capture keeps the save failure');
director.updateSelectedShot();
assert.match(status, /^Scene not saved/, 'failed update keeps the save failure');
assert.equal(await director.importProjectFile(
{ name: 'fixture.json' },
{ prepared: { project: structuredClone(PROJECT_FIXTURE), assets: [] } },
), true);
assert.match(status, /^Scene not saved/, 'failed import keeps the save failure');
} finally { console.warn = warn; await director.destroy(); restore(); }
});
test('startScene resolves its result, resumes after a shot without a scene id, and counts distinct scenes (#971)', async () => {
const { director, restore } = makeDirector();
try {
const options = { single: true, preview: false };
assert.deepEqual(await director.startScene('scene-1', options), { started: true, shots: 2 });
assert.equal(director._lastRun.scenesRun, 1, 'one scene with two shots is one scene run');
assert.deepEqual(
await director.startScene(undefined, { ...options, afterShotId: 'shot-a' }),
{ started: true, shots: 1 },
);
} finally { await director.destroy(); restore(); }
});
test('a caught run failure resolves as run-failed, not success (#971)', async () => {
const { director, styleManager, restore } = makeDirector();
const warn = console.warn;
console.warn = () => {};
styleManager.applyVisualState = async () => { throw new Error('visual failed'); };
try {
assert.deepEqual(
await director.startScene('scene-1', { single: true, preview: false }),
{ started: false, reason: 'run-failed' },
);
} finally { console.warn = warn; await director.destroy(); restore(); }
});
+70 -4
View File
@@ -7,9 +7,10 @@ import { firmsProxy } from 'gods-eye-view/server/providers/firms';
import { gbfsProxy } from 'gods-eye-view/server/providers/gbfs';
import { localProviderPlugins } from '../../server/providers/local.js';
function install(plugin) {
function install(plugin, hook = 'configureServer') {
const routes = new Map();
plugin.configureServer({
assert.equal(typeof plugin[hook], 'function', `${hook} is available`);
plugin[hook]({
middlewares: {
use(route, handler) {
routes.set(route, handler);
@@ -17,9 +18,16 @@ function install(plugin) {
},
});
assert.equal(routes.size, 1);
return async (url = '/', method = 'GET') => {
return async (url = '/', method = 'GET', headers = {}) => {
const res = {
headersSent: false,
headers: {},
set statusCode(status) {
this.status = status;
},
setHeader(name, value) {
this.headers[name.toLowerCase()] = value;
},
writeHead(status, headers) {
Object.assign(this, { status, headers, headersSent: true });
},
@@ -27,7 +35,7 @@ function install(plugin) {
this.body = body;
},
};
await [...routes.values()][0]({ url, method }, res);
await [...routes.values()][0]({ url, method, headers, socket: {} }, res);
return res;
};
}
@@ -191,6 +199,64 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback
assert.equal(calls, 2);
});
test('traffic refuses cross-site requests in dev and preview before spending the TomTom budget', async (t) => {
isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '3' });
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
return new Response(new Uint8Array([1, 2, 3]));
});
const host = 'localhost:5173';
const refused = [
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> from any web page
{ host, 'sec-fetch-site': 'same-site' },
{ host, origin: 'https://evil.example' }, // fetch from another page
{ host, origin: 'null' }, // sandboxed frame
{ host, 'x-forwarded-for': '203.0.113.9' }, // through a proxy
];
for (const [mode, hook] of [
['dev', 'configureServer'],
['preview', 'configurePreviewServer'],
]) {
const request = install(tomtomProxy(), hook);
// Each refused shape asks for distinct uncached tiles: without the gate
// every one would be a billed attempt counted against the daily budget.
let x = 0;
for (const headers of refused)
for (let i = 0; i < 2; i++) {
const res = await request(`/flow/16/${x++}/0.pbf`, 'GET', headers);
assert.equal(res.status, 403, `${mode}: ${JSON.stringify(headers)}`);
}
assert.equal(
(await request('/status', 'GET', refused[0])).status,
403,
`${mode}: status is gated too`,
);
assert.equal(
calls,
mode === 'dev' ? 0 : 2,
`${mode}: no refused upstream work`,
);
const app = { host, 'sec-fetch-site': 'same-origin' };
assert.equal(json(await request('/status', 'GET', app)).dailyCount, 0);
// The app itself and loopback tools (no browser headers) still get tiles.
const tile = await request('/flow/8/1/1.pbf', 'GET', app);
assert.equal(tile.status, 200);
assert.equal(tile.headers['x-tomtom-cache'], 'MISS');
assert.equal((await request('/flow/8/2/1.pbf')).status, 200);
assert.equal(
(
await request('/flow/8/1/1.pbf', 'GET', {
host,
origin: `http://${host}`,
})
).headers['x-tomtom-cache'],
'HIT',
);
assert.equal(calls, mode === 'dev' ? 2 : 4);
}
});
test('traffic middleware answers 502 to an oversized tile without caching it', async (t) => {
isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '' });
const bodies = [];
+24 -6
View File
@@ -118,9 +118,11 @@ export const findCctvCameras = defineTool({
export const getCctvSnapshot = defineTool({
name: 'get_cctv_snapshot',
title: 'Current camera view',
title: 'Camera snapshot',
description:
'The current image from one public camera, by the id from find_cctv_cameras.',
'An image from one public camera, by the id from find_cctv_cameras. ' +
'Street View fallback imagery is historical, not the current camera view; ' +
'unknown frame provenance is explicit.',
inputSchema: {
type: 'object',
properties: { camera_id: { type: 'string', minLength: 1, maxLength: 200 } },
@@ -138,7 +140,7 @@ export const getCctvSnapshot = defineTool({
`No camera has id ${args.camera_id}; use find_cctv_cameras first`,
);
const frame = await services.cctv.getFrame(camera, { signal });
if (!FRAME_TYPES.has(frame.contentType))
if (frame.sourceKind === 'synthetic' || !FRAME_TYPES.has(frame.contentType))
throw new ToolError(
'unavailable',
`Camera ${camera.name || camera.id} has no still image right now`,
@@ -149,8 +151,22 @@ export const getCctvSnapshot = defineTool({
'The camera image is too large to return',
);
const name = text(camera.name) || camera.id;
const fallback = frame.sourceKind === 'streetview';
const current = ['upstream-image', 'hls-pull'].includes(frame.sourceKind);
const credit = fallback ? 'Google Street View' : text(camera.credit);
const label = fallback
? 'Street View fallback for'
: current
? 'Current view from'
: 'Image from';
return {
summary: `Current view from ${name}${camera.city ? ` in ${camera.city}` : ''}${camera.credit ? `, courtesy of ${camera.credit}` : ''}.`,
summary:
`${label} ${name}${camera.city ? ` in ${camera.city}` : ''}${credit ? `, courtesy of ${credit}` : ''}.` +
(fallback
? ' Historical imagery; the current camera frame is unavailable.'
: current
? ''
: ' Frame provenance is unknown.'),
data: {
view: suggestView(services, {
point: { lat: camera.lat, lon: camera.lon },
@@ -160,8 +176,10 @@ export const getCctvSnapshot = defineTool({
id: camera.id,
name,
city: text(camera.city),
provider: text(camera.provider),
credit: text(camera.credit),
provider: fallback ? 'Google Street View' : text(camera.provider),
credit,
source_kind: frame.sourceKind ?? null,
current_view: current ? true : fallback ? false : null,
mime_type: frame.contentType,
bytes: frame.bytes.byteLength,
},
+184 -1
View File
@@ -30,7 +30,13 @@ const cameras = [
{ id: 'no-position', name: 'Unplaced' },
];
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const cctv = (frame = { contentType: 'image/png', bytes: PNG }) => ({
const cctv = (
frame = {
contentType: 'image/png',
bytes: PNG,
sourceKind: 'upstream-image',
},
) => ({
frames: [],
async getCatalog() {
return { sources: cameras };
@@ -104,6 +110,183 @@ test('a snapshot returns the frame as an image with its credit', async () => {
});
});
test('mounted camera fallback provenance survives the source and MCP image result', async (t) => {
const { cctvProxy } = await import('../../../server/providers/cctv.js');
const { createCctvSource } = await import('../../layers/cctv/source.js');
const camera = {
id: 'fixture-camera',
name: 'Fixture crossing',
city: 'Fixture City',
provider: 'Fixture transport agency',
credit: 'Fixture transport agency',
lat: 30.2685,
lon: -97.7425,
headingDeg: 180,
fovDeg: 60,
url: 'https://camera.example/current.png',
feedType: 'image',
};
const environment = {
CCTV_SOURCES_JSON: JSON.stringify([camera]),
CCTV_PREFER_AUSTIN: '0',
CCTV_FORCE_AUSTIN: '0',
CCTV_SOURCES_FILE: 'missing-cctv-provenance-fixture.json',
GOOGLE_MAPS_SERVER_API_KEY: 'fixture-key',
GOOGLE_MAPS_API_KEY: '',
};
for (const [name, value] of Object.entries(environment)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
process.env[name] = value;
}
t.mock.method(console, 'warn', () => {});
let upstreamAvailable = false;
const calls = [];
t.mock.method(globalThis, 'fetch', async (raw) => {
const url = new URL(raw);
calls.push(url.origin);
if (url.origin === 'https://camera.example')
return upstreamAvailable
? new Response(PNG, { headers: { 'Content-Type': 'image/png' } })
: new Response(null, { status: 503 });
assert.equal(
url.origin,
'https://maps.googleapis.com',
'no actual upstream is contacted',
);
assert.equal(url.searchParams.get('key'), 'fixture-key');
return new Response(PNG, { headers: { 'Content-Type': 'image/png' } });
});
let middleware;
cctvProxy().configureServer({
middlewares: {
use(route, handler) {
assert.equal(route, '/api/cctv');
middleware = handler;
},
},
});
const source = createCctvSource({
fetchImpl: async (path) => {
assert.ok(path.startsWith('/api/cctv/'));
const response = {
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(body) {
this.body = body;
},
};
await middleware(
{ url: path.slice('/api/cctv'.length), method: 'GET', headers: {} },
response,
);
return new Response(response.body, {
status: response.status,
headers: response.headers,
});
},
});
const catalog = composeCatalog({
tools: coreTools,
services: { cctv: source },
});
const fallback = await catalog.call('get_cctv_snapshot', {
camera_id: camera.id,
});
assert.equal(fallback.data.source_kind, 'streetview');
assert.equal(fallback.data.current_view, false);
assert.equal(fallback.data.credit, 'Google Street View');
assert.match(fallback.summary, /Street View fallback/);
assert.match(fallback.summary, /Historical imagery/);
assert.doesNotMatch(
fallback.summary,
/Current view|courtesy of Fixture transport agency/,
);
assert.equal(
fallback.images.length,
1,
'the historical image stays available with its caveat',
);
assert.deepEqual(calls, [
'https://camera.example',
'https://maps.googleapis.com',
]);
upstreamAvailable = true;
const current = await catalog.call('get_cctv_snapshot', {
camera_id: camera.id,
});
assert.equal(current.data.source_kind, 'upstream-image');
assert.equal(current.data.current_view, true);
assert.equal(current.data.credit, camera.credit);
assert.match(current.summary, /^Current view from/);
const server = createMcpServer({ catalog, name: 'test', version: '1' });
upstreamAvailable = false;
const result = await server.handle({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'get_cctv_snapshot', arguments: { camera_id: camera.id } },
});
assert.ok(
result.result.content.some(
(entry) => entry.type === 'text' && /Historical imagery/.test(entry.text),
),
);
assert.ok(result.result.content.some((entry) => entry.type === 'image'));
process.env.GOOGLE_MAPS_SERVER_API_KEY = '';
const placeholder = await source.getFrame(camera);
assert.equal(placeholder.sourceKind, 'synthetic');
assert.equal(placeholder.contentType, 'image/svg+xml');
await assert.rejects(
catalog.call('get_cctv_snapshot', { camera_id: camera.id }),
(error) => error.code === 'unavailable',
);
const unavailable = await server.handle({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'get_cctv_snapshot', arguments: { camera_id: camera.id } },
});
assert.equal(unavailable.result.isError, true);
assert.ok(
!unavailable.result.content.some((entry) => entry.type === 'image'),
);
});
test('snapshot placeholders are refused and unknown frame provenance is explicit', async () => {
const synthetic = composeCatalog({
tools: coreTools,
services: {
cctv: cctv({
contentType: 'image/png',
bytes: PNG,
sourceKind: 'synthetic',
}),
},
});
await assert.rejects(
synthetic.call('get_cctv_snapshot', { camera_id: 'austin-1' }),
(error) => error.code === 'unavailable',
);
const unknown = await composeCatalog({
tools: coreTools,
services: { cctv: cctv({ contentType: 'image/png', bytes: PNG }) },
}).call('get_cctv_snapshot', { camera_id: 'austin-1' });
assert.equal(unknown.data.current_view, null);
assert.equal(
unknown.data.credit,
cameras[0].credit,
'catalog attribution is preserved',
);
assert.match(unknown.summary, /provenance is unknown/);
assert.doesNotMatch(unknown.summary, /Current view/);
});
test('unknown cameras, video-only feeds and oversized frames are reported', async () => {
const catalog = (frame) =>
composeCatalog({ tools: coreTools, services: { cctv: cctv(frame) } });