feat(live-atc): add simulated AI background traffic on the tuned frequency

Implements the ai-traffic roadmap item per
docs/plans/2026-07-14-ai-traffic-architecture-design.md.

Simulated other aircraft on the user's frequency — callsigns, ATC
instructions, readbacks in their own stable voice, handovers — as pure
scenery. It never touches radioBackend: the Python backend keeps owning
the dialogue *with* the user, useAiTraffic owns the radio *around* the
user. The two share only the speech queue (arbitration) and the log.

Rules live as pure, seeded, framework-free modules under
shared/utils/aiTraffic/ so they run in tsx --test without a browser:
callsign collision rules, wake/in-trail separation, runway slots, the
speed ladder, direct validation, the §3 decision table, and the gating
chain. app/composables/useAiTraffic.ts wires them to Vue (1 Hz tick,
spawner, scheduler).

Gating is evaluated twice — before enqueue and again at playback, since
seconds pass in between. Traffic never keys up while the user holds PTT,
while their transmission is out at the backend, or inside the fresh
readback window. Off by default; the toggle surfaces the feature's v1
limitations rather than burying them in a doc.

Zero LLM calls: variance comes from seeded RNG over template variants.

Deviations from the design, both documented in the design doc:
- Adds SimAircraft.quietUntilSec. The design's rule table says "first
  matching row per tick" but never says an instruction must be allowed to
  take effect before the next one. Without it the planner re-derives the
  same unresolved condition every second and nags one aircraft with the
  same vector: 624 calls/30min measured, vs 90 with the cooldown.
- Airline pool limited to the 14 designators DEFAULT_AIRLINE_TELEPHONY
  already knows; UAE/AUA/WZZ from the design would be spelled out letter
  by letter instead of spoken as airline names.

Verified: 406 tests pass (176 new), no new typecheck errors, /live-atc
compiles and serves. The manual in-session walkthrough (audible traffic,
toggle mid-session) is NOT verified — it needs a login and the Python
backend. The 30-minute deterministic integration run stands in for it and
caught two of the three bugs found during development.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-16 17:46:52 +02:00
parent ab04411bc5
commit 70f0b26d90
25 changed files with 3968 additions and 3 deletions

View File

@@ -0,0 +1,250 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { createRng, trafficSeed } from '~~/shared/utils/aiTraffic/rng'
import {
TRAFFIC_AIRLINES,
VFR_REGISTRATION_POOL,
callsignPrefix,
callsignTail,
createCallsignFactory,
isCallsignDistinct,
levenshtein,
spokenCallsign,
} from '~~/shared/utils/aiTraffic/callsign'
import { findSimAircraftType } from '~~/shared/data/simAircraftTypes'
describe('seeded rng', () => {
it('replays identically for the same seed', () => {
const a = createRng('seed-1')
const b = createRng('seed-1')
for (let i = 0; i < 50; i++) assert.equal(a.next(), b.next())
})
it('diverges for different seeds', () => {
const a = createRng('seed-1')
const b = createRng('seed-2')
const left = Array.from({ length: 20 }, () => a.next())
const right = Array.from({ length: 20 }, () => b.next())
assert.notDeepEqual(left, right)
})
it('stays inside its declared ranges', () => {
const rng = createRng('ranges')
for (let i = 0; i < 500; i++) {
const u = rng.next()
assert.ok(u >= 0 && u < 1)
const n = rng.int(3, 7)
assert.ok(Number.isInteger(n) && n >= 3 && n <= 7)
const f = rng.float(1, 2)
assert.ok(f >= 1 && f < 2)
}
})
it('hits both ends of an int range', () => {
const rng = createRng('ends')
const seen = new Set<number>()
for (let i = 0; i < 200; i++) seen.add(rng.int(0, 1))
assert.deepEqual([...seen].sort(), [0, 1])
})
it('respects weights', () => {
const rng = createRng('weights')
let heavy = 0
for (let i = 0; i < 1000; i++) {
if (rng.weighted([{ value: 'a', weight: 90 }, { value: 'b', weight: 10 }]) === 'a') heavy++
}
assert.ok(heavy > 850 && heavy < 950, `expected ~900 'a', got ${heavy}`)
})
it('never returns a zero-weight option', () => {
const rng = createRng('zero-weight')
for (let i = 0; i < 100; i++) {
assert.equal(rng.weighted([{ value: 'a', weight: 1 }, { value: 'b', weight: 0 }]), 'a')
}
})
it('rejects degenerate inputs instead of returning undefined', () => {
const rng = createRng('degenerate')
assert.throws(() => rng.pick([]), /empty list/)
assert.throws(() => rng.weighted([]), /no positive weights/)
assert.throws(() => rng.weighted([{ value: 'a', weight: 0 }]), /no positive weights/)
})
it('does not degenerate when the seed hashes to zero', () => {
const rng = createRng(0)
const values = Array.from({ length: 10 }, () => rng.next())
assert.equal(new Set(values).size, 10)
})
it('derives a per-session, per-day seed', () => {
const day = new Date(2026, 6, 16)
assert.equal(trafficSeed('abc', day), 'abc|2026-7-16')
assert.notEqual(trafficSeed('abc', day), trafficSeed('abc', new Date(2026, 6, 17)))
assert.notEqual(trafficSeed('abc', day), trafficSeed('abd', day))
})
})
describe('callsign decomposition', () => {
it('splits airline callsigns into designator and tail', () => {
assert.equal(callsignPrefix('DLH472'), 'DLH')
assert.equal(callsignTail('DLH472'), '472')
assert.equal(callsignPrefix('EZY93A'), 'EZY')
assert.equal(callsignTail('EZY93A'), '93A')
})
it('treats registrations as prefix-less', () => {
assert.equal(callsignPrefix('D-EMIL'), '')
assert.equal(callsignTail('D-EMIL'), 'EMIL')
assert.equal(callsignPrefix('N123AB'), '')
assert.equal(callsignTail('N123AB'), '123AB')
})
})
describe('levenshtein', () => {
it('measures the edits between spoken tails', () => {
assert.equal(levenshtein('39A', '39A'), 0)
assert.equal(levenshtein('39A', '39B'), 1)
assert.equal(levenshtein('39A', '47B'), 3)
assert.equal(levenshtein('', '472'), 3)
assert.equal(levenshtein('472', ''), 3)
})
})
describe('isCallsignDistinct — the three collision rules', () => {
it('rule 1: rejects the user callsign verbatim', () => {
assert.equal(isCallsignDistinct('DLH39A', ['DLH39A']), false)
})
it('rule 1: ignores casing and hyphens when comparing', () => {
assert.equal(isCallsignDistinct('d-emil', ['D-EMIL']), false)
})
it('rule 2: rejects any aircraft from the user airline', () => {
assert.equal(isCallsignDistinct('DLH8172', ['DLH39A']), false)
})
it('rule 3: rejects a confusable tail across airlines', () => {
// BAW39A vs DLH39A — different airline, identical spoken number.
assert.equal(isCallsignDistinct('BAW39A', ['DLH39A']), false)
// One digit apart is still one mishearing away.
assert.equal(isCallsignDistinct('BAW39B', ['DLH39A']), false)
})
it('rule 3: accepts a tail that differs in two positions', () => {
assert.equal(isCallsignDistinct('BAW47B', ['DLH39A']), true)
})
it('rule 3: guards VFR registrations too', () => {
assert.equal(isCallsignDistinct('D-EMIT', ['D-EMIL']), false)
assert.equal(isCallsignDistinct('D-EKLM', ['D-EMIL']), true)
})
it('rejects an empty candidate', () => {
assert.equal(isCallsignDistinct('', ['DLH39A']), false)
})
it('accepts anything when nothing is blocked', () => {
assert.equal(isCallsignDistinct('DLH472', []), true)
})
})
describe('spokenCallsign', () => {
it('renders the airline telephony name and per-digit number', () => {
// Digits come out in the product's ICAO spelling ("too", "tree", "niner").
const spoken = spokenCallsign('DLH472', findSimAircraftType('A320')!)
assert.match(spoken, /^Lufthansa four seven too$/i)
})
it('appends heavy for a widebody and super for the A380', () => {
assert.match(spokenCallsign('BAW118', findSimAircraftType('B77W')!), /heavy$/i)
assert.match(spokenCallsign('UAL900', findSimAircraftType('A388')!), /super$/i)
})
it('leaves a medium without a wake suffix', () => {
const spoken = spokenCallsign('EZY93', findSimAircraftType('A320')!)
assert.doesNotMatch(spoken, /heavy|super/i)
})
})
describe('createCallsignFactory', () => {
const userCallsigns = ['DLH39A', 'DLH39A']
it('never issues anything that collides with the user or with itself', () => {
// Fresh seeds, many draws: no run may ever produce a confusable callsign.
for (let seed = 0; seed < 40; seed++) {
const factory = createCallsignFactory({
rng: createRng(`factory-${seed}`),
tier: 'major',
userCallsigns,
})
const issued: string[] = []
for (let i = 0; i < 5; i++) {
const generated = factory.next()
assert.ok(generated, 'factory ran out of distinct callsigns')
assert.ok(
isCallsignDistinct(generated.callsign, [...userCallsigns, ...issued]),
`${generated.callsign} collides (issued: ${issued.join(', ')})`,
)
issued.push(generated.callsign)
}
}
})
it('never issues a callsign from the user airline', () => {
const factory = createCallsignFactory({ rng: createRng('airline'), tier: 'major', userCallsigns })
for (let i = 0; i < 5; i++) {
assert.notEqual(callsignPrefix(factory.next()!.callsign), 'DLH')
}
})
it('never issues a registration from the VFR pool the user may be flying', () => {
const factory = createCallsignFactory({ rng: createRng('vfr'), tier: 'ga', userCallsigns: ['D-EMIL'] })
for (let i = 0; i < 5; i++) {
const cs = factory.next()!.callsign
assert.equal(VFR_REGISTRATION_POOL.includes(cs), false, `${cs} is in the VFR pool`)
}
})
it('only uses airline designators the radiotelephony normalizer can speak', () => {
const factory = createCallsignFactory({ rng: createRng('speakable'), tier: 'major', userCallsigns })
for (let i = 0; i < 5; i++) {
const prefix = callsignPrefix(factory.next()!.callsign)
if (prefix) assert.ok(TRAFFIC_AIRLINES.includes(prefix), `${prefix} has no telephony name`)
}
})
it('is deterministic for a given seed', () => {
const draw = () => {
const factory = createCallsignFactory({ rng: createRng('fixed'), tier: 'major', userCallsigns })
return [factory.next(), factory.next(), factory.next()].map(g => `${g!.callsign}/${g!.type.icao}`)
}
assert.deepEqual(draw(), draw())
})
it('weights the type mix by tier', () => {
const classesFor = (tier: 'major' | 'ga') => {
const factory = createCallsignFactory({ rng: createRng(`mix-${tier}`), tier, userCallsigns })
// Release each draw so the collision rules don't exhaust the pool.
return Array.from({ length: 200 }, () => {
const g = factory.next()
if (g) factory.release(g.callsign)
return g?.type.class
}).filter(Boolean)
}
const major = classesFor('major')
const ga = classesFor('ga')
assert.ok(major.filter(c => c === 'narrowbody').length > major.length * 0.5, 'major should be narrowbody-heavy')
assert.equal(major.filter(c => c === 'ga').length, 0, 'major must not spawn GA singles')
assert.ok(ga.filter(c => c === 'ga').length > ga.length * 0.6, 'ga field should be GA-heavy')
assert.equal(ga.filter(c => c === 'widebody').length, 0, 'a GA field must not spawn widebodies')
})
it('frees a callsign again on release', () => {
const factory = createCallsignFactory({ rng: createRng('release'), tier: 'major', userCallsigns })
const first = factory.next()!
assert.deepEqual(factory.issued(), [first.callsign])
factory.release(first.callsign)
assert.deepEqual(factory.issued(), [])
})
})

