diff --git a/.gitignore b/.gitignore index 14e8d320b..d2f0107c0 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ qa-artifacts/ 3d-models/ pinokio/ENVIRONMENT pinokio/.installed + +# Transient: a release interrupted mid-assembly (npm run changelog recovers it) +changelog.d/.assembling/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d2a0b07be..6d6b968db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -150,7 +150,7 @@ ownership and adoption process. 1. Branch off `main`. 2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors, plus the [feature gate](#feature-regression-gates) for the area you touched. -3. If you change runtime behavior, update `docs/CURRENT-STATE.md` and `CHANGELOG.md` in the same PR. +3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/-.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly. 4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead. 5. Describe what you changed and how you verified it (screenshots welcome for anything visual). diff --git a/changelog.d/1096-military-hold-track-speed.md b/changelog.d/1096-military-hold-track-speed.md new file mode 100644 index 000000000..adc1793c4 --- /dev/null +++ b/changelog.d/1096-military-hold-track-speed.md @@ -0,0 +1,3 @@ +- Military aircraft keep their last heading and speed when an adsb.lol row omits + `track` or `gs`, instead of turning north and stopping; a grounded row without + a speed still reads 0 (Aditya, @Rohilalala, #1096). diff --git a/changelog.d/1097-overpass-remark-failure-phrases.md b/changelog.d/1097-overpass-remark-failure-phrases.md new file mode 100644 index 000000000..4543710da --- /dev/null +++ b/changelog.d/1097-overpass-remark-failure-phrases.md @@ -0,0 +1,5 @@ +- Overpass data whose element tags contain a failure phrase ("timed out", + "runtime error", "too many requests" and similar, e.g. a place named "Timed + Out Escape Rooms") is returned as data instead of being refused and putting + the endpoint on cooldown; upstream failures are read from the remark (Aditya, + @Rohilalala, #1097). diff --git a/changelog.d/1115-aircraft-altitude-band-unknown.md b/changelog.d/1115-aircraft-altitude-band-unknown.md new file mode 100644 index 000000000..a6b69472d --- /dev/null +++ b/changelog.d/1115-aircraft-altitude-band-unknown.md @@ -0,0 +1,2 @@ +- Exclude aircraft without a reported altitude from requested altitude bands; + unfiltered aircraft queries still retain them (rudycelekli, #1115). diff --git a/changelog.d/1171-director-goto-shot.md b/changelog.d/1171-director-goto-shot.md new file mode 100644 index 000000000..4c08351ef --- /dev/null +++ b/changelog.d/1171-director-goto-shot.md @@ -0,0 +1,3 @@ +- Director shot actions land on the requested shot. A seek to a shot's start + could round a hair below it and select the end of the previous shot for some + shot counts and durations (Aditya, @Rohilalala, #1171). diff --git a/changelog.d/787-changelog-fragments.md b/changelog.d/787-changelog-fragments.md new file mode 100644 index 000000000..41509c046 --- /dev/null +++ b/changelog.d/787-changelog-fragments.md @@ -0,0 +1,7 @@ +- Changelog entries are now one file per pull request. A change adds + `changelog.d/-.md` instead of editing the top of + `CHANGELOG.md`, which two pull requests in flight could not share — it was the + most frequent merge conflict in the open queue, and for six of them the only + one. `npm run changelog` splices the directory into `CHANGELOG.md` at release + time and empties it; `npm run changelog:check` reports the batch and changes + nothing. Fragment text is copied through unchanged (daikaginza, #787). diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 000000000..df17e32f2 --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,52 @@ +# Changelog fragments + +One file per entry. A pull request adds a file here instead of editing +`CHANGELOG.md`, and a release concatenates them into it. + +``` +changelog.d/-.md +``` + +`723-atc-decision-layer.md`, for example. The slug is lowercase, words separated +by `-`. Two pull requests never pick the same filename, because the pull request +number is already unique — which is the point: new `CHANGELOG.md` entries go at +the top of the file, so two pull requests in flight write into the same few lines +and git has nothing to merge on. + +Write the file exactly as the entry should read in `CHANGELOG.md`: a `-` bullet, +wrapped the way the surrounding entries are, crediting yourself and the pull +request. + +```markdown +- Report which upstream declined a Street Traffic road load. The layer row now + reads `Overpass rate-limited` instead of a general "Road data temporarily + unavailable" (yourname, #665). +``` + +The text is spliced in unchanged apart from trailing whitespace, so what you +write here is what ships. Fragments are deliberately outside +`scripts/format-scope.json`, so `npm run format` never reflows one. + +At release time: + +``` +npm run changelog:check # list what would move, change nothing +npm run changelog # move every fragment into CHANGELOG.md +``` + +The assembler refuses a batch rather than guessing: a filename that is not +`-.md`, or two fragments claiming one pull request number, +stops the run before anything is written and names the files. + +If `npm run changelog` fails part-way (a full disk, a killed process), run it +again. The batch is staged in `changelog.d/.assembling/` and only lands when the +assembled file replaces `CHANGELOG.md` in a single rename, so the rerun either +finishes that batch or puts its fragments back first. It never adds an entry +twice or drops one. `npm run changelog:check` names a staged batch, if one is +left over, instead of reporting past it. + +A documentation claim that needs a regression test — the way +`src/annotations/drawTool.test.mjs` asserts against `CHANGELOG.md` — should +assert against `docs/CURRENT-STATE.md` or `README.md` instead. A pull request +still edits those directly, so the text is in the file while the change is under +review; a fragment does not reach `CHANGELOG.md` until release. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7fc40b1e2..b437f0010 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -100,6 +100,9 @@ shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). +Aircraft altitude-band queries require a reported barometric or geometric altitude +for each match, so a grounded contact that reports none is left out of a band too. +Queries without an altitude band retain unknown-altitude contacts. Catalog calls check their cancellation signal immediately before executing a tool. Already-cancelled queries do not start; cancellation after execution starts @@ -439,6 +442,8 @@ reference. Legacy projects retain their ordinary flights, content and edits. Seeking between ordinary camera poses uses the shortest longitude arc across the dateline while preserving both authored endpoint longitudes, and turns heading and roll the short way for any authored angles. +A seek to a shot's start, as a shot action makes, selects that shot rather than +the end of the previous one. See [camera directions](DIRECTOR-CAMERA.md). Realtime voice composes separate connection, response/tool, Radio handoff, input @@ -537,7 +542,7 @@ are protected from fallback writes. Version 6 is the export format, with zero pitch, low camera heights, scope and detection edits preserved. See [the document contract](SCENE-DOCUMENT.md); see [Director](DIRECTOR.md) for camera, pack, action and sharing support. -Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, unavailable, transient and throttled outcomes remain distinct. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Traffic uses OpenFreeMap roads with optional matched TomTom congestion; ALPR uses an hourly OpenStreetMap extract. Keyless installations use military-area tiles. Detailed feature queries require operator-configured Overpass. +Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, unavailable, transient and throttled outcomes remain distinct; a data reply is refused only for its `remark`, never for text in element tags. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Traffic uses OpenFreeMap roads with optional matched TomTom congestion; ALPR uses an hourly OpenStreetMap extract. Keyless installations use military-area tiles. Detailed feature queries require operator-configured Overpass. Nepal media preloads survive repeated camera-flight updates, but Stop, event disable and replacement remove abandoned frames and revoke pending Facebook sessions. Fallback evidence-card clicks respect the shared drawing-tool pointer lease. @@ -596,7 +601,9 @@ pinning, first-position grace, source time and sea-surface placement are unchang Military flights also separate portable records and source acquisition from Cesium snapshot application. The metadata owner retains aviation feet, source -fix times, sticky fields and partial-feed retention. Rendering retains ground +fix times, sticky fields and partial-feed retention. A row without track or +speed holds the last known value, as civil records do; a grounded row without +speed reads 0. Rendering retains ground model ownership, motion history, military registration and tracking cleanup. Civil flights separate source acquisition, portable record reconciliation and diff --git a/docs/MAINTAINER_WORKFLOW.md b/docs/MAINTAINER_WORKFLOW.md index e80e4bb99..ec6cd026d 100644 --- a/docs/MAINTAINER_WORKFLOW.md +++ b/docs/MAINTAINER_WORKFLOW.md @@ -49,7 +49,8 @@ failure elsewhere. These questions guide review, not a mandatory questionnaire. regressions? Prefer fixes whose tests fail on the base. Test-only PRs should close a real coverage gap with deterministic fixtures and useful assertions. Inspect removed/skipped tests, weakened assertions, mocks, snapshots, and runner - changes. Update `CURRENT-STATE.md` and `CHANGELOG.md` for runtime changes, and + changes. Update `CURRENT-STATE.md` and add a `changelog.d/-.md` + fragment (not an edit to `CHANGELOG.md`) for runtime changes, and `DATA_SOURCES.md` for source changes. ## 3. Choose execution proportionate to risk @@ -124,6 +125,15 @@ Condition the merge on the reviewed head where supported; if revisions change, return to validation. Verify the merged tree matches the validated candidate and record the merge commit/PR URL; stop on mismatch. +Pending release notes live in `changelog.d/`, one file per PR, so integration never +resolves the top of `CHANGELOG.md`. At release, `npm run changelog:check` previews +the batch and its order; `npm run changelog` splices it into `CHANGELOG.md` and +empties the directory. The assembler refuses a malformed filename or two fragments +claiming one PR number before writing, and copies text through unchanged so the +contributor's wording and credit ship. The write is staged in +`changelog.d/.assembling/`, so if it fails part-way, rerunning `npm run changelog` +finishes or rolls back the batch. It never duplicates or drops an entry. + Return this concise record privately unless posting is authorized. Keep sensitive security evidence in the private report: diff --git a/package.json b/package.json index 9d8761215..99e35c0c5 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "i18n": "node scripts/i18n.mjs", "layer-token:next": "node scripts/next-layer-state-token.mjs", "layer-token:check": "node scripts/check-layer-state-tokens.mjs", + "changelog": "node scripts/assemble-changelog.mjs --write", + "changelog:check": "node scripts/assemble-changelog.mjs --check", "dev": "vite", "dev:secure": "./scripts/dev-secure.sh", "opensky:import": "./scripts/opensky-import-client.sh", diff --git a/scripts/assemble-changelog.mjs b/scripts/assemble-changelog.mjs new file mode 100644 index 000000000..430fca77a --- /dev/null +++ b/scripts/assemble-changelog.mjs @@ -0,0 +1,204 @@ +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** + * Release-time assembly of changelog fragments. + * + * A pull request adds `changelog.d/-.md` instead of editing + * `CHANGELOG.md`. Distinct filenames never conflict, so two pull requests in + * flight no longer write into the same few lines at the top of one file. A + * release splices the fragments in and empties the directory. + * + * Fragment text is copied through unchanged apart from trailing whitespace: + * `src/annotations/drawTool.test.mjs` reads `CHANGELOG.md` as prose and slices a + * fixed window after a heading, so reflowing an entry here could move text + * across that window. + * + * A write is staged so that a failure at any point leaves a rerun unable to + * duplicate or lose an entry. The assembled file is written into + * `changelog.d/.assembling/` first, the fragments are renamed in beside it, and + * renaming the assembled file over `CHANGELOG.md` is the single commit point. + * While the assembled file is still staged the batch has not landed, so + * recovery moves the fragments back; once it is gone the batch has landed, and + * the staged fragments are leftovers to delete. Either way they are out of the + * input directory, so they are never assembled twice. + */ +export const FRAGMENT_DIR = 'changelog.d'; +export const STAGING_DIR = '.assembling'; +const STAGED_CHANGELOG = 'CHANGELOG.md'; +const FRAGMENT_NAME = /^(\d+)-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/; +const HEADING = '# Changelog\n'; + +/** Fragment file names a release may consume, highest pull request first. */ +export function orderFragmentNames(names) { + const claimed = new Map(); + for (const name of names) { + if (name === 'README.md') continue; + const match = FRAGMENT_NAME.exec(name); + if (!match) { + throw new Error( + `Unexpected file in ${FRAGMENT_DIR}/: ${name}` + + ' — expected -.md with a lowercase slug', + ); + } + const number = Number(match[1]); + const prior = claimed.get(number); + if (prior) { + throw new Error( + `Two fragments claim pull request ${number}: ${prior} and ${name}`, + ); + } + claimed.set(number, name); + } + return [...claimed.entries()] + .sort(([a], [b]) => b - a) + .map(([, name]) => name); +} + +/** Splice fragment bodies above the existing entries, newest batch on top. */ +export function assembleChangelog(changelog, fragments) { + if (!fragments.length) return changelog; + if (!changelog.startsWith(HEADING)) { + throw new Error('CHANGELOG.md must start with "# Changelog"'); + } + const entries = changelog.slice(HEADING.length).replace(/^\n+/, ''); + const batch = fragments.map((text) => text.replace(/\s+$/, '')).join('\n\n'); + return `${HEADING}\n${batch}\n\n${entries}`; +} + +/** + * Finish or roll back a batch an earlier `--write` left in the staging + * directory. A no-op when there is none. + * + * @returns {Promise<'none'|'rolled-back'|'cleaned-up'>} + */ +export async function recoverInterruptedBatch(root, io = fs) { + const directory = path.join(root, FRAGMENT_DIR); + const staging = path.join(directory, STAGING_DIR); + let staged; + try { + staged = await io.readdir(staging); + } catch (error) { + if (error.code === 'ENOENT') return 'none'; + throw error; + } + if (!staged.includes(STAGED_CHANGELOG)) { + // The assembled file was renamed over CHANGELOG.md: the batch landed. + await io.rm(staging, { recursive: true, force: true }); + return 'cleaned-up'; + } + // The batch never reached CHANGELOG.md, so every fragment goes back. + const pending = new Set(await io.readdir(directory)); + const fragments = staged.filter((name) => name !== STAGED_CHANGELOG); + const clash = fragments.filter((name) => pending.has(name)); + if (clash.length) { + throw new Error( + `Cannot roll back ${FRAGMENT_DIR}/${STAGING_DIR}/: ${clash.join(', ')}` + + ` also exist in ${FRAGMENT_DIR}/ — resolve by hand`, + ); + } + for (const name of fragments) { + await io.rename(path.join(staging, name), path.join(directory, name)); + } + await io.rm(staging, { recursive: true, force: true }); + return 'rolled-back'; +} + +/** + * Report or apply the pending fragments in `/changelog.d`. + * + * The batch is validated in full before anything is written, so a malformed + * filename or a duplicate pull request number leaves the directory untouched. + * `--write` first recovers any batch an interrupted run left staged. + * + * @param {string} root repository root + * @param {'--check'|'--write'} mode `--check` reports and changes nothing + * @param {typeof fs} [io] filesystem calls, replaceable for failure injection + * @returns {Promise<{ consumed: string[], changelog: string }>} + */ +export async function assembleFragments(root, mode, io = fs) { + if (!['--check', '--write'].includes(mode)) { + throw new Error( + 'Usage: node scripts/assemble-changelog.mjs --check|--write', + ); + } + const directory = path.join(root, FRAGMENT_DIR); + const staging = path.join(directory, STAGING_DIR); + const changelogFile = path.join(root, 'CHANGELOG.md'); + if (mode === '--write') { + await recoverInterruptedBatch(root, io); + } + const names = await io.readdir(directory); + if (names.includes(STAGING_DIR)) { + throw new Error( + `${FRAGMENT_DIR}/${STAGING_DIR}/ holds a batch an interrupted release` + + ' left behind — `npm run changelog` finishes or rolls it back', + ); + } + const consumed = orderFragmentNames(names); + const bodies = []; + for (const name of consumed) { + bodies.push(await io.readFile(path.join(directory, name), 'utf8')); + } + const changelog = assembleChangelog( + await io.readFile(changelogFile, 'utf8'), + bodies, + ); + if (mode === '--write' && consumed.length) { + try { + await io.mkdir(staging); + await io.writeFile( + path.join(staging, STAGED_CHANGELOG), + changelog, + 'utf8', + ); + for (const name of consumed) { + await io.rename(path.join(directory, name), path.join(staging, name)); + } + await io.rename(path.join(staging, STAGED_CHANGELOG), changelogFile); + await io.rm(staging, { recursive: true, force: true }); + } catch (error) { + try { + await recoverInterruptedBatch(root, io); + } catch { + throw new Error( + `${error.message} — the batch is staged in ${FRAGMENT_DIR}/${STAGING_DIR}/,` + + ' and the next `npm run changelog` finishes or rolls it back', + { cause: error }, + ); + } + throw error; + } + } + return { consumed, changelog }; +} + +const invoked = process.argv[1] + ? pathToFileURL(path.resolve(process.argv[1])).href + : ''; +if (import.meta.url === invoked) { + try { + if (process.argv.length !== 3) { + throw new Error( + 'Usage: node scripts/assemble-changelog.mjs --check|--write', + ); + } + const mode = process.argv[2]; + const { consumed } = await assembleFragments( + fileURLToPath(new URL('../', import.meta.url)), + mode, + ); + if (!consumed.length) { + console.log(`No fragments in ${FRAGMENT_DIR}/.`); + } else { + console.log( + `${mode === '--write' ? 'Assembled' : 'Would assemble'} ${consumed.length} fragment(s), in order:` + + `\n ${consumed.join('\n ')}`, + ); + } + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c25797f38..a2b3e01f8 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1058,5 +1058,7 @@ "src/tooling/webAppIcons.test.mjs", "src/ui/flyToDataSource.js", "src/ui/flyToDataSource.test.mjs", + "scripts/assemble-changelog.mjs", + "src/tooling/changelogFragments.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs" ] diff --git a/server/providers/overpass/transport.js b/server/providers/overpass/transport.js index 38c31bb08..e3776e291 100644 --- a/server/providers/overpass/transport.js +++ b/server/providers/overpass/transport.js @@ -14,7 +14,7 @@ import { simplifyOverpassPayloadBody } from './geometry.js'; * Checks for known rate-limit phrases in the body text regardless of * HTTP status code, since some mirrors return 200 with an error payload. * - * @param {string} bodyText - Upstream response body. + * @param {string} bodyText - The remark of a data reply, or a non-data body. * @returns {boolean} True if the body looks rate-limited. */ function overpassLooksRateLimited(bodyText) { @@ -41,6 +41,14 @@ function overpassLooksRuntimeError(bodyText) { ); } +function parseJson(text) { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + /** * True only for an upstream response that is actually Overpass data. * @@ -151,15 +159,22 @@ async function fetchOverpassPayload( signal: controller.signal, }); const responseBody = await readBody(upstream, maxResponseBytes); + const parsed = parseJson(responseBody); + // An Overpass data reply reports failure in its remark; its element + // tags are data and can contain any phrase. Any other body (an error + // page, or JSON without elements) is matched whole. + const failureText = Array.isArray(parsed?.elements) + ? String(parsed.remark || '') + : responseBody; const rateLimited = upstream.status === 429 || upstream.status === 406 || - overpassLooksRateLimited(responseBody); + overpassLooksRateLimited(failureText); if ( rateLimited || upstream.status < 200 || upstream.status >= 300 || - overpassLooksRuntimeError(responseBody) + overpassLooksRuntimeError(failureText) ) { const failures = (previous?.failures || 0) + 1; const delay = retryDelay( @@ -178,7 +193,6 @@ async function fetchOverpassPayload( failure = refusal(status, delay); continue; } - const parsed = JSON.parse(responseBody); if (!Array.isArray(parsed?.elements) || parsed.remark) throw new Error('Malformed Overpass response'); cooldowns.delete(endpoint); diff --git a/src/director/timeline.js b/src/director/timeline.js index f7cffcece..5bcb42db6 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -72,19 +72,27 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) { let shotIndex = scene.shots.length - 1; for (let index = 0; index < scene.shots.length; index += 1) { const endElapsedSec = startElapsedSec + durations[index]; - if (targetSec < endElapsedSec || index === scene.shots.length - 1) { + // Compare in progress units: a shot's startProgress (start / total) + // times total can round below its start and select the previous shot. + if ( + normalized < endElapsedSec / totalSec || + index === scene.shots.length - 1 + ) { shotIndex = index; break; } startElapsedSec = endElapsedSec; } + // Keep the elapsed time inside the selected shot, so it agrees with + // shotIndex when targetSec rounded below the shot's start. + const elapsedSec = Math.max(targetSec, startElapsedSec); const shot = scene.shots[shotIndex]; const flightDurationSec = shot.durationSec || DEFAULT_SHOT_DURATION_SEC; const holdDurationSec = holdForShot(scene, shot); const shotDurationSec = Math.max(0.001, flightDurationSec + holdDurationSec); const shotElapsedSec = Math.max( 0, - Math.min(shotDurationSec, targetSec - startElapsedSec), + Math.min(shotDurationSec, elapsedSec - startElapsedSec), ); const cameraProgress = clamp01( shotElapsedSec / Math.max(0.001, flightDurationSec), @@ -99,8 +107,8 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) { ); const move = resolveCameraMove(scene, shot); return { - sceneProgress: totalSec > 0 ? targetSec / totalSec : 0, - sceneElapsedSec: targetSec, + sceneProgress: totalSec > 0 ? elapsedSec / totalSec : 0, + sceneElapsedSec: elapsedSec, sceneDurationSec: totalSec, shotIndex, shot, diff --git a/src/director/timeline.test.mjs b/src/director/timeline.test.mjs index 1950eeef0..69a370c98 100644 --- a/src/director/timeline.test.mjs +++ b/src/director/timeline.test.mjs @@ -49,3 +49,39 @@ test('camera seeking preserves cubic easing and shortest-angle orientation', () ); assert.deepEqual(cameraAtProgress(null, camera(20, 350), 0), camera(20, 350)); }); + +test('seeking to a shot start lands in that shot for every shot count and duration', () => { + const misses = []; + for (let count = 2; count <= 12; count += 1) { + for (let flightTenths = 1; flightTenths <= 100; flightTenths += 1) { + for (let holdTenths = 0; holdTenths <= 50; holdTenths += 1) { + const shots = Array.from({ length: count }, (_, index) => ({ + id: `s${index}`, + // Every third shot is longer, so boundaries are not all equal steps. + durationSec: flightTenths / 10 + (index % 3 === 2 ? 1.7 : 0), + holdSec: holdTenths / 10, + camera: camera(20, 0), + })); + const mixed = { shots }; + for (const [index, shot] of shots.entries()) { + const { startProgress, startElapsedSec } = sceneTimingForShot( + mixed, + shot, + duration, + ); + const seek = sceneSeekState(mixed, startProgress, duration, hold); + // One or two ulps earlier still belongs to the previous shot. + const before = startProgress * (1 - Number.EPSILON); + const previous = sceneSeekState(mixed, before, duration, hold); + if ( + seek.shotIndex !== index || + seek.sceneElapsedSec < startElapsedSec || + previous.shotIndex !== Math.max(0, index - 1) + ) + misses.push(`${count}x${flightTenths}/${holdTenths}#${index}`); + } + } + } + } + assert.deepEqual(misses.slice(0, 5), []); +}); diff --git a/src/layers/military/records.js b/src/layers/military/records.js index 9cd6754e6..05d91ccf0 100644 --- a/src/layers/military/records.js +++ b/src/layers/military/records.js @@ -39,8 +39,6 @@ export class MilitaryFlightRecords { ? prevMeta.altitudeFt * 0.3048 : 0 : 3048); - const track = aircraft.courseDeg || 0; - const speedMps = aircraft.speedMps ?? 0; const verticalRateMps = aircraft.verticalRateMps; const callsign = aircraft.callsign; const type = aircraft.typeCode; @@ -148,8 +146,15 @@ export class MilitaryFlightRecords { // altitudeFt/baro). geoAltitudeM, renderAltitudeM, - speedMps: stickyNumber(speedMps, prevMeta?.speedMps, null), - track: stickyNumber(track, prevMeta?.track, null), + // A poll that omits track/speed holds the last values, like the civil + // records; 0 only until one is known. A grounded row without a speed + // reads 0, so a parked plane never coasts at its landing speed. + speedMps: stickyNumber( + aircraft.speedMps, + onGround ? 0 : prevMeta?.speedMps, + 0, + ), + track: stickyNumber(aircraft.courseDeg, prevMeta?.track, 0), // Analyst seam (additive): sticky like the other kinematics. verticalRateMps: stickyNumber( verticalRateMps, diff --git a/src/layers/military/records.test.mjs b/src/layers/military/records.test.mjs index 5054fb28a..ce00c7761 100644 --- a/src/layers/military/records.test.mjs +++ b/src/layers/military/records.test.mjs @@ -114,3 +114,39 @@ test('military emergency status follows the latest poll instead of sticking', () ); assert.equal(next.meta.emergency, null); }); + +test('military records hold the last track and speed when a poll omits them', () => { + const records = store(); + records.receive(observation({ courseDeg: 270, speedMps: 200 }), context()); + const missing = records.receive( + observation({ courseDeg: null, speedMps: null }), + context(), + ); + assert.equal(missing.meta.track, 270); + assert.equal(missing.meta.speedMps, 200); + const north = records.receive( + observation({ courseDeg: 0, speedMps: 0 }), + context(), + ); + assert.equal(north.meta.track, 0); + assert.equal(north.meta.speedMps, 0); + const unseen = store().receive( + observation({ courseDeg: undefined, speedMps: undefined }), + context(), + ); + assert.equal(unseen.meta.track, 0); + assert.equal(unseen.meta.speedMps, 0); + const landing = store(); + landing.receive(observation({ courseDeg: 90, speedMps: 70 }), context()); + const parked = landing.receive( + observation({ + onGround: true, + baroAltitudeM: null, + courseDeg: null, + speedMps: null, + }), + context(), + ); + assert.equal(parked.meta.speedMps, 0); + assert.equal(parked.meta.track, 90); +}); diff --git a/src/overpassProxy.test.mjs b/src/overpassProxy.test.mjs index ed4539fda..788846db7 100644 --- a/src/overpassProxy.test.mjs +++ b/src/overpassProxy.test.mjs @@ -126,6 +126,32 @@ test('production reader rejects oversized, malformed and runtime-failure bodies' } }); +test('element data that mentions a failure phrase is data, and sets no cooldown', async () => { + const names = ['Timed Out Escape Rooms', 'Runtime Error Bar', 'Out of Memory Lane', 'Too Many Requests Cafe', 'rate_limited']; + for (const [i, name] of names.entries()) { + const body = JSON.stringify({ elements: [{ type: 'node', id: 1, lat: 1, lon: 2, tags: { name } }] }); + let count = 0; + const options = { endpoints: [`https://phrase-${i}.example/`], fetchImpl: async () => { + count++; return new Response(body); + } }; + const payload = await fetchOverpassPayload('first', 1024, options); + assert.equal(payload.status, 200, name); + assert.ok(overpassPayloadIsData(payload), name); + assert.ok(payload.body.includes(name), name); + await fetchOverpassPayload('different', 1024, options); + assert.equal(count, 2, name); + } + const remark = JSON.stringify({ elements: [], remark: 'runtime error: rate_limited' }); + const limited = await fetchOverpassPayload('x', 1024, { endpoints: ['https://remark.example/'], + fetchImpl: async () => new Response(remark, { headers: { 'Retry-After': '90' } }) }); + assert.equal(limited.status, 429); + assert.equal(limited.retryAfterMs, 90_000); + const error = await fetchOverpassPayload('x', 1024, { endpoints: ['https://error.example/'], + fetchImpl: async () => new Response('{"error":"rate_limited"}', { headers: { 'Retry-After': '120' } }) }); + assert.equal(error.status, 429); + assert.equal(error.retryAfterMs, 120_000); +}); + function proxyHandler() { const plugin = createViteConfig({ mode: 'test' }).plugins.find(p => p.name === 'overpass-proxy'); const routes = new Map(); diff --git a/src/tooling/changelogFragments.test.mjs b/src/tooling/changelogFragments.test.mjs new file mode 100644 index 000000000..792542f68 --- /dev/null +++ b/src/tooling/changelogFragments.test.mjs @@ -0,0 +1,319 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import { + mkdtemp, + mkdir, + writeFile, + readFile, + readdir, + rm, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + FRAGMENT_DIR, + STAGING_DIR, + assembleChangelog, + assembleFragments, + orderFragmentNames, + recoverInterruptedBatch, +} from '../../scripts/assemble-changelog.mjs'; + +const EXISTING = + '# Changelog\n\n- An entry that was already released (someone, #1).\n'; + +const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url)); + +async function fixture(t, fragments = {}) { + const root = await mkdtemp(path.join(tmpdir(), 'gev-changelog-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, FRAGMENT_DIR)); + await writeFile( + path.join(root, FRAGMENT_DIR, 'README.md'), + '# How to add one\n', + ); + await writeFile(path.join(root, 'CHANGELOG.md'), EXISTING); + for (const [name, body] of Object.entries(fragments)) { + await writeFile(path.join(root, FRAGMENT_DIR, name), body); + } + return root; +} + +test('the batch reads newest pull request first', () => { + assert.deepEqual(orderFragmentNames(['7-b.md', '112-c.md', '19-a.md']), [ + '112-c.md', + '19-a.md', + '7-b.md', + ]); +}); + +test('the directory README is documentation, never an entry', () => { + assert.deepEqual(orderFragmentNames(['README.md', '5-a.md']), ['5-a.md']); +}); + +test('two fragments claiming one pull request fail loudly, not silently', () => { + assert.throws( + () => orderFragmentNames(['5-first.md', '5-second.md']), + /Two fragments claim pull request 5/, + ); +}); + +test('a name that is not -.md is refused', () => { + for (const name of ['notes.md', '5_underscore.md', '5-Caps.md', '5-a.txt']) { + assert.throws(() => orderFragmentNames([name]), /Unexpected file/, name); + } +}); + +test('fragment text is spliced in unchanged, above the existing entries', () => { + // Wrapped, indented and punctuated the way a real entry is: the continuation + // indent and the blank line inside the entry are the parts a reflow would eat. + const entry = [ + '- A new thing, with `code` and a — dash. The second line is indented the', + ' way the surrounding entries wrap, and it keeps its two spaces.', + '', + ' A second paragraph, because some entries have one (someone, #9).', + ].join('\n'); + const result = assembleChangelog(EXISTING, [`${entry}\n\n\n`]); + assert.equal( + result, + `# Changelog\n\n${entry}\n\n- An entry that was already released (someone, #1).\n`, + ); + // The entry survives byte for byte: drawTool.test.mjs reads this file as + // prose and slices a fixed window, so reflowing would move text across it. + assert.ok(result.includes(entry)); +}); + +test('a CHANGELOG without the heading is refused rather than prepended to', () => { + assert.throws( + () => assembleChangelog('- orphan entry\n', ['- new\n']), + /must start with "# Changelog"/, + ); +}); + +test('assembling consumes the fragments and leaves the README', async (t) => { + const root = await fixture(t, { + '20-second.md': '- Second (someone, #20).', + '100-first.md': '- First (someone, #100).', + }); + const { consumed } = await assembleFragments(root, '--write'); + assert.deepEqual(consumed, ['100-first.md', '20-second.md']); + const changelog = await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'); + assert.match( + changelog, + /# Changelog\n\n- First \(someone, #100\)\.\n\n- Second \(someone, #20\)\.\n\n- An entry that was already released/, + ); + assert.deepEqual(await readdir(path.join(root, FRAGMENT_DIR)), ['README.md']); +}); + +test('a rejected batch leaves the directory and the file untouched', async (t) => { + const root = await fixture(t, { + '8-first.md': '- First (someone, #8).', + '8-again.md': '- Again (someone, #8).', + }); + await assert.rejects( + assembleFragments(root, '--write'), + /Two fragments claim pull request 8/, + ); + assert.equal( + await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + EXISTING, + ); + assert.deepEqual((await readdir(path.join(root, FRAGMENT_DIR))).sort(), [ + '8-again.md', + '8-first.md', + 'README.md', + ]); +}); + +test('check mode changes nothing on disk', async (t) => { + const root = await fixture(t, { '3-a.md': '- Something (someone, #3).' }); + const { consumed } = await assembleFragments(root, '--check'); + assert.deepEqual(consumed, ['3-a.md']); + assert.equal( + await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + EXISTING, + ); + assert.deepEqual((await readdir(path.join(root, FRAGMENT_DIR))).sort(), [ + '3-a.md', + 'README.md', + ]); +}); + +test('an empty directory is a no-op, so a release can always run it', async (t) => { + const root = await fixture(t); + const { consumed } = await assembleFragments(root, '--write'); + assert.deepEqual(consumed, []); + assert.equal( + await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + EXISTING, + ); +}); + +test('a mode that is neither check nor write is refused', async (t) => { + const root = await fixture(t); + await assert.rejects(assembleFragments(root, '--force'), /--check\|--write/); +}); + +// Failure injection. Every filesystem call the write path makes is a point +// where a release can die: a full disk, a permissions error, a killed process. + +const IO_CALLS = ['readdir', 'readFile', 'mkdir', 'writeFile', 'rename', 'rm']; +const BATCH = { + '20-second.md': '- Second (someone, #20).', + '100-first.md': '- First (someone, #100).', + '7-third.md': '- Third (someone, #7).', +}; + +/** + * The real filesystem, except that `call` throws the `nth` time it runs. With + * `crash`, every later call throws as well, the way a killed process makes no + * further calls: the in-process rollback cannot run either, so the next + * invocation is the only thing that can recover. + */ +function failing(call, nth, { crash = false } = {}) { + let seen = 0; + let dead = false; + const io = { ...fs }; + for (const name of IO_CALLS) { + io[name] = async (...args) => { + if (dead) throw new Error('process already gone'); + if (name === call && ++seen === nth) { + dead = crash; + throw new Error(`injected ${call} failure #${nth}`); + } + return fs[name](...args); + }; + } + return io; +} + +/** How many times one uninterrupted `--write` makes each filesystem call. */ +async function countCalls(t) { + const root = await fixture(t, BATCH); + const counts = Object.fromEntries(IO_CALLS.map((name) => [name, 0])); + const io = { ...fs }; + for (const name of IO_CALLS) { + io[name] = (...args) => { + counts[name] += 1; + return fs[name](...args); + }; + } + await assembleFragments(root, '--write', io); + return { + counts, + expected: await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + }; +} + +test('a write that fails at any call never lets a retry duplicate or lose an entry', async (t) => { + const { counts, expected } = await countCalls(t); + // The write path itself: one mkdir, one writeFile, a rename per fragment plus + // the commit rename, and the cleanup rm. Fewer would mean a step went missing. + assert.ok(counts.rename >= Object.keys(BATCH).length + 1); + for (const crash of [false, true]) { + for (const call of IO_CALLS) { + for (let nth = 1; nth <= counts[call]; nth += 1) { + const label = `${crash ? 'crash' : 'error'} at ${call} #${nth}`; + const root = await fixture(t, BATCH); + await assert.rejects( + assembleFragments(root, '--write', failing(call, nth, { crash })), + /injected|already gone/, + label, + ); + await assembleFragments(root, '--write'); + const changelog = await readFile( + path.join(root, 'CHANGELOG.md'), + 'utf8', + ); + assert.equal(changelog, expected, label); + for (const pr of [100, 20, 7, 1]) { + assert.equal( + changelog.split(`(someone, #${pr}).`).length - 1, + 1, + `${label}: #${pr} appears exactly once`, + ); + } + assert.deepEqual( + await readdir(path.join(root, FRAGMENT_DIR)), + ['README.md'], + label, + ); + } + } + } +}); + +test('an error before the commit rolls the batch back in the same run', async (t) => { + const root = await fixture(t, BATCH); + // The second rename has already moved one fragment into the staging directory. + await assert.rejects( + assembleFragments(root, '--write', failing('rename', 2)), + /injected rename failure #2/, + ); + assert.equal( + await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + EXISTING, + ); + assert.deepEqual( + (await readdir(path.join(root, FRAGMENT_DIR))).sort(), + [...Object.keys(BATCH), 'README.md'].sort(), + ); +}); + +test('check mode reports a batch an interrupted release left staged', async (t) => { + const root = await fixture(t, BATCH); + await assert.rejects( + assembleFragments(root, '--write', failing('rename', 2, { crash: true })), + ); + await assert.rejects( + assembleFragments(root, '--check'), + new RegExp(`${STAGING_DIR}/ holds a batch an interrupted release`), + ); + assert.equal(await recoverInterruptedBatch(root), 'rolled-back'); + assert.equal(await recoverInterruptedBatch(root), 'none'); +}); + +test('recovery refuses to roll a fragment back over a file of the same name', async (t) => { + const root = await fixture(t, BATCH); + await assert.rejects( + assembleFragments(root, '--write', failing('rename', 2, { crash: true })), + ); + await writeFile( + path.join(root, FRAGMENT_DIR, '100-first.md'), + '- Rewritten.', + ); + await assert.rejects( + assembleFragments(root, '--write'), + /Cannot roll back .*100-first\.md/, + ); + assert.equal( + await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + EXISTING, + ); +}); + +// The two below run against the repository itself, so a fragment that the +// release could not assemble fails `npm test` in the pull request that adds it +// rather than at release time, when its author has moved on. + +test('every pending fragment in the repository can be assembled', async () => { + const names = await readdir(path.join(repositoryRoot, FRAGMENT_DIR)); + assert.ok( + names.includes('README.md'), + `${FRAGMENT_DIR}/README.md documents the convention and keeps the directory tracked`, + ); + // Throws on a name that is not -.md, and on two fragments + // claiming one pull request number. Every other file is an entry. + assert.equal(orderFragmentNames(names).length, names.length - 1); +}); + +test('CHANGELOG.md still carries the heading a release splices under', async () => { + const changelog = await readFile( + path.join(repositoryRoot, 'CHANGELOG.md'), + 'utf8', + ); + assert.ok(changelog.startsWith('# Changelog\n')); +}); diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 0b8b54b79..235f542e1 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -111,6 +111,8 @@ export const aircraftInArea = defineTool({ (record) => areaContains(area, { lat: record.latitude, lon: record.longitude }) && !(args.airborne_only && record.onGround) && + ((args.min_altitude_m == null && args.max_altitude_m == null) || + Number.isFinite(altitude(record))) && (args.min_altitude_m == null || altitude(record) >= args.min_altitude_m) && (args.max_altitude_m == null || diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 20ec833aa..d0e979ed6 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -388,3 +388,62 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as assert.deepEqual(result.data.stale_feeds, ['civil']); assert.match(result.summary, /may be stale\.$/); }); + +test('altitude bands require a reported altitude from normalized OpenSky records', async () => { + const { normalizeOpenSkyAircraft } = + await import('../../sources/live/aircraft.js'); + const state = (id, baro, geometric) => + normalizeOpenSkyAircraft([ + id, + 'TEST', + 'Test', + 1767225600, + 1767225600, + 0, + 0, + baro, + false, + 10, + 90, + 0, + [], + geometric, + ]); + const records = [ + state('abc001', null, null), + state('abc002', 0, null), + state('abc003', null, 50), + state('abc004', 500, null), + ]; + const catalog = composeCatalog({ + tools: coreTools, + services: { + aircraft: { getSnapshot: async () => ({ records }) }, + }, + }); + const area = { bbox: [-1, -1, 1, 1] }; + assert.equal( + (await catalog.call('aircraft_in_area', { area })).data.total, + 4, + 'unfiltered answers retain unknown altitude', + ); + for (const band of [ + { max_altitude_m: 100 }, + { min_altitude_m: -500, max_altitude_m: 100 }, + ]) { + const result = await catalog.call('aircraft_in_area', { area, ...band }); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['abc002', 'abc003'], + ); + assert.deepEqual( + result.data.rows.map((row) => row.altitude_m), + [0, 50], + ); + } + const minimum = await catalog.call('aircraft_in_area', { + area, + min_altitude_m: -500, + }); + assert.equal(minimum.data.total, 3); +});