feat(atis): resolver core for reliable information letter and runway

VATSIM is the only ATIS source today, so an airport with no controller online
(EDDF, on the day this was reported) falls back to a raw METAR — which carries
neither an information letter nor a runway in use.

Add shared/utils/atisReport.ts, which turns whatever the live sources returned
into one AtisReport:

  vatsim   station broadcasting text; letter from atis_code or parsed from the
           text; runway parsed from the text
  metar    no station: letter derived from the observation time, runway from the
           headwind component over the published runway ends
  fallback no METAR either: letter from the clock, airport's main runway

Deriving the letter from the observation keeps it stable across background
refetches while still advancing once per METAR cycle. Runway selection compares
METAR true wind against OpenAIP trueHeading (no magnetic correction needed) and
honours takeOffOnly/landingOnly ends.

The synthesised broadcast leaves the METAR groups coded, because
normalizeAtisForSpeech() already expands them for TTS.

Also fixes runway extraction for "RUNWAY IN USE 22" — the phrasing German
VATSIM ATIS actually uses, which the previous pattern missed entirely.

Tests run offline against VATSIM/METAR/OpenAIP responses recorded 2026-07-26,
including the EDDC case where the wind-derived runway matches what the live
controller published.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-26 19:32:59 +02:00
parent a9f6e6df42
commit aee42e31df
2 changed files with 802 additions and 0 deletions

440
shared/utils/atisReport.ts Normal file
View File

@@ -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<string, string> = {
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<MetarObservation, 'windDeg' | 'windKt' | 'windVariable'>,
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()
}

View File

@@ -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')
})