diff --git a/app/composables/useLiveAtcSession.ts b/app/composables/useLiveAtcSession.ts index 7053adf..81d35f0 100644 --- a/app/composables/useLiveAtcSession.ts +++ b/app/composables/useLiveAtcSession.ts @@ -12,6 +12,7 @@ import { generateGermanRegistration } from '../../shared/utils/registration' import { gateTransmission } from '../../shared/utils/transmissionGate' import { silenceWindowFor } from '../../shared/utils/silenceTimer' import { createAutoTuneScheduler } from '../../shared/utils/autoTune' +import { buildBackendVariables } from '../../shared/utils/backendVariables' import { isSimControlMatch, isSimControlRejection, @@ -670,36 +671,7 @@ export function useLiveAtcSession( // departure_freq for tower-v1, etc.) are already populated with real airport // values when the session advances to those flows. const v = (vars as any).value - const backendVariables: Record = { - callsign: v.callsign || flightPlan.callsign || 'UNKNOWN', - information: v.atis_code || 'K', - destination: v.dest || flightPlan.arr || flightPlan.arrival || 'Unknown', - stand: v.stand || 'A1', - sid: v.sid || 'UNKNOWN1A', - initial_altitude: String(v.initial_altitude_ft ?? 5000), - squawk: String(v.squawk ?? '2000'), - // Shared / arrival variables. The engine generates these, but they were not - // being forwarded — so arrival flows (and taxi/tower on departure) fell back - // to YAML defaults and ignored the selected flight. Names are mapped to the - // backend flow conventions (qnh_hpa→qnh, acf_type→aircraft_type, …). - runway: atisRunway || v.runway || '25R', - qnh: String(v.qnh_hpa ?? '1013'), - surface_wind: v.surface_wind || '250/08', - // taxi_route is intentionally NOT sent: the backend computes the real OSM - // taxi route (and crossings) from airport_icao + stand/runway, and falls - // back to the flow's YAML default on its own. Sending a placeholder here - // would count as a caller override and suppress that computation. - aircraft_type: v.acf_type || 'A320', - cruise_level: v.cruise_flight_level || 'FL360', - assigned_squawk: String(v.squawk ?? '2000'), - // All airport frequencies — available to every flow in the chain. - delivery_freq: v.delivery_freq || '121.950', - ground_freq: v.ground_freq || '121.800', - tower_freq: v.tower_freq || '118.700', - departure_freq: v.departure_freq || '120.000', - approach_freq: v.approach_freq || '119.000', - handoff_freq: v.handoff_freq || '131.150', - } + const backendVariables = buildBackendVariables({ flightPlan, vars: v, atisRunway }) // Special scenarios. Only sent when switched on: the backend treats the // variable's absence as "leave it to chance", and sending false would pin diff --git a/shared/utils/backendVariables.ts b/shared/utils/backendVariables.ts new file mode 100644 index 0000000..1ebd84b --- /dev/null +++ b/shared/utils/backendVariables.ts @@ -0,0 +1,61 @@ +/** + * Build the variable payload the Python backend session is created with. + * + * The backend stores every key, so frequencies declared in downstream chained + * flows (tower_freq for taxi-v1, departure_freq for tower-v1, …) are already + * populated with real airport values by the time the session advances to them. + * + * Pure on purpose: the mapping is the part with real logic — fallback chains, + * ATIS overrides, string coercion — and keeping it out of the composable is + * what makes it testable. + */ + +export type BackendVariableInputs = { + /** The selected flight plan (VATSIM, demo, manual, or a restored snapshot). */ + flightPlan: Record + /** The local engine's variables, after initializeFlight() and the ATIS patch. */ + vars: Record + /** Runway from the resolved ATIS, when the airport publishes runway data. */ + atisRunway?: string | null +} + +export function buildBackendVariables( + { flightPlan, vars, atisRunway }: BackendVariableInputs +): Record { + const v = vars + return { + callsign: v.callsign || flightPlan.callsign || 'UNKNOWN', + information: v.atis_code || 'K', + destination: v.dest || flightPlan.arr || flightPlan.arrival || 'Unknown', + stand: v.stand || 'A1', + sid: v.sid || 'UNKNOWN1A', + initial_altitude: String(v.initial_altitude_ft ?? 5000), + squawk: String(v.squawk ?? '2000'), + // The filed enroute route. The backend derives the point where the SID + // hands over to the enroute structure and grants it in the clearance; + // demo and manual flights file none, and the flow keeps its no-route + // wording. Always present so the payload shape does not vary by source. + route: flightPlan.route || '', + // Shared / arrival variables. The engine generates these, but they were not + // being forwarded — so arrival flows (and taxi/tower on departure) fell back + // to YAML defaults and ignored the selected flight. Names are mapped to the + // backend flow conventions (qnh_hpa→qnh, acf_type→aircraft_type, …). + runway: atisRunway || v.runway || '25R', + qnh: String(v.qnh_hpa ?? '1013'), + surface_wind: v.surface_wind || '250/08', + // taxi_route is intentionally NOT sent: the backend computes the real OSM + // taxi route (and crossings) from airport_icao + stand/runway, and falls + // back to the flow's YAML default on its own. Sending a placeholder here + // would count as a caller override and suppress that computation. + aircraft_type: v.acf_type || 'A320', + cruise_level: v.cruise_flight_level || 'FL360', + assigned_squawk: String(v.squawk ?? '2000'), + // All airport frequencies — available to every flow in the chain. + delivery_freq: v.delivery_freq || '121.950', + ground_freq: v.ground_freq || '121.800', + tower_freq: v.tower_freq || '118.700', + departure_freq: v.departure_freq || '120.000', + approach_freq: v.approach_freq || '119.000', + handoff_freq: v.handoff_freq || '131.150', + } +} diff --git a/tests/shared/backendVariables.test.ts b/tests/shared/backendVariables.test.ts new file mode 100644 index 0000000..5dd0517 --- /dev/null +++ b/tests/shared/backendVariables.test.ts @@ -0,0 +1,68 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { buildBackendVariables } from '~~/shared/utils/backendVariables' + +/** A VATSIM flight plan and the engine variables a session starts from. */ +const build = ( + flightPlan: Record = {}, + vars: Record = {}, + atisRunway: string | null = null +) => + buildBackendVariables({ + flightPlan: { callsign: 'DLH39A', dep: 'EDDF', arr: 'EDDM', ...flightPlan }, + vars: { callsign: 'DLH39A', dest: 'EDDM', sid: 'BIBAX1N', ...vars }, + atisRunway, + }) + +describe('buildBackendVariables — the filed route', () => { + it('forwards the route so the clearance can grant it', () => { + // Without this the backend never sees the route and the clearance stops + // at the SID. + assert.equal( + build({ route: 'SULUS5S SULUS Y101 ARMUT' }).route, + 'SULUS5S SULUS Y101 ARMUT' + ) + }) + + it('sends an empty route for a flight that filed none', () => { + // Demo and manual flights. The backend keeps the flow defaults, which are + // the wording without a route. + assert.equal(build().route, '') + }) + + it('sends an empty route rather than dropping the key', () => { + // A missing key and an empty one are the same to the backend, but the key + // being present keeps the payload shape stable across flight sources. + assert.ok('route' in build()) + }) +}) + +describe('buildBackendVariables — flight plan mapping', () => { + it('prefers the engine callsign over the plan', () => { + assert.equal(build({ callsign: 'DLH1' }, { callsign: 'DLH39A' }).callsign, 'DLH39A') + }) + + it('falls back to the plan callsign when the engine has none', () => { + assert.equal(build({ callsign: 'DLH1' }, { callsign: '' }).callsign, 'DLH1') + }) + + it('uses the ATIS runway over the engine one', () => { + assert.equal(build({}, { runway: '07C' }, '25R').runway, '25R') + }) + + it('falls back to the engine runway when the ATIS gives none', () => { + assert.equal(build({}, { runway: '07C' }).runway, '07C') + }) + + it('sends numeric values as strings', () => { + const payload = build({}, { squawk: 2341, initial_altitude_ft: 6000, qnh_hpa: 1015 }) + assert.equal(payload.squawk, '2341') + assert.equal(payload.initial_altitude, '6000') + assert.equal(payload.qnh, '1015') + }) + + it('leaves taxi_route out so the backend computes the real one', () => { + assert.ok(!('taxi_route' in build())) + }) +})