View File

@@ -0,0 +1,205 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
DEFAULT_READBACK_PROTECTION_MS,
evaluateGate,
gateOpen,
readbackPending,
type GateInput,
} from '~~/shared/utils/aiTraffic/gating'
const NOW = 1_000_000
/** A session in its natural resting state: open frequency, nothing pending. */
const openInput = (overrides: Partial<GateInput> = {}): GateInput => ({
aiTrafficEnabled: true,
isRecording: false,
transmitInFlight: false,
sessionActive: true,
readback: {
currentStateRole: 'atc',
backendExpectedPhrase: null,
lastControllerSpeechAtMs: null,
nowMs: NOW,
},
...overrides,
})
describe('readbackPending — the fresh-readback window', () => {
const freshWindow = {
currentStateRole: 'pilot' as const,
backendExpectedPhrase: 'Cleared to land runway 25R, DLH39A',
lastControllerSpeechAtMs: NOW,
nowMs: NOW + 1000,
}
it('locks the frequency right after ATC issues an instruction', () => {
assert.equal(readbackPending(freshWindow), true)
})
it('stays locked for the whole protection window', () => {
assert.equal(
readbackPending({ ...freshWindow, nowMs: NOW + DEFAULT_READBACK_PROTECTION_MS - 1 }),
true,
)
})
it('releases once the window lapses — a dawdling pilot must not mute the frequency forever', () => {
assert.equal(
readbackPending({ ...freshWindow, nowMs: NOW + DEFAULT_READBACK_PROTECTION_MS }),
false,
)
assert.equal(readbackPending({ ...freshWindow, nowMs: NOW + 60_000 }), false)
})
it('does not lock on an ATC or system state, even with a phrase set', () => {
assert.equal(readbackPending({ ...freshWindow, currentStateRole: 'atc' }), false)
assert.equal(readbackPending({ ...freshWindow, currentStateRole: 'system' }), false)
assert.equal(readbackPending({ ...freshWindow, currentStateRole: undefined }), false)
})
it('does not lock on a pilot state the backend expects nothing for', () => {
assert.equal(readbackPending({ ...freshWindow, backendExpectedPhrase: null }), false)
assert.equal(readbackPending({ ...freshWindow, backendExpectedPhrase: '' }), false)
})
it('does not lock before any ATC instruction has been spoken', () => {
assert.equal(readbackPending({ ...freshWindow, lastControllerSpeechAtMs: null }), false)
})
it('supports the literal-strict reading via one parameter, not a second code path', () => {
const strict = { ...freshWindow, nowMs: NOW + 3_600_000, readbackProtectionMs: Number.POSITIVE_INFINITY }
assert.equal(readbackPending(strict), true)
})
it('supports a custom finite window', () => {
const win = { ...freshWindow, readbackProtectionMs: 5000 }
assert.equal(readbackPending({ ...win, nowMs: NOW + 4999 }), true)
assert.equal(readbackPending({ ...win, nowMs: NOW + 5000 }), false)
})
})
describe('evaluateGate — the chain', () => {
it('opens on a quiet, active session', () => {
assert.deepEqual(evaluateGate(openInput()), { open: true })
assert.equal(gateOpen(openInput()), true)
})
it('is shut while the settings toggle is off', () => {
assert.deepEqual(evaluateGate(openInput({ aiTrafficEnabled: false })), {
open: false,
reason: 'disabled',
})
})
it('is shut while the user holds PTT — absolutely, no window', () => {
assert.deepEqual(evaluateGate(openInput({ isRecording: true })), {
open: false,
reason: 'recording',
})
})
it('is shut while a user transmission awaits the backend', () => {
assert.deepEqual(evaluateGate(openInput({ transmitInFlight: true })), {
open: false,
reason: 'transmit_in_flight',
})
})
it('is shut without an active session', () => {
assert.deepEqual(evaluateGate(openInput({ sessionActive: false })), {
open: false,
reason: 'session_inactive',
})
})
it('is shut inside the fresh readback window', () => {
const input = openInput({
readback: {
currentStateRole: 'pilot',
backendExpectedPhrase: 'Runway 25R, cleared to land, DLH39A',
lastControllerSpeechAtMs: NOW,
nowMs: NOW + 2000,
},
})
assert.deepEqual(evaluateGate(input), { open: false, reason: 'readback_pending' })
})
it('reopens after the readback window lapses', () => {
const input = openInput({
readback: {
currentStateRole: 'pilot',
backendExpectedPhrase: 'Runway 25R, cleared to land, DLH39A',
lastControllerSpeechAtMs: NOW,
nowMs: NOW + DEFAULT_READBACK_PROTECTION_MS + 1,
},
})
assert.equal(gateOpen(input), true)
})
it('reports the most specific reason when several block at once', () => {
const everythingShut = openInput({
aiTrafficEnabled: false,
isRecording: true,
transmitInFlight: true,
sessionActive: false,
})
assert.equal(evaluateGate(everythingShut).reason, 'disabled')
assert.equal(evaluateGate({ ...everythingShut, aiTrafficEnabled: true }).reason, 'recording')
assert.equal(
evaluateGate({ ...everythingShut, aiTrafficEnabled: true, isRecording: false }).reason,
'transmit_in_flight',
)
})
it('PTT outranks the readback window: the user speaking is never negotiable', () => {
const input = openInput({
isRecording: true,
readback: {
currentStateRole: 'pilot',
backendExpectedPhrase: 'anything',
lastControllerSpeechAtMs: NOW - 60_000, // window long lapsed
nowMs: NOW,
},
})
assert.equal(gateOpen(input), false)
})
})
describe('evaluateGate — the two evaluation points', () => {
// The chain is called before enqueuing AND again when the task starts playing.
// Seconds pass in between; these are the cases that must differ across them.
it('a gate that was open at enqueue time is shut at play time once PTT goes down', () => {
const atEnqueue = openInput()
assert.equal(gateOpen(atEnqueue), true)
const atPlayback = { ...atEnqueue, isRecording: true }
assert.equal(gateOpen(atPlayback), false)
})
it('a gate that was open at enqueue time is shut at play time once the toggle flips off', () => {
// This is what makes "finish the current call, start nothing new" work
// without touching the speech queue's mechanics.
assert.equal(gateOpen(openInput()), true)
assert.equal(gateOpen(openInput({ aiTrafficEnabled: false })), false)
})
it('a gate that was open at enqueue time is shut at play time once ATC issues an instruction', () => {
assert.equal(gateOpen(openInput()), true)
const afterAtcSpoke = openInput({
readback: {
currentStateRole: 'pilot',
backendExpectedPhrase: 'Descend 4000 feet, DLH39A',
lastControllerSpeechAtMs: NOW + 500,
nowMs: NOW + 900,
},
})
assert.equal(gateOpen(afterAtcSpoke), false)
})
it('a gate that was shut at enqueue time can be open at play time', () => {
const duringPtt = openInput({ isRecording: true })
assert.equal(gateOpen(duringPtt), false)
assert.equal(gateOpen({ ...duringPtt, isRecording: false }), true)
})
})

