mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge remote-tracking branch 'origin/main' into feat/alpr-visual-treatment
# Conflicts: # scripts/package-boundaries.json
This commit is contained in:
commit
fdf030cbc9
56 files changed
+10029
-47
No files matched your search
@@ -0,0 +1,389 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
calgaryCameraId,
|
||||
calgaryCameraName,
|
||||
calgaryCameraToSource,
|
||||
loadCalgarySourcesFromOpenData,
|
||||
normalizeCalgaryImageUrl,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
CALGARY_IMAGE_ORIGIN,
|
||||
CALGARY_MAX_CATALOG_BYTES,
|
||||
DEFAULT_CALGARY_ROWS_URL,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
|
||||
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import { directionToHeading } from './directionText.js';
|
||||
|
||||
/**
|
||||
* A response whose body is a live stream, plus a flag that flips when the
|
||||
* stream is cancelled. A rejection path that returns without cancelling holds
|
||||
* the transport open, so the flag is what the refusal tests actually assert.
|
||||
*/
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('['));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
/** One Open Calgary row, shaped like the live `k7p9-kppz` payload. */
|
||||
const row = (overrides = {}) => ({
|
||||
camera_url: {
|
||||
url: 'http://trafficcam.calgary.ca/loc142.jpg',
|
||||
description: 'Camera 143',
|
||||
},
|
||||
quadrant: 'SW',
|
||||
camera_location: 'Bow Trail / 37 Street SW',
|
||||
point: { type: 'Point', coordinates: [-114.1413616, 51.0453097] },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('a Calgary row maps to a source on the pinned HTTPS frame host', () => {
|
||||
const source = calgaryCameraToSource(row());
|
||||
assert.equal(source.id, 'calgary-142');
|
||||
assert.equal(source.name, 'Bow Trail / 37 Street SW');
|
||||
assert.equal(source.cityId, 'calgary');
|
||||
assert.equal(source.provider, 'The City of Calgary');
|
||||
assert.equal(source.lat, 51.0453097);
|
||||
assert.equal(source.lon, -114.1413616);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'calgary-open-data');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'Contains information licensed under the Open Government Licence – City of Calgary',
|
||||
);
|
||||
// The catalog publishes http://; the registered URL is the upgraded one.
|
||||
assert.equal(source.url, 'https://trafficcam.calgary.ca/loc142.jpg');
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(CALGARY_IMAGE_ORIGIN));
|
||||
});
|
||||
|
||||
test('no heading is derived from the quadrant or the address suffix', () => {
|
||||
// Both fields parse as a confident compass bearing, and both would be wrong
|
||||
// for every camera in the city: they are Calgary's address grid, not a
|
||||
// camera facing. Nobody may "fix" this by wiring either one up.
|
||||
assert.equal(directionToHeading('SW', true), 225);
|
||||
assert.equal(directionToHeading('Bow Trail / 37 Street SW', true), 225);
|
||||
|
||||
for (const quadrant of ['NE', 'NW', 'SE', 'SW', 'NW/NE', 'S']) {
|
||||
const source = calgaryCameraToSource(row({ quadrant }));
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(source.headingDeg));
|
||||
// Same id, same fallback heading, whatever the quadrant says.
|
||||
assert.equal(source.headingDeg, calgaryCameraToSource(row()).headingDeg);
|
||||
}
|
||||
});
|
||||
|
||||
test('rows outside Calgary, off-host frames and unusable geometry are dropped', () => {
|
||||
// Toronto: a plausible lat/lon, but not a Calgary camera.
|
||||
assert.equal(
|
||||
calgaryCameraToSource(
|
||||
row({ point: { type: 'Point', coordinates: [-79.3832, 43.6532] } }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
// Null island, and the string coordinates that would become it.
|
||||
assert.equal(
|
||||
calgaryCameraToSource(
|
||||
row({ point: { type: 'Point', coordinates: [0, 0] } }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(calgaryCameraToSource(row({ point: null })), null);
|
||||
assert.equal(
|
||||
calgaryCameraToSource(row({ point: { coordinates: [-114.14] } })),
|
||||
null,
|
||||
);
|
||||
// A catalog edit cannot steer the frame proxy off the city host.
|
||||
assert.equal(
|
||||
calgaryCameraToSource(
|
||||
row({ camera_url: { url: 'https://evil.example/loc1.jpg' } }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
calgaryCameraToSource(
|
||||
row({
|
||||
camera_url: { url: 'https://trafficcam.calgary.ca.evil.test/1.jpg' },
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
calgaryCameraToSource(row({ camera_url: { url: 'file:///etc/passwd' } })),
|
||||
null,
|
||||
);
|
||||
assert.equal(calgaryCameraToSource(row({ camera_url: null })), null);
|
||||
assert.equal(calgaryCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('frame URLs upgrade to HTTPS and ids stay stable', () => {
|
||||
assert.equal(
|
||||
normalizeCalgaryImageUrl('http://trafficcam.calgary.ca/loc86.jpg'),
|
||||
'https://trafficcam.calgary.ca/loc86.jpg',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCalgaryImageUrl('https://trafficcam.calgary.ca/loc86.jpg'),
|
||||
'https://trafficcam.calgary.ca/loc86.jpg',
|
||||
);
|
||||
assert.equal(normalizeCalgaryImageUrl(''), null);
|
||||
assert.equal(normalizeCalgaryImageUrl('not a url'), null);
|
||||
|
||||
assert.equal(
|
||||
calgaryCameraId('https://trafficcam.calgary.ca/loc86.jpg'),
|
||||
'calgary-86',
|
||||
);
|
||||
// A filename-scheme change degrades to a still-stable slug, not a dropped camera.
|
||||
assert.equal(
|
||||
calgaryCameraId('https://trafficcam.calgary.ca/cams/deerfoot_16av.jpg'),
|
||||
'calgary-cams-deerfoot-16av',
|
||||
);
|
||||
assert.equal(calgaryCameraId(''), null);
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
assert.equal(
|
||||
calgaryCameraName(
|
||||
{ camera_location: ' 9 Avenue / 3 Street SE ' },
|
||||
'calgary-1',
|
||||
),
|
||||
'9 Avenue / 3 Street SE',
|
||||
);
|
||||
assert.equal(
|
||||
calgaryCameraName(
|
||||
{ camera_url: { description: 'Camera 12' } },
|
||||
'calgary-11',
|
||||
),
|
||||
'Camera 12',
|
||||
);
|
||||
assert.equal(calgaryCameraName({}, 'calgary-11'), 'Calgary Camera 11');
|
||||
});
|
||||
|
||||
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json([
|
||||
row(),
|
||||
// Same frame file twice: one camera, not two.
|
||||
row({ camera_location: 'Bow Trail / 37 Street SW (duplicate)' }),
|
||||
row({
|
||||
camera_url: { url: 'http://trafficcam.calgary.ca/loc86.jpg' },
|
||||
camera_location: 'Stoney Trail / Deerfoot Trail SE',
|
||||
point: { type: 'Point', coordinates: [-113.9766063, 50.9007257] },
|
||||
}),
|
||||
row({ camera_url: { url: 'https://evil.example/x.jpg' } }),
|
||||
]);
|
||||
});
|
||||
const cameras = await loadCalgarySourcesFromOpenData();
|
||||
assert.deepEqual(requested, [DEFAULT_CALGARY_ROWS_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
// Nearest downtown first: Bow Trail is inner-city, Stoney Trail is the ring road.
|
||||
['calgary-142', 'calgary-86'],
|
||||
);
|
||||
assert.equal(cameras[0].name, 'Bow Trail / 37 Street SW');
|
||||
});
|
||||
|
||||
test('an upstream failure yields an empty pack and releases the response', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// A 503 can still arrive with a streaming body; returning without cancelling
|
||||
// it would hold the connection until the socket times out.
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the unselected label is the intersection, trimmed to the code width', () => {
|
||||
assert.equal(calgaryCameraToSource(row()).code, 'BOW TRAIL / 37 STREET SW');
|
||||
const long = calgaryCameraToSource(
|
||||
row({
|
||||
camera_location: 'Bow Trail & Old Banff Coach Rd SW / Strathcona Blvd SW',
|
||||
}),
|
||||
);
|
||||
assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(long.code.endsWith('…'));
|
||||
assert.ok(long.code.startsWith('BOW TRAIL & OLD BANFF'));
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// A redirect is never followed: the list host cannot be steered. Its body is
|
||||
// a live stream, so the test also proves the refusal releases the transport.
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/rows.json' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[DEFAULT_CALGARY_ROWS_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
// A body over the cap is refused rather than buffered.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const body = JSON.stringify([row()]);
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'content-length': String(CALGARY_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
|
||||
|
||||
// So is a body that only declares its size once it is already too long.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(CALGARY_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('a reduced catalog cap thins every pack instead of dropping Calgary', () => {
|
||||
// Calgary merges last in LIVE_PACKS, so a positional slice would delete it
|
||||
// outright. The round-robin allocation must give it its share.
|
||||
const lane = (name, count) => ({
|
||||
name,
|
||||
sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })),
|
||||
});
|
||||
const { sources, packs } = allocateSourceCap(
|
||||
[lane('austin', 250), lane('nsw', 217), lane('calgary', 215)],
|
||||
30,
|
||||
);
|
||||
assert.equal(sources.length, 30);
|
||||
assert.deepEqual(
|
||||
packs.map((p) => [p.name, p.kept]),
|
||||
[
|
||||
['austin', 10],
|
||||
['nsw', 10],
|
||||
['calgary', 10],
|
||||
],
|
||||
);
|
||||
// Calgary contributes its own highest-priority cameras, in its own order.
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.id.startsWith('calgary-')).map((s) => s.id),
|
||||
Array.from({ length: 10 }, (_, i) => `calgary-${i}`),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the Calgary catalog to the Calgary endpoint and an empty payload to
|
||||
* every other pack, so one catalog refresh exercises the registration without
|
||||
* reaching the network. Returns the URLs that were requested.
|
||||
*/
|
||||
const runCatalogWithMockedUpstreams = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href.startsWith('https://data.calgary.ca/')) {
|
||||
return Response.json([
|
||||
row(),
|
||||
row({
|
||||
camera_url: { url: 'http://trafficcam.calgary.ca/loc86.jpg' },
|
||||
camera_location: 'Stoney Trail / Deerfoot Trail SE',
|
||||
point: { type: 'Point', coordinates: [-113.9766063, 50.9007257] },
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
// A source root with no curated catalogs or ground-height sidecar, so the
|
||||
// file-based packs contribute nothing and only the live lanes are in play.
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
test('the Calgary lane is wired into the catalog and its loader runs', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_CALGARY_ENABLED;
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.ok(
|
||||
requested.includes(DEFAULT_CALGARY_ROWS_URL),
|
||||
'the catalog refresh invokes the Calgary loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'calgary').map((s) => s.id),
|
||||
['calgary-142', 'calgary-86'],
|
||||
'Calgary cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
process.env.CCTV_CALGARY_ENABLED = '0';
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.equal(
|
||||
requested.includes(DEFAULT_CALGARY_ROWS_URL),
|
||||
false,
|
||||
'the disabled lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'calgary'),
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
});
|
||||
@@ -24,6 +24,7 @@
|
||||
* invariant is untouched.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from './inputOwnership.js';
|
||||
|
||||
// Grazing guard (spec §5): reject plane intersections when the view ray is
|
||||
// nearly parallel to the constraint plane — the hit point races to infinity
|
||||
@@ -506,6 +507,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
}
|
||||
|
||||
handler.setInputAction((event) => {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the press.
|
||||
if (!isPointerFree()) return;
|
||||
if (!enabled) return;
|
||||
const part = pickGizmoPart(event.position);
|
||||
debugLog('LEFT_DOWN', event.position, 'part:', part);
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
// Client `Range` handling for the CCTV media proxy. The route used to relay
|
||||
// req.headers.range verbatim to a third-party upstream; these pin what is now
|
||||
// allowed through, both as string policy and through the mounted route with an
|
||||
// injected upstream.
|
||||
//
|
||||
// Run with: npm test (node --test)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import { Readable } from 'node:stream';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
import { sanitizeCctvRangeHeader } from '../../server/providers/cctv/range.js';
|
||||
import { CCTV_MEDIA_MAX_BODY_BYTES as CAP } from '../../server/providers/cctv/constants.js';
|
||||
|
||||
test('single well-formed byte ranges pass through canonicalized', () => {
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=0-1023'), 'bytes=0-1023');
|
||||
// A single range that names the same byte twice is legal.
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=7-7'), 'bytes=7-7');
|
||||
// The unit is case-insensitive (RFC 7233 §2.1); surrounding space is legal.
|
||||
assert.equal(sanitizeCctvRangeHeader(' BYTES=0-99 '), 'bytes=0-99');
|
||||
});
|
||||
|
||||
test('multi-range is dropped so the upstream never answers multipart/byteranges', () => {
|
||||
// The relay forwards Content-Length/Content-Range and caps on a single
|
||||
// declared body; a multipart response defeats that accounting.
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=0-1,2-3'), '');
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=0-1, 100-200, 300-'), '');
|
||||
});
|
||||
|
||||
test('malformed, non-byte and CR/LF-bearing values are dropped, not forwarded', () => {
|
||||
for (const bad of [
|
||||
'bytes=abc-def',
|
||||
'bytes=',
|
||||
'bytes=-',
|
||||
'bytes=1-2-3',
|
||||
'0-100', // no unit
|
||||
'items=0-100', // a unit the proxy does not understand
|
||||
'bytes=1.5-2',
|
||||
'bytes=-0', // zero-length suffix is unsatisfiable
|
||||
'bytes=0x10-0x20',
|
||||
'bytes=+5-10',
|
||||
'bytes=-5-10',
|
||||
// An outbound request refuses to carry these, and the failure used to be
|
||||
// written into the camera's health message.
|
||||
'bytes=0-10\r\nX-Injected: 1',
|
||||
'bytes=0-10\nHost: evil.example',
|
||||
]) {
|
||||
assert.equal(
|
||||
sanitizeCctvRangeHeader(bad),
|
||||
'',
|
||||
`expected drop: ${JSON.stringify(bad)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('non-string and empty inputs yield no Range header', () => {
|
||||
// req.headers.range is undefined on a normal GET, and Node hands back an
|
||||
// array when a client sends the header twice.
|
||||
assert.equal(sanitizeCctvRangeHeader(undefined), '');
|
||||
assert.equal(sanitizeCctvRangeHeader(null), '');
|
||||
assert.equal(sanitizeCctvRangeHeader(''), '');
|
||||
assert.equal(sanitizeCctvRangeHeader(' '), '');
|
||||
assert.equal(sanitizeCctvRangeHeader(['bytes=0-1', 'bytes=2-3']), '');
|
||||
assert.equal(sanitizeCctvRangeHeader(42), '');
|
||||
});
|
||||
|
||||
test('every accepted form is bounded to the relay body cap', () => {
|
||||
// Explicit span.
|
||||
assert.equal(
|
||||
sanitizeCctvRangeHeader('bytes=0-999999999999'),
|
||||
`bytes=0-${CAP - 1}`,
|
||||
);
|
||||
assert.equal(
|
||||
sanitizeCctvRangeHeader('bytes=1000-999999999999'),
|
||||
`bytes=1000-${1000 + CAP - 1}`,
|
||||
);
|
||||
// Open-ended: bounded too, rather than asking an upstream for a whole file
|
||||
// of unknown size.
|
||||
assert.equal(
|
||||
sanitizeCctvRangeHeader('bytes=500-'),
|
||||
`bytes=500-${500 + CAP - 1}`,
|
||||
);
|
||||
// Suffix: bounded to one span of the tail.
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=-500'), 'bytes=-500');
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=-999999999999'), `bytes=-${CAP}`);
|
||||
// Exactly at the cap, and comfortably under it, are untouched.
|
||||
assert.equal(
|
||||
sanitizeCctvRangeHeader(`bytes=0-${CAP - 1}`),
|
||||
`bytes=0-${CAP - 1}`,
|
||||
);
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=0-1048575'), 'bytes=0-1048575');
|
||||
});
|
||||
|
||||
test('inverted and unsafe-integer positions are dropped', () => {
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=500-100'), '');
|
||||
// Beyond Number.MAX_SAFE_INTEGER the arithmetic silently loses precision.
|
||||
assert.equal(sanitizeCctvRangeHeader('bytes=99999999999999999-'), '');
|
||||
// Absurd digit counts are refused before any Number() conversion.
|
||||
assert.equal(sanitizeCctvRangeHeader(`bytes=0-${'9'.repeat(64)}`), '');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The mounted route, with the upstream injected through globalThis.fetch.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CAMERA = {
|
||||
id: 'range-fixture',
|
||||
name: 'Range fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'video',
|
||||
url: 'https://upstream.invalid/live.mp4',
|
||||
};
|
||||
|
||||
/** A minimal upstream Response with a streaming body. */
|
||||
function upstreamResponse({ status = 206, headers = {}, body = 'payload' }) {
|
||||
const store = new Map(
|
||||
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
|
||||
);
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: (name) => store.get(String(name).toLowerCase()) ?? null },
|
||||
body:
|
||||
body === null
|
||||
? null
|
||||
: Readable.toWeb(Readable.from([Buffer.from(String(body))])),
|
||||
arrayBuffer: async () => Buffer.from(String(body)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Collects what a handler wrote, the way a Node ServerResponse would. */
|
||||
function recordingResponse() {
|
||||
const chunks = [];
|
||||
let finished;
|
||||
const done = new Promise((resolve) => {
|
||||
finished = resolve;
|
||||
});
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
headers: {},
|
||||
writableEnded: false,
|
||||
body: '',
|
||||
writeHead(status, headers) {
|
||||
res.statusCode = status;
|
||||
res.headers = headers || {};
|
||||
},
|
||||
write(chunk) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
return true;
|
||||
},
|
||||
end(chunk) {
|
||||
if (chunk) chunks.push(Buffer.from(chunk));
|
||||
res.writableEnded = true;
|
||||
res.body = Buffer.concat(chunks).toString('utf8');
|
||||
finished();
|
||||
},
|
||||
on() {},
|
||||
once() {},
|
||||
emit() {},
|
||||
destroy() {},
|
||||
};
|
||||
return { res, done };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the real provider, serve one camera, and answer its upstream from the
|
||||
* supplied function.
|
||||
*/
|
||||
function mount(t, respond) {
|
||||
const before = {
|
||||
json: process.env.CCTV_SOURCES_JSON,
|
||||
file: process.env.CCTV_SOURCES_FILE,
|
||||
austin: process.env.CCTV_FORCE_AUSTIN,
|
||||
};
|
||||
process.env.CCTV_SOURCES_JSON = JSON.stringify([CAMERA]);
|
||||
process.env.CCTV_SOURCES_FILE = 'absent-source-file.json';
|
||||
process.env.CCTV_FORCE_AUSTIN = '0';
|
||||
const nativeFetch = globalThis.fetch;
|
||||
const requests = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
requests.push({
|
||||
url: String(url),
|
||||
headers: init.headers || {},
|
||||
signal: init.signal || null,
|
||||
});
|
||||
return respond({ url: String(url), init });
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = nativeFetch;
|
||||
for (const [name, value] of [
|
||||
['CCTV_SOURCES_JSON', before.json],
|
||||
['CCTV_SOURCES_FILE', before.file],
|
||||
['CCTV_FORCE_AUSTIN', before.austin],
|
||||
]) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
});
|
||||
|
||||
let handler = null;
|
||||
const plugin = cctvProxy();
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use: (_route, fn) => {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.ok(handler, 'the CCTV provider must mount a middleware');
|
||||
|
||||
/** Drive one request through the mounted handler. */
|
||||
const call = async (path, headers = {}) => {
|
||||
const { res, done } = recordingResponse();
|
||||
await handler({ url: path, headers, method: 'GET', on() {} }, res);
|
||||
await done;
|
||||
return res;
|
||||
};
|
||||
/** Read the camera's current health entry. */
|
||||
const health = async () => {
|
||||
const res = await call('/health');
|
||||
return JSON.parse(res.body).cameras.find(({ id }) => id === CAMERA.id);
|
||||
};
|
||||
return { call, health, requests, handler: (req, res) => handler(req, res) };
|
||||
}
|
||||
|
||||
test('a canonical range reaches the upstream and its 206 is passed through', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
status: 206,
|
||||
headers: {
|
||||
'content-type': 'video/mp4',
|
||||
'content-range': 'bytes 0-1023/4096',
|
||||
'content-length': '1024',
|
||||
'accept-ranges': 'bytes',
|
||||
},
|
||||
body: 'partial',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' });
|
||||
assert.equal(app.requests.at(-1).headers.Range, 'bytes=0-1023');
|
||||
assert.equal(res.statusCode, 206);
|
||||
assert.equal(res.headers['Content-Range'], 'bytes 0-1023/4096');
|
||||
assert.equal(res.headers['Accept-Ranges'], 'bytes');
|
||||
assert.equal(res.body, 'partial');
|
||||
});
|
||||
|
||||
test('an unbounded seek is bounded before it is forwarded', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
status: 206,
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: 'tail',
|
||||
}),
|
||||
);
|
||||
await app.call(`/media/${CAMERA.id}`, { range: 'bytes=500-' });
|
||||
assert.equal(app.requests.at(-1).headers.Range, `bytes=500-${500 + CAP - 1}`);
|
||||
});
|
||||
|
||||
test('a rejected range is dropped and the request proceeds without one', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: 'whole body',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${CAMERA.id}`, {
|
||||
range: 'bytes=0-10\r\nX-Injected: 1',
|
||||
});
|
||||
// Documented choice: drop the header rather than answer 400, so a client
|
||||
// that sends something this proxy will not carry still gets its media.
|
||||
assert.equal(app.requests.at(-1).headers.Range, undefined);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.body, 'whole body');
|
||||
});
|
||||
|
||||
test('a rejected range leaves the camera health report untouched', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: 'whole body',
|
||||
}),
|
||||
);
|
||||
await app.call(`/media/${CAMERA.id}`, {
|
||||
range: 'bytes=0-10\r\nX-Injected: 1',
|
||||
});
|
||||
const entry = await app.health();
|
||||
assert.equal(entry.status, 'ok');
|
||||
// The client's own string must never appear in a report other callers read.
|
||||
assert.doesNotMatch(JSON.stringify(entry), /X-Injected/);
|
||||
});
|
||||
|
||||
test('a multi-range request is served whole rather than as multipart', async (t) => {
|
||||
const app = mount(t, ({ init }) =>
|
||||
upstreamResponse({
|
||||
status: init?.headers?.Range ? 206 : 200,
|
||||
headers: {
|
||||
'content-type': init?.headers?.Range
|
||||
? 'multipart/byteranges; boundary=x'
|
||||
: 'video/mp4',
|
||||
},
|
||||
body: 'whole body',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${CAMERA.id}`, {
|
||||
range: 'bytes=0-1,2-3',
|
||||
});
|
||||
assert.equal(app.requests.at(-1).headers.Range, undefined);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['Content-Type'], 'video/mp4');
|
||||
});
|
||||
|
||||
test('an upstream 416 is reported as the upstream status, not as a proxy error', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
status: 416,
|
||||
headers: { 'content-range': 'bytes */1024' },
|
||||
body: null,
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${CAMERA.id}`, {
|
||||
range: `bytes=${2 ** 40}-${2 ** 40 + 10}`,
|
||||
});
|
||||
assert.equal(res.statusCode, 416);
|
||||
assert.match(res.body, /416/);
|
||||
const entry = await app.health();
|
||||
assert.equal(entry.status, 'degraded');
|
||||
assert.equal(entry.message, 'Upstream HTTP 416');
|
||||
});
|
||||
|
||||
test('an upstream that ignores the range still serves its whole body', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'video/mp4', 'content-length': '10' },
|
||||
body: 'whole body',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' });
|
||||
assert.equal(app.requests.at(-1).headers.Range, 'bytes=0-1023');
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.body, 'whole body');
|
||||
});
|
||||
|
||||
test('an oversized declared body is refused even when a bounded range asked for it', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
status: 206,
|
||||
headers: {
|
||||
'content-type': 'video/mp4',
|
||||
'content-length': String(CAP + 1),
|
||||
},
|
||||
body: 'too big',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' });
|
||||
assert.equal(res.statusCode, 502);
|
||||
assert.match(res.body, /exceeds size cap/);
|
||||
});
|
||||
|
||||
test('a client that disconnects mid-stream stops the upstream read', async (t) => {
|
||||
// A live feed with no end of its own, counting what it is asked to produce.
|
||||
let produced = 0;
|
||||
let stopped = false;
|
||||
const endlessBody = () =>
|
||||
Readable.toWeb(
|
||||
Readable.from(
|
||||
(async function* () {
|
||||
try {
|
||||
for (;;) {
|
||||
produced += 1;
|
||||
yield Buffer.from('frame-chunk-');
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
} finally {
|
||||
stopped = true;
|
||||
}
|
||||
})(),
|
||||
),
|
||||
);
|
||||
const app = mount(t, () => ({
|
||||
ok: true,
|
||||
status: 206,
|
||||
headers: {
|
||||
get: (name) =>
|
||||
String(name).toLowerCase() === 'content-type' ? 'video/mp4' : null,
|
||||
},
|
||||
body: endlessBody(),
|
||||
arrayBuffer: async () => Buffer.alloc(0),
|
||||
}));
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
req.url = req.url.replace('/api/cctv', '') || '/';
|
||||
Promise.resolve(app.handler(req, res)).catch(() => {});
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
|
||||
const failures = [];
|
||||
const onUncaught = (error) => failures.push(error);
|
||||
process.on('uncaughtException', onUncaught);
|
||||
t.after(() => process.off('uncaughtException', onUncaught));
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = http.get(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: `/media/${CAMERA.id}`.replace(/^/, '/api/cctv'),
|
||||
headers: { Range: 'bytes=0-1023' },
|
||||
},
|
||||
(response) => {
|
||||
response.once('data', () => {
|
||||
// Walk away mid-body, the way a browser does when the operator
|
||||
// switches camera.
|
||||
request.destroy();
|
||||
resolve();
|
||||
});
|
||||
response.on('error', () => resolve());
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const afterDisconnect = produced;
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
// The point: the camera host is no longer being read from. Without the
|
||||
// release this counter keeps climbing for as long as the upstream will feed.
|
||||
assert.equal(
|
||||
produced,
|
||||
afterDisconnect,
|
||||
`the upstream was still being read after the client left (${afterDisconnect} → ${produced} chunks)`,
|
||||
);
|
||||
assert.equal(stopped, true, 'the upstream body was never released');
|
||||
assert.deepEqual(
|
||||
failures.map((error) => error?.message),
|
||||
[],
|
||||
'a client walking away must not fault the server',
|
||||
);
|
||||
const entry = await app.health();
|
||||
// The camera was serving fine; the client left. Nothing about that is a
|
||||
// camera fault other viewers should see.
|
||||
assert.equal(entry.status, 'ok');
|
||||
});
|
||||
|
||||
test('a client that leaves before the headers arrive cancels the upstream request', async (t) => {
|
||||
// The camera is slow, or dead: nothing comes back until the request is
|
||||
// cancelled. Everything worth testing here happens before any header.
|
||||
let aborted = false;
|
||||
const app = mount(t, ({ init }) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
init.signal.addEventListener('abort', () => {
|
||||
aborted = true;
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
req.url = req.url.replace('/api/cctv', '') || '/';
|
||||
Promise.resolve(app.handler(req, res)).catch(() => {});
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
|
||||
const failures = [];
|
||||
const onUncaught = (error) => failures.push(error);
|
||||
process.on('uncaughtException', onUncaught);
|
||||
t.after(() => process.off('uncaughtException', onUncaught));
|
||||
|
||||
const request = http.get({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: `/api/cctv/media/${CAMERA.id}`,
|
||||
headers: { Range: 'bytes=0-1023' },
|
||||
});
|
||||
request.on('error', () => {});
|
||||
// Let the route reach its upstream request, then walk away before it answers.
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
assert.equal(app.requests.length, 1, 'the upstream request went out');
|
||||
assert.equal(aborted, false, 'nothing was cancelled while the client waited');
|
||||
request.destroy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
assert.equal(aborted, true, 'the upstream request outlived the client');
|
||||
assert.deepEqual(
|
||||
failures.map((error) => error?.message),
|
||||
[],
|
||||
'a client leaving early must not fault the server',
|
||||
);
|
||||
// The camera did nothing wrong; the viewer left.
|
||||
const entry = await app.health();
|
||||
assert.notEqual(entry?.status, 'degraded');
|
||||
});
|
||||
|
||||
test('a folded CR/LF range never reaches the upstream through a real client', async (t) => {
|
||||
// Node's own parser, not a hand-built headers object, so the reachability of
|
||||
// this shape through an ordinary HTTP request is shown rather than assumed.
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: 'whole body',
|
||||
}),
|
||||
);
|
||||
const server = http.createServer((req, res) => {
|
||||
req.url = req.url.replace('/api/cctv', '') || '/';
|
||||
Promise.resolve(app.handler(req, res)).catch(() => {});
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
|
||||
// A client can fold a value across a continuation line; Node's parser
|
||||
// rejoins it, so what the route sees contains whitespace a header cannot
|
||||
// carry back out.
|
||||
const raw = await new Promise((resolve, reject) => {
|
||||
const socket = net.connect(port, '127.0.0.1', () => {
|
||||
socket.write(
|
||||
`GET /api/cctv/media/${CAMERA.id} HTTP/1.1\r\n` +
|
||||
'Host: 127.0.0.1\r\n' +
|
||||
'Range: bytes=0-10\r\n\tX-Injected: 1\r\n' +
|
||||
'Connection: close\r\n\r\n',
|
||||
);
|
||||
});
|
||||
let text = '';
|
||||
socket.on('data', (chunk) => {
|
||||
text += chunk.toString('utf8');
|
||||
});
|
||||
socket.on('end', () => resolve(text));
|
||||
socket.on('error', reject);
|
||||
});
|
||||
|
||||
// Either Node's parser refuses the request outright, or it reaches the route
|
||||
// and the route drops the value — but it must never be forwarded.
|
||||
const reachedRoute = app.requests.length > 0;
|
||||
assert.match(raw, /^HTTP\/1\.1 (200|400)/);
|
||||
if (reachedRoute) {
|
||||
assert.equal(
|
||||
app.requests.at(-1).headers.Range,
|
||||
undefined,
|
||||
'nothing was forwarded',
|
||||
);
|
||||
assert.match(raw, /^HTTP\/1\.1 200/);
|
||||
} else {
|
||||
assert.match(raw, /^HTTP\/1\.1 400/);
|
||||
}
|
||||
console.log(
|
||||
` [range] folded CR/LF request ${reachedRoute ? 'reached the route and was dropped' : 'was refused by the HTTP parser'}`,
|
||||
);
|
||||
const entry = await app.health();
|
||||
assert.doesNotMatch(JSON.stringify(entry ?? {}), /X-Injected/);
|
||||
});
|
||||
|
||||
test('two sequential seeks are served as two partial responses', async (t) => {
|
||||
const app = mount(t, ({ init }) => {
|
||||
const range = init?.headers?.Range || '';
|
||||
const [, first, last] = /bytes=(\d+)-(\d+)/.exec(range) || [];
|
||||
return upstreamResponse({
|
||||
status: 206,
|
||||
headers: {
|
||||
'content-type': 'video/mp4',
|
||||
'content-range': `bytes ${first}-${last}/4096`,
|
||||
'content-length': String(Number(last) - Number(first) + 1),
|
||||
'accept-ranges': 'bytes',
|
||||
},
|
||||
body: `chunk-${first}`,
|
||||
});
|
||||
});
|
||||
const first = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-99' });
|
||||
const second = await app.call(`/media/${CAMERA.id}`, {
|
||||
range: 'bytes=100-199',
|
||||
});
|
||||
assert.equal(app.requests[0].headers.Range, 'bytes=0-99');
|
||||
assert.equal(app.requests[1].headers.Range, 'bytes=100-199');
|
||||
assert.equal(first.statusCode, 206);
|
||||
assert.equal(second.statusCode, 206);
|
||||
assert.equal(first.headers['Content-Range'], 'bytes 0-99/4096');
|
||||
assert.equal(second.headers['Content-Range'], 'bytes 100-199/4096');
|
||||
assert.equal(first.body, 'chunk-0');
|
||||
assert.equal(second.body, 'chunk-100');
|
||||
});
|
||||
@@ -163,6 +163,12 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://www.digitraffic.fi/en/" target="_blank" rel="noopener">digitraffic.fi</a>, ' +
|
||||
'license CC BY 4.0',
|
||||
},
|
||||
{
|
||||
key: 'calgary-cctv',
|
||||
html:
|
||||
'Traffic cameras (Calgary): contains information licensed under the ' +
|
||||
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Who owns the pointer over the globe.
|
||||
*
|
||||
* Most layers bind their own `ScreenSpaceEventHandler` to the same canvas and
|
||||
* select whatever is under a left click. That is right when nothing else is
|
||||
* going on, and wrong the moment a tool needs the pointer for itself: a draw
|
||||
* session placing a vertex on top of an aircraft must not also track the
|
||||
* aircraft, and the next such tool must not have to be added to a list inside
|
||||
* every layer.
|
||||
*
|
||||
* So there is one claim, held by at most one owner at a time:
|
||||
*
|
||||
* - A TOOL that needs the pointer calls `claimPointer('draw')` when it turns on
|
||||
* and gets back a LEASE — an opaque token. It releases with
|
||||
* `releasePointer(lease)` when it turns off. It does not consult anyone;
|
||||
* holding the claim is what makes it the owner.
|
||||
* - An AMBIENT SELECTION HANDLER — anything that picks an entity because the
|
||||
* user clicked — early-returns while `isPointerFree()` is false. It never
|
||||
* claims.
|
||||
*
|
||||
* Claims do not stack and are never stolen: while one owner holds the pointer,
|
||||
* a second `claimPointer` returns null rather than displacing it — and so does
|
||||
* a second claim under the SAME name, because two live instances of one tool
|
||||
* (an old one mid-teardown, its replacement already running) are two owners,
|
||||
* not one. The lease is what tells them apart: releasing takes the token that
|
||||
* was issued, so a superseded instance calling `releasePointer` with its own
|
||||
* stale lease frees nothing and its successor keeps the pointer.
|
||||
*
|
||||
* Ownership is page-scoped, like the layers and handlers it arbitrates.
|
||||
*/
|
||||
|
||||
/** @typedef {{owner: string, id: number}} PointerLease */
|
||||
|
||||
/** @type {PointerLease|null} */
|
||||
let currentLease = null;
|
||||
let nextLeaseId = 1;
|
||||
|
||||
/**
|
||||
* Take the pointer for `owner`.
|
||||
* @param {string} owner Stable identifier for the claiming tool, e.g. 'draw'.
|
||||
* @returns {PointerLease|null} The lease to release with, or null if the
|
||||
* pointer is already held — including by another instance of the same tool.
|
||||
*/
|
||||
export function claimPointer(owner) {
|
||||
const name = normalizeOwner(owner);
|
||||
if (!name) return null;
|
||||
if (currentLease !== null) return null;
|
||||
currentLease = Object.freeze({ owner: name, id: nextLeaseId++ });
|
||||
return currentLease;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give the pointer back. Only the exact lease that took it can release it, so a
|
||||
* superseded instance cannot free the claim its replacement now holds.
|
||||
* @param {PointerLease|null} lease The token `claimPointer` returned.
|
||||
* @returns {boolean} Whether this call released the claim.
|
||||
*/
|
||||
export function releasePointer(lease) {
|
||||
if (!currentLease || !lease || lease.id !== currentLease.id) return false;
|
||||
currentLease = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @returns {string|null} The current owner's name, or null when free. */
|
||||
export function pointerOwner() {
|
||||
return currentLease ? currentLease.owner : null;
|
||||
}
|
||||
|
||||
/** @returns {boolean} True when no tool holds the pointer. */
|
||||
export function isPointerFree() {
|
||||
return currentLease === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} owner
|
||||
* @returns {boolean} True when a tool of that name is holding the pointer.
|
||||
*/
|
||||
export function isPointerOwnedBy(owner) {
|
||||
const name = normalizeOwner(owner);
|
||||
return Boolean(name) && pointerOwner() === name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerLease|null} lease
|
||||
* @returns {boolean} True when THIS lease is the live one.
|
||||
*/
|
||||
export function isLeaseCurrent(lease) {
|
||||
return Boolean(currentLease && lease && lease.id === currentLease.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop any claim. For tests and for application teardown, which cannot rely on
|
||||
* a half-disposed tool to release its own lease.
|
||||
* @returns {string|null} The owner that was holding it, if any.
|
||||
*/
|
||||
export function resetPointerOwnership() {
|
||||
const previous = pointerOwner();
|
||||
currentLease = null;
|
||||
return previous;
|
||||
}
|
||||
|
||||
function normalizeOwner(owner) {
|
||||
return typeof owner === 'string' && owner.trim() ? owner.trim() : null;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
claimPointer,
|
||||
isLeaseCurrent,
|
||||
isPointerFree,
|
||||
isPointerOwnedBy,
|
||||
pointerOwner,
|
||||
releasePointer,
|
||||
resetPointerOwnership,
|
||||
} from './inputOwnership.js';
|
||||
|
||||
const ROOT = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
);
|
||||
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||
|
||||
test.beforeEach(() => resetPointerOwnership());
|
||||
|
||||
test('the pointer starts free and a claim hands back a lease', () => {
|
||||
assert.equal(isPointerFree(), true);
|
||||
assert.equal(pointerOwner(), null);
|
||||
|
||||
const lease = claimPointer('draw');
|
||||
assert.ok(lease, 'a successful claim returns a lease, not a boolean');
|
||||
assert.equal(isPointerFree(), false);
|
||||
assert.equal(pointerOwner(), 'draw');
|
||||
assert.equal(isPointerOwnedBy('draw'), true);
|
||||
assert.equal(isPointerOwnedBy('directions'), false);
|
||||
assert.equal(isLeaseCurrent(lease), true);
|
||||
});
|
||||
|
||||
test('a claim is never stolen — not even by the same tool name', () => {
|
||||
const first = claimPointer('draw');
|
||||
assert.ok(first);
|
||||
|
||||
assert.equal(
|
||||
claimPointer('directions'),
|
||||
null,
|
||||
'another tool must not displace it',
|
||||
);
|
||||
assert.equal(
|
||||
claimPointer('draw'),
|
||||
null,
|
||||
'a SECOND instance of the same tool is a second owner, not a no-op',
|
||||
);
|
||||
assert.equal(pointerOwner(), 'draw');
|
||||
assert.equal(isLeaseCurrent(first), true);
|
||||
});
|
||||
|
||||
test('a replaced instance cannot free its successor’s claim', () => {
|
||||
// The bug this rule exists for. An old Draw instance is torn down while a new
|
||||
// one is already running; the old teardown calls releasePointer. Under
|
||||
// release-by-name that freed the LIVE claim and every layer started selecting
|
||||
// through the new instance's vertices.
|
||||
const stale = claimPointer('draw');
|
||||
assert.ok(releasePointer(stale), 'the old instance gives the pointer back');
|
||||
|
||||
const live = claimPointer('draw');
|
||||
assert.ok(live, 'the replacement takes it');
|
||||
assert.notEqual(live.id, stale.id, 'a fresh claim is a fresh lease');
|
||||
|
||||
assert.equal(releasePointer(stale), false, 'the stale lease frees nothing');
|
||||
assert.equal(pointerOwner(), 'draw', 'the replacement still holds it');
|
||||
assert.equal(isLeaseCurrent(stale), false);
|
||||
assert.equal(isLeaseCurrent(live), true);
|
||||
|
||||
assert.equal(releasePointer(live), true);
|
||||
assert.equal(isPointerFree(), true);
|
||||
});
|
||||
|
||||
test('release ignores anything that is not the live lease', () => {
|
||||
const lease = claimPointer('draw');
|
||||
for (const bad of [
|
||||
null,
|
||||
undefined,
|
||||
'draw',
|
||||
{},
|
||||
{ owner: 'draw', id: lease.id + 99 },
|
||||
]) {
|
||||
assert.equal(releasePointer(bad), false, JSON.stringify(bad));
|
||||
assert.equal(pointerOwner(), 'draw');
|
||||
}
|
||||
assert.equal(releasePointer(lease), true);
|
||||
assert.equal(releasePointer(lease), false, 'releasing twice changes nothing');
|
||||
});
|
||||
|
||||
test('a claim needs a real owner id', () => {
|
||||
for (const bad of ['', ' ', null, undefined, 7, {}]) {
|
||||
assert.equal(claimPointer(bad), null, JSON.stringify(bad));
|
||||
assert.equal(isPointerFree(), true);
|
||||
}
|
||||
const lease = claimPointer(' draw ');
|
||||
assert.ok(lease, 'ids are trimmed, not rejected for padding');
|
||||
assert.equal(pointerOwner(), 'draw');
|
||||
assert.equal(releasePointer(lease), true);
|
||||
});
|
||||
|
||||
test('reset reports who was holding it, for teardown', () => {
|
||||
assert.equal(resetPointerOwnership(), null);
|
||||
const lease = claimPointer('draw');
|
||||
assert.equal(resetPointerOwnership(), 'draw');
|
||||
assert.equal(isPointerFree(), true);
|
||||
assert.equal(isLeaseCurrent(lease), false);
|
||||
});
|
||||
|
||||
test('every scene click handler consults ownership before it picks', () => {
|
||||
// This is the whole point of a shared contract: a new tool must not have to
|
||||
// find and edit each of these. If a layer grows a click handler it has to
|
||||
// appear here too.
|
||||
const guarded = [
|
||||
['src/data/trackingClickGesture.js', 'onClick(click, gesture);'],
|
||||
['src/data/localGeojsonCore.js', 'viewer.scene.pick(click.position)'],
|
||||
['src/data/cctvGizmo.js', 'pickGizmoPart(event.position)'],
|
||||
['src/layers/alpr/presentation.js', 'viewer.scene.pick(click.position)'],
|
||||
['src/layers/bikeshare/selection.js', 'viewer.scene.pick(click.position)'],
|
||||
['src/layers/firms/selection.js', 'scene.pick(click.position)'],
|
||||
[
|
||||
'src/layers/installations/selection.js',
|
||||
'viewer.scene.pick(click.position)',
|
||||
],
|
||||
['src/layers/launches/lifecycle.js', 'drillPick(movement.position'],
|
||||
['src/layers/radio/interaction.js', 'pickedRadioStationAt(click.position)'],
|
||||
[
|
||||
'src/layers/satellites/interaction.js',
|
||||
'viewer.scene.pick(click.position)',
|
||||
],
|
||||
[
|
||||
'src/layers/submarineCables/interaction.js',
|
||||
'viewer.scene.pick(click.position)',
|
||||
],
|
||||
['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'],
|
||||
];
|
||||
for (const [file, firstPick] of guarded) {
|
||||
const source = read(file);
|
||||
assert.match(
|
||||
source,
|
||||
/import \{ isPointerFree \} from '[^']*inputOwnership\.js';/,
|
||||
`${file} must consult the shared pointer claim`,
|
||||
);
|
||||
const guardAt = source.indexOf('if (!isPointerFree()) return;');
|
||||
const pickAt = source.indexOf(firstPick);
|
||||
assert.ok(guardAt >= 0, `${file} is missing the guard`);
|
||||
assert.ok(
|
||||
pickAt >= 0,
|
||||
`${file}: could not find its pick call (${firstPick})`,
|
||||
);
|
||||
assert.ok(
|
||||
guardAt < pickAt,
|
||||
`${file} must yield before it picks, not after`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('ambient selection handlers never claim the pointer themselves', () => {
|
||||
// Claiming from a selection handler would deadlock every other layer the
|
||||
// moment a user clicked anything. Only tools claim.
|
||||
for (const file of [
|
||||
'src/data/trackingClickGesture.js',
|
||||
'src/data/localGeojsonCore.js',
|
||||
'src/data/cctvGizmo.js',
|
||||
'src/layers/vessels/selection.js',
|
||||
'src/layers/satellites/interaction.js',
|
||||
]) {
|
||||
assert.doesNotMatch(
|
||||
read(file),
|
||||
/claimPointer\(/,
|
||||
`${file} must not claim the pointer`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from './inputOwnership.js';
|
||||
import {
|
||||
selectInfraLod,
|
||||
applyInfraEvictionGrace,
|
||||
@@ -768,6 +769,8 @@ export function createLocalGeoJsonLayer(
|
||||
viewer.scene.canvas,
|
||||
);
|
||||
_clickHandler.setInputAction((click) => {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js).
|
||||
if (!isPointerFree()) return;
|
||||
if (!_enabled) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from './inputOwnership.js';
|
||||
|
||||
export const MAX_TRACKING_CLICK_TRAVEL_PX = 6;
|
||||
export const MAX_TRACKING_CLICK_DURATION_MS = 400;
|
||||
@@ -95,6 +96,9 @@ export function bindTrackingClickGesture(handler, onClick, options = {}) {
|
||||
if (pressActive) finishPress(click?.position);
|
||||
const gesture = completedGesture || { travelPx: 0, durationMs: 0 };
|
||||
completedGesture = null;
|
||||
// A tool owns the pointer: a draw vertex placed over an aircraft is a
|
||||
// vertex, not a track request (src/data/inputOwnership.js).
|
||||
if (!isPointerFree()) return;
|
||||
onClick(click, gesture);
|
||||
}, eventTypes.LEFT_CLICK);
|
||||
}
|
||||
Reference in new issue
Block a user