mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge upstream/main into feature/catalonia-cctv-cameras
Keep both: Catalonia CCTV alongside the NYC DOT TMC pack in the source imports, README layer table and CURRENT-STATE data table, and both CHANGELOG entries. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
da0a840b7b
123 files changed
+11681
-1454
No files matched your search
@@ -0,0 +1,391 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
loadNycSourcesFromOpenData,
|
||||
nycCameraIsOnline,
|
||||
nycCameraName,
|
||||
nycCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
DEFAULT_NYC_MAX_SOURCES,
|
||||
NYC_CAMERAS_URL,
|
||||
NYC_GROUND_ELEVATION_M,
|
||||
NYC_IMAGE_ORIGIN,
|
||||
NYC_IMAGE_URL,
|
||||
NYC_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
CAMERA_CODE_MAX_CHARS,
|
||||
isLikelyNycCoordinate,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.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 };
|
||||
};
|
||||
|
||||
const MIDTOWN_ID = '8a6bc417-4877-4ebe-8052-88c1b261baf1';
|
||||
const BROOKLYN_ID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
/** One TMC camera row, shaped like the live `webcams.nyctmc.org` payload. */
|
||||
const row = (overrides = {}) => ({
|
||||
id: MIDTOWN_ID,
|
||||
name: 'Central Park West @ 86 St',
|
||||
latitude: 40.785302,
|
||||
longitude: -73.969353,
|
||||
area: 'Manhattan',
|
||||
isOnline: 'true',
|
||||
imageUrl: NYC_IMAGE_URL(MIDTOWN_ID),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('a NYC row maps to a source on the pinned HTTPS frame host', () => {
|
||||
const source = nycCameraToSource(row());
|
||||
assert.equal(source.id, `nyc-${MIDTOWN_ID}`);
|
||||
assert.equal(source.name, 'Central Park West @ 86 St');
|
||||
assert.equal(source.city, 'Manhattan');
|
||||
assert.equal(source.cityId, 'nyc');
|
||||
assert.equal(source.provider, 'NYC DOT Traffic Management Center');
|
||||
assert.equal(source.lat, 40.785302);
|
||||
assert.equal(source.lon, -73.969353);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'nyc-dot-tmc');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'Public NYC DOT Traffic Management Center traffic camera',
|
||||
);
|
||||
assert.equal(source.groundElevationM, NYC_GROUND_ELEVATION_M);
|
||||
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(NYC_IMAGE_ORIGIN));
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(source.headingDeg));
|
||||
});
|
||||
|
||||
test('frame URLs are synthesized from the UUID; the payload URL is ignored', () => {
|
||||
const source = nycCameraToSource(
|
||||
row({ imageUrl: 'https://evil.example/cameras/x/image' }),
|
||||
);
|
||||
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('offline cameras, off-city geometry and unusable ids are dropped', () => {
|
||||
assert.equal(nycCameraToSource(row({ isOnline: 'false' })), null);
|
||||
assert.equal(nycCameraToSource(row({ isOnline: false })), null);
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ isOnline: 'TRUE' })).id,
|
||||
`nyc-${MIDTOWN_ID}`,
|
||||
);
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ isOnline: true })).id,
|
||||
`nyc-${MIDTOWN_ID}`,
|
||||
);
|
||||
// Austin: a plausible lat/lon, but not a New York camera.
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ latitude: 30.2672, longitude: -97.7431 })),
|
||||
null,
|
||||
);
|
||||
assert.equal(nycCameraToSource(row({ latitude: 0, longitude: 0 })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: '../etc/passwd' })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: 'not-a-uuid' })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: `${MIDTOWN_ID}/../x` })), null);
|
||||
assert.equal(nycCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('ids lower-case the UUID and unknown boroughs fall back to New York', () => {
|
||||
const mixed = nycCameraToSource(
|
||||
row({ id: MIDTOWN_ID.toUpperCase(), area: 'Long Island' }),
|
||||
);
|
||||
assert.equal(mixed.id, `nyc-${MIDTOWN_ID}`);
|
||||
assert.equal(mixed.city, 'New York');
|
||||
assert.equal(
|
||||
mixed.url,
|
||||
NYC_IMAGE_URL(MIDTOWN_ID.toUpperCase()),
|
||||
'the registered frame path keeps the catalog UUID spelling',
|
||||
);
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
assert.equal(
|
||||
nycCameraName({ name: ' FDR Drive @ 23 St ' }, 'nyc-x'),
|
||||
'FDR Drive @ 23 St',
|
||||
);
|
||||
assert.equal(nycCameraName({}, `nyc-${MIDTOWN_ID}`), 'NYC Camera 8a6bc417');
|
||||
assert.equal(nycCameraIsOnline('true'), true);
|
||||
assert.equal(nycCameraIsOnline('false'), false);
|
||||
});
|
||||
|
||||
test('the unselected label is the intersection, trimmed to the code width', () => {
|
||||
assert.equal(nycCameraToSource(row()).code, 'CENTRAL PARK WEST @ 86 ST');
|
||||
const long = nycCameraToSource(
|
||||
row({
|
||||
name: 'Brooklyn Queens Expressway @ Atlantic Avenue / Flatbush Avenue Extension',
|
||||
}),
|
||||
);
|
||||
assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(long.code.endsWith('…'));
|
||||
assert.ok(long.code.startsWith('BROOKLYN'));
|
||||
});
|
||||
|
||||
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, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
return Response.json([
|
||||
row({ latitude: 40.758, longitude: -73.9855 }),
|
||||
row({
|
||||
name: 'Central Park West @ 86 St (duplicate)',
|
||||
latitude: 40.758,
|
||||
longitude: -73.9855,
|
||||
}),
|
||||
row({
|
||||
id: BROOKLYN_ID,
|
||||
name: 'Atlantic Ave @ Flatbush Ave',
|
||||
latitude: 40.65,
|
||||
longitude: -73.96,
|
||||
area: 'Brooklyn',
|
||||
imageUrl: NYC_IMAGE_URL(BROOKLYN_ID),
|
||||
}),
|
||||
row({ isOnline: 'false', name: 'Offline camera' }),
|
||||
row({ id: 'https://evil.example/x' }),
|
||||
]);
|
||||
});
|
||||
const cameras = await loadNycSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [[NYC_CAMERAS_URL, 'manual']]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
// Nearest to a borough anchor first: the Times Square camera before Brooklyn.
|
||||
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
|
||||
);
|
||||
assert.equal(cameras[0].name, 'Central Park West @ 86 St');
|
||||
assert.equal(cameras[1].city, 'Brooklyn');
|
||||
});
|
||||
|
||||
test('an upstream failure yields an empty pack and releases the response', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
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 loadNycSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras.json' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[NYC_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
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(NYC_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(NYC_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 loadNycSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('a reduced pack cap keeps cameras nearest the borough anchors', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const saved = process.env.CCTV_NYC_MAX_SOURCES;
|
||||
t.after(() => {
|
||||
if (saved === undefined) delete process.env.CCTV_NYC_MAX_SOURCES;
|
||||
else process.env.CCTV_NYC_MAX_SOURCES = saved;
|
||||
});
|
||||
process.env.CCTV_NYC_MAX_SOURCES = '8';
|
||||
const rows = [
|
||||
// Far southern Staten Island, listed first so only distance can keep them.
|
||||
...[40.5, 40.505, 40.51, 40.515, 40.52, 40.525].map((lat, i) =>
|
||||
row({
|
||||
id: `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`,
|
||||
latitude: lat,
|
||||
longitude: -74.24,
|
||||
area: 'Staten Island',
|
||||
name: `SI ${i}`,
|
||||
}),
|
||||
),
|
||||
...[40.758, 40.757, 40.756, 40.755, 40.754, 40.753].map((lat, i) =>
|
||||
row({
|
||||
id: `bbbbbbbb-cccc-dddd-eeee-${String(i).padStart(12, '0')}`,
|
||||
latitude: lat,
|
||||
longitude: -73.9855,
|
||||
area: 'Manhattan',
|
||||
name: `Midtown ${i}`,
|
||||
}),
|
||||
),
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
|
||||
const cameras = await loadNycSourcesFromOpenData();
|
||||
assert.equal(cameras.length, 8);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.city),
|
||||
[
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Staten Island',
|
||||
'Staten Island',
|
||||
],
|
||||
'Midtown fills first; the Staten Island anchor still keeps a share',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the NYC catalog to the NYC endpoint and an empty payload to every
|
||||
* other pack, so one catalog refresh exercises the registration without
|
||||
* reaching the network.
|
||||
*/
|
||||
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 === NYC_CAMERAS_URL) {
|
||||
return Response.json([
|
||||
row({ latitude: 40.758, longitude: -73.9855 }),
|
||||
row({
|
||||
id: BROOKLYN_ID,
|
||||
name: 'Atlantic Ave @ Flatbush Ave',
|
||||
latitude: 40.65,
|
||||
longitude: -73.96,
|
||||
area: 'Brooklyn',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
/** Run one catalog refresh with CCTV_NYC_ENABLED set to `flag` (undefined
|
||||
* leaves it unset), restoring the environment afterwards. */
|
||||
const runCatalogWithNycFlag = async (t, flag) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
if (flag === undefined) delete process.env.CCTV_NYC_ENABLED;
|
||||
else process.env.CCTV_NYC_ENABLED = flag;
|
||||
return await runCatalogWithMockedUpstreams(t);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('CCTV_NYC_ENABLED=1 wires the NYC lane into the catalog', async (t) => {
|
||||
const { requested, sources } = await runCatalogWithNycFlag(t, '1');
|
||||
assert.ok(
|
||||
requested.includes(NYC_CAMERAS_URL),
|
||||
'the catalog refresh invokes the NYC loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'nyc').map((s) => s.id),
|
||||
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
|
||||
'NYC cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
});
|
||||
|
||||
// NYC DOT requires a data-sharing agreement for this feed, so an install that
|
||||
// has not opted in must never contact it.
|
||||
for (const [label, flag] of [
|
||||
['an unset CCTV_NYC_ENABLED', undefined],
|
||||
['an empty CCTV_NYC_ENABLED', ''],
|
||||
['CCTV_NYC_ENABLED=0', '0'],
|
||||
['CCTV_NYC_ENABLED=true', 'true'],
|
||||
]) {
|
||||
test(`${label} makes no NYC request`, async (t) => {
|
||||
const { requested, sources } = await runCatalogWithNycFlag(t, flag);
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('nyctmc.org')),
|
||||
false,
|
||||
'the lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'nyc'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
|
||||
});
|
||||
|
||||
test('isLikelyNycCoordinate spans the five boroughs and rejects the rest', () => {
|
||||
assert.equal(isLikelyNycCoordinate(40.758, -73.9855), true); // Times Square
|
||||
assert.equal(isLikelyNycCoordinate(40.5795, -74.1502), true); // Staten Island
|
||||
assert.equal(isLikelyNycCoordinate(40.8448, -73.8648), true); // Bronx
|
||||
assert.equal(isLikelyNycCoordinate(40.5077, -74.2304), true); // southern SI slack
|
||||
assert.equal(isLikelyNycCoordinate(30.2672, -97.7431), false); // Austin
|
||||
assert.equal(isLikelyNycCoordinate(0, 0), false);
|
||||
assert.equal(isLikelyNycCoordinate(NaN, -73.98), false);
|
||||
});
|
||||
@@ -91,6 +91,13 @@ export function hideOsmCredit(viewer, owner) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** NYC DOT asks every application that uses its data to carry this
|
||||
* disclaimer verbatim (nyc.gov/html/dot/html/about/datafeeds.shtml). */
|
||||
export const NYC_DOT_DISCLAIMER =
|
||||
'This application may contain and/or utilize information which was originally compiled by the New York City Department of Transportation (NYC DOT) for governmental purposes; the information may subsequently been modified by entity/entities other than NYC DOT. ' +
|
||||
'NYC DOT and the City of New York make no representation as to the accuracy or usefulness of the information provided by this application or the information's suitability for any purpose and disclaim any liability for omissions or errors that may be contained therein. ' +
|
||||
'The public is advised to observe posted signage for compliance with applicable laws and regulations.';
|
||||
|
||||
export const DATA_CREDITS = [
|
||||
// ── Live sources ────────────────────────────────────────────────
|
||||
{
|
||||
@@ -255,6 +262,13 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'distributed by Statens vegvesen',
|
||||
},
|
||||
{
|
||||
key: 'nyc-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (New York City, when enabled): ' +
|
||||
'<a href="https://webcams.nyctmc.org/" target="_blank" rel="noopener">NYC DOT Traffic Management Center</a>. ' +
|
||||
NYC_DOT_DISCLAIMER,
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA_CREDITS } from './dataCredits.js';
|
||||
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
|
||||
|
||||
test('every credit carries a unique key and some markup to render', () => {
|
||||
const keys = DATA_CREDITS.map((entry) => entry.key);
|
||||
@@ -120,3 +120,11 @@ test('tile attribution follows source changes and viewers have independent owner
|
||||
assert.equal(a.credits.size, 0);
|
||||
assert.equal(b.credits.size, 1);
|
||||
});
|
||||
|
||||
test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'nyc-cctv');
|
||||
assert.ok(credit, 'NYC DOT camera frames must be credited');
|
||||
assert.ok(credit.html.includes(NYC_DOT_DISCLAIMER));
|
||||
assert.match(credit.html, /make no representation as to the accuracy/);
|
||||
assert.match(credit.html, /observe posted signage/);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseFirmsCsv } from './firmsCsv.js';
|
||||
import { adaptFirmsRecords } from './firmsAdapt.js';
|
||||
test('missing positions do not become fire detections at zero coordinates', () => {
|
||||
const csv =
|
||||
'latitude,longitude,acq_date,acq_time,confidence,frp\n,1,2026-07-16,1200,n,1\n1, ,2026-07-16,1200,n,1\n95,1,2026-07-16,1200,n,1\n1,181,2026-07-16,1200,n,1\n0,0,2026-07-16,1200,n,1\n-90,-180,2026-07-16,1200,n,1';
|
||||
assert.deepEqual(
|
||||
parseFirmsCsv(csv).map((r) => [r.lat, r.lon]),
|
||||
[
|
||||
[0, 0],
|
||||
[-90, -180],
|
||||
],
|
||||
);
|
||||
const records = [
|
||||
{ lat: null, lon: 1 },
|
||||
{ lat: '', lon: 1 },
|
||||
{ lat: false, lon: 1 },
|
||||
{ lat: 95, lon: 1 },
|
||||
{ lat: 1, lon: 181 },
|
||||
{ lat: '0', lon: 0 },
|
||||
{ lat: -90, lon: -180 },
|
||||
];
|
||||
assert.deepEqual(
|
||||
adaptFirmsRecords(records).map((r) => [r.index, r.lat, r.lon]),
|
||||
[
|
||||
[0, 0, 0],
|
||||
[1, -90, -180],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('both axes keep their exact bounds and drop values just past them', () => {
|
||||
const row = (lat, lon) => `${lat},${lon},2026-07-16,1200,n,1`;
|
||||
const csv = [
|
||||
'latitude,longitude,acq_date,acq_time,confidence,frp',
|
||||
row(90, 180),
|
||||
row(-90, -180),
|
||||
row(90.000001, 0),
|
||||
row(-90.000001, 0),
|
||||
row(0, 180.000001),
|
||||
row(0, -180.000001),
|
||||
row(' ', 5),
|
||||
row(5, '\t'),
|
||||
].join('\n');
|
||||
assert.deepEqual(
|
||||
parseFirmsCsv(csv).map((r) => [r.lat, r.lon]),
|
||||
[
|
||||
[90, 180],
|
||||
[-90, -180],
|
||||
],
|
||||
);
|
||||
const kept = adaptFirmsRecords([
|
||||
{ lat: 90, lon: 180 },
|
||||
{ lat: '-90', lon: '-180' },
|
||||
{ lat: -90.000001, lon: 0 },
|
||||
{ lat: 0, lon: -180.000001 },
|
||||
{ lat: 5, lon: ' ' },
|
||||
{ lat: 5, lon: undefined },
|
||||
{ lat: [5], lon: 5 },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
kept.map((r) => [r.lat, r.lon]),
|
||||
[
|
||||
[90, 180],
|
||||
[-90, -180],
|
||||
],
|
||||
);
|
||||
});
|
||||
+19
-3
@@ -7,8 +7,8 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Map proxy fire records into internal fire records. Records with
|
||||
* non-finite coordinates are skipped; `index` is the post-skip position
|
||||
* Map proxy fire records into internal fire records. Records with missing,
|
||||
* non-numeric or out-of-range (WGS84) coordinates are skipped; `index` is the post-skip position
|
||||
* (it keys pick ids and context-store ids, so it must stay sequential).
|
||||
* `contextEntity`/`position` start null and are lazily filled by the layer.
|
||||
* @param {?Array<Object>} records - /api/firms `fires` array.
|
||||
@@ -21,7 +21,15 @@ export function adaptFirmsRecords(records) {
|
||||
for (const record of records) {
|
||||
const lat = Number(record?.lat);
|
||||
const lon = Number(record?.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
if (
|
||||
!hasCoordinateValue(record?.lat) ||
|
||||
!hasCoordinateValue(record?.lon) ||
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lon) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
Math.abs(lon) > 180
|
||||
)
|
||||
continue;
|
||||
fires.push({
|
||||
index: fires.length,
|
||||
lat,
|
||||
@@ -110,3 +118,11 @@ function finiteNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
// Same rule as parseFirmsCsv: a blank or non-numeric value is not a zero.
|
||||
function hasCoordinateValue(value) {
|
||||
return (
|
||||
typeof value === 'number' ||
|
||||
(typeof value === 'string' && value.trim() !== '')
|
||||
);
|
||||
}
|
||||
+11
-1
@@ -105,7 +105,17 @@ export function parseFirmsCsv(text) {
|
||||
if (parts.length < header.length) continue; // malformed row — skip
|
||||
const lat = Number(parts[iLat]);
|
||||
const lon = Number(parts[iLon]);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
// Blank or out-of-range positions are dropped, never read as 0 (see
|
||||
// hasCoordinateValue in firmsAdapt.js for the proxy-record side).
|
||||
if (
|
||||
!parts[iLat].trim() ||
|
||||
!parts[iLon].trim() ||
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lon) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
Math.abs(lon) > 180
|
||||
)
|
||||
continue;
|
||||
|
||||
records.push({
|
||||
lat,
|
||||
|
||||
@@ -2398,10 +2398,13 @@ test('a newer absolute intent aborts a hung guard-compensation instead of starvi
|
||||
removeGuard();
|
||||
// The newest intent must be able to abort the hung compensation and run.
|
||||
const pendingFinalOn = mgr.setEnabled('flights', true, { origin: 'user' });
|
||||
let giveUpTimer;
|
||||
const finalOn = await Promise.race([
|
||||
pendingFinalOn,
|
||||
new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)),
|
||||
]);
|
||||
new Promise((resolve) => {
|
||||
giveUpTimer = setTimeout(() => resolve('starved'), 2000);
|
||||
}),
|
||||
]).finally(() => clearTimeout(giveUpTimer));
|
||||
assert.notEqual(finalOn, 'starved', 'newest intent must not starve behind the compensation');
|
||||
assert.equal(compensationAborted, true, 'the hung compensation was aborted');
|
||||
assert.equal(await pendingBlockedOn, false, 'the guard-blocked request stays unfulfilled');
|
||||
@@ -2451,10 +2454,13 @@ test('re-entrant setEnabled from a blocked-adoption listener supersedes the comp
|
||||
releaseFirstDisable();
|
||||
|
||||
const blockedOn = await pendingBlockedOn;
|
||||
let giveUpTimer;
|
||||
const reentrant = await Promise.race([
|
||||
(async () => reentrantResult === null ? 'never-fired' : await reentrantResult)(),
|
||||
new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)),
|
||||
]);
|
||||
new Promise((resolve) => {
|
||||
giveUpTimer = setTimeout(() => resolve('starved'), 2000);
|
||||
}),
|
||||
]).finally(() => clearTimeout(giveUpTimer));
|
||||
await pendingOff;
|
||||
await mgr.waitForLayerSettled('flights');
|
||||
unsubscribe();
|
||||
|
||||
@@ -87,14 +87,22 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
|
||||
return articles;
|
||||
}
|
||||
|
||||
/** A reported number, or null when the observation is missing or not numeric. */
|
||||
function observedNumber(value) {
|
||||
if (
|
||||
value == null ||
|
||||
typeof value === 'boolean' ||
|
||||
(typeof value === 'string' && !value.trim())
|
||||
)
|
||||
return null;
|
||||
return Number.isFinite(Number(value)) ? Number(value) : null;
|
||||
}
|
||||
|
||||
/** Normalize Open-Meteo current conditions into a small source-stamped record. */
|
||||
export function normalizeRegionalWeather(payload) {
|
||||
const current = payload?.current;
|
||||
if (!current || !Number.isFinite(Number(current.temperature_2m))) return null;
|
||||
const numberOrNull = (value) => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
return Number.isFinite(Number(value)) ? Number(value) : null;
|
||||
};
|
||||
if (!current || observedNumber(current.temperature_2m) == null) return null;
|
||||
const numberOrNull = observedNumber;
|
||||
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
|
||||
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
|
||||
const observedRaw =
|
||||
@@ -119,8 +127,8 @@ export function normalizeRegionalWeather(payload) {
|
||||
|
||||
/** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */
|
||||
export function weatherCodeLabel(code) {
|
||||
const value = Number(code);
|
||||
if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN';
|
||||
const value = observedNumber(code);
|
||||
if (value == null) return 'CONDITIONS UNKNOWN';
|
||||
if (value === 0) return 'CLEAR';
|
||||
if ([1, 2].includes(value)) return 'PARTLY CLOUDY';
|
||||
if (value === 3) return 'OVERCAST';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizeOsrmSteps, ROUTE_STEPS_MAX } from './routeSteps.js';
|
||||
const step = (type) => ({
|
||||
maneuver: { type, location: [10, 20] },
|
||||
distance: 10,
|
||||
duration: 1,
|
||||
});
|
||||
test('exact-cap routes remain complete and boundary exits retain their distance', () => {
|
||||
const steps = Array.from({ length: ROUTE_STEPS_MAX }, () => step('turn'));
|
||||
assert.equal(normalizeOsrmSteps({ legs: [{ steps }] }).truncated, false);
|
||||
assert.equal(
|
||||
normalizeOsrmSteps({ legs: [{ steps: [...steps, step('turn')] }] })
|
||||
.truncated,
|
||||
true,
|
||||
);
|
||||
steps[steps.length - 1] = step('roundabout');
|
||||
const result = normalizeOsrmSteps({
|
||||
legs: [{ steps: [...steps, step('exit roundabout')] }],
|
||||
});
|
||||
assert.equal(result.truncated, false);
|
||||
assert.equal(result.steps.length, ROUTE_STEPS_MAX);
|
||||
assert.equal(result.steps.at(-1).distanceM, 20);
|
||||
});
|
||||
@@ -185,9 +185,9 @@ export function normalizeOsrmSteps(route) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true };
|
||||
step.instruction = instructionFor(step);
|
||||
out.push(step);
|
||||
if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true };
|
||||
}
|
||||
}
|
||||
return { steps: out, truncated: false };
|
||||
|
||||
@@ -206,9 +206,9 @@ test('the step list is capped, and the cap is reported rather than hidden', () =
|
||||
// would present a route that simply stops in the middle of a road.
|
||||
assert.equal(cut.truncated, true);
|
||||
const exact = normalizeOsrmSteps({
|
||||
legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX - 1) }],
|
||||
legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX) }],
|
||||
});
|
||||
assert.equal(exact.steps.length, ROUTE_STEPS_MAX - 1);
|
||||
assert.equal(exact.steps.length, ROUTE_STEPS_MAX);
|
||||
assert.equal(exact.truncated, false, 'a route that fits is not cut');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizeRegionalWeather, weatherCodeLabel } from './regionalModel.js';
|
||||
test('missing weather observations never assert clear conditions', () => {
|
||||
for (const value of [null, undefined, '', ' ', false, true]) {
|
||||
assert.equal(weatherCodeLabel(value), 'CONDITIONS UNKNOWN');
|
||||
assert.equal(
|
||||
normalizeRegionalWeather({ current: { temperature_2m: value } }),
|
||||
null,
|
||||
);
|
||||
}
|
||||
assert.equal(weatherCodeLabel(0), 'CLEAR');
|
||||
assert.equal(weatherCodeLabel('0'), 'CLEAR');
|
||||
const record = normalizeRegionalWeather({
|
||||
current: { temperature_2m: 0, weather_code: null, wind_speed_10m: false },
|
||||
});
|
||||
assert.equal(record.temperatureC, 0);
|
||||
assert.equal(record.weatherCode, null);
|
||||
assert.equal(record.windKph, null);
|
||||
});
|
||||
Reference in new issue
Block a user