View File

@@ -0,0 +1,424 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { createRng } from '~~/shared/utils/aiTraffic/rng'
import { findSimAircraftType } from '~~/shared/data/simAircraftTypes'
import {
SPEED_LADDER,
applyDirect,
isAtMinimumSpeed,
nextSpeedStep,
planInstruction,
renderInstruction,
speedLadderFor,
validateDirect,
type PlannerContext,
} from '~~/shared/utils/aiTraffic/instructions'
import type { SimAircraft, SimPhase } from '~~/shared/utils/aiTraffic/types'
const A320 = findSimAircraftType('A320')! // Medium, Vref 130
const B77W = findSimAircraftType('B77W')! // Heavy, Vref 145
const C172 = findSimAircraftType('C172')! // Light
function aircraft(overrides: Partial<SimAircraft> = {}): SimAircraft {
return {
callsign: 'BAW118',
callsignSpoken: 'Speedbird one one eight',
type: A320,
voiceId: 'nova',
phase: 'approach',
frequency: '119.000',
routeFixes: ['KOTAP', 'RIVAK', 'DOMUX'],
distanceToFieldNm: 20,
altitudeFt: 6000,
iasKts: 250,
assignedSpeedKts: null,
vectorDelaySec: 0,
runwaySlot: null,
nextEventAtSec: Number.POSITIVE_INFINITY,
quietUntilSec: 0,
...overrides,
}
}
describe('nextSpeedStep — the ladder', () => {
it('steps down one rung at a time, ending on the type approach speed', () => {
let ac = aircraft({ iasKts: 250, assignedSpeedKts: null })
const issued: number[] = []
for (let i = 0; i < 10; i++) {
const step = nextSpeedStep(ac)
if (step === null) break
issued.push(step)
ac = { ...ac, assignedSpeedKts: step }
}
assert.deepEqual(issued, [220, 210, 190, 180, 170, 160, A320.approachKts])
})
it('drops rungs that sit below a slower type approach speed', () => {
// B744 rotates at Vref 150 — the 160 rung is its last fixed one, then 150.
assert.deepEqual(speedLadderFor(findSimAircraftType('B744')!), [250, 220, 210, 190, 180, 170, 160, 150])
// A hypothetical fast-approaching type never sees the low rungs at all.
assert.deepEqual(speedLadderFor({ ...A320, approachKts: 185 }), [250, 220, 210, 190, 185])
})
it('never assigns above 250 kt below 10,000 ft', () => {
const fast = aircraft({ altitudeFt: 8000, iasKts: 300, assignedSpeedKts: null })
const step = nextSpeedStep(fast)!
assert.ok(step <= 250, `assigned ${step} below 10,000 ft`)
assert.equal(step, 250)
})
it('may assign above 250 kt above 10,000 ft', () => {
const high = aircraft({ altitudeFt: 14000, iasKts: 300, assignedSpeedKts: null })
assert.equal(nextSpeedStep(high), 250)
})
it('bottoms out at the type Vref and has nothing left to say below it', () => {
// A320 Vref 130: 160 → 130, and 130 is the end of the road.
assert.equal(nextSpeedStep(aircraft({ assignedSpeedKts: 160 })), 130)
assert.equal(nextSpeedStep(aircraft({ assignedSpeedKts: 130 })), null)
// B77W Vref 145 — its floor is higher than the A320's, as it must be.
assert.equal(nextSpeedStep(aircraft({ type: B77W, assignedSpeedKts: 160 })), 145)
assert.equal(nextSpeedStep(aircraft({ type: B77W, assignedSpeedKts: 145 })), null)
})
it('never assigns below Vref for any type, from any rung', () => {
for (const type of [A320, B77W, findSimAircraftType('E190')!, findSimAircraftType('A388')!, findSimAircraftType('B744')!]) {
for (const rung of [...SPEED_LADDER, type.approachKts]) {
const step = nextSpeedStep(aircraft({ type, assignedSpeedKts: rung + 1 }))
if (step !== null) assert.ok(step >= type.approachKts, `${type.icao}: ${step} < Vref ${type.approachKts}`)
}
}
})
it('gives a Cessna no jet speed callout at all', () => {
assert.equal(nextSpeedStep(aircraft({ type: C172, iasKts: 110 })), null)
assert.equal(isAtMinimumSpeed(aircraft({ type: C172 })), true)
})
it('only controls speed in phases where it makes sense', () => {
const controllable: SimPhase[] = ['inbound', 'approach']
const nonsense: SimPhase[] = ['final', 'rollout', 'taxi_out', 'lineup', 'takeoff', 'climbout', 'handed_off']
for (const phase of controllable) {
assert.notEqual(nextSpeedStep(aircraft({ phase })), null, `${phase} should allow speed control`)
}
for (const phase of nonsense) {
assert.equal(nextSpeedStep(aircraft({ phase })), null, `${phase} must not get an approach reduction`)
}
})
it('measures from the assigned speed, not the current one, while IAS is still catching up', () => {
// Told 220, still decelerating through 240 — the next rung is 210, not 220 again.
assert.equal(nextSpeedStep(aircraft({ iasKts: 240, assignedSpeedKts: 220 })), 210)
})
})
describe('validateDirect', () => {
const ctx = { gapNm: 30, requiredNm: 5, nmPerFix: 8 }
it('accepts a fix that lies ahead with plenty of air', () => {
const result = validateDirect(aircraft(), 'DOMUX', ctx)
assert.equal(result.valid, true)
assert.equal(result.fixIndex, 2)
assert.equal(result.savedNm, 16)
})
it('rejects the fix the aircraft is already proceeding to', () => {
assert.deepEqual(validateDirect(aircraft(), 'KOTAP', ctx), { valid: false, reason: 'behind' })
})
it('rejects a fix that is not on the remaining route', () => {
assert.deepEqual(validateDirect(aircraft(), 'NOWAY', ctx), { valid: false, reason: 'not_on_route' })
})
it('rejects a direct without the headroom that makes it a reward, not a fix', () => {
const result = validateDirect(aircraft(), 'DOMUX', { ...ctx, gapNm: 9 })
assert.deepEqual(result, { valid: false, reason: 'no_headroom' })
})
it('rejects a shortcut that would bust the minimum the moment it is read back', () => {
// 20 NM gap clears the 2× headroom, but cutting 16 NM leaves only 4 < 5 required.
const result = validateDirect(aircraft(), 'DOMUX', { ...ctx, gapNm: 20 })
assert.deepEqual(result, { valid: false, reason: 'gap_conflict' })
})
it('is never generated outside the phases where a shortcut means anything', () => {
for (const phase of ['final', 'climbout', 'taxi_out', 'rollout'] as SimPhase[]) {
assert.deepEqual(validateDirect(aircraft({ phase }), 'DOMUX', ctx), { valid: false, reason: 'wrong_phase' })
}
})
})
describe('applyDirect', () => {
it('shortens the route and the distance together, so later phraseology stays consistent', () => {
const ac = aircraft({ distanceToFieldNm: 40 })
const validation = validateDirect(ac, 'DOMUX', { gapNm: 60, requiredNm: 5, nmPerFix: 8 })
applyDirect(ac, validation)
assert.deepEqual(ac.routeFixes, ['DOMUX'])
assert.equal(ac.distanceToFieldNm, 24)
})
it('ignores an invalid direct rather than corrupting the route', () => {
const ac = aircraft()
applyDirect(ac, { valid: false, reason: 'behind' })
assert.deepEqual(ac.routeFixes, ['KOTAP', 'RIVAK', 'DOMUX'])
assert.equal(ac.distanceToFieldNm, 20)
})
it('never drives the distance to the field negative', () => {
const ac = aircraft({ distanceToFieldNm: 4 })
applyDirect(ac, { valid: true, fixIndex: 2, fix: 'DOMUX', savedNm: 16 })
assert.equal(ac.distanceToFieldNm, 0)
})
})
describe('planInstruction — the decision table', () => {
const baseCtx = (overrides: Partial<PlannerContext> = {}): PlannerContext => ({
nowSec: 1000,
rng: createRng('planner'),
leader: null,
occupiedSlots: [],
lastDeparture: null,
handover: null,
nmPerFix: 8,
silentForSec: 0,
ambientAfterSec: 60,
...overrides,
})
it('row 7: a sector boundary hands the aircraft off', () => {
const plan = planInstruction(
aircraft({ nextEventAtSec: 900 }),
baseCtx({ handover: { station: 'Frankfurt Tower', frequency: '119.900' } }),
)
assert.equal(plan?.kind, 'handover')
assert.equal(plan?.handoverFrequency, '119.900')
})
it('row 3: a departure behind a heavy is held for the wake timer', () => {
const plan = planInstruction(
aircraft({ phase: 'lineup', nextEventAtSec: 900 }),
baseCtx({ lastDeparture: { type: B77W, atSec: 950 } }),
)
assert.equal(plan?.kind, 'wake_hold')
assert.equal(plan?.holdSec, 130) // 180 required 50 elapsed
})
it('row 3: no hold once the wake timer has run out', () => {
const plan = planInstruction(
aircraft({ phase: 'lineup', nextEventAtSec: 900 }),
baseCtx({ lastDeparture: { type: B77W, atSec: 800 } }),
)
assert.notEqual(plan?.kind, 'wake_hold')
})
it('row 3: no hold behind a medium — runway occupancy covers that', () => {
const plan = planInstruction(
aircraft({ phase: 'lineup', nextEventAtSec: 900 }),
baseCtx({ lastDeparture: { type: A320, atSec: 995 } }),
)
assert.notEqual(plan?.kind, 'wake_hold')
})
it('row 2: simulated traffic yields to the user runway reservation', () => {
const plan = planInstruction(
aircraft({ phase: 'final', runwaySlot: { fromSec: 1000, toSec: 1060 }, nextEventAtSec: 900 }),
baseCtx({ occupiedSlots: [{ fromSec: 1030, toSec: 1120 }] }),
)
assert.equal(plan?.kind, 'slot_hold')
})
it('row 2: a free slot does not trigger a hold', () => {
const plan = planInstruction(
aircraft({ phase: 'final', runwaySlot: { fromSec: 1000, toSec: 1060 } }),
baseCtx({ occupiedSlots: [{ fromSec: 1200, toSec: 1260 }] }),
)
assert.notEqual(plan?.kind, 'slot_hold')
})
it('row 2: an aircraft does not conflict with its own reservation', () => {
// The caller passes the whole runway timeline, own slot included. Counting it
// would hold every aircraft against itself forever instead of ever flying.
const slot = { fromSec: 1000, toSec: 1060 }
const plan = planInstruction(
aircraft({ phase: 'final', runwaySlot: slot }),
baseCtx({ occupiedSlots: [slot] }),
)
assert.notEqual(plan?.kind, 'slot_hold')
})
it('row 1: a due phase change produces a phase call', () => {
const plan = planInstruction(aircraft({ nextEventAtSec: 999 }), baseCtx())
assert.equal(plan?.kind, 'phase')
})
it('row 4: closing up with speed left produces one step down', () => {
const plan = planInstruction(
aircraft({ distanceToFieldNm: 15.5, iasKts: 250 }),
baseCtx({ leader: { distanceToFieldNm: 10, type: B77W } }), // required 5, gap 5.5
)
assert.equal(plan?.kind, 'speed')
assert.equal(plan?.speedKts, 220)
})
it('row 5: a busted minimum at minimum speed produces a vector', () => {
// Already told to fly its Vref — speed control has nothing left to give.
const plan = planInstruction(
aircraft({ distanceToFieldNm: 14, iasKts: 130, assignedSpeedKts: A320.approachKts }),
baseCtx({ leader: { distanceToFieldNm: 10, type: B77W } }), // required 5, gap 4
)
assert.equal(plan?.kind, 'vector')
assert.ok(plan!.headingDeg! >= 10 && plan!.headingDeg! <= 360)
assert.ok(plan!.vectorDelaySec! >= 60 && plan!.vectorDelaySec! <= 120)
})
it('row 5: prefers speed over a vector while a rung remains', () => {
const plan = planInstruction(
aircraft({ distanceToFieldNm: 14, iasKts: 250 }),
baseCtx({ leader: { distanceToFieldNm: 10, type: B77W } }),
)
assert.equal(plan?.kind, 'speed', 'a vector is the last resort, not the first')
})
it('row 5: a GA aircraft with no ladder left goes straight to a vector', () => {
const plan = planInstruction(
aircraft({ type: C172, distanceToFieldNm: 12, iasKts: 90 }),
baseCtx({ leader: { distanceToFieldNm: 10, type: B77W } }),
)
assert.equal(plan?.kind, 'vector')
})
it('row 6: a direct only shows up with a lot of air, and only rarely', () => {
const kinds = Array.from({ length: 400 }, (_, i) =>
planInstruction(
aircraft({ distanceToFieldNm: 60 }),
baseCtx({ rng: createRng(`direct-${i}`), leader: { distanceToFieldNm: 10, type: A320 } }),
)?.kind,
)
const directs = kinds.filter(k => k === 'direct').length
assert.ok(directs > 0, 'a direct should occasionally fire on a quiet picture')
assert.ok(directs < 100, `a direct should stay rare, fired ${directs}/400`)
})
it('row 6: never issues a direct when the picture is tight', () => {
for (let i = 0; i < 200; i++) {
const plan = planInstruction(
aircraft({ distanceToFieldNm: 14, iasKts: 130, assignedSpeedKts: A320.approachKts }),
baseCtx({ rng: createRng(`tight-${i}`), leader: { distanceToFieldNm: 10, type: B77W } }),
)
assert.notEqual(plan?.kind, 'direct')
}
})
it('row 8: a long-silent frequency gets ambient chatter', () => {
const plan = planInstruction(aircraft(), baseCtx({ silentForSec: 75, ambientAfterSec: 60 }))
assert.equal(plan?.kind, 'ambient')
})
it('row 8: no ambient chatter while the frequency is still busy', () => {
const plan = planInstruction(aircraft(), baseCtx({ silentForSec: 20, ambientAfterSec: 60 }))
assert.equal(plan, null)
})
it('says nothing at all when no row matches', () => {
assert.equal(planInstruction(aircraft(), baseCtx()), null)
})
it('is deterministic for a given seed', () => {
const run = () =>
planInstruction(
aircraft({ distanceToFieldNm: 14, iasKts: 130, assignedSpeedKts: A320.approachKts }),
baseCtx({ rng: createRng('fixed'), leader: { distanceToFieldNm: 10, type: B77W } }),
)
assert.deepEqual(run(), run())
})
})
describe('renderInstruction — phraseology', () => {
const ctx = { rng: createRng('render'), station: 'Frankfurt Approach', runway: '25R' }
it('always names the aircraft in both the call and the readback', () => {
for (const kind of ['speed', 'vector', 'direct', 'handover', 'wake_hold', 'slot_hold', 'phase'] as const) {
const event = renderInstruction(
aircraft(),
{
kind,
callsign: 'BAW118',
speedKts: 180,
headingDeg: 250,
direct: { valid: true, fix: 'DOMUX', fixIndex: 2, savedNm: 16 },
handoverStation: 'Frankfurt Tower',
handoverFrequency: '119.900',
},
{ ...ctx, rng: createRng(`render-${kind}`) },
)
assert.match(event.atcText, /BAW118/, `${kind} ATC call omits the callsign`)
assert.match(event.pilotReadbackText, /BAW118/, `${kind} readback omits the callsign`)
}
})
it('gives a Cessna "slowest practical speed" instead of a number', () => {
const event = renderInstruction(
aircraft({ type: C172, callsign: 'D-EKLM' }),
{ kind: 'speed', callsign: 'D-EKLM', speedKts: 180 },
ctx,
)
assert.match(event.atcText, /slowest practical|as slow as practical/i)
assert.doesNotMatch(event.atcText, /180/)
})
it('reads the assigned speed back verbatim', () => {
const event = renderInstruction(aircraft(), { kind: 'speed', callsign: 'BAW118', speedKts: 190 }, ctx)
assert.match(event.atcText, /190 knots/)
assert.match(event.pilotReadbackText, /190/)
})
it('reads the handover frequency back — the one thing that must not be misheard', () => {
const event = renderInstruction(
aircraft(),
{ kind: 'handover', callsign: 'BAW118', handoverStation: 'Frankfurt Tower', handoverFrequency: '119.900' },
ctx,
)
assert.match(event.atcText, /Frankfurt Tower/)
assert.match(event.pilotReadbackText, /119\.900/)
})
it('tells an arrival to continue approach but a departure to hold', () => {
const arrival = renderInstruction(aircraft({ phase: 'final' }), { kind: 'slot_hold', callsign: 'BAW118' }, ctx)
assert.match(arrival.atcText, /continue approach/i)
const departure = renderInstruction(aircraft({ phase: 'lineup' }), { kind: 'slot_hold', callsign: 'BAW118' }, ctx)
assert.match(departure.atcText, /hold (position|short)/i)
})
it('emits every instruction ATC-first', () => {
for (const kind of ['speed', 'vector', 'direct', 'handover', 'wake_hold', 'slot_hold', 'phase'] as const) {
const event = renderInstruction(aircraft(), { kind, callsign: 'BAW118', speedKts: 180 }, ctx)
assert.equal(event.order, 'atc_first', `${kind} must be ATC-first`)
}
})
it('emits an ambient check-in pilot-first — the aircraft calls, ATC answers', () => {
const event = renderInstruction(aircraft(), { kind: 'ambient', callsign: 'BAW118' }, ctx)
assert.equal(event.order, 'pilot_first')
assert.match(event.pilotReadbackText, /Frankfurt Approach/)
})
it('varies its wording across draws', () => {
const texts = new Set(
Array.from({ length: 20 }, (_, i) =>
renderInstruction(
aircraft(),
{ kind: 'speed', callsign: 'BAW118', speedKts: 180 },
{ ...ctx, rng: createRng(`variant-${i}`) },
).atcText,
),
)
assert.ok(texts.size > 1, 'template variants should produce more than one phrasing')
})
it('is deterministic for a given seed', () => {
const render = () =>
renderInstruction(aircraft(), { kind: 'phase', callsign: 'BAW118' }, { ...ctx, rng: createRng('fixed') })
assert.deepEqual(render(), render())
})
})

