feat(sim-control): wire frequency-sim-control command channel end-to-end

Implements the open items from docs/plans/2026-07-14-frequency-sim-control-design.md
§4/§"Offen für die Implementierungsphase": a per-bridge-token in-memory command
queue piggybacked on the existing telemetry channel, plus the client-side gate
and TTS confirmations.

- server/utils/simControlQueue.ts: TTL-based queue keyed by bridge token
  (enqueue → drainPending → resolve → drainResultsForClient).
- server/api/bridge/data.post.ts: response gains a `commands` field the
  bridge drains on its next telemetry POST.
- server/api/bridge/command.post.ts (new): client enqueues a parsed command,
  re-validated server-side via isValidSimControlCommand.
- server/api/bridge/command-result.post.ts (new): bridge reports ok/failed.
- server/api/bridge/live.get.ts: response gains `commandResults` so the
  client can announce outcomes.
- shared/utils/simControl.ts: wire types, isValidSimControlCommand, and
  simControlRejectionSpeech/simControlResultSpeech TTS phrasing.
- useLiveAtcSession.ts: parseSimControl() gated on bridgeConnected, wired in
  right after the local special cases and before the frequency check —
  matched commands never reach radioBackend.transmit().
- useSimBridgeSync.ts / live-atc.vue: bridgeToken threaded through, command
  results forwarded from the telemetry poll to TTS.

43 new tests (shared parser/validation/speech + server queue lifecycle/TTL).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-15 09:31:17 +02:00
parent e18995d7cd
commit ab04411bc5
11 changed files with 691 additions and 4 deletions

View File

