Files
OpenSquawk/tests/shared/trafficTiers.test.ts
itsrubberduck 7d49f18cc6 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>
2026-07-16 17:46:52 +02:00

105 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`)
}
}
})
})