View File

@@ -0,0 +1,271 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { createRng } from '~~/shared/utils/aiTraffic/rng'
import { createCallsignFactory, isCallsignDistinct } from '~~/shared/utils/aiTraffic/callsign'
import { planInstruction, renderInstruction, applyDirect, cooldownSecFor } from '~~/shared/utils/aiTraffic/instructions'
import { assessInTrail, nextFreeSlot } from '~~/shared/utils/aiTraffic/separation'
import {
NM_PER_FIX,
advanceAircraft,
advancePhase,
createSimAircraft,
findLeader,
generateFixPool,
isArrival,
isDespawnable,
} from '~~/shared/utils/aiTraffic/sim'
import { MAX_ACTIVE_TRAFFIC, targetTrafficCount } from '~~/shared/data/trafficTiers'
import type { RadioEvent } from '~~/shared/utils/aiTraffic/instructions'
import type { SimAircraft } from '~~/shared/utils/aiTraffic/types'
/**
* A long deterministic run of the pure sim core, wired the way useAiTraffic wires
* it. This is the closest thing to the real loop that runs without a browser: it
* catches the failures unit tests structurally cannot — runaway spawning, a
* wedged pending queue, separation that degrades over time, a callsign pool that
* collides once aircraft start cycling through it.
*
* It mirrors the composable's orchestration rather than importing it (the
* composable is Vue-bound); the RULES it exercises are the real, shipped ones.
*/
const USER_CALLSIGNS = ['DLH39A', 'DLH39A']
const RUNWAY_SLOT_SEC = 90
interface RunResult {
events: RadioEvent[]
pool: SimAircraft[]
maxPopulation: number
gapViolationTicks: number
}
function runSim(seed: string, ticks: number, target: number, gateOpen: () => boolean): RunResult {
const rng = createRng(seed)
const fixPool = generateFixPool(createRng(`${seed}|fixes`))
const callsigns = createCallsignFactory({ rng, tier: 'major', userCallsigns: USER_CALLSIGNS })
let pool: SimAircraft[] = []
let pending: RadioEvent[] = []
const events: RadioEvent[] = []
let nowSec = 0
let lastRadioAtSec = 0
let nextSpawnAtSec = 0
let ambientAfterSec = rng.int(45, 90)
let lastDeparture: { type: SimAircraft['type']; atSec: number } | null = null
let maxPopulation = 0
let gapViolationTicks = 0
const occupiedSlots = () => pool.filter(a => a.runwaySlot).map(a => a.runwaySlot!)
for (let t = 0; t < ticks; t++) {
nowSec += 1
for (const aircraft of [...pool]) {
advanceAircraft(aircraft, 1)
if (isDespawnable(aircraft)) {
callsigns.release(aircraft.callsign)
pool = pool.filter(a => a !== aircraft)
pending = pending.filter(e => e.callsign !== aircraft.callsign)
}
}
if (pool.length < target && nowSec >= nextSpawnAtSec && pool.length < MAX_ACTIVE_TRAFFIC) {
const generated = callsigns.next()
if (generated) {
const kind = rng.chance(0.6) ? 'arrival' : 'departure'
const aircraft = createSimAircraft(generated, kind, { rng, nowSec, frequency: '119.000', fixPool })
aircraft.runwaySlot = nextFreeSlot(
{ fromSec: nowSec + 120, toSec: nowSec + 120 + RUNWAY_SLOT_SEC },
occupiedSlots(),
)
pool.push(aircraft)
}
nextSpawnAtSec = nowSec + rng.int(30, 120)
}
maxPopulation = Math.max(maxPopulation, pool.length)
// Separation health check across the arrival stream.
for (const aircraft of pool.filter(isArrival)) {
const leader = findLeader(aircraft, pool.filter(isArrival))
if (!leader) continue
const gap = assessInTrail({
leaderDistanceNm: leader.distanceToFieldNm,
leaderWake: leader.type.wake,
followerDistanceNm: aircraft.distanceToFieldNm,
followerWake: aircraft.type.wake,
followerKts: aircraft.iasKts,
})
if (gap.violated) gapViolationTicks++
}
if (pending.length === 0) {
for (const aircraft of pool) {
const plan = planInstruction(aircraft, {
nowSec,
rng,
leader: findLeader(aircraft, pool.filter(isArrival)),
occupiedSlots: occupiedSlots(),
lastDeparture,
handover: null,
nmPerFix: NM_PER_FIX,
silentForSec: nowSec - lastRadioAtSec,
ambientAfterSec,
})
if (!plan) continue
pending.push(renderInstruction(aircraft, plan, { rng, station: 'Frankfurt Approach', runway: '25R' }))
ambientAfterSec = rng.int(45, 90)
break
}
}
// Dispatch, gated exactly as the scheduler gates it.
if (pending.length && gateOpen()) {
const event = pending.shift()!
const aircraft = pool.find(a => a.callsign === event.callsign)
if (aircraft) {
events.push(event)
lastRadioAtSec = nowSec
const plan = event.plan
aircraft.quietUntilSec = nowSec + cooldownSecFor(plan)
if (plan.kind === 'speed' && plan.speedKts && aircraft.type.wake !== 'L') {
aircraft.assignedSpeedKts = plan.speedKts
} else if (plan.kind === 'vector') {
aircraft.vectorDelaySec += plan.vectorDelaySec ?? 90
} else if (plan.kind === 'direct' && plan.direct) {
applyDirect(aircraft, plan.direct)
} else if (plan.kind === 'phase') {
advancePhase(aircraft, rng, nowSec)
if (aircraft.phase === 'takeoff') lastDeparture = { type: aircraft.type, atSec: nowSec }
} else if (plan.kind === 'wake_hold') {
aircraft.nextEventAtSec = nowSec + (plan.holdSec ?? 60)
} else if (plan.kind === 'slot_hold' && aircraft.runwaySlot) {
// Mirrors the composable: a hold moves the reservation to the next free
// window, which is what stops the rule from re-firing every tick.
aircraft.runwaySlot = nextFreeSlot(
aircraft.runwaySlot,
occupiedSlots().filter(s => s !== aircraft.runwaySlot),
)
aircraft.nextEventAtSec = aircraft.runwaySlot.fromSec
}
}
}
}
return { events, pool, maxPopulation, gapViolationTicks }
}
describe('ai-traffic — a 30-minute run with an open frequency', () => {
const run = runSim('integration-open', 1800, 4, () => true)
it('keeps the frequency alive', () => {
assert.ok(run.events.length > 20, `expected a lively frequency, got ${run.events.length} calls in 30 min`)
})
it('does not flood it either — a real ATC reply must never queue behind a wall of chatter', () => {
// Every call is an ATC+readback pair of a few seconds of audio, and the queue
// is FIFO: sustained traffic denser than roughly one pair per 15 s would mean
// a real ATC reply always waits behind scenery. A busy major airport is ~2
// movements a minute on one frequency, so this is generous already.
const perMinute = run.events.length / 30
assert.ok(perMinute <= 4, `frequency is saturated: ${run.events.length} calls in 30 min (${perMinute.toFixed(1)}/min)`)
})
it('never nags one aircraft with the same instruction twice in a breath', () => {
// The failure this guards is the planner re-deriving an unresolved condition
// every tick — a vector, then another vector one second later, forever.
const bySeconds = new Map<string, number[]>()
run.events.forEach((e, i) => {
const key = `${e.callsign}|${e.kind}`
if (!bySeconds.has(key)) bySeconds.set(key, [])
bySeconds.get(key)!.push(i)
})
for (const [key, indices] of bySeconds) {
const repeats = indices.length
assert.ok(repeats < 12, `${key} was issued ${repeats} times in one 30-minute run`)
}
})
it('never exceeds the population cap that protects the speech queue and TTS budget', () => {
assert.ok(run.maxPopulation <= MAX_ACTIVE_TRAFFIC, `population hit ${run.maxPopulation}`)
})
it('never issues a callsign confusable with the user', () => {
for (const event of run.events) {
assert.ok(
isCallsignDistinct(event.callsign, USER_CALLSIGNS),
`${event.callsign} is confusable with ${USER_CALLSIGNS[0]}`,
)
}
})
it('never speaks the user callsign, in either half of a pair', () => {
for (const event of run.events) {
assert.doesNotMatch(event.atcText, /DLH\s?39A/i, `ATC call addressed the user: ${event.atcText}`)
assert.doesNotMatch(event.pilotReadbackText, /DLH\s?39A/i, `readback used the user callsign: ${event.pilotReadbackText}`)
}
})
it('never assigns a speed below the aircraft own approach speed', () => {
for (const event of run.events) {
if (event.plan.kind !== 'speed' || !event.plan.speedKts) continue
const aircraft = run.pool.find(a => a.callsign === event.callsign)
if (aircraft) assert.ok(event.plan.speedKts >= aircraft.type.approachKts, event.atcText)
}
})
it('resolves conflicts by slowing and vectoring rather than letting separation rot', () => {
// Some violation ticks are expected (that is what triggers a vector), but the
// stream must not spend its life inside the minima.
assert.ok(run.gapViolationTicks < 400, `separation was busted on ${run.gapViolationTicks} ticks`)
})
it('produces a varied mix of instruction types, not one rule firing forever', () => {
const kinds = new Set(run.events.map(e => e.kind))
assert.ok(kinds.size >= 3, `expected varied instructions, got: ${[...kinds].join(', ')}`)
})
it('always names an aircraft in every single transmission', () => {
for (const event of run.events) {
assert.ok(event.atcText.includes(event.callsign) || event.pilotReadbackText.includes(event.callsign))
}
})
it('is fully reproducible from its seed', () => {
const again = runSim('integration-open', 1800, 4, () => true)
assert.deepEqual(again.events.map(e => e.atcText), run.events.map(e => e.atcText))
})
})
describe('ai-traffic — a frequency the user never releases', () => {
it('stays completely silent, and does not lose the events it never got to speak', () => {
const run = runSim('integration-closed', 1800, 4, () => false)
assert.equal(run.events.length, 0, 'traffic transmitted while the gate was shut')
// The sim itself keeps running — aircraft still fly, they just say nothing.
assert.ok(run.pool.length > 0, 'the pool should still be populated')
})
it('resumes the moment the frequency frees up', () => {
let open = false
const rng = createRng('resume')
// Shut for the first half of the run, open for the second.
let tick = 0
const gate = () => { tick++; return open }
const first = runSim('integration-resume', 900, 4, gate)
assert.equal(first.events.length, 0)
open = true
const second = runSim('integration-resume', 900, 4, gate)
assert.ok(second.events.length > 0, 'traffic never came back after the gate reopened')
void rng
})
})
describe('ai-traffic — a dead GA field at 03:00', () => {
it('stays silent, because a target of zero is the correct answer', () => {
const target = targetTrafficCount('ga', 3)
assert.equal(target, 0)
const run = runSim('integration-night', 1800, target, () => true)
assert.equal(run.pool.length, 0)
assert.equal(run.events.length, 0)
})
})

