diff --git a/shared/utils/atisReport.ts b/shared/utils/atisReport.ts new file mode 100644 index 0000000..ca68db3 --- /dev/null +++ b/shared/utils/atisReport.ts @@ -0,0 +1,440 @@ +/** + * ATIS resolution. + * + * A session needs an information letter and a runway in use *every* time, but + * the only live source — the VATSIM datafeed — only has an ATIS when a + * controller happens to be online for that airport. These helpers turn whatever + * is available (VATSIM ATIS, a METAR, or nothing at all) into one `AtisReport`, + * so the spoken broadcast and the flow variables can never disagree. + * + * Everything here is pure: the caller does the fetching. See + * `server/api/airports/[icao]/atis.get.ts` for the wiring. + */ + +/** One runway end, as published by OpenAIP under `airport.runways[]`. */ +export interface RunwayEnd { + designator: string + trueHeading: number | null + mainRunway?: boolean + takeOffOnly?: boolean + landingOnly?: boolean + lengthM?: number | null +} + +export interface MetarObservation { + raw: string + station: string | null + observedAt: { day: number; hour: number; minute: number } | null + /** Mean wind direction in degrees true, or null when variable. */ + windDeg: number | null + windKt: number | null + gustKt: number | null + windVariable: boolean + qnhHpa: number | null + /** The report minus station and timestamp, for `normalizeAtisForSpeech`. */ + body: string +} + +export interface AtisStation { + frequency: string + callsign?: string + variant: 'arrival' | 'departure' | null + letter: string | null + text: string + lastUpdated?: string +} + +export interface AtisReport { + icao: string + /** Where the broadcast came from — `metar`/`fallback` mean it is synthesised. */ + source: 'vatsim' | 'metar' | 'fallback' + /** Always set, so a flow never renders an empty information letter. */ + letter: string + letterDep: string + letterArr: string + runwayDep: string | null + runwayArr: string | null + runwaySource: 'atis' | 'wind' | 'main' | null + observedAt: string | null + stations: AtisStation[] +} + +export interface ResolveAtisInput { + icao: string + airportName?: string + vatsimStations?: Array<{ + frequency?: string + callsign?: string + atisCode?: string + text?: string + lastUpdated?: string + }> + metar?: string | null + runways?: RunwayEnd[] + /** Published ATIS frequency, used when a synthesised broadcast needs one. */ + atisFrequency?: string + /** Injectable clock, for the no-METAR fallback and for tests. */ + now?: Date +} + +const FREQUENCY_UNKNOWN = '---' + +/** Below this the wind gives no meaningful runway preference. */ +const CALM_WIND_KT = 5 + +/** Headwind components within this margin count as equal. */ +const HEADWIND_TIE_KT = 0.5 + +const NATO_TO_LETTER: Record = { + ALPHA: 'A', ALFA: 'A', BRAVO: 'B', CHARLIE: 'C', DELTA: 'D', ECHO: 'E', + FOXTROT: 'F', GOLF: 'G', HOTEL: 'H', INDIA: 'I', JULIETT: 'J', JULIET: 'J', + KILO: 'K', LIMA: 'L', MIKE: 'M', NOVEMBER: 'N', OSCAR: 'O', PAPA: 'P', + QUEBEC: 'Q', ROMEO: 'R', SIERRA: 'S', TANGO: 'T', UNIFORM: 'U', + VICTOR: 'V', WHISKEY: 'W', WHISKY: 'W', XRAY: 'X', ZULU: 'Z', YANKEE: 'Y', +} + +// --- METAR ------------------------------------------------------------------- + +/** Parse the fields an ATIS needs out of a raw METAR. Null when it isn't one. */ +export function parseMetar(raw: string): MetarObservation | null { + const text = (raw || '').replace(/\s+/g, ' ').trim().toUpperCase() + if (!text) return null + + // The timestamp is what makes it a METAR; without it there is nothing to + // anchor an observation time (and therefore an information letter) on. + const timeMatch = text.match(/\b(\d{2})(\d{2})(\d{2})Z\b/) + if (!timeMatch) return null + + const stationMatch = text.match(/^([A-Z]{4})\b/) + + const windMatch = text.match(/\b(VRB|\d{3})(\d{2,3})(?:G(\d{2,3}))?KT\b/) + const windVariable = windMatch?.[1] === 'VRB' + const windDeg = windMatch && !windVariable ? Number.parseInt(windMatch[1]!, 10) : null + const windKt = windMatch ? Number.parseInt(windMatch[2]!, 10) : null + const gustKt = windMatch?.[3] ? Number.parseInt(windMatch[3], 10) : null + + let qnhHpa: number | null = null + const qnhMatch = text.match(/\bQ(\d{4})\b/) + const altimeterMatch = text.match(/\bA(\d{4})\b/) + if (qnhMatch) { + qnhHpa = Number.parseInt(qnhMatch[1]!, 10) + } else if (altimeterMatch) { + // inHg, hundredths: 2992 -> 29.92 inHg -> 1013 hPa + qnhHpa = Math.round((Number.parseInt(altimeterMatch[1]!, 10) / 100) * 33.8639) + } + + // Everything after the timestamp is weather; the caller re-labels the rest. + const body = text.slice(text.indexOf(timeMatch[0]) + timeMatch[0].length).trim() + + return { + raw: text, + station: stationMatch?.[1] ?? null, + observedAt: { + day: Number.parseInt(timeMatch[1]!, 10), + hour: Number.parseInt(timeMatch[2]!, 10), + minute: Number.parseInt(timeMatch[3]!, 10), + }, + windDeg, + windKt, + gustKt, + windVariable: Boolean(windVariable), + qnhHpa, + body, + } +} + +// --- Information letter ------------------------------------------------------ + +/** + * Information letter for an observation time. + * + * Real ATIS letters advance one per issue. Deriving the letter from the + * observation instead of drawing it randomly gives two properties the session + * depends on: a background refetch of the same METAR never changes the letter, + * and a new METAR cycle always does. + */ +export function deriveInfoLetter(at: { day: number; hour: number; minute: number } | Date): string { + const parts = at instanceof Date + ? { day: at.getUTCDate(), hour: at.getUTCHours(), minute: at.getUTCMinutes() } + : at + const halfHours = parts.day * 48 + parts.hour * 2 + (parts.minute >= 30 ? 1 : 0) + return String.fromCharCode(65 + (halfHours % 26)) +} + +/** The information letter stated in an ATIS text, as a letter or a NATO word. */ +export function parseAtisLetter(text: string): string | null { + const match = (text || '').match(/\b(?:INFORMATION|INFO|ATIS)\s+([A-Z]+)\b/i) + if (!match) return null + const token = match[1]!.toUpperCase() + if (token.length === 1) return token + return NATO_TO_LETTER[token] ?? null +} + +// --- Runway in use ----------------------------------------------------------- + +// "RWY 25C", "RUNWAY IN USE 22", "RUNWAYS 07L". The optional IN USE matters: +// German VATSIM ATIS writes "RUNWAY IN USE 22", which a bare RUNWAY-then-digits +// pattern misses entirely. +const RWY_PATTERN = String.raw`(?:RWY|RUNWAY)S?(?:\s+IN\s+USE)?\s*([0-3]?\d\s*[LRC]?)\b` + +/** + * Runway in use as stated in ATIS text. Prefers a phrase matching the requested + * kind, then any runway mention. Null when the text names no runway. + */ +export function extractRunwayFromAtisText(texts: string[], kind: 'dep' | 'arr'): string | null { + const kindPatterns = kind === 'dep' + ? [new RegExp(String.raw`DEP(?:ARTURE)?S?[^.]{0,40}?${RWY_PATTERN}`, 'i')] + : [ + new RegExp(String.raw`(?:ARR(?:IVAL)?S?|LANDING)[^.]{0,40}?${RWY_PATTERN}`, 'i'), + new RegExp(String.raw`EXPECT[^.]{0,60}?APPROACH[^.]{0,20}?${RWY_PATTERN}`, 'i'), + ] + const genericPattern = new RegExp(RWY_PATTERN, 'i') + + const candidates = texts.map(text => (text || '').trim()).filter(Boolean) + + for (const patterns of [kindPatterns, [genericPattern]]) { + for (const text of candidates) { + for (const pattern of patterns) { + const match = pattern.exec(text) + if (match?.[1]) return normalizeDesignator(match[1]) + } + } + } + return null +} + +/** "7L" -> "07L", so designators match OSM runway refs and OpenAIP alike. */ +function normalizeDesignator(raw: string): string { + const designator = raw.replace(/\s+/g, '').toUpperCase() + return /^\d[LRC]?$/.test(designator) ? `0${designator}` : designator +} + +export interface RunwaySelection { + designator: string + source: 'wind' | 'main' +} + +/** + * Pick the runway in use from published runway ends. + * + * Scores each end by headwind component. METAR wind is referenced to true + * north and OpenAIP `trueHeading` is true, so the two compare directly with no + * magnetic correction. In calm or variable wind there is no preference, so the + * airport's main runway wins instead. + */ +export function selectRunwayInUse( + runways: RunwayEnd[], + wind: Pick, + kind: 'dep' | 'arr', +): RunwaySelection | null { + const usable = (runways || []).filter(runway => + runway.designator && typeof runway.trueHeading === 'number') + if (!usable.length) return null + + // A runway restricted to the other direction of traffic is not a candidate + // (EDDF 18 is departure-only), but never filter down to nothing. + const allowed = usable.filter(runway => kind === 'dep' ? !runway.landingOnly : !runway.takeOffOnly) + const candidates = allowed.length ? allowed : usable + + const windUsable = wind.windDeg !== null + && wind.windKt !== null + && wind.windKt >= CALM_WIND_KT + && !wind.windVariable + + if (!windUsable) { + return { designator: normalizeDesignator(pickPreferred(candidates).designator), source: 'main' } + } + + const headwind = (runway: RunwayEnd) => + Math.cos(((runway.trueHeading! - wind.windDeg!) * Math.PI) / 180) * wind.windKt! + + const best = Math.max(...candidates.map(headwind)) + const tied = candidates.filter(runway => best - headwind(runway) <= HEADWIND_TIE_KT) + + return { designator: normalizeDesignator(pickPreferred(tied).designator), source: 'wind' } +} + +/** Main runway first, then the longest — the tiebreak for parallel runways. */ +function pickPreferred(runways: RunwayEnd[]): RunwayEnd { + return [...runways].sort((a, b) => { + if (Boolean(a.mainRunway) !== Boolean(b.mainRunway)) return a.mainRunway ? -1 : 1 + return (b.lengthM ?? 0) - (a.lengthM ?? 0) + })[0]! +} + +// --- Synthesised broadcast --------------------------------------------------- + +export interface SyntheticAtisInput { + airportName?: string + letter: string + observation?: MetarObservation | null + runwayDep?: string | null + runwayArr?: string | null +} + +/** + * Build an ATIS broadcast from a METAR, shaped like a real one. + * + * The METAR weather groups are deliberately left coded: `normalizeAtisForSpeech` + * in `radioSpeech.ts` already expands `24016KT`, `26/13` and `Q1006` into spoken + * form, so re-implementing that here would only add a second thing to maintain. + */ +export function buildSyntheticAtisText(input: SyntheticAtisInput): string { + const { airportName, letter, observation, runwayDep, runwayArr } = input + const parts: string[] = [] + + if (airportName) parts.push(airportName) + parts.push(`INFORMATION ${letter}`) + + if (observation?.observedAt) { + const { hour, minute } = observation.observedAt + parts.push(`MET REPORT TIME ${pad2(hour)}${pad2(minute)}`) + } + + if (runwayDep && runwayArr && runwayDep === runwayArr) { + parts.push(`RUNWAY ${runwayDep} IN USE`) + } else { + if (runwayArr) parts.push(`EXPECT APPROACH RUNWAY ${runwayArr}`) + if (runwayDep) parts.push(`DEPARTURE RUNWAY ${runwayDep}`) + } + + if (observation?.body) parts.push(observation.body) + + parts.push(`INFORMATION ${letter} OUT`) + + return parts.join(' ').replace(/\s+/g, ' ').trim() +} + +function pad2(value: number): string { + return String(value).padStart(2, '0') +} + +// --- Resolver ---------------------------------------------------------------- + +/** Arrival/departure variant from the VATSIM callsign (EDDF_A_ATIS / EDDF_D_ATIS). */ +function stationVariant(callsign: string | undefined): 'arrival' | 'departure' | null { + const normalized = (callsign || '').toUpperCase() + if (normalized.includes('_A_')) return 'arrival' + if (normalized.includes('_D_')) return 'departure' + return null +} + +/** + * Resolve one ATIS report from whatever the live sources returned. + * + * VATSIM wins when a station is broadcasting text. Otherwise the report is + * synthesised from the METAR, and if there is no METAR either it still carries + * a letter and a runway so the session stays consistent. + */ +export function resolveAtisReport(input: ResolveAtisInput): AtisReport { + const icao = (input.icao || '').toUpperCase() + const now = input.now ?? new Date() + const runways = input.runways ?? [] + const observation = input.metar ? parseMetar(input.metar) : null + + const liveStations: AtisStation[] = (input.vatsimStations ?? []) + .filter(station => (station.text || '').trim()) + .map(station => { + const text = station.text!.trim() + return { + frequency: station.frequency || input.atisFrequency || FREQUENCY_UNKNOWN, + callsign: station.callsign, + variant: stationVariant(station.callsign), + letter: (station.atisCode || '').trim().toUpperCase() || parseAtisLetter(text), + text, + lastUpdated: station.lastUpdated, + } + }) + + const observedAt = observation ? observedAtIso(observation, now) : null + + if (liveStations.length) { + const letterFor = (kind: 'dep' | 'arr'): string | null => { + const wanted = kind === 'dep' ? 'departure' : 'arrival' + return liveStations.find(s => s.variant === wanted)?.letter + ?? liveStations.find(s => s.letter)?.letter + ?? null + } + const letter = letterFor('dep') ?? deriveInfoLetter(observation?.observedAt ?? now) + + const runway = (kind: 'dep' | 'arr') => { + const wanted = kind === 'dep' ? 'departure' : 'arrival' + const matching = liveStations.filter(s => s.variant === wanted) + const texts = (matching.length ? matching : liveStations).map(s => s.text) + return extractRunwayFromAtisText(texts, kind) + } + + const fromText = { dep: runway('dep'), arr: runway('arr') } + const wind = observation ?? { windDeg: null, windKt: null, windVariable: false } + const byWind = { + dep: fromText.dep ? null : selectRunwayInUse(runways, wind, 'dep'), + arr: fromText.arr ? null : selectRunwayInUse(runways, wind, 'arr'), + } + + return { + icao, + source: 'vatsim', + letter, + letterDep: letterFor('dep') ?? letter, + letterArr: letterFor('arr') ?? letter, + runwayDep: fromText.dep ?? byWind.dep?.designator ?? null, + runwayArr: fromText.arr ?? byWind.arr?.designator ?? null, + runwaySource: fromText.dep || fromText.arr + ? 'atis' + : byWind.dep?.source ?? byWind.arr?.source ?? null, + observedAt, + stations: liveStations, + } + } + + // No live broadcast — synthesise one so the pilot still gets a letter and a + // runway. Keep the real ATIS frequency and callsign if VATSIM listed a + // station that simply had no text attached. + const silentStation = (input.vatsimStations ?? [])[0] + const wind = observation ?? { windDeg: null, windKt: null, windVariable: false } + const dep = selectRunwayInUse(runways, wind, 'dep') + const arr = selectRunwayInUse(runways, wind, 'arr') + const letter = deriveInfoLetter(observation?.observedAt ?? now) + + const text = buildSyntheticAtisText({ + airportName: input.airportName, + letter, + observation, + runwayDep: dep?.designator ?? null, + runwayArr: arr?.designator ?? null, + }) + + return { + icao, + source: observation ? 'metar' : 'fallback', + letter, + letterDep: letter, + letterArr: letter, + runwayDep: dep?.designator ?? null, + runwayArr: arr?.designator ?? null, + runwaySource: dep?.source ?? arr?.source ?? null, + observedAt, + stations: [{ + frequency: silentStation?.frequency || input.atisFrequency || FREQUENCY_UNKNOWN, + callsign: silentStation?.callsign, + variant: stationVariant(silentStation?.callsign), + letter, + text, + }], + } +} + +/** + * Absolute time for a METAR day-of-month stamp, resolved against `now`. + * A report near a month boundary belongs to the previous month. + */ +function observedAtIso(observation: MetarObservation, now: Date): string | null { + const at = observation.observedAt + if (!at) return null + const candidate = new Date(Date.UTC( + now.getUTCFullYear(), now.getUTCMonth(), at.day, at.hour, at.minute, 0, 0)) + if (candidate.getTime() - now.getTime() > 12 * 60 * 60 * 1000) { + candidate.setUTCMonth(candidate.getUTCMonth() - 1) + } + return candidate.toISOString() +} diff --git a/tests/shared/atisReport.test.ts b/tests/shared/atisReport.test.ts new file mode 100644 index 0000000..aa4abe0 --- /dev/null +++ b/tests/shared/atisReport.test.ts @@ -0,0 +1,362 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + buildSyntheticAtisText, + deriveInfoLetter, + extractRunwayFromAtisText, + parseAtisLetter, + parseMetar, + resolveAtisReport, + selectRunwayInUse, + type RunwayEnd, +} from '../../shared/utils/atisReport.ts' + +// --- Fixtures: recorded from the live sources on 2026-07-26 ------------------- + +// OpenAIP `airports?search=EDDF` -> items[0].runways +const EDDF_RUNWAYS: RunwayEnd[] = [ + { designator: '18', trueHeading: 176, mainRunway: false, takeOffOnly: true, landingOnly: false, lengthM: 3999 }, + { designator: '07R', trueHeading: 66, mainRunway: false, takeOffOnly: false, landingOnly: false, lengthM: 4000 }, + { designator: '25L', trueHeading: 246, mainRunway: false, takeOffOnly: false, landingOnly: false, lengthM: 4000 }, + { designator: '07L', trueHeading: 66, mainRunway: false, takeOffOnly: false, landingOnly: true, lengthM: 2800 }, + { designator: '25R', trueHeading: 246, mainRunway: false, takeOffOnly: false, landingOnly: true, lengthM: 2800 }, + { designator: '07C', trueHeading: 66, mainRunway: true, takeOffOnly: false, landingOnly: false, lengthM: 4000 }, + { designator: '25C', trueHeading: 246, mainRunway: true, takeOffOnly: false, landingOnly: false, lengthM: 4000 }, +] + +const EDDC_RUNWAYS: RunwayEnd[] = [ + { designator: '04', trueHeading: 38, mainRunway: true, takeOffOnly: false, landingOnly: false, lengthM: 2850 }, + { designator: '22', trueHeading: 218, mainRunway: true, takeOffOnly: false, landingOnly: false, lengthM: 2850 }, +] + +const EDDF_METAR = 'EDDF 261650Z AUTO 24016KT CAVOK 26/13 Q1006 BECMG 24011KT' +const EDDC_METAR = 'EDDC 261650Z AUTO 22006KT CAVOK 27/11 Q1003 BECMG 33008KT' +const EDDM_METAR = 'EDDM 261650Z AUTO 26014KT 230V290 CAVOK 26/13 Q1008 NOSIG' + +// VATSIM datafeed, `atis[]` entry for EDDC_ATIS +const EDDC_VATSIM_TEXT = [ + 'DRESDEN INFORMATION Q MET REPORT TIME 1650 EXPECT ILS APPROACH', + 'RUNWAY IN USE 22 TRANSITION LEVEL 70 DEPARTURE FREQUENCY', + 'UNICOM ON FREQUENCY 122.800 WIND 220 DEGREES 6 KNOTS', + 'CLOUDS AND VISIBILITY OK TEMPERATURE 27 DEW POINT 11 QNH', + '1003 TREND BECOMING WIND 330 DEGREES 8 KNOTS INFORMATION Q', + 'OUT', +].join(' ') + +// --- METAR parsing ----------------------------------------------------------- + +test('parseMetar reads observation time, wind and QNH', () => { + const obs = parseMetar(EDDF_METAR) + assert.ok(obs) + assert.equal(obs.station, 'EDDF') + assert.deepEqual(obs.observedAt, { day: 26, hour: 16, minute: 50 }) + assert.equal(obs.windDeg, 240) + assert.equal(obs.windKt, 16) + assert.equal(obs.windVariable, false) + assert.equal(obs.qnhHpa, 1006) +}) + +test('parseMetar drops the station and timestamp from the spoken body', () => { + const obs = parseMetar(EDDF_METAR) + assert.ok(obs) + assert.ok(!obs.body.includes('EDDF'), `body still carries the station: ${obs.body}`) + assert.ok(!obs.body.includes('261650Z'), `body still carries the timestamp: ${obs.body}`) + assert.ok(obs.body.startsWith('AUTO 24016KT'), obs.body) +}) + +test('parseMetar handles variable wind', () => { + const obs = parseMetar('EDDN 261650Z VRB03KT CAVOK 24/12 Q1010') + assert.ok(obs) + assert.equal(obs.windVariable, true) + assert.equal(obs.windDeg, null) + assert.equal(obs.windKt, 3) +}) + +test('parseMetar handles calm wind and gusts', () => { + const calm = parseMetar('EDDN 261650Z 00000KT CAVOK 24/12 Q1010') + assert.equal(calm?.windKt, 0) + + const gusty = parseMetar('EDDH 261650Z 28015G28KT 9999 FEW035 18/12 Q1004') + assert.equal(gusty?.windDeg, 280) + assert.equal(gusty?.windKt, 15) + assert.equal(gusty?.gustKt, 28) +}) + +test('parseMetar reads an inHg altimeter as hPa', () => { + const obs = parseMetar('KJFK 261651Z 24016KT 10SM FEW040 26/13 A2992') + assert.ok(obs) + assert.equal(obs.qnhHpa, 1013) +}) + +test('parseMetar rejects junk', () => { + assert.equal(parseMetar(''), null) + assert.equal(parseMetar('no metar available'), null) +}) + +// --- Information letter ------------------------------------------------------ + +test('deriveInfoLetter is stable for the same observation', () => { + const a = deriveInfoLetter({ day: 26, hour: 16, minute: 50 }) + const b = deriveInfoLetter({ day: 26, hour: 16, minute: 50 }) + assert.equal(a, b) + assert.match(a, /^[A-Z]$/) +}) + +test('deriveInfoLetter advances once per METAR cycle', () => { + const at1620 = deriveInfoLetter({ day: 26, hour: 16, minute: 20 }) + const at1650 = deriveInfoLetter({ day: 26, hour: 16, minute: 50 }) + const at1720 = deriveInfoLetter({ day: 26, hour: 17, minute: 20 }) + assert.notEqual(at1620, at1650) + assert.notEqual(at1650, at1720) +}) + +test('deriveInfoLetter does not jump within one METAR cycle', () => { + // Every minute of the 16:30-16:59 half hour maps to the same letter, so a + // background refetch never makes the broadcast letter change spuriously. + const expected = deriveInfoLetter({ day: 26, hour: 16, minute: 30 }) + for (let minute = 30; minute < 60; minute++) { + assert.equal(deriveInfoLetter({ day: 26, hour: 16, minute }), expected) + } +}) + +test('deriveInfoLetter rolls continuously across midnight', () => { + const before = deriveInfoLetter({ day: 26, hour: 23, minute: 50 }) + const after = deriveInfoLetter({ day: 27, hour: 0, minute: 20 }) + assert.notEqual(before, after) +}) + +test('parseAtisLetter reads the letter out of VATSIM ATIS text', () => { + assert.equal(parseAtisLetter(EDDC_VATSIM_TEXT), 'Q') + assert.equal(parseAtisLetter('EDDM INFORMATION T, RUNWAY 26R'), 'T') + assert.equal(parseAtisLetter('MUNICH INFO D'), 'D') +}) + +test('parseAtisLetter accepts a phonetic letter word', () => { + assert.equal(parseAtisLetter('FRANKFURT INFORMATION CHARLIE, RUNWAY 25C'), 'C') + assert.equal(parseAtisLetter('HAMBURG INFORMATION XRAY'), 'X') +}) + +test('parseAtisLetter returns null when the text carries no letter', () => { + assert.equal(parseAtisLetter('RUNWAY 25C IN USE, QNH 1013'), null) + assert.equal(parseAtisLetter(''), null) +}) + +// --- Runway in use ----------------------------------------------------------- + +test('selectRunwayInUse agrees with the real controller at EDDC', () => { + // Live VATSIM ATIS for EDDC that day: "RUNWAY IN USE 22", wind 220/06. + const obs = parseMetar(EDDC_METAR)! + const picked = selectRunwayInUse(EDDC_RUNWAYS, obs, 'dep') + assert.equal(picked?.designator, '22') + assert.equal(picked?.source, 'wind') +}) + +test('selectRunwayInUse picks the westerly runways at EDDF in a westerly wind', () => { + const obs = parseMetar(EDDF_METAR)! // 240/16 + assert.equal(selectRunwayInUse(EDDF_RUNWAYS, obs, 'dep')?.designator, '25C') + assert.equal(selectRunwayInUse(EDDF_RUNWAYS, obs, 'arr')?.designator, '25C') +}) + +test('selectRunwayInUse picks into the wind when it reverses', () => { + const easterly = parseMetar('EDDF 261650Z 07012KT CAVOK 26/13 Q1006')! + assert.equal(selectRunwayInUse(EDDF_RUNWAYS, easterly, 'dep')?.designator, '07C') +}) + +test('selectRunwayInUse honours takeOffOnly and landingOnly', () => { + // EDDF 18 is departure-only, so it must never be offered as an arrival runway. + const southerly = parseMetar('EDDF 261650Z 18025KT CAVOK 26/13 Q1006')! + assert.equal(selectRunwayInUse(EDDF_RUNWAYS, southerly, 'dep')?.designator, '18') + assert.notEqual(selectRunwayInUse(EDDF_RUNWAYS, southerly, 'arr')?.designator, '18') +}) + +test('selectRunwayInUse falls back to the main runway in calm wind', () => { + const calm = parseMetar('EDDC 261650Z 00000KT CAVOK 24/12 Q1013')! + const picked = selectRunwayInUse(EDDC_RUNWAYS, calm, 'dep') + assert.equal(picked?.source, 'main') + assert.ok(picked && ['04', '22'].includes(picked.designator)) +}) + +test('selectRunwayInUse falls back to the main runway in variable wind', () => { + const vrb = parseMetar('EDDC 261650Z VRB04KT CAVOK 24/12 Q1013')! + assert.equal(selectRunwayInUse(EDDC_RUNWAYS, vrb, 'dep')?.source, 'main') +}) + +test('selectRunwayInUse returns null without runway data', () => { + const obs = parseMetar(EDDF_METAR)! + assert.equal(selectRunwayInUse([], obs, 'dep'), null) +}) + +test('extractRunwayFromAtisText reads the runway out of VATSIM text', () => { + assert.equal(extractRunwayFromAtisText([EDDC_VATSIM_TEXT], 'arr'), '22') + assert.equal(extractRunwayFromAtisText(['DEP RWY 25C, ARR RWY 25L'], 'dep'), '25C') + assert.equal(extractRunwayFromAtisText(['DEP RWY 25C, ARR RWY 25L'], 'arr'), '25L') +}) + +test('extractRunwayFromAtisText pads a single-digit designator', () => { + assert.equal(extractRunwayFromAtisText(['RUNWAY IN USE 7'], 'dep'), '07') +}) + +test('extractRunwayFromAtisText returns null when no runway is mentioned', () => { + assert.equal(extractRunwayFromAtisText(['QNH 1013 TEMPERATURE 12'], 'dep'), null) + assert.equal(extractRunwayFromAtisText([], 'dep'), null) +}) + +// --- Synthetic text ---------------------------------------------------------- + +test('buildSyntheticAtisText names the airport, the letter and the runway', () => { + const text = buildSyntheticAtisText({ + airportName: 'Frankfurt Main', + letter: 'H', + observation: parseMetar(EDDF_METAR)!, + runwayDep: '25C', + runwayArr: '25C', + }) + assert.ok(text.startsWith('Frankfurt Main INFORMATION H'), text) + assert.ok(text.includes('RUNWAY 25C IN USE'), text) + assert.ok(text.includes('MET REPORT TIME 1650'), text) + // Closes with the letter, the way a real ATIS does. + assert.ok(/INFORMATION H OUT$/.test(text), text) +}) + +test('buildSyntheticAtisText names both runways when they differ', () => { + const text = buildSyntheticAtisText({ + airportName: 'Frankfurt Main', + letter: 'H', + observation: parseMetar(EDDF_METAR)!, + runwayDep: '25C', + runwayArr: '25L', + }) + assert.ok(text.includes('DEPARTURE RUNWAY 25C'), text) + assert.ok(text.includes('APPROACH RUNWAY 25L'), text) +}) + +test('buildSyntheticAtisText keeps the METAR groups the speech normalizer expands', () => { + const text = buildSyntheticAtisText({ + airportName: 'Frankfurt Main', + letter: 'H', + observation: parseMetar(EDDF_METAR)!, + runwayDep: '25C', + runwayArr: '25C', + }) + assert.ok(text.includes('24016KT'), text) + assert.ok(text.includes('Q1006'), text) + assert.ok(text.includes('26/13'), text) +}) + +// --- Resolver cascade -------------------------------------------------------- + +test('resolveAtisReport prefers a VATSIM station and keeps its letter', () => { + const report = resolveAtisReport({ + icao: 'EDDC', + airportName: 'Dresden', + vatsimStations: [ + { frequency: '118.880', callsign: 'EDDC_ATIS', atisCode: 'Q', text: EDDC_VATSIM_TEXT, lastUpdated: '2026-07-26T17:18:29Z' }, + ], + metar: EDDC_METAR, + runways: EDDC_RUNWAYS, + }) + assert.equal(report.source, 'vatsim') + assert.equal(report.letter, 'Q') + assert.equal(report.runwayArr, '22') + assert.equal(report.runwaySource, 'atis') + assert.equal(report.stations.length, 1) + assert.equal(report.stations[0]!.text, EDDC_VATSIM_TEXT) +}) + +test('resolveAtisReport recovers the letter from text when atis_code is missing', () => { + // EDXW_ATIS was live that day with no atis_code field. + const report = resolveAtisReport({ + icao: 'EDDC', + vatsimStations: [{ frequency: '118.880', callsign: 'EDDC_ATIS', text: EDDC_VATSIM_TEXT }], + metar: EDDC_METAR, + runways: EDDC_RUNWAYS, + }) + assert.equal(report.source, 'vatsim') + assert.equal(report.letter, 'Q') +}) + +test('resolveAtisReport synthesises an ATIS when no VATSIM station is online', () => { + // EDDF had no VATSIM ATIS on 2026-07-26 — the case the bug report came from. + const report = resolveAtisReport({ + icao: 'EDDF', + airportName: 'Frankfurt Main', + vatsimStations: [], + metar: EDDF_METAR, + runways: EDDF_RUNWAYS, + }) + assert.equal(report.source, 'metar') + assert.match(report.letter, /^[A-Z]$/) + assert.equal(report.runwayDep, '25C') + assert.equal(report.runwaySource, 'wind') + assert.equal(report.stations.length, 1) + assert.ok(report.stations[0]!.text.includes('INFORMATION'), report.stations[0]!.text) +}) + +test('resolveAtisReport still yields a letter and runway with no METAR at all', () => { + const report = resolveAtisReport({ + icao: 'EDDF', + airportName: 'Frankfurt Main', + vatsimStations: [], + metar: null, + runways: EDDF_RUNWAYS, + now: new Date('2026-07-26T16:50:00Z'), + }) + assert.equal(report.source, 'fallback') + assert.match(report.letter, /^[A-Z]$/) + assert.equal(report.runwaySource, 'main') + assert.ok(report.runwayDep) +}) + +test('resolveAtisReport never returns an empty letter', () => { + const report = resolveAtisReport({ icao: 'ZZZZ', vatsimStations: [], metar: null, runways: [] }) + assert.match(report.letter, /^[A-Z]$/) + assert.equal(report.runwayDep, null) +}) + +test('resolveAtisReport keeps arrival and departure ATIS stations apart', () => { + const report = resolveAtisReport({ + icao: 'EDDF', + vatsimStations: [ + { frequency: '118.025', callsign: 'EDDF_A_ATIS', atisCode: 'C', text: 'FRANKFURT ARRIVAL INFORMATION C EXPECT ILS APPROACH RUNWAY 25L' }, + { frequency: '118.725', callsign: 'EDDF_D_ATIS', atisCode: 'D', text: 'FRANKFURT DEPARTURE INFORMATION D DEP RWY 18' }, + ], + metar: EDDF_METAR, + runways: EDDF_RUNWAYS, + }) + assert.equal(report.stations.length, 2) + assert.equal(report.letterArr, 'C') + assert.equal(report.letterDep, 'D') + assert.equal(report.runwayArr, '25L') + assert.equal(report.runwayDep, '18') +}) + +test('resolveAtisReport ignores a VATSIM station that carries no text', () => { + const report = resolveAtisReport({ + icao: 'EDDF', + airportName: 'Frankfurt Main', + vatsimStations: [{ frequency: '118.025', callsign: 'EDDF_ATIS' }], + metar: EDDF_METAR, + runways: EDDF_RUNWAYS, + }) + assert.equal(report.source, 'metar') + assert.ok(report.stations[0]!.text.length > 0) + // The synthesised broadcast must be published on the real ATIS frequency. + assert.equal(report.stations[0]!.frequency, '118.025') +}) + +test('resolveAtisReport falls back to wind when the ATIS text has no runway', () => { + const report = resolveAtisReport({ + icao: 'EDDM', + vatsimStations: [{ frequency: '123.125', callsign: 'EDDM_ATIS', atisCode: 'T', text: 'MUNICH INFORMATION T QNH 1008 TEMPERATURE 26' }], + metar: EDDM_METAR, + runways: [ + { designator: '08L', trueHeading: 81, mainRunway: true, lengthM: 4000 }, + { designator: '26R', trueHeading: 261, mainRunway: true, lengthM: 4000 }, + { designator: '08R', trueHeading: 81, mainRunway: false, lengthM: 4000 }, + { designator: '26L', trueHeading: 261, mainRunway: false, lengthM: 4000 }, + ], + }) + assert.equal(report.letter, 'T') + assert.equal(report.runwaySource, 'wind') + assert.equal(report.runwayDep, '26R') +})