mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-07-31 05:45:35 +08:00
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:
144
tests/server/simControlQueue.test.ts
Normal file
144
tests/server/simControlQueue.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user