View File

@@ -0,0 +1,193 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
TERMINAL_STANDARD_NM,
assessInTrail,
departureWakeDelaySec,
gapToTimeSec,
isGapViolated,
needsSpacing,
nextFreeSlot,
requiredGapNm,
slotIsFree,
slotsOverlap,
wakeSeparationNm,
} from '~~/shared/utils/aiTraffic/separation'
describe('wake separation matrix', () => {
it('reproduces the design table, leader → follower', () => {
assert.equal(wakeSeparationNm('J', 'H'), 6)
assert.equal(wakeSeparationNm('J', 'M'), 7)
assert.equal(wakeSeparationNm('J', 'L'), 8)
assert.equal(wakeSeparationNm('H', 'H'), 4)
assert.equal(wakeSeparationNm('H', 'M'), 5)
assert.equal(wakeSeparationNm('H', 'L'), 6)
assert.equal(wakeSeparationNm('M', 'L'), 5)
})
it('has no supplement where the standard already covers it', () => {
assert.equal(wakeSeparationNm('M', 'M'), 0)
assert.equal(wakeSeparationNm('M', 'H'), 0)
assert.equal(wakeSeparationNm('L', 'L'), 0)
assert.equal(wakeSeparationNm('L', 'M'), 0)
assert.equal(wakeSeparationNm('L', 'H'), 0)
})
it('is not symmetric — the leader is what generates the wake', () => {
assert.notEqual(wakeSeparationNm('H', 'L'), wakeSeparationNm('L', 'H'))
})
})
describe('requiredGapNm', () => {
it('is the design reading: a Medium behind a Heavy needs 5 NM, not 3+5', () => {
assert.equal(requiredGapNm('H', 'M'), 5)
})
it('falls back to the terminal standard for pairs with no supplement', () => {
assert.equal(requiredGapNm('M', 'M'), TERMINAL_STANDARD_NM)
assert.equal(requiredGapNm('L', 'L'), TERMINAL_STANDARD_NM)
})
it('never returns less than the terminal standard', () => {
for (const leader of ['L', 'M', 'H', 'J'] as const) {
for (const follower of ['L', 'M', 'H', 'J'] as const) {
assert.ok(requiredGapNm(leader, follower) >= TERMINAL_STANDARD_NM)
}
}
})
})
describe('gapToTimeSec', () => {
it('converts distance into time at the follower speed', () => {
assert.equal(gapToTimeSec(3, 180), 60) // 3 NM at 180 kt = 1 minute
assert.equal(gapToTimeSec(5, 150), 120)
})
it('treats a stationary follower as never closing', () => {
assert.equal(gapToTimeSec(3, 0), Number.POSITIVE_INFINITY)
assert.equal(gapToTimeSec(3, -10), Number.POSITIVE_INFINITY)
})
})
describe('spacing thresholds', () => {
it('acts on the 20% buffer before the minimum is actually busted', () => {
// Required 5 NM → act below 6 NM, violated below 5 NM.
assert.equal(needsSpacing(6.1, 5), false)
assert.equal(needsSpacing(5.5, 5), true)
assert.equal(isGapViolated(5.5, 5), false)
assert.equal(isGapViolated(4.9, 5), true)
})
it('treats a violated gap as also needing action', () => {
assert.equal(needsSpacing(4.9, 5), true)
})
})
describe('departureWakeDelaySec', () => {
it('holds a Medium three minutes behind a Heavy', () => {
assert.equal(departureWakeDelaySec('H', 'M'), 180)
assert.equal(departureWakeDelaySec('H', 'L'), 180)
assert.equal(departureWakeDelaySec('J', 'M'), 180)
})
it('holds a Heavy two minutes behind a Heavy', () => {
assert.equal(departureWakeDelaySec('H', 'H'), 120)
assert.equal(departureWakeDelaySec('J', 'J'), 120)
})
it('imposes no time delay behind a non-heavy — runway occupancy covers it', () => {
assert.equal(departureWakeDelaySec('M', 'M'), 0)
assert.equal(departureWakeDelaySec('M', 'L'), 0)
assert.equal(departureWakeDelaySec('L', 'M'), 0)
})
})
describe('runway slots', () => {
it('detects overlap and treats touching slots as free', () => {
assert.equal(slotsOverlap({ fromSec: 0, toSec: 60 }, { fromSec: 30, toSec: 90 }), true)
assert.equal(slotsOverlap({ fromSec: 0, toSec: 60 }, { fromSec: 60, toSec: 90 }), false)
assert.equal(slotsOverlap({ fromSec: 60, toSec: 90 }, { fromSec: 0, toSec: 60 }), false)
})
it('detects full containment', () => {
assert.equal(slotsOverlap({ fromSec: 10, toSec: 20 }, { fromSec: 0, toSec: 100 }), true)
})
it('reports a slot free only when it clears everything', () => {
const occupied = [{ fromSec: 0, toSec: 60 }, { fromSec: 120, toSec: 180 }]
assert.equal(slotIsFree({ fromSec: 60, toSec: 120 }, occupied), true)
assert.equal(slotIsFree({ fromSec: 100, toSec: 130 }, occupied), false)
assert.equal(slotIsFree({ fromSec: 0, toSec: 10 }, []), true)
})
it('pushes a blocked slot past the blocker, preserving its duration', () => {
const slot = nextFreeSlot({ fromSec: 30, toSec: 90 }, [{ fromSec: 0, toSec: 60 }])
assert.deepEqual(slot, { fromSec: 60, toSec: 120 })
})
it('walks past a chain of back-to-back blockers', () => {
const occupied = [
{ fromSec: 0, toSec: 60 },
{ fromSec: 60, toSec: 120 },
{ fromSec: 120, toSec: 200 },
]
const slot = nextFreeSlot({ fromSec: 10, toSec: 40 }, occupied)
assert.deepEqual(slot, { fromSec: 200, toSec: 230 })
assert.equal(slotIsFree(slot, occupied), true)
})
it('leaves an already-free slot exactly where it was', () => {
const desired = { fromSec: 300, toSec: 360 }
assert.deepEqual(nextFreeSlot(desired, [{ fromSec: 0, toSec: 60 }]), desired)
})
it('only ever moves a slot later — simulated traffic yields, it never cuts in', () => {
const occupied = [{ fromSec: 100, toSec: 200 }]
for (let from = 0; from < 300; from += 17) {
const result = nextFreeSlot({ fromSec: from, toSec: from + 30 }, occupied)
assert.ok(result.fromSec >= from, `slot moved earlier: ${from}${result.fromSec}`)
assert.equal(slotIsFree(result, occupied), true)
}
})
})
describe('assessInTrail', () => {
const heavyLeaderMediumFollower = {
leaderDistanceNm: 10,
leaderWake: 'H' as const,
followerWake: 'M' as const,
followerKts: 180,
}
it('measures the 1D gap along the approach', () => {
const result = assessInTrail({ ...heavyLeaderMediumFollower, followerDistanceNm: 18 })
assert.equal(result.gapNm, 8)
assert.equal(result.requiredNm, 5)
assert.equal(result.needsAction, false)
assert.equal(result.violated, false)
})
it('flags a gap inside the buffer as needing action but not yet violated', () => {
const result = assessInTrail({ ...heavyLeaderMediumFollower, followerDistanceNm: 15.5 })
assert.equal(result.gapNm, 5.5)
assert.equal(result.needsAction, true)
assert.equal(result.violated, false)
})
it('flags a busted minimum', () => {
const result = assessInTrail({ ...heavyLeaderMediumFollower, followerDistanceNm: 14 })
assert.equal(result.violated, true)
})
it('clamps a follower that has somehow passed the leader to a zero gap', () => {
const result = assessInTrail({ ...heavyLeaderMediumFollower, followerDistanceNm: 5 })
assert.equal(result.gapNm, 0)
assert.equal(result.violated, true)
})
it('reports how long the gap lasts at the follower speed', () => {
const result = assessInTrail({ ...heavyLeaderMediumFollower, followerDistanceNm: 19 })
assert.equal(result.gapSec, gapToTimeSec(9, 180))
})
})

