mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge main into fix/cyclone-movement-knots
This commit is contained in:
commit
aa9a7d41bf
30 files changed
+748
-134
No files matched your search
+5
-2
@@ -115,7 +115,8 @@ GEMINI_LIVE_MODEL=gemini-3.8-live
|
|||||||
# Client Token). Client-exposed by design, like GOOGLE_MAPS_API_KEY.
|
# Client Token). Client-exposed by design, like GOOGLE_MAPS_API_KEY.
|
||||||
MAPILLARY_CLIENT_TOKEN=
|
MAPILLARY_CLIENT_TOKEN=
|
||||||
|
|
||||||
# Optional: OpenSky auth mode (oauth | basic | auto | anon)
|
# Optional: OpenSky auth mode (oauth | auto | anon). OpenSky retired
|
||||||
|
# username/password (basic) sign-in on 2026-03-18; "basic" now loads anonymously.
|
||||||
# Default: oauth — requires client credentials below
|
# Default: oauth — requires client credentials below
|
||||||
# Set to "anon" to skip aircraft auth (rate-limited but works)
|
# Set to "anon" to skip aircraft auth (rate-limited but works)
|
||||||
OPENSKY_AUTH_MODE=oauth
|
OPENSKY_AUTH_MODE=oauth
|
||||||
@@ -128,7 +129,9 @@ OPENSKY_CLIENT_SECRET=
|
|||||||
# Optional paid Patreon tokens raise request limits; there is no free API key.
|
# Optional paid Patreon tokens raise request limits; there is no free API key.
|
||||||
LL2_API_TOKEN=
|
LL2_API_TOKEN=
|
||||||
|
|
||||||
# Optional: OpenSky credentials JSON file path (alternative to above)
|
# Optional: OpenSky credentials JSON file path, used when the two values above
|
||||||
|
# are empty: the credentials.json your OpenSky account page downloads when you
|
||||||
|
# create an API client. Use an absolute path for compatibility with launchers.
|
||||||
# OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json
|
# OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json
|
||||||
|
|
||||||
# Optional: dev server config
|
# Optional: dev server config
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
- OpenSky sign-in matches what OpenSky accepts since 2026-03-18. The server
|
||||||
|
now reads `OPENSKY_CREDENTIALS_FILE` (the `credentials.json` OpenSky's
|
||||||
|
account page downloads) on every platform, not only through the macOS
|
||||||
|
launcher, and no longer sends retired username/password Basic auth:
|
||||||
|
`OPENSKY_AUTH_MODE=basic` loads anonymously with one warning saying what to
|
||||||
|
set instead (quanticsoul4772, #1229).
|
||||||
|
- Credential-file warnings omit parser excerpts and filesystem error details;
|
||||||
|
the path example also works with the macOS launchers.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- Pause periodic network updates in hidden tabs; the overdue update on return restarts the polling schedule, so the next poll follows one interval later even when that update fails (asturksever, #1282).
|
||||||
|
- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282).
|
||||||
|
- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282).
|
||||||
|
- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282).
|
||||||
|
- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282).
|
||||||
|
- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282).
|
||||||
|
- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282).
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- Point distances wrap longitude differences across the antimeridian, in voice
|
||||||
|
proximity checks and `places_nearby` (quanticsoul4772, #1304).
|
||||||
+12
-7
@@ -9,13 +9,7 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled
|
|||||||
lookups preserve the previous analyst cohort and boundary source. Navigation
|
lookups preserve the previous analyst cohort and boundary source. Navigation
|
||||||
search keeps a successful geocode, with no search or view-based region recovery.
|
search keeps a successful geocode, with no search or view-based region recovery.
|
||||||
|
|
||||||
Overpass reserves each normalized query before its disk-cache lookup. The
|
Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh and restarts that layer's polling schedule from the return, so the next poll follows one interval later whether or not the catch-up update succeeds; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals.
|
||||||
initial disk lookup, upstream quota admission, fetching and memory publication
|
|
||||||
are coalesced under that reservation. A caller that joined a reservation
|
|
||||||
rejected by another client's quota retries admission with its own quota.
|
|
||||||
Stale fallback runs per response after the reservation is released, and disk
|
|
||||||
writes finish asynchronously. Settled and rejected reservations are removed
|
|
||||||
only while they still own their map entry.
|
|
||||||
|
|
||||||
Terrain height requests accept at most 64 points, matching the browser service's
|
Terrain height requests accept at most 64 points, matching the browser service's
|
||||||
existing batch size and one bounded upstream chunk. Larger requests return HTTP
|
existing batch size and one bounded upstream chunk. Larger requests return HTTP
|
||||||
@@ -1918,6 +1912,10 @@ Updated: September 23, 2026
|
|||||||
|
|
||||||
## Aircraft and vessel server modules
|
## Aircraft and vessel server modules
|
||||||
|
|
||||||
|
OpenSky credential-file failures emit a fixed warning once per process; neither
|
||||||
|
JSON parser excerpts nor filesystem error messages are logged. The environment
|
||||||
|
example uses an absolute path compatible with the macOS launchers.
|
||||||
|
|
||||||
Standalone aircraft routes now live in `server/providers/aircraft/`: OpenSky
|
Standalone aircraft routes now live in `server/providers/aircraft/`: OpenSky
|
||||||
state vectors and regional fallback, adsb.lol military positions, ADSBDB
|
state vectors and regional fallback, adsb.lol military positions, ADSBDB
|
||||||
enrichment and track backfill. Vessel routes and websocket/watchdog setup live
|
enrichment and track backfill. Vessel routes and websocket/watchdog setup live
|
||||||
@@ -4219,6 +4217,8 @@ animation. Polls, surface completions and trail fades request individual renders
|
|||||||
Disable clears vehicles, detection references, requests, timers and holds;
|
Disable clears vehicles, detection references, requests, timers and holds;
|
||||||
destruction also releases collections and registrations.
|
destruction also releases collections and registrations.
|
||||||
|
|
||||||
|
The `scripts/qa-layer-visibility.mjs` gate uses the development-only registration seam and real browser tab visibility to prove periodic work pauses while hidden, explicit refresh still works, resume performs one overdue refresh, and disabled layers stay stopped.
|
||||||
|
|
||||||
**Acceptance harness.** Run against an already running development server:
|
**Acceptance harness.** Run against an already running development server:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -5517,6 +5517,11 @@ easier to meet (detection is now on more often), but does not create it.
|
|||||||
short-way-round longitude span and wraps the padded edges, so a dateline-
|
short-way-round longitude span and wraps the padded edges, so a dateline-
|
||||||
crossing box stays its true width. Raw subtraction inflated a 0.41° metro box
|
crossing box stays its true width. Raw subtraction inflated a 0.41° metro box
|
||||||
to 86.7° and a 60° territory to 132°.
|
to 86.7° and a 60° territory to 132°.
|
||||||
|
- **Point distances take the short way round 180°.** The annotation
|
||||||
|
resolver's `approximateDistanceM` (voice "point out" proximity checks) and
|
||||||
|
`places_nearby` distances wrap the longitude difference. Raw subtraction put
|
||||||
|
Savusavu 38,148 km from a camera over the Lau Islands, so the proximity check
|
||||||
|
refused it, and sorted a place 2 km across 180° last.
|
||||||
- CCTV calibration persists at `godsEyeView.cctv.calibration.v2` (v2 rebuild;
|
- CCTV calibration persists at `godsEyeView.cctv.calibration.v2` (v2 rebuild;
|
||||||
the store was wiped clean, no import from the old `v1` key).
|
the store was wiped clean, no import from the old `v1` key).
|
||||||
- CCTV v3 floor QA pins are: zero samples for heading-only edits; zero transient
|
- CCTV v3 floor QA pins are: zero samples for heading-only edits; zero transient
|
||||||
|
|||||||
+15
-9
@@ -3,10 +3,11 @@
|
|||||||
God's Eye View uses explicit auth modes for `/api/flights`:
|
God's Eye View uses explicit auth modes for `/api/flights`:
|
||||||
|
|
||||||
- `OPENSKY_AUTH_MODE=oauth` (default, recommended)
|
- `OPENSKY_AUTH_MODE=oauth` (default, recommended)
|
||||||
- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback)
|
- `OPENSKY_AUTH_MODE=auto` (OAuth when configured, otherwise anonymous)
|
||||||
- `OPENSKY_AUTH_MODE=basic`
|
|
||||||
- `OPENSKY_AUTH_MODE=anon`
|
- `OPENSKY_AUTH_MODE=anon`
|
||||||
|
|
||||||
|
`OPENSKY_AUTH_MODE=basic` is retired (see below).
|
||||||
|
|
||||||
Reference: OpenSky REST API docs recommend OAuth2 Client Credentials flow.
|
Reference: OpenSky REST API docs recommend OAuth2 Client Credentials flow.
|
||||||
|
|
||||||
## Quick Start (OAuth Client JSON)
|
## Quick Start (OAuth Client JSON)
|
||||||
@@ -41,13 +42,18 @@ Launchers resolve OAuth creds in this order:
|
|||||||
2. `OPENSKY_CREDENTIALS_FILE`
|
2. `OPENSKY_CREDENTIALS_FILE`
|
||||||
3. Keychain (`opensky-network` / `client_id`, `client_secret`)
|
3. Keychain (`opensky-network` / `client_id`, `client_secret`)
|
||||||
|
|
||||||
## Basic Auth (Legacy)
|
The server itself reads 1 and 2, so `npm run dev`, `npm run preview` and
|
||||||
|
Pinokio also work with only `OPENSKY_CREDENTIALS_FILE` set in `.env`, on any
|
||||||
|
platform. It reads the file on each token refresh.
|
||||||
|
|
||||||
Basic auth is still supported for now, but OpenSky is moving away from it.
|
## Basic Auth (Retired)
|
||||||
|
|
||||||
```bash
|
OpenSky stopped accepting username/password (HTTP Basic) sign-in on
|
||||||
OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh
|
2026-03-18; its API now takes only OAuth2 client credentials. With
|
||||||
```
|
`OPENSKY_AUTH_MODE=basic` the server no longer sends `OPENSKY_USERNAME` /
|
||||||
|
`OPENSKY_PASSWORD`: it requests anonymously (reason `basic_retired`) and
|
||||||
|
logs once what to set instead. Create an API client on your OpenSky
|
||||||
|
account page and use its `credentials.json` as above.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -61,6 +67,6 @@ Useful reasons:
|
|||||||
|
|
||||||
- `oauth_invalid_or_missing`: OAuth client not found/usable
|
- `oauth_invalid_or_missing`: OAuth client not found/usable
|
||||||
- `oauth_invalid_credentials`: OAuth client rejected by OpenSky
|
- `oauth_invalid_credentials`: OAuth client rejected by OpenSky
|
||||||
- `basic_invalid_credentials`: username/password rejected
|
- `missing_oauth_creds`: auto mode found no OAuth client
|
||||||
- `missing_basic_creds`: basic mode missing user/pass
|
- `basic_retired`: basic mode requested; answered anonymously
|
||||||
- `rate_limited`: OpenSky throttling
|
- `rate_limited`: OpenSky throttling
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts/qa-form-fields.mjs",
|
"scripts/qa-form-fields.mjs",
|
||||||
"scripts/package-boundaries.json",
|
"scripts/package-boundaries.json",
|
||||||
"scripts/format.mjs",
|
"scripts/format.mjs",
|
||||||
|
"scripts/qa-layer-visibility.mjs",
|
||||||
"scripts/next-layer-state-token.mjs",
|
"scripts/next-layer-state-token.mjs",
|
||||||
"scripts/check-layer-state-tokens.mjs",
|
"scripts/check-layer-state-tokens.mjs",
|
||||||
"scripts/qa-layer-token-twochar.mjs",
|
"scripts/qa-layer-token-twochar.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Real-browser proof for manager-owned periodic work and document visibility.
|
||||||
|
* Uses the app's development-only layer registration seam; no provider keys.
|
||||||
|
* Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173
|
||||||
|
* Hiding uses a real background tab, never a patched document.hidden getter.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const urlIndex = args.indexOf('--url');
|
||||||
|
const url = new URL(
|
||||||
|
(urlIndex >= 0 ? args[urlIndex + 1] : undefined) ||
|
||||||
|
process.env.QA_BASE_URL ||
|
||||||
|
'http://localhost:4173',
|
||||||
|
);
|
||||||
|
url.searchParams.set('welcome', '0');
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
executablePath: chromeExecutablePath(),
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-background-timer-throttling',
|
||||||
|
'--disable-renderer-backgrounding',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const check = (name, actual, expected) => {
|
||||||
|
assert.equal(actual, expected, name);
|
||||||
|
console.log(`PASS ${name}: ${actual}`);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const focus = await page.createCDPSession();
|
||||||
|
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||||
|
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager,
|
||||||
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const manager = window.__godsEyeView.dataManager;
|
||||||
|
window.__qaVisibilityUpdates = 0;
|
||||||
|
window.__gevQaRegisterLayer(manager, {
|
||||||
|
id: 'qa-document-visibility',
|
||||||
|
name: 'Visibility QA',
|
||||||
|
updateInterval: 750,
|
||||||
|
init() {},
|
||||||
|
enable() {},
|
||||||
|
disable() {},
|
||||||
|
destroy() {},
|
||||||
|
async update() {
|
||||||
|
window.__qaVisibilityUpdates++;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
getStats() {
|
||||||
|
return { count: window.__qaVisibilityUpdates };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await manager.setEnabled('qa-document-visibility', true, { origin: 'qa' });
|
||||||
|
});
|
||||||
|
const count = () => page.evaluate(() => window.__qaVisibilityUpdates);
|
||||||
|
await page.waitForFunction(() => window.__qaVisibilityUpdates >= 3);
|
||||||
|
check('periodic updates run while visible', (await count()) >= 3, true);
|
||||||
|
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
|
||||||
|
const cover = await browser.newPage();
|
||||||
|
await cover.bringToFront();
|
||||||
|
await page.waitForFunction(() => document.visibilityState === 'hidden');
|
||||||
|
const hiddenCount = await count();
|
||||||
|
await pause(2200);
|
||||||
|
check('hidden tab makes no periodic updates', await count(), hiddenCount);
|
||||||
|
check(
|
||||||
|
'explicit refresh remains available while hidden',
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.refreshLayer('qa-document-visibility'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
check('explicit hidden refresh runs once', await count(), hiddenCount + 1);
|
||||||
|
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.visibilityState === 'visible' &&
|
||||||
|
window.__qaVisibilityUpdates > 0,
|
||||||
|
);
|
||||||
|
await pause(200);
|
||||||
|
check(
|
||||||
|
'resume performs one overdue refresh without a catch-up burst',
|
||||||
|
await count(),
|
||||||
|
hiddenCount + 2,
|
||||||
|
);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.setEnabled(
|
||||||
|
'qa-document-visibility',
|
||||||
|
false,
|
||||||
|
{ origin: 'qa' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const stopped = await count();
|
||||||
|
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
|
||||||
|
await cover.bringToFront();
|
||||||
|
await page.waitForFunction(() => document.visibilityState === 'hidden');
|
||||||
|
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||||
|
await pause(1100);
|
||||||
|
check(
|
||||||
|
'disabled layer does not resume on visibility change',
|
||||||
|
await count(),
|
||||||
|
stopped,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'QA layer destroys cleanly',
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__gevQaUnregisterLayer(
|
||||||
|
window.__godsEyeView.dataManager,
|
||||||
|
'qa-document-visibility',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
console.log('RESULT: 7 passed');
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ export async function reportTransitVisibility(page, label) {
|
|||||||
const layer = app.dataManager.layers.get('transit').module;
|
const layer = app.dataManager.layers.get('transit').module;
|
||||||
const state = layer._transitStateForTest();
|
const state = layer._transitStateForTest();
|
||||||
return {
|
return {
|
||||||
|
pageVisibility: document.visibilityState,
|
||||||
total: state._vehicles.size,
|
total: state._vehicles.size,
|
||||||
visible: state._visible.size,
|
visible: state._visible.size,
|
||||||
moving: state._moving.size,
|
moving: state._moving.size,
|
||||||
@@ -59,6 +60,10 @@ export async function reportTransitVisibility(page, label) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
console.log(` ${label}: ${JSON.stringify(result)}`);
|
console.log(` ${label}: ${JSON.stringify(result)}`);
|
||||||
|
if (result.pageVisibility !== 'visible')
|
||||||
|
throw new Error(
|
||||||
|
`${label}: Transit QA requires a visible page; hidden pages deliberately pause polling`,
|
||||||
|
);
|
||||||
if (result.renderLoop === false || result.renderErrors.length)
|
if (result.renderLoop === false || result.renderErrors.length)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
|
`${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
export const BOSTON = {
|
export const BOSTON = {
|
||||||
id: 'boston',
|
id: 'boston',
|
||||||
|
feedId: 'mbta',
|
||||||
label: 'Boston (MBTA)',
|
label: 'Boston (MBTA)',
|
||||||
lat: 42.3554,
|
lat: 42.3554,
|
||||||
lon: -71.0605,
|
lon: -71.0605,
|
||||||
@@ -11,6 +12,7 @@ const LIVE_CITIES = [
|
|||||||
BOSTON,
|
BOSTON,
|
||||||
{
|
{
|
||||||
id: 'austin',
|
id: 'austin',
|
||||||
|
feedId: 'capmetro-austin',
|
||||||
label: 'Austin (CapMetro)',
|
label: 'Austin (CapMetro)',
|
||||||
lat: 30.2672,
|
lat: 30.2672,
|
||||||
lon: -97.7431,
|
lon: -97.7431,
|
||||||
@@ -19,6 +21,7 @@ const LIVE_CITIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'minneapolis',
|
id: 'minneapolis',
|
||||||
|
feedId: 'metrotransit-msp',
|
||||||
label: 'Minneapolis (Metro Transit)',
|
label: 'Minneapolis (Metro Transit)',
|
||||||
lat: 44.9778,
|
lat: 44.9778,
|
||||||
lon: -93.265,
|
lon: -93.265,
|
||||||
@@ -27,6 +30,7 @@ const LIVE_CITIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'helsinki',
|
id: 'helsinki',
|
||||||
|
feedId: 'hsl-helsinki',
|
||||||
label: 'Helsinki (HSL)',
|
label: 'Helsinki (HSL)',
|
||||||
lat: 60.1699,
|
lat: 60.1699,
|
||||||
lon: 24.9384,
|
lon: 24.9384,
|
||||||
@@ -35,6 +39,7 @@ const LIVE_CITIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'oslo',
|
id: 'oslo',
|
||||||
|
feedId: 'entur-norway',
|
||||||
label: 'Oslo (Entur)',
|
label: 'Oslo (Entur)',
|
||||||
lat: 59.9139,
|
lat: 59.9139,
|
||||||
lon: 10.7522,
|
lon: 10.7522,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** Camera-jump regression against an existing server; never starts a server. */
|
/** Camera-jump regression against an existing server; never starts a server. */
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
|
||||||
import { mkdir, writeFile } from 'node:fs/promises';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
|
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
|
||||||
|
|
||||||
@@ -307,7 +307,7 @@ try {
|
|||||||
result.pixelPass = pixels.pass;
|
result.pixelPass = pixels.pass;
|
||||||
result.pixelDetails = pixels;
|
result.pixelDetails = pixels;
|
||||||
result.errors = errors;
|
result.errors = errors;
|
||||||
await page.screenshot({ path: `${shots}/recovery.png` });
|
await captureQaScreenshot(page, { path: `${shots}/recovery.png` });
|
||||||
await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2));
|
await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2));
|
||||||
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
|
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
|
||||||
const pass =
|
const pass =
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { captureQaScreenshot } from './qa-chrome.mjs';
|
||||||
import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js';
|
import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js';
|
||||||
|
|
||||||
// Not registered or active: camera discovery and live polls never own this feed.
|
// Not registered or active: camera discovery and live polls never own this feed.
|
||||||
@@ -219,7 +220,7 @@ export async function runFleetBudgets({
|
|||||||
allocations.layerFrameBytes / allocationTrace.rows.length <= 8192,
|
allocations.layerFrameBytes / allocationTrace.rows.length <= 8192,
|
||||||
`${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`,
|
`${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`,
|
||||||
);
|
);
|
||||||
await page.screenshot({
|
await captureQaScreenshot(page, {
|
||||||
path: `${shots}/${tag}-fleet-${count}.jpg`,
|
path: `${shots}/${tag}-fleet-${count}.jpg`,
|
||||||
type: 'jpeg',
|
type: 'jpeg',
|
||||||
quality: 85,
|
quality: 85,
|
||||||
@@ -392,7 +393,7 @@ export async function runBostonMatrix({
|
|||||||
JSON.stringify({ pixels, contrast }),
|
JSON.stringify({ pixels, contrast }),
|
||||||
{ unexercised: pixels.length < 6 },
|
{ unexercised: pixels.length < 6 },
|
||||||
);
|
);
|
||||||
await page.screenshot({
|
await captureQaScreenshot(page, {
|
||||||
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`,
|
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`,
|
||||||
type: 'jpeg',
|
type: 'jpeg',
|
||||||
quality: 90,
|
quality: 90,
|
||||||
@@ -407,7 +408,7 @@ export async function runBostonMatrix({
|
|||||||
target.key,
|
target.key,
|
||||||
);
|
);
|
||||||
await wait(500);
|
await wait(500);
|
||||||
await page.screenshot({
|
await captureQaScreenshot(page, {
|
||||||
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`,
|
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`,
|
||||||
type: 'jpeg',
|
type: 'jpeg',
|
||||||
quality: 90,
|
quality: 90,
|
||||||
@@ -431,7 +432,7 @@ export async function runBostonMatrix({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await wait(300);
|
await wait(300);
|
||||||
await page.screenshot({
|
await captureQaScreenshot(page, {
|
||||||
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`,
|
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`,
|
||||||
type: 'jpeg',
|
type: 'jpeg',
|
||||||
quality: 90,
|
quality: 90,
|
||||||
@@ -579,7 +580,7 @@ export async function runBostonLive({
|
|||||||
JSON.stringify(selection),
|
JSON.stringify(selection),
|
||||||
{ unexercised: !target },
|
{ unexercised: !target },
|
||||||
);
|
);
|
||||||
await page.screenshot({
|
await captureQaScreenshot(page, {
|
||||||
path: `${shots}/${tag}-boston-live-${view.name}.jpg`,
|
path: `${shots}/${tag}-boston-live-${view.name}.jpg`,
|
||||||
type: 'jpeg',
|
type: 'jpeg',
|
||||||
quality: 90,
|
quality: 90,
|
||||||
@@ -1010,7 +1011,7 @@ export async function runTrailVisibility({
|
|||||||
stable: pixels.stable[i],
|
stable: pixels.stable[i],
|
||||||
eligible: pixels.eligible[i],
|
eligible: pixels.eligible[i],
|
||||||
}));
|
}));
|
||||||
await page.screenshot({
|
await captureQaScreenshot(page, {
|
||||||
path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`,
|
path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`,
|
||||||
type: 'jpeg',
|
type: 'jpeg',
|
||||||
quality: 95,
|
quality: 95,
|
||||||
|
|||||||
+67
-20
@@ -36,10 +36,11 @@ import {
|
|||||||
*/
|
*/
|
||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
|
||||||
import { createTransitHistory } from '../server/providers/transitHistory.js';
|
import { createTransitHistory } from '../server/providers/transitHistory.js';
|
||||||
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
|
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
|
||||||
import { transitModeTier } from '../src/data/transitPresetStyle.js';
|
import { transitModeTier } from '../src/data/transitPresetStyle.js';
|
||||||
|
import { TRANSIT_POLL_MS } from '../src/layers/transit/policy.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
reduceAnchor,
|
reduceAnchor,
|
||||||
@@ -264,6 +265,7 @@ const browser = await puppeteer.launch({
|
|||||||
executablePath: chromeExecutablePath(),
|
executablePath: chromeExecutablePath(),
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
|
'--disable-backgrounding-occluded-windows',
|
||||||
'--disable-background-timer-throttling',
|
'--disable-background-timer-throttling',
|
||||||
'--disable-renderer-backgrounding',
|
'--disable-renderer-backgrounding',
|
||||||
'--window-size=1920,1080',
|
'--window-size=1920,1080',
|
||||||
@@ -271,6 +273,10 @@ const browser = await puppeteer.launch({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
// This gate measures visible-page motion. OS minimization must not silently
|
||||||
|
// switch it into the app's intentional hidden-tab polling pause.
|
||||||
|
const focus = await page.createCDPSession();
|
||||||
|
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||||
boundPageEvaluations(page);
|
boundPageEvaluations(page);
|
||||||
await page.setViewport({ width: 1920, height: 1080 });
|
await page.setViewport({ width: 1920, height: 1080 });
|
||||||
const pageErrors = [];
|
const pageErrors = [];
|
||||||
@@ -286,11 +292,13 @@ page.on('request', (request) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const shot = (name, caption) =>
|
const shot = (name, caption) =>
|
||||||
page
|
captureQaScreenshot(page, {
|
||||||
.screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 })
|
path: `${SHOTS}/${name}`,
|
||||||
.then(() => {
|
type: 'jpeg',
|
||||||
console.log(` shot ${name} — ${caption}`);
|
quality: 82,
|
||||||
});
|
}).then(() => {
|
||||||
|
console.log(` shot ${name} — ${caption}`);
|
||||||
|
});
|
||||||
|
|
||||||
const view = (city) =>
|
const view = (city) =>
|
||||||
`${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`;
|
`${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`;
|
||||||
@@ -389,22 +397,42 @@ try {
|
|||||||
orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 },
|
orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 },
|
||||||
});
|
});
|
||||||
}, city);
|
}, city);
|
||||||
await page.waitForFunction(
|
const arrived = await page
|
||||||
() =>
|
.waitForFunction(
|
||||||
(window.__godsEyeView.dataManager.layers
|
(feedId) =>
|
||||||
.get('transit')
|
[
|
||||||
.module.getStats().count || 0) > 0,
|
...window.__godsEyeView.dataManager.layers
|
||||||
{ timeout: 90_000 },
|
.get('transit')
|
||||||
);
|
.module._transitStateForTest()
|
||||||
|
._vehicles.keys(),
|
||||||
|
].some((key) => key.startsWith(`${feedId}:`)),
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
city.feedId,
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
if (!arrived) {
|
||||||
|
check(
|
||||||
|
`${city.label}: live feed arrived`,
|
||||||
|
false,
|
||||||
|
`No vehicles from ${city.feedId}; retained vehicles from other cities cannot qualify this view`,
|
||||||
|
{ unexercised: true },
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await wait(9_000);
|
await wait(9_000);
|
||||||
const fleet = await page.evaluate(() => {
|
const fleet = await page.evaluate((feedId) => {
|
||||||
const layer =
|
const layer =
|
||||||
window.__godsEyeView.dataManager.layers.get('transit').module;
|
window.__godsEyeView.dataManager.layers.get('transit').module;
|
||||||
return {
|
return {
|
||||||
count: layer._transitStateForTest()._vehicles.size,
|
count: [...layer._transitStateForTest()._vehicles.keys()].filter(
|
||||||
|
(key) => key.startsWith(`${feedId}:`),
|
||||||
|
).length,
|
||||||
stats: layer.getStats(),
|
stats: layer.getStats(),
|
||||||
};
|
};
|
||||||
});
|
}, city.feedId);
|
||||||
check(
|
check(
|
||||||
`${city.label}: vehicles are rendered`,
|
`${city.label}: vehicles are rendered`,
|
||||||
fleet.count >= 20,
|
fleet.count >= 20,
|
||||||
@@ -426,6 +454,7 @@ try {
|
|||||||
.module._transitStateForTest();
|
.module._transitStateForTest();
|
||||||
let best = null;
|
let best = null;
|
||||||
for (const entry of state._vehicles.values()) {
|
for (const entry of state._vehicles.values()) {
|
||||||
|
if (!entry.key.startsWith(`${c.feedId}:`)) continue;
|
||||||
const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon);
|
const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon);
|
||||||
if (!best || d < best.d)
|
if (!best || d < best.d)
|
||||||
best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key };
|
best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key };
|
||||||
@@ -1607,6 +1636,7 @@ try {
|
|||||||
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
|
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
|
||||||
];
|
];
|
||||||
const frames = [];
|
const frames = [];
|
||||||
|
const observedPolls = new Set();
|
||||||
const publicationsBefore = state._cardPublications || 0;
|
const publicationsBefore = state._cardPublications || 0;
|
||||||
let headSamples = 0,
|
let headSamples = 0,
|
||||||
headErrorPx = 0,
|
headErrorPx = 0,
|
||||||
@@ -1623,6 +1653,13 @@ try {
|
|||||||
const proj = scene.camera.frustum.projectionMatrix;
|
const proj = scene.camera.frustum.projectionMatrix;
|
||||||
const p = entry.marker.position;
|
const p = entry.marker.position;
|
||||||
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
|
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
|
||||||
|
if (
|
||||||
|
entry.marker.show !== false &&
|
||||||
|
rect &&
|
||||||
|
Number.isFinite(entry.pollSeq)
|
||||||
|
) {
|
||||||
|
observedPolls.add(entry.pollSeq);
|
||||||
|
}
|
||||||
frames.push({
|
frames.push({
|
||||||
t: performance.now() - t0,
|
t: performance.now() - t0,
|
||||||
mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth,
|
mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth,
|
||||||
@@ -1668,7 +1705,15 @@ try {
|
|||||||
headSamples++;
|
headSamples++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (performance.now() - t0 >= durationMs) finish();
|
const elapsed = performance.now() - t0;
|
||||||
|
// Keep at least 20 seconds of motion and require an observed poll
|
||||||
|
// transition. A fixed 20s wall-clock window can miss a 15s poll
|
||||||
|
// when an earlier refresh is still settling.
|
||||||
|
if (
|
||||||
|
(elapsed >= 20_000 && observedPolls.size >= 2) ||
|
||||||
|
elapsed >= durationMs
|
||||||
|
)
|
||||||
|
finish();
|
||||||
};
|
};
|
||||||
let remove;
|
let remove;
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
@@ -1676,6 +1721,7 @@ try {
|
|||||||
remove?.();
|
remove?.();
|
||||||
resolve({
|
resolve({
|
||||||
selected: true,
|
selected: true,
|
||||||
|
elapsedMs: performance.now() - t0,
|
||||||
frames,
|
frames,
|
||||||
textPublishes:
|
textPublishes:
|
||||||
(state._cardPublications || 0) - publicationsBefore,
|
(state._cardPublications || 0) - publicationsBefore,
|
||||||
@@ -1690,7 +1736,7 @@ try {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
target.key,
|
target.key,
|
||||||
20_000,
|
3 * TRANSIT_POLL_MS,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
check(
|
check(
|
||||||
@@ -1711,8 +1757,9 @@ try {
|
|||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'card text publication stays throttled while the anchor runs at frame rate',
|
'card text publication stays throttled while the anchor runs at frame rate',
|
||||||
anchor.textPublishes > 0 && anchor.textPublishes <= 20_000 / 250 + 3,
|
anchor.textPublishes > 0 &&
|
||||||
`${anchor.textPublishes} text publications in 20 s`,
|
anchor.textPublishes <= anchor.elapsedMs / 250 + 3,
|
||||||
|
`${anchor.textPublishes} text publications in ${(anchor.elapsedMs / 1000).toFixed(1)} s`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
check(
|
check(
|
||||||
|
|||||||
@@ -124,6 +124,19 @@ export function resolveOpenSkyAuthMode({
|
|||||||
return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT;
|
return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether OPENSKY_CREDENTIALS_FILE is set, with the same precedence as the auth mode. The server reads that file when the client id and secret are empty. */
|
||||||
|
export function resolveOpenSkyCredentialsFile({
|
||||||
|
authoritativeEnvironment = false,
|
||||||
|
environment = process.env,
|
||||||
|
rootDir = ROOT,
|
||||||
|
} = {}) {
|
||||||
|
const environmentDefines = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_CREDENTIALS_FILE');
|
||||||
|
const value = authoritativeEnvironment && environmentDefines
|
||||||
|
? String(environment.OPENSKY_CREDENTIALS_FILE || '')
|
||||||
|
: String(environment.OPENSKY_CREDENTIALS_FILE || '') || readDoctorDotenvValue('OPENSKY_CREDENTIALS_FILE', rootDir);
|
||||||
|
return isConfiguredValue(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
function hasKeychainItem(service, account) {
|
function hasKeychainItem(service, account) {
|
||||||
if (process.platform !== 'darwin') return false;
|
if (process.platform !== 'darwin') return false;
|
||||||
const result = spawnSync('security', [
|
const result = spawnSync('security', [
|
||||||
@@ -153,10 +166,11 @@ export function resolveCredential(spec, {
|
|||||||
|
|
||||||
export function buildCapabilitySummary(
|
export function buildCapabilitySummary(
|
||||||
credentials,
|
credentials,
|
||||||
{ openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {},
|
{ openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT, openSkyCredentialsFile = false } = {},
|
||||||
) {
|
) {
|
||||||
const configured = (name) => credentials[name]?.configured === true;
|
const configured = (name) => credentials[name]?.configured === true;
|
||||||
const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET');
|
const hasClientPair = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET');
|
||||||
|
const hasOAuthCredentials = hasClientPair || openSkyCredentialsFile === true;
|
||||||
const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase();
|
const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase();
|
||||||
const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode)
|
const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode)
|
||||||
? requestedOpenSkyMode
|
? requestedOpenSkyMode
|
||||||
@@ -164,11 +178,13 @@ export function buildCapabilitySummary(
|
|||||||
const flights = openSkyMode === 'anon'
|
const flights = openSkyMode === 'anon'
|
||||||
? 'OpenSky keyless anonymous access (rate-limited)'
|
? 'OpenSky keyless anonymous access (rate-limited)'
|
||||||
: openSkyMode === 'basic'
|
: openSkyMode === 'basic'
|
||||||
? 'OpenSky Basic mode selected (credential presence and validity not verified)'
|
? 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)'
|
||||||
: openSkyMode === 'auto'
|
: openSkyMode === 'auto'
|
||||||
? 'OpenSky auto mode selected (runtime credential choice and validity not verified)'
|
? 'OpenSky auto mode selected (runtime credential choice and validity not verified)'
|
||||||
: hasOAuthCredentials
|
: hasClientPair
|
||||||
? 'OpenSky OAuth credentials present (runtime mode and validity not verified)'
|
? 'OpenSky OAuth credentials present (runtime mode and validity not verified)'
|
||||||
|
: hasOAuthCredentials
|
||||||
|
? 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)'
|
||||||
: 'OpenSky keyless anonymous access (rate-limited)';
|
: 'OpenSky keyless anonymous access (rate-limited)';
|
||||||
const route = selectMapStartupRoute({
|
const route = selectMapStartupRoute({
|
||||||
googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '',
|
googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '',
|
||||||
@@ -215,6 +231,7 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment
|
|||||||
credentials,
|
credentials,
|
||||||
capabilities: buildCapabilitySummary(credentials, {
|
capabilities: buildCapabilitySummary(credentials, {
|
||||||
openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }),
|
openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }),
|
||||||
|
openSkyCredentialsFile: resolveOpenSkyCredentialsFile({ authoritativeEnvironment, rootDir }),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js';
|
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js';
|
||||||
import {
|
import {
|
||||||
coalesceProxyRequest,
|
coalesceProxyRequest,
|
||||||
@@ -137,6 +139,10 @@ function openskyAdaptiveTtlMs(remaining) {
|
|||||||
}
|
}
|
||||||
/** @type {boolean} Guards duplicate auth-failure warnings in logs. */
|
/** @type {boolean} Guards duplicate auth-failure warnings in logs. */
|
||||||
let _openskyAuthWarned = false;
|
let _openskyAuthWarned = false;
|
||||||
|
/** One warning per process when OPENSKY_CREDENTIALS_FILE cannot be read. */
|
||||||
|
let _openskyCredentialsFileWarned = false;
|
||||||
|
/** One warning per process when OPENSKY_AUTH_MODE=basic is requested. */
|
||||||
|
let _openskyBasicRetiredWarned = false;
|
||||||
/** @type {boolean} Guards duplicate invalid-auth-mode warnings. */
|
/** @type {boolean} Guards duplicate invalid-auth-mode warnings. */
|
||||||
let _openskyAuthModeWarned = false;
|
let _openskyAuthModeWarned = false;
|
||||||
/** Default auth mode when OPENSKY_AUTH_MODE env is unset. */
|
/** Default auth mode when OPENSKY_AUTH_MODE env is unset. */
|
||||||
@@ -156,6 +162,48 @@ const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|||||||
// current instead of coasting a stale worldwide frame indefinitely.
|
// current instead of coasting a stale worldwide frame indefinitely.
|
||||||
const OPENSKY_SOURCE_STALE_MS = 120_000;
|
const OPENSKY_SOURCE_STALE_MS = 120_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OpenSky API client for OAuth: OPENSKY_CLIENT_ID and
|
||||||
|
* OPENSKY_CLIENT_SECRET, or else the credentials.json that OpenSky's account
|
||||||
|
* page downloads, named by OPENSKY_CREDENTIALS_FILE ("~" is the home
|
||||||
|
* directory). The file is read on each token refresh, so a replaced file is
|
||||||
|
* picked up without a restart.
|
||||||
|
*
|
||||||
|
* @param {Record<string, string|undefined>} [env]
|
||||||
|
* @param {(path: string) => string} [readFile]
|
||||||
|
* @returns {{ clientId: string, clientSecret: string } | null}
|
||||||
|
*/
|
||||||
|
export function openSkyClientCredentials(
|
||||||
|
env = process.env,
|
||||||
|
readFile = (path) => fs.readFileSync(path, 'utf8'),
|
||||||
|
) {
|
||||||
|
const clientId = String(env.OPENSKY_CLIENT_ID || '').trim();
|
||||||
|
const clientSecret = String(env.OPENSKY_CLIENT_SECRET || '').trim();
|
||||||
|
if (clientId && clientSecret) return { clientId, clientSecret };
|
||||||
|
const file = String(env.OPENSKY_CREDENTIALS_FILE || '').trim();
|
||||||
|
if (!file) return null;
|
||||||
|
const path = file.replace(/^~(?=$|[\\/])/, os.homedir());
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(readFile(path));
|
||||||
|
} catch {
|
||||||
|
if (!_openskyCredentialsFileWarned) {
|
||||||
|
console.warn(
|
||||||
|
'[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.',
|
||||||
|
);
|
||||||
|
_openskyCredentialsFileWarned = true;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const fileId = String(raw?.clientId ?? raw?.client_id ?? '').trim();
|
||||||
|
const fileSecret = String(
|
||||||
|
raw?.clientSecret ?? raw?.client_secret ?? '',
|
||||||
|
).trim();
|
||||||
|
return fileId && fileSecret
|
||||||
|
? { clientId: fileId, clientSecret: fileSecret }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtain a valid OpenSky OAuth2 bearer token, refreshing if needed.
|
* Obtain a valid OpenSky OAuth2 bearer token, refreshing if needed.
|
||||||
*
|
*
|
||||||
@@ -174,9 +222,9 @@ export async function getOpenSkyToken() {
|
|||||||
// return the same promise instead of issuing a duplicate token request
|
// return the same promise instead of issuing a duplicate token request
|
||||||
if (_openskyTokenPromise) return _openskyTokenPromise;
|
if (_openskyTokenPromise) return _openskyTokenPromise;
|
||||||
|
|
||||||
const clientId = process.env.OPENSKY_CLIENT_ID;
|
const credentials = openSkyClientCredentials();
|
||||||
const clientSecret = process.env.OPENSKY_CLIENT_SECRET;
|
if (!credentials) return null;
|
||||||
if (!clientId || !clientSecret) return null;
|
const { clientId, clientSecret } = credentials;
|
||||||
|
|
||||||
// Wrap the async token fetch in a shared promise stored in _openskyTokenPromise
|
// Wrap the async token fetch in a shared promise stored in _openskyTokenPromise
|
||||||
_openskyTokenPromise = (async () => {
|
_openskyTokenPromise = (async () => {
|
||||||
@@ -417,8 +465,10 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
|
|||||||
*
|
*
|
||||||
* Supports four auth modes controlled by OPENSKY_AUTH_MODE env:
|
* Supports four auth modes controlled by OPENSKY_AUTH_MODE env:
|
||||||
* - 'oauth' (default) — client_credentials bearer token
|
* - 'oauth' (default) — client_credentials bearer token
|
||||||
* - 'basic' — HTTP Basic with OPENSKY_USERNAME / OPENSKY_PASSWORD
|
* - 'basic' — retired: OpenSky stopped accepting HTTP Basic (username and
|
||||||
* - 'auto' — try OAuth first, fall back to Basic, then anon
|
* password) on 2026-03-18, so this requests anonymously and
|
||||||
|
* warns once
|
||||||
|
* - 'auto' — try OAuth first, then anon
|
||||||
* - 'anon' — no credentials
|
* - 'anon' — no credentials
|
||||||
*
|
*
|
||||||
* Successful responses are cached for OPENSKY_CACHE_MS (~9 s). On
|
* Successful responses are cached for OPENSKY_CACHE_MS (~9 s). On
|
||||||
@@ -524,21 +574,21 @@ export function openSkyProxy() {
|
|||||||
_openskySnapshotInFlight,
|
_openskySnapshotInFlight,
|
||||||
'global',
|
'global',
|
||||||
async () => {
|
async () => {
|
||||||
const basicUser = process.env.OPENSKY_USERNAME || '';
|
|
||||||
const basicPass = process.env.OPENSKY_PASSWORD || '';
|
|
||||||
const hasBasicCreds = Boolean(basicUser && basicPass);
|
|
||||||
const headers = { Accept: 'application/json' };
|
const headers = { Accept: 'application/json' };
|
||||||
let usedMode = 'anon';
|
let usedMode = 'anon';
|
||||||
let reason = 'forced_anonymous';
|
let reason = 'forced_anonymous';
|
||||||
|
|
||||||
if (requestedMode === 'basic') {
|
if (requestedMode === 'basic') {
|
||||||
if (hasBasicCreds) {
|
// OpenSky no longer accepts a Basic header, so sending
|
||||||
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
|
// OPENSKY_USERNAME / OPENSKY_PASSWORD would only fail; ask
|
||||||
usedMode = 'basic';
|
// anonymously and say once what to configure instead.
|
||||||
reason = 'basic_credentials';
|
if (!_openskyBasicRetiredWarned) {
|
||||||
} else {
|
console.warn(
|
||||||
reason = 'missing_basic_creds';
|
'[OpenSky] OPENSKY_AUTH_MODE=basic: OpenSky retired username/password sign-in on 2026-03-18, so flights load anonymously (rate-limited). Use OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.',
|
||||||
|
);
|
||||||
|
_openskyBasicRetiredWarned = true;
|
||||||
}
|
}
|
||||||
|
reason = 'basic_retired';
|
||||||
} else if (requestedMode === 'oauth') {
|
} else if (requestedMode === 'oauth') {
|
||||||
const token = await getOpenSkyToken();
|
const token = await getOpenSkyToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -554,31 +604,12 @@ export function openSkyProxy() {
|
|||||||
headers.Authorization = `Bearer ${token}`;
|
headers.Authorization = `Bearer ${token}`;
|
||||||
usedMode = 'oauth';
|
usedMode = 'oauth';
|
||||||
reason = 'oauth_token';
|
reason = 'oauth_token';
|
||||||
} else if (hasBasicCreds) {
|
|
||||||
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
|
|
||||||
usedMode = 'basic';
|
|
||||||
reason = 'oauth_unavailable_fallback_basic';
|
|
||||||
} else {
|
} else {
|
||||||
reason = 'missing_oauth_and_basic_creds';
|
reason = 'missing_oauth_creds';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let { upstream, body } = await fetchOpenSkyStates(headers);
|
const { upstream, body } = await fetchOpenSkyStates(headers);
|
||||||
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
|
|
||||||
if (
|
|
||||||
(upstream.status === 401 || upstream.status === 403) &&
|
|
||||||
requestedMode === 'auto' &&
|
|
||||||
usedMode === 'oauth' &&
|
|
||||||
hasBasicCreds
|
|
||||||
) {
|
|
||||||
const retryHeaders = {
|
|
||||||
Accept: 'application/json',
|
|
||||||
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
|
|
||||||
};
|
|
||||||
({ upstream, body } = await fetchOpenSkyStates(retryHeaders));
|
|
||||||
usedMode = 'basic';
|
|
||||||
reason = 'oauth_rejected_fallback_basic';
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceEpochMs = upstream.ok
|
const sourceEpochMs = upstream.ok
|
||||||
? openSkySourceEpochMs(body)
|
? openSkySourceEpochMs(body)
|
||||||
@@ -586,12 +617,6 @@ export function openSkyProxy() {
|
|||||||
// Refine the reason string to reflect the actual outcome
|
// Refine the reason string to reflect the actual outcome
|
||||||
if (upstream.ok && reason === 'forced_anonymous') {
|
if (upstream.ok && reason === 'forced_anonymous') {
|
||||||
reason = 'anonymous_ok';
|
reason = 'anonymous_ok';
|
||||||
} else if (
|
|
||||||
upstream.ok &&
|
|
||||||
usedMode === 'basic' &&
|
|
||||||
reason === 'basic_credentials'
|
|
||||||
) {
|
|
||||||
reason = 'basic_ok';
|
|
||||||
} else if (
|
} else if (
|
||||||
upstream.ok &&
|
upstream.ok &&
|
||||||
usedMode === 'oauth' &&
|
usedMode === 'oauth' &&
|
||||||
@@ -627,12 +652,11 @@ export function openSkyProxy() {
|
|||||||
body,
|
body,
|
||||||
usedMode,
|
usedMode,
|
||||||
reason,
|
reason,
|
||||||
hasBasicCreds,
|
|
||||||
sourceEpochMs,
|
sourceEpochMs,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let { upstream, body, usedMode, reason, hasBasicCreds, sourceEpochMs } =
|
let { upstream, body, usedMode, reason, sourceEpochMs } =
|
||||||
await refresh.promise;
|
await refresh.promise;
|
||||||
if (
|
if (
|
||||||
upstream.ok &&
|
upstream.ok &&
|
||||||
@@ -684,35 +708,24 @@ export function openSkyProxy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (upstream.status === 401 || upstream.status === 403) {
|
if (upstream.status === 401 || upstream.status === 403) {
|
||||||
if (requestedMode === 'basic' && !hasBasicCreds) {
|
if (requestedMode === 'oauth' && usedMode !== 'oauth') {
|
||||||
body = JSON.stringify({
|
body = JSON.stringify({
|
||||||
error:
|
error:
|
||||||
'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.',
|
'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.',
|
||||||
});
|
|
||||||
reason = 'missing_basic_creds';
|
|
||||||
} else if (requestedMode === 'oauth' && usedMode !== 'oauth') {
|
|
||||||
body = JSON.stringify({
|
|
||||||
error:
|
|
||||||
'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.',
|
|
||||||
});
|
});
|
||||||
reason = 'oauth_invalid_or_missing';
|
reason = 'oauth_invalid_or_missing';
|
||||||
} else if (usedMode === 'basic') {
|
|
||||||
body = JSON.stringify({
|
|
||||||
error: 'OpenSky auth invalid. Username/password were rejected.',
|
|
||||||
});
|
|
||||||
reason = 'basic_invalid_credentials';
|
|
||||||
} else if (usedMode === 'oauth') {
|
} else if (usedMode === 'oauth') {
|
||||||
body = JSON.stringify({
|
body = JSON.stringify({
|
||||||
error:
|
error:
|
||||||
'OpenSky auth invalid. OAuth client credentials were rejected.',
|
'OpenSky auth invalid. OAuth client credentials were rejected.',
|
||||||
});
|
});
|
||||||
reason = 'oauth_invalid_credentials';
|
reason = 'oauth_invalid_credentials';
|
||||||
} else if (requestedMode === 'auto' && !hasBasicCreds) {
|
} else if (requestedMode === 'auto') {
|
||||||
body = JSON.stringify({
|
body = JSON.stringify({
|
||||||
error:
|
error:
|
||||||
'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.',
|
'OpenSky auth missing. Provide OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.',
|
||||||
});
|
});
|
||||||
reason = 'missing_oauth_and_basic_creds';
|
reason = 'missing_oauth_creds';
|
||||||
} else {
|
} else {
|
||||||
body = JSON.stringify({
|
body = JSON.stringify({
|
||||||
error: 'OpenSky auth required.',
|
error: 'OpenSky auth required.',
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { openSkyClientCredentials } from './opensky.js';
|
||||||
|
|
||||||
|
const file = (contents) => (filePath) => {
|
||||||
|
file.read = filePath;
|
||||||
|
if (contents instanceof Error) throw contents;
|
||||||
|
return contents;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('OpenSky client env vars come first', () => {
|
||||||
|
const read = () => assert.fail('the file is not read when env vars are set');
|
||||||
|
assert.deepEqual(
|
||||||
|
openSkyClientCredentials(
|
||||||
|
{
|
||||||
|
OPENSKY_CLIENT_ID: ' id ',
|
||||||
|
OPENSKY_CLIENT_SECRET: 'secret',
|
||||||
|
OPENSKY_CREDENTIALS_FILE: 'credentials.json',
|
||||||
|
},
|
||||||
|
read,
|
||||||
|
),
|
||||||
|
{ clientId: 'id', clientSecret: 'secret' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OPENSKY_CREDENTIALS_FILE supplies the client when env vars are empty', () => {
|
||||||
|
const env = {
|
||||||
|
OPENSKY_CLIENT_ID: '',
|
||||||
|
OPENSKY_CREDENTIALS_FILE: '~/Downloads/credentials.json',
|
||||||
|
};
|
||||||
|
// The file OpenSky's account page downloads uses camelCase keys.
|
||||||
|
assert.deepEqual(
|
||||||
|
openSkyClientCredentials(
|
||||||
|
env,
|
||||||
|
file('{"clientId":"cid","clientSecret":"csecret"}'),
|
||||||
|
),
|
||||||
|
{ clientId: 'cid', clientSecret: 'csecret' },
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
path.normalize(file.read),
|
||||||
|
path.normalize(path.join(os.homedir(), 'Downloads/credentials.json')),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
openSkyClientCredentials(
|
||||||
|
env,
|
||||||
|
file('{"client_id":"cid","client_secret":"csecret"}'),
|
||||||
|
),
|
||||||
|
{ clientId: 'cid', clientSecret: 'csecret' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing, unreadable or incomplete credentials file means no client', () => {
|
||||||
|
assert.equal(openSkyClientCredentials({}, file('{}')), null);
|
||||||
|
const env = { OPENSKY_CREDENTIALS_FILE: 'credentials.json' };
|
||||||
|
const missing = Object.assign(new Error('nope'), { code: 'ENOENT' });
|
||||||
|
assert.equal(openSkyClientCredentials(env, file(missing)), null);
|
||||||
|
assert.equal(openSkyClientCredentials(env, file('not json')), null);
|
||||||
|
assert.equal(openSkyClientCredentials(env, file('{"clientId":"cid"}')), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('credential file warnings never expose file contents or read errors', async (t) => {
|
||||||
|
const warnings = [];
|
||||||
|
t.mock.method(console, 'warn', (message) => warnings.push(message));
|
||||||
|
for (const contents of [
|
||||||
|
'{"clientId":"id","clientSecret":FAKE_SECRET_123}',
|
||||||
|
new Error('could not read FAKE_SECRET_123'),
|
||||||
|
]) {
|
||||||
|
const { openSkyClientCredentials: resolve } = await import(
|
||||||
|
`./opensky.js?warning=${warnings.length}`
|
||||||
|
);
|
||||||
|
const env = { OPENSKY_CREDENTIALS_FILE: '/synthetic/credentials.json' };
|
||||||
|
assert.equal(resolve(env, file(contents)), null);
|
||||||
|
assert.equal(resolve(env, file(contents)), null);
|
||||||
|
}
|
||||||
|
assert.deepEqual(warnings, [
|
||||||
|
'[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.',
|
||||||
|
'[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ export function createApplicationData({
|
|||||||
// Initialize data layer manager
|
// Initialize data layer manager
|
||||||
const dataManager = new LayerLifecycle(viewer, {
|
const dataManager = new LayerLifecycle(viewer, {
|
||||||
allowQaRegistration,
|
allowQaRegistration,
|
||||||
|
visibilityTarget: document,
|
||||||
getSourceAvailability: catalog?.getSourceAvailability,
|
getSourceAvailability: catalog?.getSourceAvailability,
|
||||||
});
|
});
|
||||||
defer(async () => {
|
defer(async () => {
|
||||||
|
|||||||
+60
-17
@@ -72,13 +72,20 @@ function refreshFailureFromStats(stats, label) {
|
|||||||
/**
|
/**
|
||||||
* LayerLifecycle — Manages registration, toggling, and update loops
|
* LayerLifecycle — Manages registration, toggling, and update loops
|
||||||
* for real-time data overlays on the CesiumJS globe.
|
* for real-time data overlays on the CesiumJS globe.
|
||||||
|
* Hosts may inject a visibilityTarget with hidden/visibilityState and event
|
||||||
|
* listener methods. Without one, periodic work retains its headless behavior.
|
||||||
*/
|
*/
|
||||||
export class LayerLifecycle {
|
export class LayerLifecycle {
|
||||||
constructor(
|
constructor(
|
||||||
viewer,
|
viewer,
|
||||||
{ allowQaRegistration = false, getSourceAvailability } = {},
|
{
|
||||||
|
allowQaRegistration = false,
|
||||||
|
getSourceAvailability,
|
||||||
|
visibilityTarget = null,
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
this._catalogSourceAvailability = getSourceAvailability;
|
this._catalogSourceAvailability = getSourceAvailability;
|
||||||
|
this._visibilityTarget = visibilityTarget;
|
||||||
this.viewer = viewer;
|
this.viewer = viewer;
|
||||||
this._activityListeners = new Set();
|
this._activityListeners = new Set();
|
||||||
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
|
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
|
||||||
@@ -528,6 +535,13 @@ export class LayerLifecycle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_stopUpdateLoop(entry) {
|
||||||
|
clearInterval(entry.intervalId);
|
||||||
|
entry.intervalId = null;
|
||||||
|
entry.removeVisibilityListener?.();
|
||||||
|
entry.removeVisibilityListener = null;
|
||||||
|
}
|
||||||
|
|
||||||
_armUpdateLoop(layerId, entry) {
|
_armUpdateLoop(layerId, entry) {
|
||||||
const configuredRefreshInterval = Number(entry.module.refreshInterval);
|
const configuredRefreshInterval = Number(entry.module.refreshInterval);
|
||||||
const updateInterval = Number(entry.module.updateInterval);
|
const updateInterval = Number(entry.module.updateInterval);
|
||||||
@@ -538,9 +552,45 @@ export class LayerLifecycle {
|
|||||||
? updateInterval
|
? updateInterval
|
||||||
: 0;
|
: 0;
|
||||||
if (refreshInterval > 0) {
|
if (refreshInterval > 0) {
|
||||||
entry.intervalId = setInterval(() => {
|
const visibilityDocument = this._visibilityTarget;
|
||||||
|
let intervalId = null;
|
||||||
|
let dueAt = 0;
|
||||||
|
const canPoll = () =>
|
||||||
|
!visibilityDocument?.hidden &&
|
||||||
|
visibilityDocument?.visibilityState !== 'hidden' &&
|
||||||
|
entry.enabled &&
|
||||||
|
!entry.destroying &&
|
||||||
|
!entry.refreshing &&
|
||||||
|
entry.intervalId === intervalId;
|
||||||
|
// dueAt and the interval phase share one origin, so every interval tick
|
||||||
|
// is a due poll. A visibility catch-up re-arms the interval at the new
|
||||||
|
// deadline; otherwise the old phase would skip its next tick.
|
||||||
|
const schedule = () => {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
dueAt = Date.now() + refreshInterval;
|
||||||
|
intervalId = setInterval(onInterval, refreshInterval);
|
||||||
|
entry.intervalId = intervalId;
|
||||||
|
};
|
||||||
|
const onInterval = () => {
|
||||||
|
if (!canPoll()) return;
|
||||||
|
dueAt = Date.now() + refreshInterval;
|
||||||
void this._runPeriodicUpdate(layerId, entry);
|
void this._runPeriodicUpdate(layerId, entry);
|
||||||
}, refreshInterval);
|
};
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
if (!canPoll() || Date.now() < dueAt) return;
|
||||||
|
schedule();
|
||||||
|
void this._runPeriodicUpdate(layerId, entry);
|
||||||
|
};
|
||||||
|
schedule();
|
||||||
|
visibilityDocument?.addEventListener?.(
|
||||||
|
'visibilitychange',
|
||||||
|
onVisibilityChange,
|
||||||
|
);
|
||||||
|
entry.removeVisibilityListener = () =>
|
||||||
|
visibilityDocument?.removeEventListener?.(
|
||||||
|
'visibilitychange',
|
||||||
|
onVisibilityChange,
|
||||||
|
);
|
||||||
} else if (updateInterval === 0) {
|
} else if (updateInterval === 0) {
|
||||||
entry.intervalId = setInterval(() => {
|
entry.intervalId = setInterval(() => {
|
||||||
if (!entry.enabled) return;
|
if (!entry.enabled) return;
|
||||||
@@ -772,8 +822,7 @@ export class LayerLifecycle {
|
|||||||
entry.lifecycleUncertain = !compensated && !cleanupConfirmed;
|
entry.lifecycleUncertain = !compensated && !cleanupConfirmed;
|
||||||
settleLifecycle();
|
settleLifecycle();
|
||||||
if (!entry.enabled && entry.intervalId) {
|
if (!entry.enabled && entry.intervalId) {
|
||||||
clearInterval(entry.intervalId);
|
this._stopUpdateLoop(entry);
|
||||||
entry.intervalId = null;
|
|
||||||
}
|
}
|
||||||
this._publishActivity({ type: 'status' });
|
this._publishActivity({ type: 'status' });
|
||||||
if (!compensated) {
|
if (!compensated) {
|
||||||
@@ -833,8 +882,7 @@ export class LayerLifecycle {
|
|||||||
}
|
}
|
||||||
if (signal?.aborted) return finishCancelledDisable('disable');
|
if (signal?.aborted) return finishCancelledDisable('disable');
|
||||||
if (entry.intervalId) {
|
if (entry.intervalId) {
|
||||||
clearInterval(entry.intervalId);
|
this._stopUpdateLoop(entry);
|
||||||
entry.intervalId = null;
|
|
||||||
}
|
}
|
||||||
entry.enabled = false;
|
entry.enabled = false;
|
||||||
entry.lifecycleUncertain = false;
|
entry.lifecycleUncertain = false;
|
||||||
@@ -845,8 +893,7 @@ export class LayerLifecycle {
|
|||||||
let abortCleanup = null;
|
let abortCleanup = null;
|
||||||
const cancelEnable = () => {
|
const cancelEnable = () => {
|
||||||
if (entry.intervalId) {
|
if (entry.intervalId) {
|
||||||
clearInterval(entry.intervalId);
|
this._stopUpdateLoop(entry);
|
||||||
entry.intervalId = null;
|
|
||||||
}
|
}
|
||||||
// Disable immediately so modules with their own AbortController (Radio)
|
// Disable immediately so modules with their own AbortController (Radio)
|
||||||
// cancel pending update work at the same turn boundary. A second
|
// cancel pending update work at the same turn boundary. A second
|
||||||
@@ -876,8 +923,7 @@ export class LayerLifecycle {
|
|||||||
// a later abort cannot revoke a successful retry.
|
// a later abort cannot revoke a successful retry.
|
||||||
signal?.removeEventListener('abort', cancelEnable);
|
signal?.removeEventListener('abort', cancelEnable);
|
||||||
if (entry.intervalId) {
|
if (entry.intervalId) {
|
||||||
clearInterval(entry.intervalId);
|
this._stopUpdateLoop(entry);
|
||||||
entry.intervalId = null;
|
|
||||||
}
|
}
|
||||||
await abortCleanup;
|
await abortCleanup;
|
||||||
let cleanupConfirmed = false;
|
let cleanupConfirmed = false;
|
||||||
@@ -918,8 +964,7 @@ export class LayerLifecycle {
|
|||||||
};
|
};
|
||||||
const finishFailedEnable = async (phase, error) => {
|
const finishFailedEnable = async (phase, error) => {
|
||||||
if (entry.intervalId) {
|
if (entry.intervalId) {
|
||||||
clearInterval(entry.intervalId);
|
this._stopUpdateLoop(entry);
|
||||||
entry.intervalId = null;
|
|
||||||
}
|
}
|
||||||
let cleanupConfirmed = false;
|
let cleanupConfirmed = false;
|
||||||
try {
|
try {
|
||||||
@@ -1029,8 +1074,7 @@ export class LayerLifecycle {
|
|||||||
// Always clear any stale interval before assigning a new one, so we never
|
// Always clear any stale interval before assigning a new one, so we never
|
||||||
// orphan a running timer and end up double-polling.
|
// orphan a running timer and end up double-polling.
|
||||||
if (entry.intervalId) {
|
if (entry.intervalId) {
|
||||||
clearInterval(entry.intervalId);
|
this._stopUpdateLoop(entry);
|
||||||
entry.intervalId = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager-owned periodic refresh work has one normalized loading/error
|
// Manager-owned periodic refresh work has one normalized loading/error
|
||||||
@@ -2158,8 +2202,7 @@ export class LayerLifecycle {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (entry.intervalId) {
|
if (entry.intervalId) {
|
||||||
clearInterval(entry.intervalId);
|
this._stopUpdateLoop(entry);
|
||||||
entry.intervalId = null;
|
|
||||||
}
|
}
|
||||||
entry.enabled = false;
|
entry.enabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ export { layerFeedState } from '../ui/layers.js';
|
|||||||
/** Compatibility facade for callers that construct a manager with its panel. */
|
/** Compatibility facade for callers that construct a manager with its panel. */
|
||||||
export class DataLayerManager extends LayerLifecycle {
|
export class DataLayerManager extends LayerLifecycle {
|
||||||
constructor(viewer, options) {
|
constructor(viewer, options) {
|
||||||
super(viewer, options);
|
super(viewer, { visibilityTarget: globalThis.document, ...options });
|
||||||
this._presentation = new LayerPresentation(this);
|
this._presentation = new LayerPresentation(this);
|
||||||
}
|
}
|
||||||
buildTogglePanel(container) {
|
buildTogglePanel(container) {
|
||||||
|
|||||||
@@ -3492,3 +3492,92 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async
|
|||||||
lifecycle.refreshLayerStats();
|
lifecycle.refreshLayerStats();
|
||||||
assert.deepEqual(changes, ['status']);
|
assert.deepEqual(changes, ['status']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => {
|
||||||
|
const listeners = new Set();
|
||||||
|
const doc = {
|
||||||
|
hidden: false,
|
||||||
|
addEventListener: (_name, fn) => listeners.add(fn),
|
||||||
|
removeEventListener: (_name, fn) => listeners.delete(fn),
|
||||||
|
};
|
||||||
|
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
|
||||||
|
const { LayerLifecycle } = await import('./lifecycle.js');
|
||||||
|
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
|
||||||
|
const { module, calls } = makeSlowLayer('hidden-poll');
|
||||||
|
manager.register(module);
|
||||||
|
await manager.setEnabled('hidden-poll', true);
|
||||||
|
assert.equal(calls.update, 1);
|
||||||
|
doc.hidden = true;
|
||||||
|
t.mock.timers.tick(10000);
|
||||||
|
assert.equal(calls.update, 1);
|
||||||
|
assert.equal(await manager.refreshLayer('hidden-poll'), true);
|
||||||
|
assert.equal(calls.update, 2, 'explicit refresh remains allowed');
|
||||||
|
doc.hidden = false;
|
||||||
|
for (const fn of listeners) fn();
|
||||||
|
for (const fn of listeners) fn();
|
||||||
|
assert.equal(calls.update, 3);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await manager.destroyAll();
|
||||||
|
assert.equal(listeners.size, 0);
|
||||||
|
t.mock.timers.tick(10000);
|
||||||
|
assert.equal(calls.update, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function resumeBetweenTicks(t, { failResume = false } = {}) {
|
||||||
|
const listeners = new Set();
|
||||||
|
const doc = {
|
||||||
|
hidden: false,
|
||||||
|
addEventListener: (_name, fn) => listeners.add(fn),
|
||||||
|
removeEventListener: (_name, fn) => listeners.delete(fn),
|
||||||
|
};
|
||||||
|
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
|
||||||
|
const { LayerLifecycle } = await import('./lifecycle.js');
|
||||||
|
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
|
||||||
|
const { module, calls } = makeSlowLayer('resume-poll');
|
||||||
|
const updates = [];
|
||||||
|
module.update = async () => {
|
||||||
|
calls.update++;
|
||||||
|
updates.push(Date.now());
|
||||||
|
await Promise.resolve();
|
||||||
|
if (failResume && updates.length === 2) throw new Error('resume failed');
|
||||||
|
};
|
||||||
|
manager.register(module);
|
||||||
|
await manager.setEnabled('resume-poll', true);
|
||||||
|
assert.deepEqual(updates, [1000]);
|
||||||
|
doc.hidden = true;
|
||||||
|
t.mock.timers.tick(1000); // 2000: due tick skipped while hidden
|
||||||
|
t.mock.timers.tick(500); // 2500: return between ticks
|
||||||
|
doc.hidden = false;
|
||||||
|
for (const fn of listeners) fn();
|
||||||
|
assert.deepEqual(updates, [1000, 2500], 'overdue poll runs once on return');
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
const managerRefreshError = manager.layers.get('resume-poll').managerRefreshError;
|
||||||
|
if (failResume) assert.equal(managerRefreshError, 'resume failed');
|
||||||
|
else assert.equal(managerRefreshError, null);
|
||||||
|
t.mock.timers.tick(999); // 3499: the old 3000 phase must not fire
|
||||||
|
assert.deepEqual(updates, [1000, 2500]);
|
||||||
|
t.mock.timers.tick(1); // 3500: one interval after the resume poll
|
||||||
|
assert.deepEqual(updates, [1000, 2500, 3500]);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
t.mock.timers.tick(1000);
|
||||||
|
assert.deepEqual(updates, [1000, 2500, 3500, 4500], 'new phase keeps one interval');
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await manager.destroyAll();
|
||||||
|
assert.equal(listeners.size, 0);
|
||||||
|
t.mock.timers.tick(5000);
|
||||||
|
assert.equal(updates.length, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('periodic polls realign to the deadline after a between-ticks resume', async (t) => {
|
||||||
|
await resumeBetweenTicks(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('periodic polls realign to the deadline after a failed resume refresh', async (t) => {
|
||||||
|
await resumeBetweenTicks(t, { failResume: true });
|
||||||
|
});
|
||||||
@@ -13,8 +13,11 @@ function approximateDistanceM(latA, lonA, latB, lonB) {
|
|||||||
return Number.MAX_SAFE_INTEGER;
|
return Number.MAX_SAFE_INTEGER;
|
||||||
const latitudeScale = 111320;
|
const latitudeScale = 111320;
|
||||||
const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180);
|
const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180);
|
||||||
|
// The short way round, so places either side of 180° stay close.
|
||||||
|
let dLon = lonB - lonA;
|
||||||
|
dLon -= 360 * Math.round(dLon / 360);
|
||||||
return Math.round(
|
return Math.round(
|
||||||
Math.hypot((latB - latA) * latitudeScale, (lonB - lonA) * longitudeScale),
|
Math.hypot((latB - latA) * latitudeScale, dLon * longitudeScale),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export function createLifecycle({ state, services, parts }) {
|
|||||||
else if (hidden) {
|
else if (hidden) {
|
||||||
hidden = false;
|
hidden = false;
|
||||||
parts.rendering.resumePlayback();
|
parts.rendering.resumePlayback();
|
||||||
|
parts.viewport.runProximityCheck();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function bindVisibility() {
|
function bindVisibility() {
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ export function createViewport({ state, services, parts }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runProximityCheck() {
|
function runProximityCheck() {
|
||||||
|
if (
|
||||||
|
globalThis.document?.hidden ||
|
||||||
|
globalThis.document?.visibilityState === 'hidden'
|
||||||
|
)
|
||||||
|
return;
|
||||||
if (!state._enabled || !state._viewer) return;
|
if (!state._enabled || !state._viewer) return;
|
||||||
refreshViewBounds();
|
refreshViewBounds();
|
||||||
const altitude = getCameraAltitude();
|
const altitude = getCameraAltitude();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
npmProcessSpec,
|
npmProcessSpec,
|
||||||
readDoctorDotenvValue,
|
readDoctorDotenvValue,
|
||||||
resolveOpenSkyAuthMode,
|
resolveOpenSkyAuthMode,
|
||||||
|
resolveOpenSkyCredentialsFile,
|
||||||
resolveCredential,
|
resolveCredential,
|
||||||
} from '../scripts/setup-doctor.mjs';
|
} from '../scripts/setup-doctor.mjs';
|
||||||
|
|
||||||
@@ -237,7 +238,7 @@ test('doctor retains OAuth capability wording for a complete client pair', () =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('doctor reports Basic mode without inferring runtime auth from OAuth credentials', () => {
|
test('doctor reports retired Basic mode as anonymous, whatever OAuth credentials exist', () => {
|
||||||
const oauthCredentials = {
|
const oauthCredentials = {
|
||||||
OPENSKY_CLIENT_ID: { configured: true },
|
OPENSKY_CLIENT_ID: { configured: true },
|
||||||
OPENSKY_CLIENT_SECRET: { configured: true },
|
OPENSKY_CLIENT_SECRET: { configured: true },
|
||||||
@@ -245,7 +246,7 @@ test('doctor reports Basic mode without inferring runtime auth from OAuth creden
|
|||||||
for (const credentials of [{}, oauthCredentials]) {
|
for (const credentials of [{}, oauthCredentials]) {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
buildCapabilitySummary(credentials, { openSkyAuthMode: 'basic' }).flights,
|
buildCapabilitySummary(credentials, { openSkyAuthMode: 'basic' }).flights,
|
||||||
'OpenSky Basic mode selected (credential presence and validity not verified)',
|
'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -393,3 +394,39 @@ test('doctor never calls a dependency-missing setup ready', () => {
|
|||||||
assert.match(output, /Setup needs attention/);
|
assert.match(output, /Setup needs attention/);
|
||||||
assert.doesNotMatch(output, /Ready\. Run/);
|
assert.doesNotMatch(output, /Ready\. Run/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('doctor counts OPENSKY_CREDENTIALS_FILE as OAuth credentials, from the environment or .env', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildCapabilitySummary({}, { openSkyCredentialsFile: true }).flights,
|
||||||
|
'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
buildCapabilitySummary({}, { openSkyCredentialsFile: false }).flights,
|
||||||
|
'OpenSky keyless anonymous access (rate-limited)',
|
||||||
|
);
|
||||||
|
const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-opensky-file-'));
|
||||||
|
try {
|
||||||
|
assert.equal(
|
||||||
|
resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
path.join(root, '.env'),
|
||||||
|
'OPENSKY_CREDENTIALS_FILE=~/Downloads/credentials.json\n',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveOpenSkyCredentialsFile({
|
||||||
|
authoritativeEnvironment: true,
|
||||||
|
environment: { OPENSKY_CREDENTIALS_FILE: '' },
|
||||||
|
rootDir: root,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -250,10 +250,13 @@ export function approximateAreaM2(coords) {
|
|||||||
return Math.abs(area) / 2;
|
return Math.abs(area) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Local equirectangular distance in metres, the short way round 180°. */
|
||||||
export function approximateDistanceM(latA, lonA, latB, lonB) {
|
export function approximateDistanceM(latA, lonA, latB, lonB) {
|
||||||
const latScale = 111_320;
|
const latScale = 111_320;
|
||||||
const lonScale = latScale * Math.cos(((latA + latB) / 2) * (Math.PI / 180));
|
const lonScale = latScale * Math.cos(((latA + latB) / 2) * (Math.PI / 180));
|
||||||
return Math.hypot((latB - latA) * latScale, (lonB - lonA) * lonScale);
|
let dLon = lonB - lonA;
|
||||||
|
dLon -= 360 * Math.round(dLon / 360);
|
||||||
|
return Math.hypot((latB - latA) * latScale, dLon * lonScale);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pointInPolygon(lon, lat, coords) {
|
export function pointInPolygon(lon, lat, coords) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { approximateDistanceM } from './featureGeometry.js';
|
||||||
|
|
||||||
|
test('approximate distance takes the short way round 180°', () => {
|
||||||
|
// Lau Islands (-179.0) to Savusavu (179.33): about 178 km apart.
|
||||||
|
const across = approximateDistanceM(-17.5, -179.0, -16.78, 179.33);
|
||||||
|
assert.ok(across > 150_000 && across < 200_000, String(across));
|
||||||
|
assert.equal(
|
||||||
|
Math.round(approximateDistanceM(0, 179.9, 0, -179.9)),
|
||||||
|
Math.round(approximateDistanceM(0, -0.1, 0, 0.1)),
|
||||||
|
);
|
||||||
|
// Away from 180° nothing changes.
|
||||||
|
assert.equal(Math.round(approximateDistanceM(0, 0, 0, 1)), 111_320);
|
||||||
|
});
|
||||||
@@ -31,9 +31,7 @@ function openSkyError(response) {
|
|||||||
const reasons = {
|
const reasons = {
|
||||||
oauth_invalid_or_missing: 'OpenSky OAuth client missing/invalid',
|
oauth_invalid_or_missing: 'OpenSky OAuth client missing/invalid',
|
||||||
oauth_invalid_credentials: 'OpenSky OAuth rejected credentials',
|
oauth_invalid_credentials: 'OpenSky OAuth rejected credentials',
|
||||||
basic_invalid_credentials: 'OpenSky username/password rejected',
|
missing_oauth_creds: 'OpenSky auth missing',
|
||||||
missing_basic_creds: 'OpenSky auth missing',
|
|
||||||
missing_oauth_and_basic_creds: 'OpenSky auth missing',
|
|
||||||
auth_required: 'OpenSky auth required',
|
auth_required: 'OpenSky auth required',
|
||||||
forced_anonymous: 'OpenSky auth required',
|
forced_anonymous: 'OpenSky auth required',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -584,6 +584,69 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
|
|||||||
assert.equal(callCount, 1);
|
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) => {
|
test('retired military history refuses without upstream I/O while validating identifiers', async (t) => {
|
||||||
t.mock.method(globalThis, 'fetch', async () => {
|
t.mock.method(globalThis, 'fetch', async () => {
|
||||||
assert.fail('retired history must not contact the upstream');
|
assert.fail('retired history must not contact the upstream');
|
||||||
|
|||||||
@@ -81,6 +81,24 @@ test('nearby labels rank landmarks, deduplicate names/addresses and bound the pr
|
|||||||
assert.deepEqual(projectNearbyPlaces({}, 0, 0), []);
|
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', () => {
|
test('text search preserves bounds, rejects malformed bounds and tolerates absent locations', () => {
|
||||||
const viewport = {
|
const viewport = {
|
||||||
low: { latitude: 1, longitude: 2 },
|
low: { latitude: 1, longitude: 2 },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { getTransitFeed } from '../data/transitFeeds.js';
|
||||||
import {
|
import {
|
||||||
chooseLiveCities,
|
chooseLiveCities,
|
||||||
selectStyle,
|
selectStyle,
|
||||||
@@ -101,6 +102,18 @@ test('live visual cities follow local operating hours in summer and winter', ()
|
|||||||
const cities = chooseLiveCities(now);
|
const cities = chooseLiveCities(now);
|
||||||
assert.ok(cities.length >= 1);
|
assert.ok(cities.length >= 1);
|
||||||
for (const city of cities) {
|
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(
|
const local = Number(
|
||||||
new Intl.DateTimeFormat('en', {
|
new Intl.DateTimeFormat('en', {
|
||||||
timeZone: city.zone,
|
timeZone: city.zone,
|
||||||
|
|||||||
Reference in new issue
Block a user