Merge remote-tracking branch 'origin/main' into codex/overpass-admission-review-fixes

# Conflicts:
#	README.md
#	SECURITY.md
#	scripts/format-scope.json
#	server/providers/overpass.js
This commit is contained in:
Said Turksever committed 2026-10-26 14:21:39 +00:00
commit 0ac32f82ba
545 files changed
+29995 -2293

No files matched your search

+27 -23
View File
@@ -98,7 +98,7 @@ test('standalone composition mounts every extracted provider exactly once withou
assert.equal(plugins.filter((p) => p.name === factory().name).length, 1);
});
test('terrain middleware chunks missing points and reconstructs repeated/reordered requests from cache', async (t) => {
test('terrain middleware bounds missing points and reconstructs repeated/reordered requests from cache', async (t) => {
isolate(t);
let calls = 0;
const sizes = [];
@@ -116,25 +116,23 @@ test('terrain middleware chunks missing points and reconstructs repeated/reorder
});
});
const request = install(terrainHeightsProxy());
const points = Array.from({ length: 257 }, (_, i) => `${i / 100},1`);
const points = Array.from({ length: 64 }, (_, i) => `${i / 100},1`);
const res = await request('/?points=' + points.join(';'));
assert.equal(res.status, 200);
// Tracks UPSTREAM_CHUNK in server/providers/terrain.js (64): 257 points
// split into four full chunks and a remainder.
assert.deepEqual(sizes, [64, 64, 64, 64, 1]);
const reordered = await request('/?points=2.56,1;0,1;2.56,1');
assert.deepEqual(sizes, [64]);
const reordered = await request('/?points=0.63,1;0,1;0.63,1');
assert.deepEqual(json(reordered), {
results: [{ ellipsoid: 102.56 }, { ellipsoid: 100 }, { ellipsoid: 102.56 }],
results: [{ ellipsoid: 100.63 }, { ellipsoid: 100 }, { ellipsoid: 100.63 }],
});
// Five chunks for the first batch; the reordered request is fully cached.
assert.equal(calls, 5);
// One bounded batch; the reordered request is fully cached.
assert.equal(calls, 1);
assert.equal((await request('/?points=invalid')).status, 400);
assert.equal(
(await request('/?points=' + Array(2001).fill('0,1').join(';'))).status,
500,
413,
);
// Rejected requests add no upstream calls: still the five from the first batch.
assert.equal(calls, 5);
// Rejected requests add no upstream calls.
assert.equal(calls, 1);
});
test('terrain middleware migrates valid legacy disk points without fabricating omitted heights', async (t) => {
@@ -160,6 +158,7 @@ test('terrain middleware migrates valid legacy disk points without fabricating o
test('traffic middleware preserves keyless mode, caching, stale budget fallback and UTC rollover', async (t) => {
isolate(t, { TOMTOM_API_KEY: '', TOMTOM_DAILY_TILE_BUDGET: '1' });
let now = Date.UTC(2026, 8, 12, 12);
const fetchedAt = String(now);
t.mock.method(Date, 'now', () => now);
let calls = 0;
t.mock.method(globalThis, 'fetch', async (raw) => {
@@ -184,10 +183,10 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback
assert.equal(json(await request('/status')).dailyCount, 1);
assert.equal(calls, 1);
now += 120001;
assert.equal(
(await request('/flow/8/1/1.pbf')).headers['x-tomtom-cache'],
'STALE-BUDGET',
);
const staleTile = await request('/flow/8/1/1.pbf');
assert.equal(staleTile.headers['x-tomtom-cache'], 'STALE-BUDGET');
// The stale copy still says when TomTom produced it.
assert.equal(staleTile.headers['x-tomtom-fetched-at'], fetchedAt);
assert.equal((await request('/flow/8/2/1.pbf')).status, 429);
assert.equal(calls, 1);
now += 86400000;
@@ -388,21 +387,26 @@ test('terrain middleware retains successful chunks around a failure and retries
});
const request = install(terrainHeightsProxy());
const points = Array.from({ length: 130 }, (_, i) => `${i / 100},1`);
const url = '/?points=' + points.join(';');
assert.equal((await request(url)).status, 502);
const urls = [0, 64, 128].map(
(start) => '/?points=' + points.slice(start, start + 64).join(';'),
);
assert.deepEqual(
requests.map((p) => p.length),
[64, 64, 2],
await Promise.all(urls.map(async (url) => (await request(url)).status)),
[200, 502, 200],
);
assert.deepEqual(
requests.map((p) => p.length).sort((a, b) => a - b),
[2, 64, 64],
);
fail = false;
const recovered = await request(url);
assert.equal(recovered.status, 200);
const recovered = await Promise.all(urls.map((url) => request(url)));
assert.ok(recovered.every((response) => response.status === 200));
assert.deepEqual(
requests[3],
Array.from({ length: 64 }, (_, i) => [(i + 64) / 100, 1]),
);
assert.deepEqual(
json(recovered).results,
recovered.flatMap((response) => json(response).results),
Array.from({ length: 130 }, (_, i) => ({ ellipsoid: i / 100 + 100 })),
);
assert.equal(requests.length, 4);
@@ -0,0 +1,94 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { createGoogleTokenSource } from '../maps/googleTokens.js';
import { googleTokenUnavailablePlugin } from '../../server/standalone/google-token-unavailable.js';
async function withServer({ beforeFallback } = {}, check) {
const middlewares = [];
const app = {
middlewares: {
use: (handler) => middlewares.push(handler),
},
};
if (beforeFallback) app.middlewares.use(beforeFallback);
googleTokenUnavailablePlugin().configureServer(app);
app.middlewares.use((_req, res) => {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unknown API route' }));
});
const server = createServer((req, res) => {
let index = 0;
const next = () => middlewares[index++]?.(req, res, next);
next();
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
const url = 'http://127.0.0.1:' + address.port;
try {
await check(url);
} finally {
server.close();
await once(server, 'close');
}
}
test('keyless GET and HEAD return uncached 204 instead of 404', async () => {
await withServer({}, async (url) => {
for (const method of ['GET', 'HEAD']) {
const response = await fetch(url + '/api/google/tiles-token?probe=1', {
method,
});
assert.equal(response.status, 204);
assert.equal(response.headers.get('cache-control'), 'no-store');
assert.equal(await response.text(), '');
}
});
});
test('the existing client treats unavailable tokens as null', async () => {
await withServer({}, async (url) => {
const source = createGoogleTokenSource({
fetchImpl: (path, options) => fetch(new URL(path, url), options),
});
assert.equal(await source.token(), null);
});
});
test('unrelated API paths and unsupported methods remain 404', async () => {
await withServer({}, async (url) => {
for (const [path, method] of [
['/api/google/tiles-token/extra', 'GET'],
['/api/not-a-token', 'GET'],
['/api/google/tiles-token', 'POST'],
]) {
const response = await fetch(new URL(path, url), { method });
assert.equal(response.status, 404);
}
});
});
test('a real token provider registered first still wins', async () => {
await withServer(
{
beforeFallback: (req, res, next) => {
if (req.url !== '/api/google/tiles-token') return next();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
accessToken: 'server-token',
expiresAt: 10000000000000,
}),
);
},
},
async (url) => {
const response = await fetch(url + '/api/google/tiles-token');
assert.equal(response.status, 200);
assert.equal((await response.json()).accessToken, 'server-token');
},
);
});
+7 -2
View File
@@ -156,13 +156,18 @@ test('self package imports cannot evade portable ownership', (t) => {
assert.throws(() => checkImportDirections(root), /Source imports rendering/);
});
test('compatibility exceptions are limited to the two existing composition entries', (t) => {
test('the compatibility exception is limited to the existing composition entry', (t) => {
const { root, write } = fixture(t);
write('src/ui.js', "import './standalone/catalog.js';");
write('src/standalone/catalog.js');
write('server/providers/local.js', "import '../standalone/key-setup.js';");
write('server/standalone/key-setup.js');
assert.doesNotThrow(() => checkImportDirections(root));
write('src/ui.js', "import './standalone/catalog.js';");
assert.throws(
() => checkImportDirections(root),
/Reusable module imports standalone/,
);
write('src/ui.js');
write('src/ui/new.js', "import '../standalone/catalog.js';");
assert.throws(
() => checkImportDirections(root),
+202
View File
@@ -583,3 +583,205 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
assert.equal(res2.statusCode, 502);
assert.equal(callCount, 1);
});
test('OpenSky Basic mode no longer sends username and password: it asks anonymously and warns once', async (t) => {
environment(t, {
OPENSKY_CLIENT_ID: undefined,
OPENSKY_CLIENT_SECRET: undefined,
OPENSKY_CREDENTIALS_FILE: undefined,
OPENSKY_AUTH_MODE: 'basic',
OPENSKY_USERNAME: 'fixture-user',
OPENSKY_PASSWORD: 'fixture-password',
});
const warnings = [];
t.mock.method(console, 'warn', (message) => warnings.push(message));
const authorizations = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
if (url.includes('/states/')) {
authorizations.push(options.headers.Authorization);
return Response.json({ time: Math.floor(Date.now() / 1000), states: [] });
}
throw Error(`Unexpected URL: ${url}`);
});
const fresh = await import(
`../../server/providers/aircraft/opensky.js?basic=${Math.random()}`
);
const flights = install(fresh.openSkyProxy());
const first = await flights('/api/flights');
assert.equal(first.statusCode, 200);
assert.equal(first.headers['x-opensky-auth'], 'anon');
assert.equal(first.headers['x-opensky-auth-reason'], 'basic_retired');
assert.deepEqual(authorizations, [undefined]);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /retired username\/password sign-in/);
assert.ok(!warnings[0].includes('fixture-password'));
});
test('OpenSky auto mode never falls back to Basic, even when OAuth is rejected', async (t) => {
environment(t, {
OPENSKY_CLIENT_ID: 'fixture-client',
OPENSKY_CLIENT_SECRET: 'fixture-secret',
OPENSKY_CREDENTIALS_FILE: undefined,
OPENSKY_AUTH_MODE: 'auto',
OPENSKY_USERNAME: 'fixture-user',
OPENSKY_PASSWORD: 'fixture-password',
});
t.mock.method(console, 'warn', () => {});
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'error', () => {});
const authorizations = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
if (url.includes('/token'))
return Response.json({ access_token: 'fixture-token', expires_in: 1800 });
if (url.includes('/states/')) {
authorizations.push(options.headers.Authorization);
return new Response('', { status: 401 });
}
if (url.includes('/lat/')) return regionalFallback();
throw Error(`Unexpected URL: ${url}`);
});
const fresh = await import(
`../../server/providers/aircraft/opensky.js?auto=${Math.random()}`
);
await install(fresh.openSkyProxy())('/api/flights', '?lat=30&lon=-97');
assert.deepEqual(authorizations, ['Bearer fixture-token']);
});
test('retired military history refuses without upstream I/O while validating identifiers', async (t) => {
t.mock.method(globalThis, 'fetch', async () => {
assert.fail('retired history must not contact the upstream');
});
const route = install(providers.trackBackfillProxies());
for (const hex of ['abc123', 'abc456']) {
const response = await route('/api/military/track', '?hex=' + hex);
assert.equal(response.statusCode, 503);
assert.equal(JSON.parse(response.body).code, 'history_unavailable');
assert.equal(response.headers['retry-after'], '1800');
assert.equal(response.headers['cache-control'], 'no-store');
}
assert.equal(
(await route('/api/military/track', '?hex=invalid')).statusCode,
400,
);
});
test('global aircraft snapshots coalesce cold and expired callers and clear rejected flights', async (t) => {
environment(t, { OPENSKY_AUTH_MODE: 'anon' });
t.mock.method(console, 'warn', () => {});
t.mock.method(console, 'error', () => {});
let now = Date.now();
t.mock.method(Date, 'now', () => now);
for (const kind of ['opensky', 'military']) {
const module =
kind === 'opensky'
? await import(
`../../server/providers/aircraft/opensky.js?coalesce=${Math.random()}`
)
: await import('../../server/providers/aircraft/adsb-lol.js');
const request = install(
kind === 'opensky' ? module.openSkyProxy() : module.adsbLolProxy(),
);
const route = kind === 'opensky' ? '/api/flights' : '/api/military';
let calls = 0;
let release;
let blocked;
let fail = false;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
await blocked;
if (fail) throw new Error('fixture connection failed');
return Response.json(
{ time: now / 1000, states: [], ac: [] },
{ headers: { 'x-rate-limit-remaining': '5000' } },
);
});
for (const phase of ['cold', 'expired', 'failed', 'recovered']) {
now += 13000;
fail = phase === 'failed';
blocked = new Promise((resolve) => {
release = resolve;
});
const before = calls;
const pending = Array.from({ length: 5 }, () => request(route));
await new Promise(setImmediate);
assert.equal(calls - before, 1, `${kind} ${phase}: one refresh starts`);
release();
const responses = await Promise.all(pending);
assert.equal(calls - before, fail && kind === 'opensky' ? 2 : 1);
assert.ok(responses.every((res) => res.statusCode === 200));
assert.ok(responses.every((res) => res.body === responses[0].body));
}
}
});
test('OpenSky 5xx retains dated last-good data, cancels error bodies and cools down before recovery', async (t) => {
environment(t, { OPENSKY_AUTH_MODE: 'anon' });
let now = Date.now();
t.mock.method(Date, 'now', () => now);
for (const status of [502, 503, 504]) {
const module = await import(
`../../server/providers/aircraft/opensky.js?server-error=${status}`
);
const request = install(module.openSkyProxy());
let fail = false;
let calls = 0;
let cancelled = false;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
if (fail)
return new Response(
new ReadableStream({
cancel() {
cancelled = true;
},
}),
{ status },
);
return Response.json(
{ time: now / 1000, states: [] },
{ headers: { 'x-rate-limit-remaining': '5000' } },
);
});
const good = await request('/api/flights');
now += 10000;
fail = true;
const stale = await request('/api/flights');
assert.equal(stale.statusCode, 200);
assert.equal(stale.body, good.body);
assert.equal(stale.headers['x-opensky-cache'], 'STALE');
assert.equal(
stale.headers['x-opensky-auth-reason'],
`upstream_http_${status}_serving_stale`,
);
assert.equal(stale.headers['x-opensky-stale-seconds'], '10');
assert.equal(cancelled, true);
const cooling = await request('/api/flights');
assert.equal(cooling.body, good.body);
assert.equal(
cooling.headers['x-opensky-auth-reason'],
`upstream_http_${status}_serving_stale`,
);
assert.equal(calls, 2);
now += 16000;
fail = false;
assert.equal(
(await request('/api/flights')).headers['x-opensky-cache'],
'MISS',
);
assert.equal(calls, 3);
}
const coldModule =
await import('../../server/providers/aircraft/opensky.js?cold-server-error');
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
return new Response('<html>failure</html>', { status: 503 });
});
const cold = install(coldModule.openSkyProxy());
for (let i = 0; i < 2; i++) {
const response = await cold('/api/flights');
assert.equal(response.statusCode, 503);
assert.match(JSON.parse(response.body).error, /OpenSky/);
}
assert.equal(calls, 1);
});
+18
View File
@@ -81,6 +81,24 @@ test('nearby labels rank landmarks, deduplicate names/addresses and bound the pr
assert.deepEqual(projectNearbyPlaces({}, 0, 0), []);
});
test('nearby place distances take the short way round 180°', () => {
const at = (name, longitude) => ({
id: name,
displayName: { text: name },
location: { latitude: -16.8, longitude },
types: ['point_of_interest'],
});
// From Taveuni at 179.98: 2 km east across 180°, 4 km west on this side.
const result = projectNearbyPlaces(
{ places: [at('West', 179.942), at('East', -179.999)] },
-16.8,
179.98,
);
const byName = Object.fromEntries(result.map((p) => [p.name, p.distanceM]));
assert.ok(byName.East > 1500 && byName.East < 2500, String(byName.East));
assert.ok(byName.West > 3500 && byName.West < 4500, String(byName.West));
});
test('text search preserves bounds, rejects malformed bounds and tolerates absent locations', () => {
const viewport = {
low: { latitude: 1, longitude: 2 },
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import sharp from 'sharp';
import {
CUBE_FACES,
FACE_FILE_SUFFIX,
PLANETS,
cubeFaceDirection,
icrfToGalactic,
} from '../../scripts/build-milky-way.mjs';
import { raDecToUnitVector } from '../sky/stars.js';
const SKY = new URL('../data/local_data/sky/', import.meta.url);
// Allowed excess of the painted disc over the surrounding sky, in 8-bit
// levels of the encoded face: the blur and dither move values by a few.
const TOLERANCE = 4;
test('painted-out planets leave no disc brighter than the sky around them', async () => {
const centres = PLANETS.map((planet) => ({
planet,
vector: raDecToUnitVector(planet.l, planet.b),
disc: [],
ring: [],
}));
for (const face of CUBE_FACES) {
const { data, info } = await sharp(
new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY).pathname,
)
.raw()
.toBuffer({ resolveWithObject: true });
const size = info.width;
for (let j = 0; j < size; j++) {
for (let i = 0; i < size; i++) {
const d = cubeFaceDirection(face, (i + 0.5) / size, (j + 0.5) / size);
const n = Math.hypot(...d);
const g = icrfToGalactic(d.map((x) => x / n));
for (const centre of centres) {
const cos =
g[0] * centre.vector[0] +
g[1] * centre.vector[1] +
g[2] * centre.vector[2];
const degrees = (Math.acos(Math.min(1, cos)) * 180) / Math.PI;
const k = (j * size + i) * info.channels;
const luma = (data[k] + data[k + 1] + data[k + 2]) / 3;
if (degrees <= centre.planet.radiusDeg - 0.5) centre.disc.push(luma);
else if (
degrees >= centre.planet.radiusDeg + 1 &&
degrees <= centre.planet.radiusDeg + 3.5
)
centre.ring.push(luma);
}
}
}
}
const median = (values) => values.sort((a, b) => a - b)[values.length >> 1];
for (const { planet, disc, ring } of centres) {
const discMean = disc.reduce((s, v) => s + v, 0) / disc.length;
const ringMedian = median(ring);
assert.ok(
discMean <= ringMedian + TOLERANCE,
`l=${planet.l} b=${planet.b}: disc mean ${discMean.toFixed(1)} vs surrounding sky ${ringMedian.toFixed(1)}`,
);
}
});
+132
View File
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildStarPack,
parseBscRecord,
} from '../../scripts/build-bright-stars.mjs';
import {
CUBE_FACES,
cubeFaceDirection,
directionToPlateCarree,
icrfToGalactic,
smoothstep,
softBlack,
} from '../../scripts/build-milky-way.mjs';
import { raDecToUnitVector } from '../sky/stars.js';
// Verbatim CDS V/50 records: HR 1, Sirius (HR 2491) and a nova with no J2000
// position. Columns matter, so the padding is part of the fixture.
const HR_1 =
' 1 BD+44 4550 3 36042 46 000001.1+444022000509.9+451345114.44-16.88 6.70 +0.07 +0.08 A1Vn -0.012-0.018 -018 195 4.2 21.6AC 3';
const SIRIUS =
'2491 9Alp CMaBD-16 1591 48915151881 257I 5423 064044.6-163444064508.9-164258227.22-08.88-1.46 0.00 -0.05 -0.03 A1Vm -0.553-1.205 +.375-008SBO 13 10.3 11.2AB 4*';
const NOVA =
' 92 NOVA 1572 B Cas *';
const close = (actual, expected, epsilon = 1e-9) =>
assert.ok(
Math.abs(actual - expected) <= epsilon,
`${actual} is not within ${epsilon} of ${expected}`,
);
test('BSC5 records parse J2000 position, V magnitude and B−V', () => {
const sirius = parseBscRecord(SIRIUS);
assert.equal(sirius.hr, 2491);
close(sirius.raDeg, (6 + 45 / 60 + 8.9 / 3600) * 15);
close(sirius.decDeg, -(16 + 42 / 60 + 58 / 3600));
assert.equal(sirius.vmag, -1.46);
assert.equal(sirius.bv, 0);
const first = parseBscRecord(HR_1);
close(first.raDeg, (5 / 60 + 9.9 / 3600) * 15);
close(first.decDeg, 45 + 13 / 60 + 45 / 3600);
assert.equal(first.vmag, 6.7);
assert.equal(first.bv, 0.07);
assert.equal(parseBscRecord(NOVA), null);
});
test('the star pack is a flat, rounded column array that counts its drops', () => {
const pack = buildStarPack([HR_1, NOVA, SIRIUS, ''].join('\n'), {
fetched: '2026-10-19',
});
assert.deepEqual(
pack.stars,
[1.291, 45.229, 6.7, 0.07, 101.287, -16.716, -1.46, 0],
);
assert.equal(pack.meta.count, 2);
assert.equal(pack.meta.dropped, 1);
assert.equal(pack.meta.fetched, '2026-10-19');
});
test('cube faces use the WebGL major-axis layout', () => {
// Each face centre looks down its own axis …
const axes = {
positiveX: [1, 0, 0],
negativeX: [-1, 0, 0],
positiveY: [0, 1, 0],
negativeY: [0, -1, 0],
positiveZ: [0, 0, 1],
negativeZ: [0, 0, -1],
};
for (const face of CUBE_FACES)
assert.deepEqual(
cubeFaceDirection(face, 0.5, 0.5).map((v) => v + 0),
axes[face],
);
// … and the first texel row and column follow OpenGL ES 3.0 §3.8.10:
// on +X, s grows towards −Z and t towards −Y.
assert.deepEqual(cubeFaceDirection('positiveX', 0, 0), [1, 1, 1]);
assert.deepEqual(cubeFaceDirection('positiveZ', 1, 1), [1, -1, 1]);
assert.deepEqual(cubeFaceDirection('negativeY', 0, 1), [-1, -1, -1]);
assert.throws(() => cubeFaceDirection('up', 0, 0), RangeError);
});
test('plate carrée is centred on longitude 0, growing to the left, north up', () => {
assert.deepEqual(directionToPlateCarree(1, 0, 0), [0.5, 0.5]);
const sixHours = directionToPlateCarree(0, 1, 0);
close(sixHours[0], 0.25);
const eighteenHours = directionToPlateCarree(0, -1, 0);
close(eighteenHours[0], 0.75);
close(directionToPlateCarree(0, 0, 1)[1], 0);
close(directionToPlateCarree(0, 0, -1)[1], 1);
const twelveHours = directionToPlateCarree(-1, 0, 0);
assert.ok(twelveHours[0] === 0 || twelveHours[0] === 1);
});
test('the galactic rotation puts the Galactic Centre and pole where the IAU does', () => {
// Galactic origin l = b = 0 at RA 266.405°, Dec −28.936° (J2000).
const centre = icrfToGalactic(raDecToUnitVector(266.40499, -28.93617));
close(centre[0], 1, 1e-6);
// North galactic pole at RA 192.85948°, Dec +27.12825°.
const pole = icrfToGalactic(raDecToUnitVector(192.85948, 27.12825));
close(pole[2], 1, 1e-6);
// ESO's panorama: the Large Magellanic Cloud (l ≈ 280.5°, b ≈ −32.9°) sits
// right of and below the centre.
const [u, v] = directionToPlateCarree(
...icrfToGalactic(raDecToUnitVector(80.894, -69.756)),
);
close(u, 0.5 + (360 - 280.47) / 360, 2e-3);
close(v, 0.5 + 32.89 / 180, 2e-3);
});
test('the black level eases in instead of cutting the faint sky into contours', () => {
assert.equal(softBlack(-0.2), 0);
assert.equal(softBlack(0), 0);
// Continuous, rising, with no step at the black level.
let previous = 0;
for (let x = 0.001; x <= 1; x += 0.001) {
const y = softBlack(x);
assert.ok(y > previous && y - previous < 0.0011, `step at ${x.toFixed(3)}`);
previous = y;
}
// Starts flat, then joins the straight line just below it.
assert.ok(softBlack(0.003) < 0.0003);
close(softBlack(1), 1 - 0.03, 1e-3);
});
test('the faint-glow blend weight eases between its edges', () => {
assert.equal(smoothstep(0.1, 0.2, 0), 0);
assert.equal(smoothstep(0.1, 0.2, 0.1), 0);
close(smoothstep(0.1, 0.2, 0.15), 0.5, 1e-9);
assert.equal(smoothstep(0.1, 0.2, 0.2), 1);
assert.equal(smoothstep(0.1, 0.2, 9), 1);
});
+205 -3
View File
@@ -12,6 +12,7 @@ import {
launchLibraryRecentUrl,
} from 'gods-eye-view/sources/space';
import * as compatibility from '../../server/providers/local.js';
import { createLaunchSource } from 'gods-eye-view/layers/launches/source';
function install(plugin, preview = false) {
const routes = new Map();
@@ -22,17 +23,23 @@ function install(plugin, preview = false) {
},
},
});
return async (route, url = '/', method = 'GET') => {
return async (route, url = '/', method = 'GET', headers = {}) => {
const res = {
headersSent: false,
headers: {},
writeHead(status, headers) {
Object.assign(this, { status, headers, headersSent: true });
},
// The same-site gates answer 403 through statusCode + setHeader.
setHeader(name, value) {
this.headers[name] = value;
},
end(body) {
this.body = body;
this.status ??= this.statusCode;
},
};
await routes.get(route)({ url, method }, res);
await routes.get(route)({ url, method, headers, socket: {} }, res);
return res;
};
}
@@ -161,7 +168,11 @@ test('CelesTrak answers 502 to an oversized TLE body without caching it', async
assert.ok(bodies[0].pulled <= 17, 'reading stops at the 16 MiB cap');
assert.equal(fsp.writeFile.mock.callCount(), 0);
assert.equal((await request('/api/celestrak', '/stations')).status, 502);
assert.equal(bodies.length, 2, 'nothing was cached in memory');
assert.equal(
bodies.length,
1,
'failed bodies are not cached but a cooldown bounds retry work',
);
});
for (const preview of [false, true])
@@ -190,3 +201,194 @@ for (const preview of [false, true])
assert.equal(hit.body, first.body);
assert.equal(calls, 1);
});
test('space providers honor Retry-After, gate groups, preserve stale dates and recover', async (t) => {
isolateDisk(t);
t.mock.method(console, 'warn', () => {});
let now = Date.now();
t.mock.method(Date, 'now', () => now);
for (const kind of ['tle', 'launches']) {
for (const retryAfter of ['90', new Date(now + 90000).toUTCString()]) {
let calls = 0;
let fail = false;
let cancelled = false;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
if (fail)
return new Response(
new ReadableStream({
cancel() {
cancelled = true;
},
}),
{
status: kind === 'tle' ? 403 : 429,
headers: { 'Retry-After': retryAfter },
},
);
return new Response(
kind === 'tle'
? 'ISS\n1 25544U fixture\n2 25544 fixture\n'
: '{"results":[]}',
);
});
const request = install(
kind === 'tle' ? celestrakProxy() : rocketLaunchesProxy(),
);
const route = kind === 'tle' ? '/api/celestrak' : '/api/launches';
const url = kind === 'tle' ? '/stations' : '/';
if (kind === 'tle') {
assert.equal(
(await request(route, '/not-a-supported-group')).status,
400,
);
assert.equal(calls, 0);
}
const first = await request(route, url);
now += 7 * 3600000;
// Recompute the HTTP-date relative to the clock at the failed refresh.
const originalFetch = globalThis.fetch;
t.mock.method(globalThis, 'fetch', async (...args) => {
const response = await originalFetch(...args);
if (fail && !/^\d+$/.test(retryAfter))
response.headers.set(
'Retry-After',
new Date(now + 90000).toUTCString(),
);
return response;
});
fail = true;
const stale = await request(route, url);
assert.equal(stale.status, 200);
assert.equal(stale.body, first.body);
assert.equal(stale.headers['Retry-After'], '90');
assert.ok(
stale.headers[kind === 'tle' ? 'X-TLE-Cached-At' : 'X-GEV-Cached-At'],
);
assert.equal(cancelled, true);
await request(route, url);
if (kind === 'tle')
assert.equal((await request(route, '/visual')).status, 502);
assert.equal(calls, 2, 'cooldown applies across TLE groups');
now += 91000;
fail = false;
assert.equal((await request(route, url)).status, 200);
assert.equal(calls, 3);
}
}
});
test('Space Missions reads the active catalog through the real proxy while unknown groups stay rejected', async (t) => {
isolateDisk(t);
const tle = 'ISS\n1 25544U fixture\n2 25544 fixture\n';
const fetched = [];
t.mock.method(globalThis, 'fetch', async (url) => {
fetched.push(new URL(url).searchParams.get('GROUP'));
return new Response(tle);
});
const request = install(celestrakProxy());
// Route the browser source's real request into the middleware.
const source = createLaunchSource({
fetchImpl: async (url) => {
const prefix = '/api/celestrak';
assert.ok(url.startsWith(`${prefix}/`));
const res = await request(prefix, url.slice(prefix.length));
return new Response(res.body, { status: res.status });
},
});
assert.equal(await source.getActiveTle(), tle);
assert.deepEqual(fetched, ['active']);
for (const group of ['/not-a-group', '/active-extra', '/../active'])
assert.equal((await request('/api/celestrak', group)).status, 400);
assert.deepEqual(fetched, ['active'], 'rejected groups never reach upstream');
});
test('CelesTrak admits a same-origin request behind the documented reverse proxy and refuses foreign origins', async (t) => {
isolateDisk(t);
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
return new Response('ISS\n1 25544U fixture\n2 25544 fixture\n');
});
const request = install(celestrakProxy());
const proxied = {
host: 'gev.example.test',
'x-forwarded-for': '203.0.113.7',
'x-forwarded-host': 'gev.example.test',
'x-forwarded-proto': 'https',
via: '1.1 proxy',
};
const app = await request('/api/celestrak', '/stations', 'GET', {
...proxied,
origin: 'https://gev.example.test',
'sec-fetch-site': 'same-origin',
});
assert.equal(app.status, 200);
assert.equal(app.headers['x-tle-cache'], 'MISS');
assert.equal(calls, 1);
for (const headers of [
{ ...proxied, origin: 'https://unrelated.example' },
{ ...proxied, origin: 'null' },
{ ...proxied, 'sec-fetch-site': 'cross-site' },
]) {
const refused = await request('/api/celestrak', '/visual', 'GET', headers);
assert.equal(refused.status, 403);
}
assert.equal(calls, 1, 'refused requests do no provider or cache work');
});
for (const longFirst of [true, false])
test(`CelesTrak keeps the longest cooldown when concurrent failures finish ${longFirst ? 'long then short' : 'short then long'}`, async (t) => {
isolateDisk(t);
t.mock.method(console, 'warn', () => {});
let now = Date.now();
t.mock.method(Date, 'now', () => now);
const pending = new Map();
let calls = 0;
t.mock.method(globalThis, 'fetch', async (url) => {
calls++;
const group = new URL(url).searchParams.get('GROUP');
await new Promise((resolve) => pending.set(group, resolve));
return group === 'stations'
? new Response('slow down', {
status: 429,
headers: { 'Retry-After': '1800' },
})
: new Response('unavailable', { status: 503 });
});
const request = install(celestrakProxy());
const long = request('/api/celestrak', '/stations');
const short = request('/api/celestrak', '/visual');
while (pending.size < 2) await new Promise(setImmediate);
const order = longFirst
? [
['stations', long],
['visual', short],
]
: [
['visual', short],
['stations', long],
];
for (const [group, response] of order) {
pending.get(group)();
assert.equal((await response).status, 502);
}
assert.equal(calls, 2);
const cooling = await request('/api/celestrak', '/geo');
assert.equal(cooling.status, 502);
assert.equal(cooling.headers['Retry-After'], '1800');
now += 61_000;
assert.equal((await request('/api/celestrak', '/geo')).status, 502);
assert.equal(
calls,
2,
'the 60 s fallback did not replace the 30 min deadline',
);
now += 1800_000;
t.mock.method(
globalThis,
'fetch',
async () => new Response('ISS\n1 25544U fixture\n2 25544 fixture\n'),
);
assert.equal((await request('/api/celestrak', '/geo')).status, 200);
});
+170
View File
@@ -0,0 +1,170 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
trafficFixtureResponse,
trafficFixtureTile,
} from '../../scripts/traffic-fixtures.mjs';
import { decodeOpenFreeMapTile } from '../sources/openFreeMap.js';
import { isDrivableFlowSegment } from '../layers/traffic/roadModes.js';
import { decodeFlowTile } from '../layers/traffic/flowDecode.js';
test('traffic fixtures provide aligned current road tiles and partial flow coverage', () => {
for (const z of [12, 14]) {
const roads = decodeOpenFreeMapTile(
trafficFixtureTile(z),
z,
935,
1686,
).roads;
const flow = decodeFlowTile(trafficFixtureTile(z, true), z, 935, 1686);
assert.ok(roads.length > flow.length && flow.length > 0);
for (const segment of flow) {
assert.equal(segment.trafficLevel, 0.6);
assert.equal(isDrivableFlowSegment(segment), true);
assert.ok(
roads.some(
(road) =>
JSON.stringify(road.coordinates) === JSON.stringify(segment.coords),
),
);
}
}
});
test('fixture TileJSON and every road/flow request stay on intercepted sources', () => {
const request = (url) => trafficFixtureResponse({ url: () => url });
const tileJson = request('https://tiles.openfreemap.org/planet');
assert.equal(tileJson.headers['access-control-allow-origin'], '*');
const metadata = JSON.parse(tileJson.body);
const tileUrl = metadata.tiles[0]
.replace('{z}', '14')
.replace('{x}', '935')
.replace('{y}', '1686');
assert.ok(
decodeOpenFreeMapTile(request(tileUrl).body, 14, 935, 1686).roads.length,
);
assert.ok(
decodeFlowTile(
request('http://localhost/api/tomtom/flow/12/935/1686.pbf').body,
12,
935,
1686,
).length,
);
assert.throws(
() => request('https://tiles.openfreemap.org/unexpected'),
/Unrecognized/,
);
assert.equal(request('http://localhost/api/unrelated'), null);
});
test('flat terrain fixture decodes through Cesium and advertises no network children', async () => {
const { createServer } = await import('node:http');
const { CesiumTerrainProvider } = await import('cesium');
const server = createServer((req, res) => {
const response = trafficFixtureResponse({
url: () => `https://terrain.reearth.land${req.url}`,
});
res.writeHead(response.status, { 'content-type': response.contentType });
res.end(response.body);
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
const provider = await CesiumTerrainProvider.fromUrl(
`http://127.0.0.1:${server.address().port}`,
);
for (const x of [0, 1]) {
const data = await provider.requestTileGeometry(x, 0, 0);
const rect = provider.tilingScheme.tileXYToRectangle(x, 0, 0);
assert.equal(
data.interpolateHeight(rect, (rect.west + rect.east) / 2, 0),
0,
);
for (const dx of [0, 1])
for (const dy of [0, 1]) {
assert.equal(data.isChildAvailable(x, 0, x * 2 + dx, dy), false);
}
}
} finally {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
}
});
test('fixture interception targets provider URLs and leaves app workers alone', async () => {
const { installTrafficRoutes } =
await import('../../scripts/traffic-fixtures.mjs');
const sent = [];
let paused;
const session = {
on: (_, handler) => {
paused = handler;
},
send: async (name, args) => {
sent.push({ name, args });
},
};
await installTrafficRoutes(
{ createCDPSession: async () => session, isClosed: () => false },
{ origin: 'http://localhost:4173', fixtures: true },
);
const patterns = sent[0].args.patterns.map((p) => p.urlPattern);
assert.deepEqual(patterns, [
'http://localhost:4173/api/tomtom/status*',
'http://localhost:4173/api/tomtom/flow/*',
'https://tiles.openfreemap.org/*',
'https://terrain.reearth.land/*',
]);
await paused({
requestId: 'terrain',
request: { url: 'https://terrain.reearth.land/layer.json' },
});
const reply = sent.at(-1);
assert.equal(reply.name, 'Fetch.fulfillRequest');
assert.equal(
JSON.parse(Buffer.from(reply.args.body, 'base64')).format,
'heightmap-1.0',
);
assert.ok(
reply.args.responseHeaders.some(
(h) => h.name === 'access-control-allow-origin' && h.value === '*',
),
);
});
test('keyless interception refuses and records unexpected flow requests', async () => {
const { installTrafficRoutes } =
await import('../../scripts/traffic-fixtures.mjs');
const sent = [],
flows = [];
let paused;
const session = {
on: (_, handler) => {
paused = handler;
},
send: async (name, args) => {
sent.push({ name, args });
},
};
await installTrafficRoutes(
{ createCDPSession: async () => session, isClosed: () => false },
{
origin: 'http://localhost:4173',
keyless: true,
onFlow: (url) => flows.push(url),
},
);
await paused({
requestId: 'status',
request: { url: 'http://localhost:4173/api/tomtom/status' },
});
assert.deepEqual(JSON.parse(Buffer.from(sent.at(-1).args.body, 'base64')), {
hasKey: false,
});
await paused({
requestId: 'flow',
request: { url: 'http://localhost:4173/api/tomtom/flow/12/1/1.pbf' },
});
assert.equal(sent.at(-1).args.responseCode, 503);
assert.equal(flows.length, 1);
});
+13
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { getTransitFeed } from '../data/transitFeeds.js';
import {
chooseLiveCities,
selectStyle,
@@ -101,6 +102,18 @@ test('live visual cities follow local operating hours in summer and winter', ()
const cities = chooseLiveCities(now);
assert.ok(cities.length >= 1);
for (const city of cities) {
const feed = getTransitFeed(city.feedId);
assert.ok(feed, `${city.id} has a registered feed`);
const distanceKm =
111.32 *
Math.hypot(
feed.center.lat - city.lat,
(feed.center.lon - city.lon) * Math.cos((city.lat * Math.PI) / 180),
);
assert.ok(
distanceKm < feed.loadRadiusKm,
`${city.id} is inside its feed's admission region`,
);
const local = Number(
new Intl.DateTimeFormat('en', {
timeZone: city.zone,
+164 -9
View File
@@ -1,9 +1,20 @@
import { fileURLToPath } from 'node:url';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { BROWSER_CSP, createBrowserViteConfig } from '../../build/vite.js';
import { rm, writeFile } from 'node:fs/promises';
import { request } from 'node:http';
import path from 'node:path';
import { build, createServer, preview } from 'vite';
import {
BROWSER_CSP,
createBrowserViteConfig,
escapeFsDenyPath,
} from '../../build/vite.js';
import standaloneConfig, * as compatibility from '../../vite.config.js';
import * as providers from '../../server/providers/local.js';
import { apiNotFoundPlugin } from '../../server/standalone/api-not-found.js';
import { makeFixtureRoot } from './fixtureRoot.mjs';
test('explicit build inputs preserve browser-only defines, plugin order and loopback protections', () => {
const plugin = { name: 'fixture-provider' };
@@ -12,15 +23,32 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
googleApiKey: 'browser-fixture',
cesiumToken: 'ion-fixture',
});
// The Host check runs before every other plugin's middleware.
assert.equal(config.plugins[0].name, 'host-check');
// API response headers, then the Host check, run before every other
// plugin's middleware.
assert.equal(config.plugins[0].name, 'api-response-headers');
assert.equal(config.plugins[0].enforce, 'pre');
assert.equal(config.plugins[3], plugin);
assert.equal(config.plugins[1].name, 'host-check');
assert.equal(config.plugins[1].enforce, 'pre');
assert.equal(config.plugins[4], plugin);
assert.equal(config.server.host, 'localhost');
assert.equal(config.server.port, 4173);
assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']);
assert.ok(config.server.fs.deny.includes('**/ENVIRONMENT'));
assert.ok(config.server.fs.deny.includes('.env.*'));
assert.ok(config.server.fs.deny.includes('**/.gev-logs/**'));
assert.ok(config.server.fs.deny.includes('**/.gev-cache/**'));
const root = fileURLToPath(new URL('../../', import.meta.url))
.replace(/\\/g, '/')
.replace(/\/$/, '');
assert.deepEqual(
config.server.fs.deny.filter((pattern) => pattern.endsWith('/dist/**')),
[`${escapeFsDenyPath(root)}/dist/**`],
'only project build output is denied, not dependency dist directories',
);
assert.equal(
escapeFsDenyPath('C:/Users/a/project (1) [test]{copy}'),
'C:/Users/a/project \\(1\\) \\[test\\]\\{copy\\}',
);
assert.equal(config.server.headers['X-Frame-Options'], 'DENY');
assert.equal(config.server.headers['X-Content-Type-Options'], 'nosniff');
const csp = config.server.headers['Content-Security-Policy'];
@@ -75,7 +103,7 @@ test('build helper does not discover environment values or construct local provi
undefined,
);
assert.deepEqual(
config.plugins.slice(3).map((plugin) => plugin.name),
config.plugins.slice(4).map((plugin) => plugin.name),
['embed-framing', 'panel-build'],
);
} finally {
@@ -89,7 +117,7 @@ test('root config retains existing named exports and standalone provider order',
assert.equal(compatibility[name], value, name);
const config = standaloneConfig({ mode: 'test' });
const standaloneProviders = config.plugins
.slice(3, -5)
.slice(4, -6)
.map((plugin) => plugin.name);
assert.equal(
standaloneProviders.filter((name) => name === 'gemini-live-proxy').length,
@@ -103,9 +131,10 @@ test('root config retains existing named exports and standalone provider order',
standaloneProviders.filter((name) => name !== 'gemini-live-proxy'),
providers.localProviderPlugins().map((plugin) => plugin.name),
);
assert.equal(config.plugins.at(-6).name, 'gev-key-setup');
// The local MCP route follows every provider and precedes the API fallback.
assert.equal(config.plugins.at(-5).name, 'local-mcp');
assert.equal(config.plugins.at(-7).name, 'gev-key-setup');
// Token fallback follows real providers and local MCP before the API 404.
assert.equal(config.plugins.at(-6).name, 'local-mcp');
assert.equal(config.plugins.at(-5).name, 'google-token-unavailable');
assert.equal(config.plugins.at(-4).name, 'api-not-found');
// Config-only, so it sits after the provider slice rather than in it.
assert.equal(config.plugins.at(-3).name, 'gev-unwatched-runtime-dirs');
@@ -257,3 +286,129 @@ test('the CSP admits the event media embeds and the Maps JavaScript API, and no
// X posts load in X's own frames.
assert.ok(frames.includes('https://platform.twitter.com'));
});
/** A raw GET, so the Host header and an absolute-form target are sent as given. */
function rawGet(port, target, headers = {}) {
return new Promise((resolve, reject) => {
const req = request(
{
host: '127.0.0.1',
port,
path: target,
headers: { host: `localhost:${port}`, ...headers },
},
(res) => {
res.resume();
res.on('end', () =>
resolve({ status: res.statusCode, headers: res.headers }),
);
},
);
req.on('error', reject);
req.end();
});
}
test('every API response carries the API policy and nosniff on real dev and preview servers', async (t) => {
const policy = "default-src 'none'; sandbox";
const root = await makeFixtureRoot('gev-api-headers-');
t.after(() => rm(root, { recursive: true, force: true }));
await writeFile(
path.join(root, 'index.html'),
'<!doctype html><title>Fixture</title><p>x</p>',
);
const install = (server) => {
server.middlewares.use('/api/fixture', (req, res) => {
const route = new URL(req.url, 'http://localhost').pathname;
if (route === '/ok') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{}');
} else if (route === '/error') {
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json');
res.end('{"error":"fixture"}');
} else if (route === '/partial') {
res.writeHead(206, { 'Content-Range': 'bytes 0-0/2' });
res.end('x');
} else if (route === '/own-policy') {
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
res.end('{}');
} else {
res.writeHead(200, { 'Content-Security-Policy': 'default-src *' });
res.end('{}');
}
});
};
const fixture = {
name: 'fixture-provider',
configureServer: install,
configurePreviewServer: install,
};
const built = createBrowserViteConfig({ plugins: [fixture] });
const base = {
root,
configFile: false,
envFile: false,
publicDir: false,
logLevel: 'silent',
};
await build(base);
for (const isPreview of [false, true]) {
const server = await (isPreview ? preview : createServer)({
...base,
plugins: [
...built.plugins.filter(
(plugin) =>
plugin === fixture ||
['api-response-headers', 'host-check'].includes(plugin.name),
),
apiNotFoundPlugin(),
],
server: { host: '127.0.0.1', port: 0, hmr: false },
preview: { host: '127.0.0.1', port: 0 },
});
if (!isPreview) await server.listen();
const { port } = server.httpServer.address();
const mode = isPreview ? 'preview' : 'dev';
try {
for (const [target, status, csp = policy, headers] of [
['/api/fixture/ok', 200],
['/api/fixture/error', 500],
['/api/fixture/partial', 206],
['/api/nope', 404],
['/api', 404],
['/api/fixture/ok?x=1', 200],
// Spellings the router mounts as the same routes.
['/API/fixture/ok', 200],
['/Api/fixture/error', 500],
['/aPi/nope', 404],
['/API', 404],
['/api.x', 404],
[`http://localhost:${port}/api/fixture/ok`, 200],
[`http://localhost:${port}/API/nope`, 404],
// The router keeps dot segments in an absolute-form target's path.
['http://elsewhere.example/api/../x', 404],
['http://elsewhere.example/API/%2e%2e/x', 404],
['/api/fixture/ok', 403, policy, { host: 'elsewhere.example' }],
// A route's own policy is added to the API policy, never replaces it.
['/api/fixture/own-policy', 200, `${policy}, frame-ancestors 'none'`],
['/api/fixture/own-policy-head', 200, `${policy}, default-src *`],
]) {
const res = await rawGet(port, target, headers);
const label = `${mode} ${target} ${JSON.stringify(headers || {})}`;
assert.equal(res.status, status, label);
assert.equal(res.headers['content-security-policy'], csp, label);
assert.equal(res.headers['x-content-type-options'], 'nosniff', label);
}
// The app document and other paths keep the server's own headers.
for (const target of ['/', '/apiary', '/src/api/x.js'])
assert.notEqual(
(await rawGet(port, target)).headers['content-security-policy'],
policy,
`${mode} ${target}`,
);
} finally {
await server.close();
}
}
});
+13
View File
@@ -234,6 +234,19 @@ test('typed argument checks feed full-call accuracy without changing the legacy
const inMeters = gradeCore(altitude, [{ name: 'analyst_query', args: { layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 12192 }] } }]);
assert.equal(inMeters.fullCallOk, true);
assert.equal(where('set_hud', (a) => a.visible === 'off')({ name: 'set_hud', args: { visible: 'off' } }), true);
const globe = byPhrase('Keep spinning the Earth slowly to the left');
const globeArgs = { motion: 'rotate_globe', direction: 'left', speed: 'slow' };
for (const mode of [undefined, 'continuous', 'once', null]) {
assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, mode } }]).fullCallOk, mode === undefined || mode === 'continuous');
}
assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, direction: 'right' } }]).fullCallOk, false);
assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, speed: 'normal' } }]).fullCallOk, false);
const right = byPhrase('Rotate the globe to the right');
assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate_globe' } }]).fullCallOk, true);
for (const direction of ['right', 'left', null]) {
assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate_globe', direction } }]).fullCallOk, direction === 'right');
}
assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate', direction: 'right' } }]).fullCallOk, false);
});
test('gradeDialogue scores turn 2 in the context of turn 1', () => {