View File

@@ -0,0 +1,257 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { createRng } from '~~/shared/utils/aiTraffic/rng'
import { findSimAircraftType } from '~~/shared/data/simAircraftTypes'
import { PILOT_VOICES, pilotVoiceFor } from '~~/shared/utils/voicePool'
import {
SPEED_DRIFT_KTS_PER_SEC,
advanceAircraft,
advancePhase,
createSimAircraft,
findLeader,
generateFixPool,
isArrival,
isDespawnable,
nextPhase,
} from '~~/shared/utils/aiTraffic/sim'
import type { SimAircraft, SimPhase } from '~~/shared/utils/aiTraffic/types'
const A320 = findSimAircraftType('A320')!
const B77W = findSimAircraftType('B77W')!
function aircraft(overrides: Partial<SimAircraft> = {}): SimAircraft {
return {
callsign: 'BAW118',
callsignSpoken: 'Speedbird one one eight',
type: A320,
voiceId: 'nova',
phase: 'approach',
frequency: '119.000',
routeFixes: ['KOTAP', 'RIVAK'],
distanceToFieldNm: 20,
altitudeFt: 6000,
iasKts: 200,
assignedSpeedKts: null,
vectorDelaySec: 0,
runwaySlot: null,
nextEventAtSec: Number.POSITIVE_INFINITY,
quietUntilSec: 0,
...overrides,
}
}
describe('generateFixPool', () => {
it('produces distinct, pronounceable 5-letter fixes', () => {
const fixes = generateFixPool(createRng('EDDF'), 8)
assert.equal(fixes.length, 8)
assert.equal(new Set(fixes).size, 8, 'fixes must be distinct')
for (const fix of fixes) assert.match(fix, /^[A-Z]{5}$/)
})
it('is deterministic per airport seed', () => {
assert.deepEqual(generateFixPool(createRng('EDDF')), generateFixPool(createRng('EDDF')))
assert.notDeepEqual(generateFixPool(createRng('EDDF')), generateFixPool(createRng('EDDM')))
})
})
describe('createSimAircraft', () => {
const generated = { callsign: 'BAW118', callsignSpoken: 'Speedbird one one eight', type: A320 }
const opts = { rng: createRng('spawn'), nowSec: 0, frequency: '119.000', fixPool: generateFixPool(createRng('EDDF')) }
it('spawns an arrival out on the STAR, descending', () => {
const ac = createSimAircraft(generated, 'arrival', { ...opts, rng: createRng('arr') })
assert.equal(ac.phase, 'inbound')
assert.ok(ac.distanceToFieldNm >= 25 && ac.distanceToFieldNm <= 60)
assert.ok(ac.altitudeFt >= 7000 && ac.altitudeFt <= 12000)
assert.ok(ac.iasKts <= 250, 'an inbound below 10,000 ft must not spawn above the speed limit')
assert.ok(ac.iasKts >= A320.approachKts)
assert.ok(ac.nextEventAtSec > 0 && Number.isFinite(ac.nextEventAtSec))
})
it('spawns a departure at the stand, stationary', () => {
const ac = createSimAircraft(generated, 'departure', { ...opts, rng: createRng('dep') })
assert.equal(ac.phase, 'taxi_out')
assert.equal(ac.distanceToFieldNm, 0)
assert.equal(ac.altitudeFt, 0)
assert.equal(ac.iasKts, 0)
})
it('gives the aircraft the voice its callsign hashes to', () => {
const ac = createSimAircraft(generated, 'arrival', opts)
assert.equal(ac.voiceId, pilotVoiceFor('BAW118'))
assert.ok(PILOT_VOICES.includes(ac.voiceId))
})
it('draws its route from the airport fix pool', () => {
const ac = createSimAircraft(generated, 'arrival', opts)
assert.ok(ac.routeFixes.length >= 2)
for (const fix of ac.routeFixes) assert.ok(opts.fixPool.includes(fix))
})
it('is deterministic for a given seed', () => {
const build = () => createSimAircraft(generated, 'arrival', { ...opts, rng: createRng('fixed') })
assert.deepEqual(build(), build())
})
})
describe('phase chains', () => {
it('walks an arrival to handoff', () => {
const chain: SimPhase[] = []
let phase: SimPhase = 'inbound'
for (let i = 0; i < 5; i++) { chain.push(phase); phase = nextPhase(phase) }
assert.deepEqual(chain, ['inbound', 'approach', 'final', 'rollout', 'handed_off'])
})
it('walks a departure to handoff', () => {
const chain: SimPhase[] = []
let phase: SimPhase = 'taxi_out'
for (let i = 0; i < 5; i++) { chain.push(phase); phase = nextPhase(phase) }
assert.deepEqual(chain, ['taxi_out', 'lineup', 'takeoff', 'climbout', 'handed_off'])
})
it('terminates at handed_off', () => {
assert.equal(nextPhase('handed_off'), 'handed_off')
assert.equal(isDespawnable(aircraft({ phase: 'handed_off' })), true)
assert.equal(isDespawnable(aircraft({ phase: 'final' })), false)
})
it('classifies arrivals and departures', () => {
for (const phase of ['inbound', 'approach', 'final', 'rollout'] as SimPhase[]) {
assert.equal(isArrival(aircraft({ phase })), true, phase)
}
for (const phase of ['taxi_out', 'lineup', 'takeoff', 'climbout'] as SimPhase[]) {
assert.equal(isArrival(aircraft({ phase })), false, phase)
}
})
})
describe('advancePhase', () => {
it('pins an arrival to its Vref once it turns final', () => {
const ac = aircraft({ phase: 'approach' })
advancePhase(ac, createRng('final'), 100)
assert.equal(ac.phase, 'final')
assert.equal(ac.assignedSpeedKts, A320.approachKts)
assert.ok(ac.nextEventAtSec > 100)
})
it('releases the speed restriction on climbout', () => {
const ac = aircraft({ phase: 'takeoff', assignedSpeedKts: 160 })
advancePhase(ac, createRng('climb'), 100)
assert.equal(ac.phase, 'climbout')
assert.equal(ac.assignedSpeedKts, null)
})
it('stops scheduling events once handed off', () => {
const ac = aircraft({ phase: 'rollout' })
advancePhase(ac, createRng('done'), 100)
assert.equal(ac.phase, 'handed_off')
assert.equal(ac.nextEventAtSec, Number.POSITIVE_INFINITY)
})
})
describe('advanceAircraft — 1D kinematics', () => {
it('closes an arrival on the field at its groundspeed', () => {
const ac = aircraft({ distanceToFieldNm: 20, iasKts: 180 })
advanceAircraft(ac, 60) // one minute at 180 kt = 3 NM
assert.ok(Math.abs(ac.distanceToFieldNm - 17) < 1e-9)
})
it('descends an arrival at the type descent rate', () => {
const ac = aircraft({ altitudeFt: 6000 })
advanceAircraft(ac, 60)
assert.equal(ac.altitudeFt, 6000 - A320.descentFpm)
})
it('climbs a departure away from the field', () => {
const ac = aircraft({ phase: 'climbout', altitudeFt: 2000, distanceToFieldNm: 3, iasKts: 180 })
advanceAircraft(ac, 60)
assert.equal(ac.altitudeFt, 2000 + A320.climbFpm)
assert.ok(ac.distanceToFieldNm > 3)
})
it('drags IAS toward the assigned speed at ~1 kt/s so phraseology stays honest', () => {
const ac = aircraft({ iasKts: 250, assignedSpeedKts: 180 })
advanceAircraft(ac, 10)
assert.equal(ac.iasKts, 250 - 10 * SPEED_DRIFT_KTS_PER_SEC)
})
it('never overshoots the assigned speed', () => {
const ac = aircraft({ iasKts: 185, assignedSpeedKts: 180 })
advanceAircraft(ac, 60)
assert.equal(ac.iasKts, 180)
})
it('accelerates as well as decelerates', () => {
const ac = aircraft({ iasKts: 150, assignedSpeedKts: 180 })
advanceAircraft(ac, 10)
assert.equal(ac.iasKts, 160)
})
it('burns a vector as time on the timeline instead of closing distance', () => {
const ac = aircraft({ vectorDelaySec: 90, distanceToFieldNm: 20, altitudeFt: 6000 })
advanceAircraft(ac, 30)
assert.equal(ac.vectorDelaySec, 60)
assert.equal(ac.distanceToFieldNm, 20, 'a vectored aircraft holds its distance to the field')
assert.equal(ac.altitudeFt, 6000)
})
it('resumes closing once the vector is flown out', () => {
const ac = aircraft({ vectorDelaySec: 10, distanceToFieldNm: 20 })
advanceAircraft(ac, 10)
assert.equal(ac.vectorDelaySec, 0)
advanceAircraft(ac, 60)
assert.ok(ac.distanceToFieldNm < 20)
})
it('never drives distance or altitude negative on landing', () => {
const ac = aircraft({ phase: 'final', distanceToFieldNm: 0.5, altitudeFt: 200, iasKts: 140 })
for (let i = 0; i < 200; i++) advanceAircraft(ac, 1)
assert.equal(ac.distanceToFieldNm, 0)
assert.equal(ac.altitudeFt, 0)
})
it('brakes on rollout without moving the aircraft backwards', () => {
const ac = aircraft({ phase: 'rollout', iasKts: 130, distanceToFieldNm: 0 })
advanceAircraft(ac, 10)
assert.equal(ac.iasKts, 80)
for (let i = 0; i < 60; i++) advanceAircraft(ac, 1)
assert.equal(ac.iasKts, 0)
assert.equal(ac.distanceToFieldNm, 0)
})
it('is a no-op for a non-positive tick', () => {
const ac = aircraft()
const before = { ...ac }
advanceAircraft(ac, 0)
advanceAircraft(ac, -5)
assert.deepEqual(ac, before)
})
})
describe('findLeader', () => {
const follower = aircraft({ callsign: 'BAW118', distanceToFieldNm: 20 })
it('picks the closest aircraft ahead on the approach', () => {
const near = aircraft({ callsign: 'DLH1', distanceToFieldNm: 15 })
const far = aircraft({ callsign: 'AFR2', distanceToFieldNm: 5 })
assert.equal(findLeader(follower, [far, near, follower])?.callsign, 'DLH1')
})
it('returns null when the aircraft is leading the sequence', () => {
const behind = aircraft({ callsign: 'DLH1', distanceToFieldNm: 30 })
assert.equal(findLeader(follower, [behind, follower]), null)
assert.equal(findLeader(follower, [follower]), null)
assert.equal(findLeader(follower, []), null)
})
it('ignores departures — they are not on the approach', () => {
const departure = aircraft({ callsign: 'DLH1', phase: 'climbout', distanceToFieldNm: 5, type: B77W })
assert.equal(findLeader(follower, [departure, follower]), null)
})
it('never returns the follower itself', () => {
const twin = aircraft({ callsign: 'BAW118', distanceToFieldNm: 10 })
assert.equal(findLeader(follower, [twin, follower]), null)
})
})

