mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge branch 'main' into codex/fix-antimeridian-polygon-containment
This commit is contained in:
commit
189978fc97
22 files changed
+821
-16
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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), []);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <pull request>-<slug>.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 <pull request>-<slug>.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'));
|
||||
});
|
||||
@@ -111,6 +111,8 @@ export const aircraftInArea = defineTool({
|
||||
(record) =>
|
||||
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
|
||||
!(args.airborne_only && record.onGround) &&
|
||||
((args.min_altitude_m == null && args.max_altitude_m == null) ||
|
||||
Number.isFinite(altitude(record))) &&
|
||||
(args.min_altitude_m == null ||
|
||||
altitude(record) >= args.min_altitude_m) &&
|
||||
(args.max_altitude_m == null ||
|
||||
|
||||
@@ -388,3 +388,62 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as
|
||||
assert.deepEqual(result.data.stale_feeds, ['civil']);
|
||||
assert.match(result.summary, /may be stale\.$/);
|
||||
});
|
||||
|
||||
test('altitude bands require a reported altitude from normalized OpenSky records', async () => {
|
||||
const { normalizeOpenSkyAircraft } =
|
||||
await import('../../sources/live/aircraft.js');
|
||||
const state = (id, baro, geometric) =>
|
||||
normalizeOpenSkyAircraft([
|
||||
id,
|
||||
'TEST',
|
||||
'Test',
|
||||
1767225600,
|
||||
1767225600,
|
||||
0,
|
||||
0,
|
||||
baro,
|
||||
false,
|
||||
10,
|
||||
90,
|
||||
0,
|
||||
[],
|
||||
geometric,
|
||||
]);
|
||||
const records = [
|
||||
state('abc001', null, null),
|
||||
state('abc002', 0, null),
|
||||
state('abc003', null, 50),
|
||||
state('abc004', 500, null),
|
||||
];
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
aircraft: { getSnapshot: async () => ({ records }) },
|
||||
},
|
||||
});
|
||||
const area = { bbox: [-1, -1, 1, 1] };
|
||||
assert.equal(
|
||||
(await catalog.call('aircraft_in_area', { area })).data.total,
|
||||
4,
|
||||
'unfiltered answers retain unknown altitude',
|
||||
);
|
||||
for (const band of [
|
||||
{ max_altitude_m: 100 },
|
||||
{ min_altitude_m: -500, max_altitude_m: 100 },
|
||||
]) {
|
||||
const result = await catalog.call('aircraft_in_area', { area, ...band });
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.id),
|
||||
['abc002', 'abc003'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.altitude_m),
|
||||
[0, 50],
|
||||
);
|
||||
}
|
||||
const minimum = await catalog.call('aircraft_in_area', {
|
||||
area,
|
||||
min_altitude_m: -500,
|
||||
});
|
||||
assert.equal(minimum.data.total, 3);
|
||||
});
|
||||
Reference in new issue
Block a user