@@ -7,6 +7,13 @@ import type { useSessionState } from '~/composables/useSessionState'
import { useApi } from '~/composables/useApi'
import type { useRadioSpeech } from '~/composables/useRadioSpeech'
import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
import {
parseSimControl,
simControlRejectionSpeech,
simControlResultSpeech,
type SimControlCommand,
type SimControlCommandResult,
} from '../../shared/utils/simControl'
export interface LiveAtcSessionDeps {
state: ReturnType<typeof useSessionState>
@@ -22,6 +29,8 @@ export interface LiveAtcSessionDeps {
isRecording: Ref<boolean>
bridgeConnected: Ref<boolean>
bridgePosition: Ref<{ lat: number; lon: number } | null>
/** ?token=… from the route — auths the frequency-sim-control channel (design §4). */
bridgeToken: Ref<string>
persistSelectedPlan: (plan: any | null) => void
maybeShowFirstRunHelp: () => void
}
@@ -47,7 +56,8 @@ export function useLiveAtcSession(
const {
state, freq, speech, radioBackend, api, config, prefetchAtisAudio,
isRecording, bridgeConnected, bridgePosition, persistSelectedPlan, maybeShowFirstRunHelp,
isRecording, bridgeConnected, bridgePosition, bridgeToken,
persistSelectedPlan, maybeShowFirstRunHelp,
} = deps
const {
@@ -205,6 +215,33 @@ export function useLiveAtcSession(
armSilenceTimer()
}
// --- Frequency-sim-control (design doc §4) ------------------------------------
// A matched command from handlePilotTransmission is queued server-side for the
// bridge to execute; the result comes back asynchronously via the /api/bridge/live
// poll (see handleSimControlResult below), so this call itself stays silent on
// success — only a network/enqueue failure gets an immediate ATC reply.
const sendSimControlCommand = async (command: SimControlCommand) => {
try {
await $fetch('/api/bridge/command', {
method: 'POST',
headers: { 'x-bridge-token': bridgeToken.value },
body: { command },
})
} catch (e) {
pmLog.warn('SIM CONTROL enqueue failed', e)
const reply = 'unable to reach bridge, say again'
scheduleControllerSpeech(reply)
appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active })
}
}
/** Speak the outcome of a bridge command surfaced by useSimBridgeSync's telemetry poll. */
const handleSimControlResult = (result: SimControlCommandResult) => {
const reply = simControlResultSpeech(result)
scheduleControllerSpeech(reply)
appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active })
}
const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = 'text') => {
const transcript = message.trim()
// Ignore empty or content-free transmissions: silence, a stray PTT tap, or
@@ -278,6 +315,26 @@ export function useLiveAtcSession(
return
}
// --- Sim control (frequency-driven simulator command) ---
// A meta channel: the pilot talking to their OWN simulator, not ATC — so it
// runs before the frequency gate (like radio check) and, unlike every other
// transmission, never reaches radioBackend.transmit()/the decision flow.
// Only live while a bridge is actually connected (design doc §4); parseSimControl's
// anchors already keep it from ever matching real ICAO phraseology either way.
if (bridgeConnected.value) {
const simResult = parseSimControl(transcript)
if (simResult.matched) {
void sendSimControlCommand(simResult.command)
return
}
if (simResult.reason !== 'no_intent') {
const reply = simControlRejectionSpeech(simResult.reason)
scheduleControllerSpeech(reply)
appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active })
return
}
}
// --- Frequency check ---
// Reject the transmission if the pilot is on the wrong frequency. A position
// can publish several valid frequencies (e.g. two Tower freqs); accept any of
@@ -700,6 +757,7 @@ export function useLiveAtcSession(
clearSilenceTimer,
applyBackendDecision,
handlePilotTransmission,
handleSimControlResult,
loadFlightPlans,
startMonitoring,
startDemoFlight,

View File

@@ -2,6 +2,7 @@ import { computed, onMounted, onUnmounted, ref, watch, type Ref } from 'vue'
import { useRoute } from 'vue-router'
import { normalizeSimFreq, normalizeBridgeTelemetry, telemetrySignature } from '../../shared/utils/bridgeTelemetry'
import { pmLog } from '../../shared/utils/pmLog'
import type { SimControlCommandResult } from '../../shared/utils/simControl'
import type { useFrequencyPresets } from '~/composables/useFrequencyPresets'
export interface SimBridgeSyncDeps {
@@ -14,6 +15,8 @@ export interface SimBridgeSyncDeps {
resumePrerecIfSuspended: () => Promise<void> | void
startRecording: (fromPad: boolean) => Promise<void> | void
stopRecording: () => void
/** A frequency-sim-control command the bridge has finished (or the server expired). */
onCommandResult: (result: SimControlCommandResult) => void
}
/**
@@ -33,7 +36,7 @@ export function useSimBridgeSync(
const { frequencies } = freq
const {
backendSessionId, radioBackend, applyBackendDecision, stopCurrentSpeech,
resumePrerecIfSuspended, startRecording, stopRecording,
resumePrerecIfSuspended, startRecording, stopRecording, onCommandResult,
} = deps
const bridgeToken = computed(() => {
@@ -88,10 +91,23 @@ export function useSimBridgeSync(
const token = bridgeToken.value
if (!token) return
try {
const res = await $fetch<{ connected: boolean; lastTelemetryAt: string | null; telemetry: any }>(
const res = await $fetch<{
connected: boolean
lastTelemetryAt: string | null
telemetry: any
commandResults?: SimControlCommandResult[]
}>(
'/api/bridge/live',
{ headers: { 'x-bridge-token': token } },
)
// Frequency-sim-control results (design §4): announce regardless of
// telemetry freshness below — a command can resolve/expire even on the
// poll where the bridge itself has just gone quiet.
for (const result of res.commandResults ?? []) {
onCommandResult(result)
}
const ts = res.lastTelemetryAt ? Date.parse(res.lastTelemetryAt) : null
const fresh = Boolean(res.connected && ts && Date.now() - ts < BRIDGE_TELEMETRY_STALE_MS)
bridgeConnected.value = fresh

View File

@@ -462,6 +462,9 @@ const handlePilotTransmission = (message: string, source: 'text' | 'ptt' = 'text
const applyBackendDecision = (
response: import('~/composables/useRadioBackend').RadioTransmitResponse,
) => session.applyBackendDecision(response)
const handleSimControlResult = (
result: import('../../shared/utils/simControl').SimControlCommandResult,
) => session.handleSimControlResult(result)
const speech = useRadioSpeech(engine, freq, speechInterrupt, {
setLastTransmission,
@@ -539,6 +542,7 @@ watch(prerecEnabled, (val) => {
})
const {
bridgeToken,
bridgeConnected,
bridgeSimActiveFreq,
bridgePosition,
@@ -551,6 +555,7 @@ const {
resumePrerecIfSuspended,
startRecording,
stopRecording,
onCommandResult: handleSimControlResult,
})
const session = useLiveAtcSession(engine, {
@@ -564,6 +569,7 @@ const session = useLiveAtcSession(engine, {
isRecording,
bridgeConnected,
bridgePosition,
bridgeToken,
persistSelectedPlan,
maybeShowFirstRunHelp,
})

View File

@@ -0,0 +1,55 @@
import { createError, readBody } from 'h3'
import { BridgeToken } from '../../models/BridgeToken'
import { getBridgeTokenFromHeader } from '../../utils/bridge'
import { logBridgeEvent } from '../../utils/bridgeLog'
import { simControlQueue } from '../../utils/simControlQueue'
interface CommandResultBody {
id?: string
status?: 'ok' | 'failed'
reason?: string
}
/**
* The bridge reports the outcome of a previously delivered frequency-sim-
* control command (design doc §4).
*
* POST /api/bridge/command-result
* x-bridge-token: <bridge-token>
* body: { id: string, status: 'ok' | 'failed', reason?: string }
*/
export default defineEventHandler(async (event) => {
const token = getBridgeTokenFromHeader(event)
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'x-bridge-token header fehlt oder ist ungültig.' })
}
const exists = await BridgeToken.exists({ token })
if (!exists) {
throw createError({ statusCode: 404, statusMessage: 'Bridge-Token ist nicht verknüpft.' })
}
const body = await readBody<CommandResultBody>(event)
const id = typeof body?.id === 'string' ? body.id : ''
const status = body?.status
if (!id || (status !== 'ok' && status !== 'failed')) {
throw createError({ statusCode: 400, statusMessage: "id und status ('ok'|'failed') sind erforderlich." })
}
const resolved = simControlQueue.resolve(token, id, status, body?.reason)
console.info(
`\x1b[32m[bridge:command-result]\x1b[0m token=\x1b[96m${token.slice(0, 6)}...\x1b[0m id=\x1b[96m${id.slice(0, 8)}\x1b[0m status=\x1b[93m${status}\x1b[0m resolved=\x1b[93m${resolved}\x1b[0m`,
)
logBridgeEvent(token, {
endpoint: '/api/bridge/command-result',
method: 'POST',
statusCode: 200,
color: status === 'ok' ? '#22c55e' : '#ef4444',
summary: `${id.slice(0, 8)}${status}${body?.reason ? `: ${body.reason}` : ''}`,
data: { id, status, reason: body?.reason ?? null, resolved },
})
return { ok: resolved }
})

