mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into fix/tle-catalog-matching
This commit is contained in:
commit
155f5165d5
520 files changed
+36512
-5209
No files matched your search
+21
-4
@@ -323,13 +323,30 @@ export function lineTouchesArea(coords, area) {
|
||||
|
||||
/** Whether a point lies inside a ring of `[lon, lat]` vertices (ray casting). */
|
||||
function ringContains(ring, point) {
|
||||
if (ring.length < 3) return false;
|
||||
// Keep adjacent vertices on the same longitude sheet, as the edge tests do.
|
||||
// Wrapping each vertex around the query point would split a dateline ring
|
||||
// when queried from Greenwich and mistake its complement for its interior.
|
||||
let previous = ring[0][0];
|
||||
let west = previous;
|
||||
let east = previous;
|
||||
const vertices = ring.map(([lon, lat]) => {
|
||||
if (Math.abs(lon - previous) > 180)
|
||||
lon += 360 * Math.round((previous - lon) / 360);
|
||||
previous = lon;
|
||||
west = Math.min(west, lon);
|
||||
east = Math.max(east, lon);
|
||||
return [lon, lat];
|
||||
});
|
||||
const middle = (west + east) / 2;
|
||||
const lon = point.lon + 360 * Math.round((middle - point.lon) / 360);
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i, i += 1) {
|
||||
const [xi, yi] = vertices[i];
|
||||
const [xj, yj] = vertices[j];
|
||||
if (
|
||||
yi > point.lat !== yj > point.lat &&
|
||||
point.lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi
|
||||
lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi
|
||||
)
|
||||
inside = !inside;
|
||||
}
|
||||
|
||||
@@ -262,3 +262,78 @@ test('a box radius reaches every point of the box, however large', async () => {
|
||||
const small = await resolveArea({ bbox: [-97.94, 30.1, -97.56, 30.52] });
|
||||
assert.ok(areaRadiusKm(small) < 30);
|
||||
});
|
||||
|
||||
test('polygon containment follows the short edges across the antimeridian', async () => {
|
||||
const ring = [
|
||||
[179, -2],
|
||||
[-179, -2],
|
||||
[-179, 2],
|
||||
[179, 2],
|
||||
];
|
||||
const areas = [
|
||||
[{ bbox: [179.4, -0.1, 179.6, 0.1] }, true],
|
||||
[{ bbox: [-179.6, -0.1, -179.4, 0.1] }, true],
|
||||
[{ bbox: [179.8, -0.1, -179.8, 0.1] }, true],
|
||||
[{ lat: 0, lon: 179.5, radius_km: 10 }, true],
|
||||
[{ lat: 0, lon: -179.5, radius_km: 10 }, true],
|
||||
[{ lat: 0, lon: 180, radius_km: 10 }, true],
|
||||
[{ lat: 0, lon: -180, radius_km: 10 }, true],
|
||||
[{ bbox: [-0.1, -0.1, 0.1, 0.1] }, false],
|
||||
[{ lat: 0, lon: 0, radius_km: 10 }, false],
|
||||
];
|
||||
for (const vertices of [
|
||||
ring,
|
||||
[...ring, [...ring[0]]],
|
||||
[...ring].reverse(),
|
||||
[...ring.slice(2), ...ring.slice(0, 2)],
|
||||
]) {
|
||||
for (const [input, expected] of areas) {
|
||||
assert.equal(
|
||||
polygonsTouchArea([[vertices]], await resolveArea(input)),
|
||||
expected,
|
||||
JSON.stringify({ vertices, input }),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('antimeridian polygons keep holes empty and include their boundaries', async () => {
|
||||
const outer = [
|
||||
[178, -3],
|
||||
[-178, -3],
|
||||
[-178, 3],
|
||||
[178, 3],
|
||||
];
|
||||
const hole = [
|
||||
[179, -1],
|
||||
[-179, -1],
|
||||
[-179, 1],
|
||||
[179, 1],
|
||||
];
|
||||
for (const input of [
|
||||
{ bbox: [179.4, -0.1, 179.6, 0.1] },
|
||||
{ bbox: [-179.6, -0.1, -179.4, 0.1] },
|
||||
{ lat: 0, lon: 180, radius_km: 10 },
|
||||
]) {
|
||||
const area = await resolveArea(input);
|
||||
assert.ok(polygonsTouchArea([[outer]], area));
|
||||
assert.ok(!polygonsTouchArea([[outer, hole]], area));
|
||||
assert.ok(!polygonsTouchArea([[outer, [...hole].reverse()]], area));
|
||||
}
|
||||
const solid = await resolveArea({ bbox: [178.4, -0.1, 178.6, 0.1] });
|
||||
assert.ok(polygonsTouchArea([[outer, hole]], solid));
|
||||
const boundary = await resolveArea({ bbox: [178.9, -0.1, 179.1, 0.1] });
|
||||
assert.ok(polygonsTouchArea([[outer, hole]], boundary));
|
||||
});
|
||||
|
||||
test('polygon containment retains rings spanning more than half the globe', async () => {
|
||||
const longitudes = [-170, -110, -50, 10, 70, 130];
|
||||
const ring = [
|
||||
...longitudes.map((lon) => [lon, -5]),
|
||||
...[...longitudes].reverse().map((lon) => [lon, 5]),
|
||||
];
|
||||
const inside = await resolveArea({ bbox: [99, -1, 101, 1] });
|
||||
const outside = await resolveArea({ bbox: [169, -1, 171, 1] });
|
||||
assert.ok(polygonsTouchArea([[ring]], inside));
|
||||
assert.ok(!polygonsTouchArea([[ring]], outside));
|
||||
});
|
||||
@@ -111,6 +111,8 @@ export const aircraftInArea = defineTool({
|
||||
(record) =>
|
||||
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
|
||||
!(args.airborne_only && record.onGround) &&
|
||||
((args.min_altitude_m == null && args.max_altitude_m == null) ||
|
||||
Number.isFinite(altitude(record))) &&
|
||||
(args.min_altitude_m == null ||
|
||||
altitude(record) >= args.min_altitude_m) &&
|
||||
(args.max_altitude_m == null ||
|
||||
|
||||
@@ -388,3 +388,62 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as
|
||||
assert.deepEqual(result.data.stale_feeds, ['civil']);
|
||||
assert.match(result.summary, /may be stale\.$/);
|
||||
});
|
||||
|
||||
test('altitude bands require a reported altitude from normalized OpenSky records', async () => {
|
||||
const { normalizeOpenSkyAircraft } =
|
||||
await import('../../sources/live/aircraft.js');
|
||||
const state = (id, baro, geometric) =>
|
||||
normalizeOpenSkyAircraft([
|
||||
id,
|
||||
'TEST',
|
||||
'Test',
|
||||
1767225600,
|
||||
1767225600,
|
||||
0,
|
||||
0,
|
||||
baro,
|
||||
false,
|
||||
10,
|
||||
90,
|
||||
0,
|
||||
[],
|
||||
geometric,
|
||||
]);
|
||||
const records = [
|
||||
state('abc001', null, null),
|
||||
state('abc002', 0, null),
|
||||
state('abc003', null, 50),
|
||||
state('abc004', 500, null),
|
||||
];
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
aircraft: { getSnapshot: async () => ({ records }) },
|
||||
},
|
||||
});
|
||||
const area = { bbox: [-1, -1, 1, 1] };
|
||||
assert.equal(
|
||||
(await catalog.call('aircraft_in_area', { area })).data.total,
|
||||
4,
|
||||
'unfiltered answers retain unknown altitude',
|
||||
);
|
||||
for (const band of [
|
||||
{ max_altitude_m: 100 },
|
||||
{ min_altitude_m: -500, max_altitude_m: 100 },
|
||||
]) {
|
||||
const result = await catalog.call('aircraft_in_area', { area, ...band });
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.id),
|
||||
['abc002', 'abc003'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.altitude_m),
|
||||
[0, 50],
|
||||
);
|
||||
}
|
||||
const minimum = await catalog.call('aircraft_in_area', {
|
||||
area,
|
||||
min_altitude_m: -500,
|
||||
});
|
||||
assert.equal(minimum.data.total, 3);
|
||||
});
|
||||
@@ -288,20 +288,25 @@ export const getFirePerimeters = defineTool({
|
||||
},
|
||||
});
|
||||
|
||||
/** The average vertex of a perimeter's outer rings, enough to place it in an area. */
|
||||
/** Average outer-ring vertices, using a circular longitude mean at the dateline. */
|
||||
function polygonCenter(polygons) {
|
||||
let lat = 0;
|
||||
let lon = 0;
|
||||
let sinLon = 0;
|
||||
let cosLon = 0;
|
||||
let count = 0;
|
||||
const toRad = Math.PI / 180;
|
||||
for (const polygon of polygons || []) {
|
||||
for (const [x, y] of polygon?.[0] || []) {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||
lon += x;
|
||||
sinLon += Math.sin(x * toRad);
|
||||
cosLon += Math.cos(x * toRad);
|
||||
lat += y;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count ? { lat: lat / count, lon: lon / count } : null;
|
||||
return count
|
||||
? { lat: lat / count, lon: Math.atan2(sinLon, cosLon) / toRad }
|
||||
: null;
|
||||
}
|
||||
|
||||
export const getTerrainHeight = defineTool({
|
||||
|
||||
@@ -227,6 +227,97 @@ test('fire perimeters are placed by their outline and sorted by size', async ()
|
||||
});
|
||||
});
|
||||
|
||||
test('fire perimeter queries include dateline interiors and exclude the opposite meridian', async () => {
|
||||
const perimeters = {
|
||||
getSnapshot: async () => [
|
||||
{
|
||||
stableId: 'dateline',
|
||||
name: 'Dateline Fire',
|
||||
acres: 100,
|
||||
polygons: [
|
||||
[
|
||||
[
|
||||
[179, -2],
|
||||
[-179, -2],
|
||||
[-179, 2],
|
||||
[179, 2],
|
||||
[179, -2],
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const catalog = catalogWith({ perimeters });
|
||||
for (const area of [
|
||||
{ bbox: [179.4, -0.1, 179.6, 0.1] },
|
||||
{ bbox: [-179.6, -0.1, -179.4, 0.1] },
|
||||
{ lat: 0, lon: 180, radius_km: 10 },
|
||||
]) {
|
||||
const result = await catalog.call('get_fire_perimeters', { area });
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.id),
|
||||
['dateline'],
|
||||
);
|
||||
}
|
||||
const elsewhere = await catalog.call('get_fire_perimeters', {
|
||||
area: { bbox: [-0.1, -0.1, 0.1, 0.1] },
|
||||
});
|
||||
assert.equal(elsewhere.data.total, 0);
|
||||
});
|
||||
|
||||
test('fire perimeter coordinates stay at the dateline for closed and multipart outlines', async () => {
|
||||
const perimeters = {
|
||||
getSnapshot: async () => [
|
||||
{
|
||||
stableId: 'closed',
|
||||
acres: 100,
|
||||
polygons: [
|
||||
[
|
||||
[
|
||||
[179, -2],
|
||||
[-179, -2],
|
||||
[-179, 2],
|
||||
[179, 2],
|
||||
[179, -2],
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
stableId: 'multipart',
|
||||
acres: 50,
|
||||
polygons: [
|
||||
[
|
||||
[
|
||||
[179, -1],
|
||||
[179.5, -1],
|
||||
[179.5, 1],
|
||||
[179, 1],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[-179.5, -1],
|
||||
[-179, -1],
|
||||
[-179, 1],
|
||||
[-179.5, 1],
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
|
||||
area: { bbox: [178, -3, -178, 3] },
|
||||
});
|
||||
assert.equal(result.data.total, 2);
|
||||
for (const row of result.data.rows) {
|
||||
assert.ok(Math.abs(row.lon) > 179, `${row.id}: ${row.lon}`);
|
||||
assert.ok(Math.abs(row.lon) <= 180);
|
||||
}
|
||||
});
|
||||
|
||||
test('terrain heights are returned in request order', async () => {
|
||||
const terrain = {
|
||||
async getHeights(points) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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) } });
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { parseTleText, tleCatalogNumber } from '../../sources/tle.js';
|
||||
import {
|
||||
noradIdFromSatnum,
|
||||
parseTleText,
|
||||
tleCatalogNumber,
|
||||
} from '../../sources/tle.js';
|
||||
|
||||
// Element sets published for 1 January 2024.
|
||||
const TLE = `ISS (ZARYA)
|
||||
@@ -85,6 +90,76 @@ SAT ALPHA5
|
||||
assert.equal(tleCatalogNumber(entries[0].line2), 100000);
|
||||
});
|
||||
|
||||
test('Alpha-5 catalog numbers decode, skipping I and O', () => {
|
||||
for (const [field, id] of [
|
||||
['A0000', 100000],
|
||||
['A0404', 100404],
|
||||
['H9999', 179999],
|
||||
['J0000', 180000],
|
||||
['N9999', 229999],
|
||||
['P0000', 230000],
|
||||
['T0449', 270449],
|
||||
['Z9999', 339999],
|
||||
]) {
|
||||
assert.equal(noradIdFromSatnum(field), id, field);
|
||||
assert.equal(tleCatalogNumber(`1 ${field}U`), id, field);
|
||||
}
|
||||
});
|
||||
|
||||
test('a digit catalog field reads as it did before', () => {
|
||||
assert.equal(noradIdFromSatnum('25544'), 25544);
|
||||
assert.equal(noradIdFromSatnum('00404'), 404);
|
||||
assert.equal(noradIdFromSatnum(' 404'), 404);
|
||||
assert.equal(tleCatalogNumber('1 25544U'), 25544);
|
||||
assert.equal(tleCatalogNumber('1 404U'), 404);
|
||||
// An OMM satrec carries the number unpadded, past five digits.
|
||||
assert.equal(noradIdFromSatnum('100000'), 100000);
|
||||
});
|
||||
|
||||
test('a malformed catalog field is no number', () => {
|
||||
for (const field of [
|
||||
'I0000',
|
||||
'O0000',
|
||||
'a0404',
|
||||
'A00',
|
||||
'2554A',
|
||||
'255 4',
|
||||
'-1234',
|
||||
'0x1F0',
|
||||
'1.5',
|
||||
'1e3',
|
||||
'',
|
||||
' ',
|
||||
]) {
|
||||
assert.ok(Number.isNaN(noradIdFromSatnum(field)), JSON.stringify(field));
|
||||
assert.equal(
|
||||
tleCatalogNumber(`1 ${field.padEnd(5)}U`),
|
||||
null,
|
||||
JSON.stringify(field),
|
||||
);
|
||||
}
|
||||
assert.ok(Number.isNaN(noradIdFromSatnum(undefined)));
|
||||
});
|
||||
|
||||
test('a satellite numbered above 99999 keeps its number and is found by it', async () => {
|
||||
// The ISS set with its catalog field in Alpha-5: A0000 is 100000.
|
||||
const text = TLE.replaceAll('25544', 'A0000');
|
||||
const [iss] = parseTleText(text);
|
||||
const { satnum } = twoline2satrec(iss.line1, iss.line2);
|
||||
assert.ok(Number.isNaN(Number(satnum))); // what the catalog builders used
|
||||
assert.equal(noradIdFromSatnum(satnum), 100000);
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { satellites: satellites(text), clock: CLOCK },
|
||||
});
|
||||
const result = await catalog.call('next_satellite_pass', {
|
||||
location: { lat: 40.7, lon: -74 },
|
||||
satellite: '100000',
|
||||
});
|
||||
assert.equal(result.data.satellite, 'ISS (ZARYA)');
|
||||
assert.equal(result.data.norad, 100000);
|
||||
});
|
||||
|
||||
test('the next pass defaults to the ISS and reports times, peak and direction', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
|
||||
Reference in new issue
Block a user