diff --git a/app/composables/useLiveAtcSession.ts b/app/composables/useLiveAtcSession.ts index dd44ff4..b86189f 100644 --- a/app/composables/useLiveAtcSession.ts +++ b/app/composables/useLiveAtcSession.ts @@ -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 | 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, diff --git a/shared/utils/autoTune.ts b/shared/utils/autoTune.ts index 10416e7..d8bf8ea 100644 --- a/shared/utils/autoTune.ts +++ b/shared/utils/autoTune.ts @@ -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 diff --git a/tests/shared/autoTune.test.ts b/tests/shared/autoTune.test.ts index 23d5109..d4269c1 100644 --- a/tests/shared/autoTune.test.ts +++ b/tests/shared/autoTune.test.ts @@ -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[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[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 = {}) => 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, []) + }) +})