View File

@@ -0,0 +1,54 @@
import { createError, readBody } from 'h3'
import { BridgeToken } from '../../models/BridgeToken'
import { getBridgeTokenFromHeader } from '../../utils/bridge'
import { logBridgeEvent } from '../../utils/bridgeLog'
import { simControlQueue } from '../../utils/simControlQueue'
import { isValidSimControlCommand, type SimControlCommand } from '../../../shared/utils/simControl'
interface CommandBody {
command?: SimControlCommand
}
/**
* Queues a frequency-sim-control command (design doc §4) for the bridge
* behind this token to pick up on its next telemetry POST.
*
* POST /api/bridge/command
* x-bridge-token: <bridge-token>
* body: { command: SimControlCommand }
*/
export default defineEventHandler(async (event) => {
const token = getBridgeTokenFromHeader(event)
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'x-bridge-token header fehlt oder ist ungültig.' })
}
// Only relay for a linked token — an unauthenticated caller can't queue commands.
const exists = await BridgeToken.exists({ token })
if (!exists) {
throw createError({ statusCode: 404, statusMessage: 'Bridge-Token ist nicht verknüpft.' })
}
const body = await readBody<CommandBody>(event)
const command = body?.command
if (!isValidSimControlCommand(command)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültiges Kommando.' })
}
const pending = simControlQueue.enqueue(token, command)
console.info(
`\x1b[32m[bridge:command]\x1b[0m token=\x1b[96m${token.slice(0, 6)}...\x1b[0m id=\x1b[96m${pending.id.slice(0, 8)}\x1b[0m type=\x1b[93m${command.type}\x1b[0m`,
)
logBridgeEvent(token, {
endpoint: '/api/bridge/command',
method: 'POST',
statusCode: 200,
color: '#22c55e',
summary: `queued ${command.type}`,
data: { id: pending.id, command },
})
return { ok: true, id: pending.id, issued_at: pending.issued_at }
})

