mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 16:22:48 +08:00
test(live-atc): cover the wait auto-tune actually depends on
The decision to tune was tested; the three seconds between announcing it and doing it were not, and that gap is where the feature can go wrong — it is the only window in which the radio moves on its own. Moves the scheduling into a testable unit and wires the composable to it, so the tested code is the code that runs. Covered: nothing is announced when no change is due, the change is announced immediately but made only after the delay, and it is dropped when the pilot tuned the radio themselves, when the session ended, and when a second handoff superseded it — the last of which would otherwise have tuned to a stale frequency. Leaving the flight or unmounting now cancels a pending change explicitly rather than relying on the fire-time guards to notice.
This commit is contained in:
@@ -11,7 +11,7 @@ import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
|
||||
import { generateGermanRegistration } from '../../shared/utils/registration'
|
||||
import { gateTransmission } from '../../shared/utils/transmissionGate'
|
||||
import { silenceWindowFor } from '../../shared/utils/silenceTimer'
|
||||
import { planAutoTune } from '../../shared/utils/autoTune'
|
||||
import { createAutoTuneScheduler } from '../../shared/utils/autoTune'
|
||||
import {
|
||||
isSimControlMatch,
|
||||
isSimControlRejection,
|
||||
@@ -199,58 +199,43 @@ export function useLiveAtcSession(
|
||||
// planAutoTune): the two cases that must NOT tune — a frequency readback that
|
||||
// was wrong, and one not yet given — both leave the session on a state that
|
||||
// still expects the frequency already dialled in, so nothing is due.
|
||||
let autoTuneTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const autoTune = createAutoTuneScheduler({
|
||||
announce: (text) => {
|
||||
// Announce before changing, never after: the pilot has to be able to
|
||||
// follow what their own radio just did.
|
||||
scheduleControllerSpeech(text)
|
||||
appendLogEntry('system', text, currentState.value?.id ?? '', {
|
||||
frequency: frequencies.value.active,
|
||||
})
|
||||
},
|
||||
tune: (frequency) => {
|
||||
// Prefer the airport's own entry so the label and the engine's notion of
|
||||
// the position come along; fall back to a bare entry for an invented one.
|
||||
const known = airportFrequencies.value.find(
|
||||
(entry: any) => normalizedFrequencyValue(entry.frequency) === normalizedFrequencyValue(frequency),
|
||||
)
|
||||
pmLog.info('AUTO-TUNE tuning →', frequency)
|
||||
setActiveFrequencyFromList(known ?? {
|
||||
type: '', label: '', frequency, source: 'openaip',
|
||||
} as any)
|
||||
},
|
||||
currentSessionId: () => backendSessionId.value || null,
|
||||
currentActive: () => frequencies.value.active,
|
||||
onCancelled: (reason) => pmLog.info('AUTO-TUNE dropped —', reason),
|
||||
})
|
||||
|
||||
function clearAutoTune() {
|
||||
if (autoTuneTimer) {
|
||||
clearTimeout(autoTuneTimer)
|
||||
autoTuneTimer = null
|
||||
}
|
||||
autoTune.cancel()
|
||||
}
|
||||
|
||||
function scheduleAutoTune() {
|
||||
clearAutoTune()
|
||||
const plan = planAutoTune({
|
||||
const plan = autoTune.schedule({
|
||||
enabled: autoTuneEnabled.value,
|
||||
active: frequencies.value.active,
|
||||
expected: expectedFrequencyForState(),
|
||||
accepted: acceptedFrequenciesForState(),
|
||||
})
|
||||
if (!plan) return
|
||||
|
||||
// Announce before changing, never after: the pilot has to be able to follow
|
||||
// what their own radio just did.
|
||||
scheduleControllerSpeech(plan.announcement)
|
||||
appendLogEntry('system', plan.announcement, currentState.value?.id ?? '', {
|
||||
frequency: frequencies.value.active,
|
||||
})
|
||||
|
||||
const sessionAtArm = backendSessionId.value
|
||||
const activeAtArm = frequencies.value.active
|
||||
pmLog.info('AUTO-TUNE armed →', plan.frequency, `in ${plan.delayMs}ms`)
|
||||
|
||||
autoTuneTimer = setTimeout(() => {
|
||||
autoTuneTimer = null
|
||||
// The session ended or a new one started while we waited.
|
||||
if (backendSessionId.value !== sessionAtArm) {
|
||||
pmLog.info('AUTO-TUNE dropped — session changed')
|
||||
return
|
||||
}
|
||||
// The pilot reached for the radio themselves; theirs wins.
|
||||
if (frequencies.value.active !== activeAtArm) {
|
||||
pmLog.info('AUTO-TUNE dropped — pilot tuned manually')
|
||||
return
|
||||
}
|
||||
// Prefer the airport's own entry so the label and the engine's notion of
|
||||
// the position come along; fall back to a bare entry for an invented one.
|
||||
const known = airportFrequencies.value.find(
|
||||
(entry: any) => normalizedFrequencyValue(entry.frequency) === normalizedFrequencyValue(plan.frequency),
|
||||
)
|
||||
pmLog.info('AUTO-TUNE tuning →', plan.frequency)
|
||||
setActiveFrequencyFromList(known ?? {
|
||||
type: '', label: '', frequency: plan.frequency, source: 'openaip',
|
||||
} as any)
|
||||
}, plan.delayMs)
|
||||
if (plan) pmLog.info('AUTO-TUNE armed →', plan.frequency, `in ${plan.delayMs}ms`)
|
||||
}
|
||||
|
||||
// Guards the ATC reply (log entry + TTS) against being applied twice for the
|
||||
@@ -853,6 +838,10 @@ export function useLiveAtcSession(
|
||||
}
|
||||
|
||||
const backToSetup = () => {
|
||||
// Leaving the flight: a pending frequency change belongs to a session that
|
||||
// no longer exists.
|
||||
clearSilenceTimer()
|
||||
clearAutoTune()
|
||||
currentScreen.value = 'login'
|
||||
selectedPlan.value = null
|
||||
persistSelectedPlan(null)
|
||||
@@ -946,7 +935,10 @@ export function useLiveAtcSession(
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(clearSilenceTimer)
|
||||
onUnmounted(() => {
|
||||
clearSilenceTimer()
|
||||
clearAutoTune()
|
||||
})
|
||||
|
||||
return {
|
||||
clearSilenceTimer,
|
||||
|
||||
@@ -48,6 +48,86 @@ export function announcementFor(frequency: string): string {
|
||||
/**
|
||||
* The change due right now, or null when none is.
|
||||
*/
|
||||
export interface AutoTuneSchedulerDeps {
|
||||
/** Speak and log the announcement. Called immediately, before the wait. */
|
||||
announce: (text: string) => void
|
||||
/** Dial the frequency in. Called only if the change is still due. */
|
||||
tune: (frequency: string) => void
|
||||
/** The session the change belongs to; a different one invalidates it. */
|
||||
currentSessionId: () => string | null
|
||||
/** What is tuned right now; a change means the pilot reached for the radio. */
|
||||
currentActive: () => string | undefined
|
||||
setTimeoutFn?: (fn: () => void, ms: number) => unknown
|
||||
clearTimeoutFn?: (handle: unknown) => void
|
||||
/** Reason a pending change was dropped, for the log. */
|
||||
onCancelled?: (reason: 'session_changed' | 'tuned_manually' | 'superseded') => void
|
||||
}
|
||||
|
||||
export interface AutoTuneScheduler {
|
||||
/** Announce and schedule, or do nothing when no change is due. */
|
||||
schedule: (input: AutoTuneInput) => AutoTunePlan | null
|
||||
/** Drop a pending change without tuning. */
|
||||
cancel: () => void
|
||||
readonly pending: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces the change, waits, then makes it — unless something happened in
|
||||
* between that means it should no longer happen.
|
||||
*
|
||||
* The wait is where this earns its keep: between announcing and tuning, the
|
||||
* session can end, the pilot can tune the radio themselves, or another handoff
|
||||
* can supersede this one. All three must drop the change silently rather than
|
||||
* moving the radio out from under the pilot.
|
||||
*/
|
||||
export function createAutoTuneScheduler(deps: AutoTuneSchedulerDeps): AutoTuneScheduler {
|
||||
const setTimer = deps.setTimeoutFn ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clearTimer = deps.clearTimeoutFn ?? ((h) => clearTimeout(h as any))
|
||||
|
||||
let handle: unknown = null
|
||||
|
||||
const cancel = () => {
|
||||
if (handle !== null) {
|
||||
clearTimer(handle)
|
||||
handle = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get pending() {
|
||||
return handle !== null
|
||||
},
|
||||
cancel,
|
||||
schedule(input: AutoTuneInput) {
|
||||
if (handle !== null) {
|
||||
cancel()
|
||||
deps.onCancelled?.('superseded')
|
||||
}
|
||||
const plan = planAutoTune(input)
|
||||
if (!plan) return null
|
||||
|
||||
deps.announce(plan.announcement)
|
||||
const sessionAtArm = deps.currentSessionId()
|
||||
const activeAtArm = deps.currentActive()
|
||||
|
||||
handle = setTimer(() => {
|
||||
handle = null
|
||||
if (deps.currentSessionId() !== sessionAtArm) {
|
||||
deps.onCancelled?.('session_changed')
|
||||
return
|
||||
}
|
||||
if (deps.currentActive() !== activeAtArm) {
|
||||
deps.onCancelled?.('tuned_manually')
|
||||
return
|
||||
}
|
||||
deps.tune(plan.frequency)
|
||||
}, plan.delayMs)
|
||||
|
||||
return plan
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function planAutoTune(input: AutoTuneInput): AutoTunePlan | null {
|
||||
if (!input.enabled) return null
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { AUTO_TUNE_DELAY_MS, planAutoTune } from '~~/shared/utils/autoTune'
|
||||
import { AUTO_TUNE_DELAY_MS, createAutoTuneScheduler, planAutoTune } from '~~/shared/utils/autoTune'
|
||||
|
||||
const plan = (over: Partial<Parameters<typeof planAutoTune>[0]> = {}) =>
|
||||
planAutoTune({ enabled: true, active: '118.700', expected: '121.800', ...over })
|
||||
@@ -65,3 +65,90 @@ describe('planAutoTune', () => {
|
||||
assert.equal(plan({ active: '118.700', expected: '118.700' }), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createAutoTuneScheduler — the wait between announcing and tuning', () => {
|
||||
function harness(over: Partial<Parameters<typeof planAutoTune>[0]> = {}) {
|
||||
const calls = { announced: [] as string[], tuned: [] as string[], cancelled: [] as string[] }
|
||||
let sessionId: string | null = 'session-1'
|
||||
let active = '118.700'
|
||||
let fire: (() => void) | null = null
|
||||
let delay = 0
|
||||
|
||||
const scheduler = createAutoTuneScheduler({
|
||||
announce: t => calls.announced.push(t),
|
||||
tune: f => { calls.tuned.push(f); active = f },
|
||||
currentSessionId: () => sessionId,
|
||||
currentActive: () => active,
|
||||
onCancelled: r => calls.cancelled.push(r),
|
||||
setTimeoutFn: (fn, ms) => { fire = fn; delay = ms; return 1 },
|
||||
clearTimeoutFn: () => { fire = null },
|
||||
})
|
||||
|
||||
return {
|
||||
calls,
|
||||
scheduler,
|
||||
get delay() { return delay },
|
||||
run: () => { const f = fire; fire = null; f?.() },
|
||||
endSession: () => { sessionId = null },
|
||||
tuneManually: (f: string) => { active = f },
|
||||
schedule: (extra: Record<string, unknown> = {}) => scheduler.schedule({
|
||||
enabled: true, active, expected: '121.800', ...over, ...extra,
|
||||
} as any),
|
||||
}
|
||||
}
|
||||
|
||||
it('announces immediately and tunes only after the delay', () => {
|
||||
const h = harness()
|
||||
h.schedule()
|
||||
assert.deepEqual(h.calls.announced, ['OpenSquawk changing frequency to 121.800'])
|
||||
assert.deepEqual(h.calls.tuned, [], 'tuned before the announcement was heard')
|
||||
assert.equal(h.delay, AUTO_TUNE_DELAY_MS)
|
||||
|
||||
h.run()
|
||||
assert.deepEqual(h.calls.tuned, ['121.800'])
|
||||
})
|
||||
|
||||
it('says nothing at all when no change is due', () => {
|
||||
const h = harness()
|
||||
assert.equal(h.schedule({ active: '121.800' }), null)
|
||||
assert.deepEqual(h.calls.announced, [])
|
||||
assert.equal(h.scheduler.pending, false)
|
||||
})
|
||||
|
||||
it('drops the change when the pilot tunes the radio first', () => {
|
||||
const h = harness()
|
||||
h.schedule()
|
||||
h.tuneManually('119.500')
|
||||
h.run()
|
||||
assert.deepEqual(h.calls.tuned, [], 'overrode the pilot')
|
||||
assert.deepEqual(h.calls.cancelled, ['tuned_manually'])
|
||||
})
|
||||
|
||||
it('drops the change when the session ended while waiting', () => {
|
||||
const h = harness()
|
||||
h.schedule()
|
||||
h.endSession()
|
||||
h.run()
|
||||
assert.deepEqual(h.calls.tuned, [])
|
||||
assert.deepEqual(h.calls.cancelled, ['session_changed'])
|
||||
})
|
||||
|
||||
it('a second handoff supersedes the first rather than tuning twice', () => {
|
||||
const h = harness()
|
||||
h.schedule()
|
||||
h.schedule({ expected: '131.150' })
|
||||
assert.deepEqual(h.calls.cancelled, ['superseded'])
|
||||
h.run()
|
||||
assert.deepEqual(h.calls.tuned, ['131.150'], 'tuned to the stale frequency')
|
||||
})
|
||||
|
||||
it('cancel() stops a pending change', () => {
|
||||
const h = harness()
|
||||
h.schedule()
|
||||
assert.equal(h.scheduler.pending, true)
|
||||
h.scheduler.cancel()
|
||||
assert.equal(h.scheduler.pending, false)
|
||||
h.run()
|
||||
assert.deepEqual(h.calls.tuned, [])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user