View File

@@ -0,0 +1,104 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
MAX_ACTIVE_TRAFFIC,
resolveTrafficTier,
targetTrafficCount,
timeOfDayFactor,
trafficTierFromFrequencies,
} from '~~/shared/data/trafficTiers'
describe('resolveTrafficTier', () => {
it('prefers the curated map over the heuristic', () => {
// EDDF publishes a full set of positions, but the curated answer wins either way.
assert.equal(resolveTrafficTier('EDDF', ['TWR']), 'major')
assert.equal(resolveTrafficTier('EDDH', ['DEL', 'GND', 'TWR', 'APP']), 'regional')
})
it('ignores casing and surrounding whitespace', () => {
assert.equal(resolveTrafficTier(' eddm '), 'major')
})
it('falls back to the frequency heuristic for unknown airports', () => {
assert.equal(resolveTrafficTier('ZZZZ', ['DEL', 'GND', 'TWR', 'APP']), 'major')
assert.equal(resolveTrafficTier('ZZZZ', ['GND', 'TWR']), 'regional')
assert.equal(resolveTrafficTier('ZZZZ', ['ATIS']), 'ga')
})
it('defaults to regional when nothing at all is known', () => {
assert.equal(resolveTrafficTier(undefined), 'regional')
assert.equal(resolveTrafficTier(null), 'regional')
assert.equal(resolveTrafficTier(''), 'regional')
assert.equal(resolveTrafficTier('ZZZZ', []), 'regional')
})
})
describe('trafficTierFromFrequencies', () => {
it('counts four distinct controller positions as major', () => {
assert.equal(trafficTierFromFrequencies({ frequencyTypes: ['DEL', 'GND', 'TWR', 'DEP'] }), 'major')
})
it('does not count ATIS as a controller position', () => {
assert.equal(trafficTierFromFrequencies({ frequencyTypes: ['ATIS', 'GND', 'TWR', 'CTR'] }), 'regional')
})
it('does not let duplicates inflate the count', () => {
assert.equal(trafficTierFromFrequencies({ frequencyTypes: ['TWR', 'TWR', 'TWR', 'TWR'] }), 'regional')
})
it('treats a tower-only field as regional and a towerless one as ga', () => {
assert.equal(trafficTierFromFrequencies({ frequencyTypes: ['TWR'] }), 'regional')
assert.equal(trafficTierFromFrequencies({ frequencyTypes: ['ATIS'] }), 'ga')
assert.equal(trafficTierFromFrequencies({ frequencyTypes: [] }), 'ga')
})
it('normalizes casing and junk entries', () => {
assert.equal(trafficTierFromFrequencies({ frequencyTypes: [' del ', 'gnd', 'twr', 'app', ''] }), 'major')
})
})
describe('timeOfDayFactor', () => {
it('covers every hour of the day with a positive factor', () => {
for (let h = 0; h < 24; h++) {
assert.ok(timeOfDayFactor(h) > 0, `hour ${h} has no factor`)
}
})
it('applies the design bands', () => {
assert.equal(timeOfDayFactor(3), 0.2) // night
assert.equal(timeOfDayFactor(22), 0.2)
assert.equal(timeOfDayFactor(5), 0.2)
assert.equal(timeOfDayFactor(7), 1.3) // morning bank
assert.equal(timeOfDayFactor(12), 1.0) // day
assert.equal(timeOfDayFactor(18), 1.3) // evening bank
assert.equal(timeOfDayFactor(21), 0.6) // winding down
})
it('truncates a fractional hour to its band', () => {
assert.equal(timeOfDayFactor(21.9), 0.6)
assert.equal(timeOfDayFactor(22.1), 0.2)
})
})
describe('targetTrafficCount', () => {
it('keeps a major airport busy during the banks and calm at night', () => {
assert.equal(targetTrafficCount('major', 7), 5) // 4 × 1.3 = 5.2 → 5
assert.equal(targetTrafficCount('major', 12), 4)
assert.equal(targetTrafficCount('major', 3), 1) // 4 × 0.2 = 0.8 → 1
})
it('leaves a GA field dead at night', () => {
assert.equal(targetTrafficCount('ga', 3), 0) // 1 × 0.2 = 0.2 → 0
assert.equal(targetTrafficCount('ga', 12), 1)
})
it('never exceeds the population cap that protects the speech queue', () => {
for (const tier of ['major', 'regional', 'ga'] as const) {
for (let h = 0; h < 24; h++) {
const n = targetTrafficCount(tier, h)
assert.ok(n >= 0 && n <= MAX_ACTIVE_TRAFFIC, `${tier}@${h}${n}`)
}
}
})
})