View File

@@ -3,6 +3,7 @@ import {BridgeToken} from '../../models/BridgeToken'
import {getBridgeTokenFromHeader} from '../../utils/bridge'
import {logBridgeEvent} from '../../utils/bridgeLog'
import {flightlabTelemetryStore} from '../../utils/flightlabTelemetry'
import {simControlQueue} from '../../utils/simControlQueue'
import type {FlightLabTelemetryState} from '../../../shared/data/flightlab/types'
type DomeLightMode = 'off' | 'white' | 'amber'
@@ -176,5 +177,8 @@ export default defineEventHandler(async (event) => {
// gear und parking break togglen
// gear_handle: !mapped.GEAR_HANDLE_POSITION,
parking_brake: !mapped.BRAKE_PARKING_POSITION,
// frequency-sim-control (design §4): any commands queued for this bridge
// token since the last telemetry POST, piggybacked on this response.
commands: simControlQueue.drainPending(bridgeToken),
}
})

View File

@@ -2,6 +2,7 @@ import { createError } from 'h3'
import { BridgeToken } from '../../models/BridgeToken'
import { getBridgeTokenFromHeader } from '../../utils/bridge'
import { flightlabTelemetryStore } from '../../utils/flightlabTelemetry'
import { simControlQueue } from '../../utils/simControlQueue'
export default defineEventHandler(async (event) => {
const token = getBridgeTokenFromHeader(event)
@@ -17,6 +18,7 @@ export default defineEventHandler(async (event) => {
connected: false,
lastTelemetryAt: null,
telemetry: null,
commandResults: [],
}
}
@@ -29,5 +31,8 @@ export default defineEventHandler(async (event) => {
connected: true,
lastTelemetryAt: timestamp,
telemetry,
// frequency-sim-control (design §4): terminal command results the client
// hasn't seen yet, so it can speak a confirmation/failure over TTS.
commandResults: simControlQueue.drainResultsForClient(token),
}
})

View File

@@ -0,0 +1,122 @@
/**
* In-memory command queue for the frequency-sim-control channel (design doc
* §4, `docs/plans/2026-07-14-frequency-sim-control-design.md`).
*
* Flow: /live-atc client → POST /api/bridge/command (enqueue) → this queue
* → POST /api/bridge/data response `commands` field (bridge picks up) →
* bridge executes → POST /api/bridge/command-result (resolve) → this queue
* → GET /api/bridge/live response `commandResults` field (client announces).
*
* Keyed by bridge token, not userId: both ends of this channel authenticate
* with the same x-bridge-token the telemetry channel already uses.
*/
import { randomUUID } from 'node:crypto'
import type { PendingCommand, SimControlCommand, SimControlCommandResult, SimControlCommandStatus } from '../../shared/utils/simControl'
type RecordStatus = 'pending' | 'delivered' | SimControlCommandStatus
interface CommandRecord {
id: string
token: string
command: SimControlCommand
issuedAt: number
status: RecordStatus
reason: string | null
consumedByClient: boolean
}
export interface SimControlQueueOptions {
/** Commands not delivered/resolved within this window are treated as expired. */
ttlMs?: number
/** Clock override for deterministic tests. */
now?: () => number
}
export class SimControlQueue {
private records = new Map<string, CommandRecord>()
private pendingByToken = new Map<string, string[]>()
private ttlMs: number
private now: () => number
constructor(opts: SimControlQueueOptions = {}) {
this.ttlMs = opts.ttlMs ?? 30_000
this.now = opts.now ?? Date.now
}
/** Enqueue a parsed command for the bridge behind this token to pick up. */
enqueue(token: string, command: SimControlCommand): PendingCommand {
const id = randomUUID()
const issuedAt = this.now()
const record: CommandRecord = { id, token, command, issuedAt, status: 'pending', reason: null, consumedByClient: false }
this.records.set(id, record)
const queue = this.pendingByToken.get(token) ?? []
queue.push(id)
this.pendingByToken.set(token, queue)
return { id, issued_at: new Date(issuedAt).toISOString(), command }
}
/**
* Called from POST /api/bridge/data: hand every still-pending command for
* this token to the bridge and mark it delivered. Each command is handed
* out exactly once — the caller does not re-poll for it.
*/
drainPending(token: string): PendingCommand[] {
this.sweepExpired()
const ids = this.pendingByToken.get(token) ?? []
this.pendingByToken.set(token, [])
const out: PendingCommand[] = []
for (const id of ids) {
const record = this.records.get(id)
if (!record || record.status !== 'pending') continue // expired while queued
record.status = 'delivered'
out.push({ id: record.id, issued_at: new Date(record.issuedAt).toISOString(), command: record.command })
}
return out
}
/**
* Called from POST /api/bridge/command-result: the bridge reports the
* outcome of a delivered command. Returns false if the id is unknown or
* belongs to a different token (never trust the caller's own claim).
*/
resolve(token: string, id: string, status: 'ok' | 'failed', reason?: string | null): boolean {
const record = this.records.get(id)
if (!record || record.token !== token) return false
if (record.status !== 'pending' && record.status !== 'delivered') return false
record.status = status
record.reason = reason ?? null
return true
}
/**
* Called from GET /api/bridge/live: hand the client every terminal result
* for this token it hasn't seen yet, then forget them (one-shot delivery,
* same as drainPending on the bridge side).
*/
drainResultsForClient(token: string): SimControlCommandResult[] {
this.sweepExpired()
const out: SimControlCommandResult[] = []
for (const record of this.records.values()) {
if (record.token !== token || record.consumedByClient) continue
if (record.status === 'ok' || record.status === 'failed' || record.status === 'expired') {
record.consumedByClient = true
out.push({ id: record.id, command: record.command, status: record.status, reason: record.reason })
}
}
for (const record of out) this.records.delete(record.id)
return out
}
private sweepExpired() {
const cutoff = this.now() - this.ttlMs
for (const record of this.records.values()) {
if ((record.status === 'pending' || record.status === 'delivered') && record.issuedAt < cutoff) {
record.status = 'expired'
}
}
}
}
// Singleton — shared across all server handlers, same pattern as flightlabTelemetryStore.
export const simControlQueue = new SimControlQueue()

View File

@@ -165,3 +165,96 @@ export function parseSimControl(input: string): SimControlParseResult {
return noMatch('no_intent', text)
}
// --- Wire contract: server ⇄ bridge command channel (design §4) -------------
// The Nuxt server queues parsed commands per bridge token and piggybacks them
// on the existing telemetry POST response; the bridge executes and reports
// back by id. Kept here, next to the command type it carries, as the single
// source of truth for both server and client.
export interface PendingCommand {
/** UUID, correlates the bridge's async command-result POST. */
id: string
/** ISO timestamp; the bridge/server discard commands older than the TTL. */
issued_at: string
command: SimControlCommand
}
export type SimControlCommandStatus = 'ok' | 'failed' | 'expired'
export interface SimControlCommandResult {
id: string
command: SimControlCommand
status: SimControlCommandStatus
reason: string | null
}
/**
* Defensive re-validation for commands arriving over the wire (the enqueue
* endpoint receives whatever the client posts, not necessarily this parser's
* own output). Reuses SIM_CONTROL_LIMITS so there is exactly one place value
* ranges are defined, per the fail-closed design.
*/
export function isValidSimControlCommand(value: unknown): value is SimControlCommand {
if (!value || typeof value !== 'object') return false
const cmd = value as Record<string, unknown>
switch (cmd.type) {
case 'set_altitude':
return typeof cmd.altitude_ft === 'number' && inRange(cmd.altitude_ft, SIM_CONTROL_LIMITS.altitude_ft)
case 'set_heading':
return typeof cmd.heading_deg === 'number' && inRange(cmd.heading_deg, SIM_CONTROL_LIMITS.heading_deg)
case 'set_speed':
return typeof cmd.ias_kts === 'number' && inRange(cmd.ias_kts, SIM_CONTROL_LIMITS.ias_kts)
case 'setup_approach': {
if (typeof cmd.airport_icao !== 'string' || !/^[A-Z]{4}$/.test(cmd.airport_icao)) return false
if (typeof cmd.runway !== 'string' || !/^(\d{2})[LRC]?$/.test(cmd.runway)) return false
if (!inRange(Number(cmd.runway.slice(0, 2)), { min: 1, max: 36 })) return false
if (cmd.altitude_ft !== undefined) {
if (typeof cmd.altitude_ft !== 'number' || !inRange(cmd.altitude_ft, SIM_CONTROL_LIMITS.altitude_ft)) return false
}
if (cmd.final_distance_nm !== undefined) {
if (typeof cmd.final_distance_nm !== 'number' || !inRange(cmd.final_distance_nm, SIM_CONTROL_LIMITS.final_distance_nm)) return false
}
return true
}
default:
return false
}
}
/** Short "say again" ATC reply for a gate-hit transmission whose slots failed to parse. */
export function simControlRejectionSpeech(reason: Exclude<SimControlNoMatchReason, 'no_intent'>): string {
switch (reason) {
case 'missing_value': return 'say again with a value'
case 'missing_unit': return 'say again with altitude in feet'
case 'out_of_range': return 'unable, value out of range, say again'
case 'invalid_runway': return 'unable, invalid runway, say again'
case 'missing_runway': return 'say again with the runway'
case 'missing_airport': return 'say again with the airport'
}
}
/** TTS confirmation/failure line for a resolved (or expired) bridge command. */
export function simControlResultSpeech(result: SimControlCommandResult): string {
if (result.status === 'expired') return 'bridge did not respond'
if (result.status === 'failed') return result.reason ? `unable, ${result.reason}` : 'unable to comply'
const command = result.command
switch (command.type) {
case 'setup_approach':
if (command.final_distance_nm) {
return `repositioned, ${command.final_distance_nm} mile final runway ${command.runway}`
}
if (command.altitude_ft) {
return `repositioned, runway ${command.runway} approach from ${command.altitude_ft} feet`
}
return `repositioned, runway ${command.runway} approach`
case 'set_altitude':
return `altitude set, ${command.altitude_ft} feet`
case 'set_heading':
return `heading set, ${command.heading_deg}`
case 'set_speed':
return `speed set, ${command.ias_kts} knots`
}
}

View File

@@ -0,0 +1,144 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { SimControlQueue } from '~~/server/utils/simControlQueue'
const HEADING: any = { type: 'set_heading', heading_deg: 270 }
const ALTITUDE: any = { type: 'set_altitude', altitude_ft: 8000 }
function clock(startMs = 0) {
let t = startMs
return { now: () => t, advance: (ms: number) => { t += ms } }
}
describe('SimControlQueue — enqueue / drainPending', () => {
it('delivers a pending command exactly once', () => {
const q = new SimControlQueue()
const pending = q.enqueue('tok-a', HEADING)
assert.equal(typeof pending.id, 'string')
assert.ok(pending.id.length > 0)
assert.deepEqual(pending.command, HEADING)
const first = q.drainPending('tok-a')
assert.equal(first.length, 1)
assert.equal(first[0]!.id, pending.id)
const second = q.drainPending('tok-a')
assert.equal(second.length, 0)
})
it('keeps queues isolated per token', () => {
const q = new SimControlQueue()
q.enqueue('tok-a', HEADING)
q.enqueue('tok-b', ALTITUDE)
const forA = q.drainPending('tok-a')
assert.equal(forA.length, 1)
assert.deepEqual(forA[0]!.command, HEADING)
const forB = q.drainPending('tok-b')
assert.equal(forB.length, 1)
assert.deepEqual(forB[0]!.command, ALTITUDE)
})
it('drainPending on an unknown token returns empty, not an error', () => {
const q = new SimControlQueue()
assert.deepEqual(q.drainPending('never-enqueued'), [])
})
})
describe('SimControlQueue — resolve', () => {
it('resolves a delivered command and the client can read the result once', () => {
const q = new SimControlQueue()
const pending = q.enqueue('tok-a', HEADING)
q.drainPending('tok-a') // bridge picks it up → status 'delivered'
const resolved = q.resolve('tok-a', pending.id, 'ok')
assert.equal(resolved, true)
const results = q.drainResultsForClient('tok-a')
assert.equal(results.length, 1)
assert.equal(results[0]!.id, pending.id)
assert.equal(results[0]!.status, 'ok')
assert.equal(results[0]!.reason, null)
// One-shot: a second poll sees nothing more for this id.
assert.deepEqual(q.drainResultsForClient('tok-a'), [])
})
it('carries a failure reason through to the client', () => {
const q = new SimControlQueue()
const pending = q.enqueue('tok-a', HEADING)
q.drainPending('tok-a')
q.resolve('tok-a', pending.id, 'failed', 'aircraft on ground')
const [result] = q.drainResultsForClient('tok-a')
assert.equal(result!.status, 'failed')
assert.equal(result!.reason, 'aircraft on ground')
})
it('rejects a resolve for a mismatched token (cannot resolve another bridge\'s command)', () => {
const q = new SimControlQueue()
const pending = q.enqueue('tok-a', HEADING)
q.drainPending('tok-a')
const resolved = q.resolve('tok-b', pending.id, 'ok')
assert.equal(resolved, false)
// The command is still awaiting resolution under the real token.
assert.deepEqual(q.drainResultsForClient('tok-a'), [])
})
it('rejects a resolve for an unknown id', () => {
const q = new SimControlQueue()
assert.equal(q.resolve('tok-a', 'no-such-id', 'ok'), false)
})
it('allows resolving a command still pending (not yet drained by the bridge)', () => {
const q = new SimControlQueue()
const pending = q.enqueue('tok-a', HEADING)
assert.equal(q.resolve('tok-a', pending.id, 'ok'), true)
})
})
describe('SimControlQueue — TTL expiry', () => {
it('expires a command that was never delivered before the TTL', () => {
const c = clock()
const q = new SimControlQueue({ ttlMs: 1000, now: c.now })
q.enqueue('tok-a', HEADING)
c.advance(1001)
assert.deepEqual(q.drainPending('tok-a'), []) // expired before the bridge ever polled
const results = q.drainResultsForClient('tok-a')
assert.equal(results.length, 1)
assert.equal(results[0]!.status, 'expired')
})
it('expires a delivered command the bridge never resolved', () => {
const c = clock()
const q = new SimControlQueue({ ttlMs: 1000, now: c.now })
const pending = q.enqueue('tok-a', HEADING)
q.drainPending('tok-a') // delivered at t=0
c.advance(1001)
const results = q.drainResultsForClient('tok-a')
assert.equal(results.length, 1)
assert.equal(results[0]!.status, 'expired')
// A late result from the bridge can no longer land on an already-expired command.
assert.equal(q.resolve('tok-a', pending.id, 'ok'), false)
})
it('does not expire a command resolved within the TTL', () => {
const c = clock()
const q = new SimControlQueue({ ttlMs: 1000, now: c.now })
const pending = q.enqueue('tok-a', HEADING)
q.drainPending('tok-a')
c.advance(500)
assert.equal(q.resolve('tok-a', pending.id, 'ok'), true)
c.advance(600) // past the original TTL, but already resolved
const results = q.drainResultsForClient('tok-a')
assert.equal(results.length, 1)
assert.equal(results[0]!.status, 'ok')
})
})

View File

@@ -1,7 +1,12 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { parseSimControl } from '~~/shared/utils/simControl'
import {
parseSimControl,
isValidSimControlCommand,
simControlRejectionSpeech,
simControlResultSpeech,
} from '~~/shared/utils/simControl'
function expectCommand(input: string) {
const result = parseSimControl(input)
@@ -146,3 +151,128 @@ describe('parseSimControl — must NEVER match ATC dialogue', () => {
expectNoMatch('set me up at 8000')
})
})
describe('isValidSimControlCommand', () => {
it('accepts each command type at valid values', () => {
assert.equal(isValidSimControlCommand({ type: 'set_altitude', altitude_ft: 8000 }), true)
assert.equal(isValidSimControlCommand({ type: 'set_heading', heading_deg: 270 }), true)
assert.equal(isValidSimControlCommand({ type: 'set_speed', ias_kts: 210 }), true)
assert.equal(
isValidSimControlCommand({ type: 'setup_approach', airport_icao: 'EDDF', runway: '07R' }),
true,
)
assert.equal(
isValidSimControlCommand({
type: 'setup_approach',
airport_icao: 'EDDF',
runway: '25',
altitude_ft: 5000,
final_distance_nm: 5,
}),
true,
)
})
it('rejects out-of-range values', () => {
assert.equal(isValidSimControlCommand({ type: 'set_altitude', altitude_ft: 80000 }), false)
assert.equal(isValidSimControlCommand({ type: 'set_heading', heading_deg: 400 }), false)
assert.equal(isValidSimControlCommand({ type: 'set_speed', ias_kts: 10 }), false)
assert.equal(
isValidSimControlCommand({ type: 'setup_approach', airport_icao: 'EDDF', runway: '07R', altitude_ft: -1 }),
false,
)
})
it('rejects malformed shapes', () => {
assert.equal(isValidSimControlCommand(null), false)
assert.equal(isValidSimControlCommand({}), false)
assert.equal(isValidSimControlCommand({ type: 'teleport' }), false)
assert.equal(isValidSimControlCommand({ type: 'set_altitude', altitude_ft: '8000' }), false)
assert.equal(
isValidSimControlCommand({ type: 'setup_approach', airport_icao: 'eddf', runway: '07R' }),
false,
)
assert.equal(
isValidSimControlCommand({ type: 'setup_approach', airport_icao: 'EDDF', runway: '99' }),
false,
)
})
})
describe('simControlRejectionSpeech', () => {
it('has a distinct short reply for every non-no_intent reason', () => {
const reasons = [
'missing_value', 'missing_unit', 'out_of_range',
'invalid_runway', 'missing_runway', 'missing_airport',
] as const
const seen = new Set<string>()
for (const reason of reasons) {
const speech = simControlRejectionSpeech(reason)
assert.equal(typeof speech, 'string')
assert.ok(speech.length > 0)
seen.add(speech)
}
assert.equal(seen.size, reasons.length)
})
})
describe('simControlResultSpeech', () => {
it('confirms an approach setup with a final distance', () => {
const speech = simControlResultSpeech({
id: '1',
status: 'ok',
reason: null,
command: { type: 'setup_approach', airport_icao: 'EDDF', runway: '07R', final_distance_nm: 5 },
})
assert.equal(speech, 'repositioned, 5 mile final runway 07R')
})
it('confirms an approach setup without a final distance but with altitude', () => {
const speech = simControlResultSpeech({
id: '1',
status: 'ok',
reason: null,
command: { type: 'setup_approach', airport_icao: 'EDDF', runway: '07R', altitude_ft: 5000 },
})
assert.equal(speech, 'repositioned, runway 07R approach from 5000 feet')
})
it('confirms altitude/heading/speed changes', () => {
assert.equal(
simControlResultSpeech({ id: '1', status: 'ok', reason: null, command: { type: 'set_altitude', altitude_ft: 8000 } }),
'altitude set, 8000 feet',
)
assert.equal(
simControlResultSpeech({ id: '1', status: 'ok', reason: null, command: { type: 'set_heading', heading_deg: 270 } }),
'heading set, 270',
)
assert.equal(
simControlResultSpeech({ id: '1', status: 'ok', reason: null, command: { type: 'set_speed', ias_kts: 210 } }),
'speed set, 210 knots',
)
})
it('relays a failure reason from the bridge', () => {
const speech = simControlResultSpeech({
id: '1',
status: 'failed',
reason: 'aircraft on ground, airborne reposition refused',
command: { type: 'set_heading', heading_deg: 270 },
})
assert.equal(speech, 'unable, aircraft on ground, airborne reposition refused')
})
it('falls back to a generic failure line when no reason is given', () => {
const speech = simControlResultSpeech({
id: '1', status: 'failed', reason: null, command: { type: 'set_heading', heading_deg: 270 },
})
assert.equal(speech, 'unable to comply')
})
it('announces an expired (unanswered) command', () => {
const speech = simControlResultSpeech({
id: '1', status: 'expired', reason: null, command: { type: 'set_heading', heading_deg: 270 },
})
assert.equal(speech, 'bridge did not respond')
})
})