View File

@@ -0,0 +1,89 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
CONTROLLER_VOICES,
PILOT_VOICES,
RESERVED_VOICES,
controllerVoiceFor,
fnv1a,
pilotVoiceFor,
voiceFromPool,
} from '~~/shared/utils/voicePool'
describe('voicePool — partitions', () => {
it('keeps the controller and pilot partitions disjoint', () => {
const overlap = CONTROLLER_VOICES.filter(v => PILOT_VOICES.includes(v))
assert.deepEqual(overlap, [], 'a simulated pilot must never sound like the controller')
})
it('never hands a reserved voice to a simulated pilot', () => {
for (const reserved of RESERVED_VOICES) {
assert.equal(PILOT_VOICES.includes(reserved), false, `${reserved} is reserved`)
}
})
})
describe('voicePool — assignment', () => {
it('is deterministic: the same callsign always gets the same voice', () => {
const first = pilotVoiceFor('DLH472')
for (let i = 0; i < 20; i++) {
assert.equal(pilotVoiceFor('DLH472'), first)
}
})
it('ignores callsign casing', () => {
assert.equal(pilotVoiceFor('dlh472'), pilotVoiceFor('DLH472'))
})
it('only ever returns voices from the pilot pool', () => {
for (let n = 100; n < 400; n++) {
assert.ok(PILOT_VOICES.includes(pilotVoiceFor(`BAW${n}`)))
}
})
it('spreads a realistic callsign set over most of the pool', () => {
const callsigns = ['DLH472', 'BAW118', 'EZY93A', 'RYR4021', 'KLM61', 'AFR1234', 'THY7', 'SWR88']
const used = new Set(callsigns.map(cs => pilotVoiceFor(cs)))
assert.ok(used.size >= 4, `expected varied voices, got ${[...used].join(', ')}`)
})
it('skips a reserved voice instead of failing', () => {
// Reserve everything but one entry — every key must land on the survivor.
const survivor = PILOT_VOICES[2]!
const reserved = PILOT_VOICES.filter(v => v !== survivor)
for (const cs of ['DLH1', 'BAW2', 'EZY3', 'RYR4']) {
assert.equal(voiceFromPool(cs, PILOT_VOICES, reserved), survivor)
}
})
it('falls back to the raw pool if everything is reserved', () => {
const voice = voiceFromPool('DLH472', PILOT_VOICES, PILOT_VOICES)
assert.ok(PILOT_VOICES.includes(voice))
})
it('throws on an empty pool rather than returning undefined', () => {
assert.throws(() => voiceFromPool('DLH472', []), /empty voice pool/)
})
it('assigns controller voices from the controller partition (multi-voice)', () => {
for (const position of ['EDDF_TWR', 'EDDF_GND', 'EDDF_APP', 'EDDF_DEL']) {
assert.ok(CONTROLLER_VOICES.includes(controllerVoiceFor(position)))
}
})
})
describe('voicePool — fnv1a', () => {
it('matches the reference vectors', () => {
assert.equal(fnv1a(''), 0x811c9dc5)
assert.equal(fnv1a('a'), 0xe40c292c)
assert.equal(fnv1a('foobar'), 0xbf9cf968)
})
it('stays inside the unsigned 32-bit range', () => {
for (const s of ['DLH472', 'a very long callsign string that is not one', 'ß∆']) {
const h = fnv1a(s)
assert.ok(Number.isInteger(h) && h >= 0 && h <= 0xffffffff, `${s}${h}`)
}
})
})