diff --git a/lib/MessageDecoder.labelindex.test.ts b/lib/MessageDecoder.labelindex.test.ts index 8ec772d..26d9074 100644 --- a/lib/MessageDecoder.labelindex.test.ts +++ b/lib/MessageDecoder.labelindex.test.ts @@ -109,6 +109,41 @@ describe('MessageDecoder label index', () => { expect(result.decoder.name).toBe('catch-all'); }); + test('wildcard registration order is preserved across existing label buckets', () => { + // Regression test: when a wildcard plugin is registered AFTER a label + // bucket has already been created, it must be inserted at the end of + // the wildcard section, not at the front. Otherwise later-registered + // wildcards would incorrectly take precedence over earlier ones for + // labels that already had buckets, and wildcard order would diverge + // from buckets created later (which seed from wildcardEntries.slice()). + const decoder = new MessageDecoder(); + + // 1. Register a label-specific plugin for '99' — this creates a + // bucket with no wildcards yet (existing wildcards from the + // constructor are in, but that's fine; we only care about the + // relative order of stubs registered here). + const labelSpecific = new StubPlugin(decoder, 'label-99-specific', ['99']); + decoder.registerPlugin(labelSpecific); + + // 2. Register two new wildcard stubs in order. + const firstWildcard = new StubPlugin(decoder, 'wild-first', ['*']); + decoder.registerPlugin(firstWildcard); + const secondWildcard = new StubPlugin(decoder, 'wild-second', ['*']); + decoder.registerPlugin(secondWildcard); + + // 3. For a label-99 message, the first-registered wildcard must run + // before the second-registered wildcard. Since both succeed, the + // decoder.name tells us which one won the race. + const result = decoder.decode({ label: '99', text: 'anything' }); + expect(result.decoded).toBe(true); + expect(result.decoder.name).toBe('wild-first'); + + // 4. The same ordering must hold for a brand-new label that didn't + // have a bucket yet (it gets seeded from wildcardEntries.slice()). + const newLabel = decoder.decode({ label: 'ZZ', text: 'anything' }); + expect(newLabel.decoder.name).toBe('wild-first'); + }); + test('preamble-based plugins only match correct preambles', () => { const decoder = new MessageDecoder(); const stub = new StubPlugin( diff --git a/lib/MessageDecoder.ts b/lib/MessageDecoder.ts index b40f43a..d4397d8 100644 --- a/lib/MessageDecoder.ts +++ b/lib/MessageDecoder.ts @@ -80,15 +80,27 @@ const pluginClasses = [ Plugins.Label_QS, ]; +/** + * Per-plugin metadata captured at registration time so that decode() can + * avoid re-invoking plugin.qualifiers() (and re-allocating its arrays) on + * every message. + */ +interface PluginEntry { + plugin: DecoderPluginInterface; + preambles: string[] | undefined; +} + export class MessageDecoder { name: string; plugins: Array; debug: boolean; - /** Maps a label string to the plugins registered for it, preserving registration order. */ - private labelIndex: Map = new Map(); - /** Plugins that match all labels (qualifier label '*'). */ - private wildcardPlugins: DecoderPluginInterface[] = []; + /** Maps a label string to the candidate entries (wildcard + label-specific) in registration order. */ + private candidatesByLabel: Map = new Map(); + /** Wildcard entries (plugins that register the '*' label). */ + private wildcardEntries: PluginEntry[] = []; + /** Membership set for wildcard entries to dedupe when a plugin also registers a specific label. */ + private wildcardSet: Set = new Set(); constructor() { this.name = 'acars-decoder-typescript'; @@ -104,16 +116,41 @@ export class MessageDecoder { this.plugins.push(plugin); const qualifiers = plugin.qualifiers(); + const entry: PluginEntry = { + plugin, + preambles: + qualifiers.preambles && qualifiers.preambles.length > 0 + ? qualifiers.preambles + : undefined, + }; + for (const label of qualifiers.labels) { if (label === '*') { - this.wildcardPlugins.push(plugin); + if (!this.wildcardSet.has(plugin)) { + this.wildcardEntries.push(entry); + this.wildcardSet.add(plugin); + // Insert the new wildcard at the end of the wildcard section + // in every existing label bucket so that wildcard plugins are + // still tried before label-specific ones while preserving + // registration order among wildcard plugins (matching how new + // buckets are seeded via wildcardEntries.slice() below). + const wildcardInsertIndex = this.wildcardEntries.length - 1; + for (const bucket of this.candidatesByLabel.values()) { + bucket.splice(wildcardInsertIndex, 0, entry); + } + } } else { - let bucket = this.labelIndex.get(label); + let bucket = this.candidatesByLabel.get(label); if (!bucket) { - bucket = []; - this.labelIndex.set(label, bucket); + // Seed new bucket with all wildcard entries (in registration order) + // so they remain ahead of label-specific plugins. + bucket = this.wildcardEntries.slice(); + this.candidatesByLabel.set(label, bucket); + } + // Skip if this plugin is a wildcard plugin already in the bucket. + if (!this.wildcardSet.has(plugin)) { + bucket.push(entry); } - bucket.push(plugin); } } @@ -121,30 +158,17 @@ export class MessageDecoder { } decode(message: Message, options: Options = {}): DecodeResult { - // Build candidate list: wildcard plugins first (e.g. CBand wrapper), - // then label-specific plugins, preserving registration order. - // Use a Set to prevent duplicate execution if a plugin registers both '*' and a specific label. - const labelPlugins = this.labelIndex.get(message.label) ?? []; - const seen = new Set(); - const candidates: DecoderPluginInterface[] = []; - for (const plugin of [...this.wildcardPlugins, ...labelPlugins]) { - if (!seen.has(plugin)) { - seen.add(plugin); - candidates.push(plugin); - } - } - - const usablePlugins = candidates.filter((plugin) => { - const preambles = plugin.qualifiers().preambles; - if (!preambles || preambles.length === 0) { - return true; - } - return preambles.some((p: string) => message.text.startsWith(p)); - }); + const text = message.text; + const candidates = + this.candidatesByLabel.get(message.label) ?? this.wildcardEntries; if (options.debug) { console.log('Usable plugins'); - console.log(usablePlugins); + console.log( + candidates + .filter((e) => this.matchesPreambles(text, e.preambles)) + .map((e) => e.plugin), + ); } let result: DecodeResult = { @@ -157,7 +181,7 @@ export class MessageDecoder { }, message: message, remaining: { - text: message.text, + text: text, }, raw: {}, formatted: { @@ -166,9 +190,12 @@ export class MessageDecoder { }, }; - for (let i = 0; i < usablePlugins.length; i++) { - const plugin = usablePlugins[i]; - result = plugin.decode(message, options); + for (let i = 0; i < candidates.length; i++) { + const entry = candidates[i]; + if (!this.matchesPreambles(text, entry.preambles)) { + continue; + } + result = entry.plugin.decode(message, options); if (result.decoded) { break; } @@ -181,4 +208,19 @@ export class MessageDecoder { return result; } + + private matchesPreambles( + text: string, + preambles: string[] | undefined, + ): boolean { + if (!preambles) { + return true; + } + for (let i = 0; i < preambles.length; i++) { + if (text.startsWith(preambles[i])) { + return true; + } + } + return false; + } } diff --git a/lib/plugins/ARINC_702.test.ts b/lib/plugins/ARINC_702.test.ts new file mode 100644 index 0000000..c5383c7 --- /dev/null +++ b/lib/plugins/ARINC_702.test.ts @@ -0,0 +1,62 @@ +import { MessageDecoder } from '../MessageDecoder'; +import { Arinc702 } from './ARINC_702'; + +describe('ARINC_702 wildcard wrapper', () => { + let plugin: Arinc702; + const message = { label: 'H1', text: '' }; + + beforeEach(() => { + const decoder = new MessageDecoder(); + plugin = new Arinc702(decoder); + }); + + test('matches wildcard qualifier', () => { + expect(plugin.qualifiers()).toEqual({ labels: ['*'] }); + expect(plugin.name).toBe('arinc-702'); + }); + + test('strips embedded CR/LF before delegating to the H1 helper', () => { + // Inserting newlines into a known-good H1 REQ POS payload should not + // prevent it from being decoded. + message.text = 'REQ\nPOS\r037B'; + const result = plugin.decode(message); + + expect(result.decoded).toBe(true); + expect(result.decoder.decodeLevel).toBe('full'); + expect(result.raw.checksum).toBe(0x037b); + expect(result.formatted.description).toBe('Request for Position Report'); + }); + + test('peels a leading / header before delegating', () => { + // /HDQDLUA.

+ message.text = '/HDQDLUA.REQPOS037B'; + const result = plugin.decode(message); + + expect(result.decoded).toBe(true); + // The delegated H1 body must actually be decoded — assert the + // REQ POS fields landed in the result, proving the '/HDQDLUA.' + // prefix was peeled before delegation. + expect(result.formatted.description).toBe('Request for Position Report'); + expect(result.raw.checksum).toBe(0x037b); + // The unparsed header also ends up in remaining text. + expect(result.remaining.text).toContain('/HDQDLUA'); + }); + + test('returns not-decoded when nothing matches', () => { + message.text = 'totally bogus payload that no H1 rule matches'; + const result = plugin.decode(message); + + expect(result.decoded).toBe(false); + expect(result.decoder.decodeLevel).toBe('none'); + // The full original text should be present in the remaining text the + // wrapper accumulates (the wrapper may also emit prefix fragments). + expect(result.remaining.text).toContain(message.text); + }); + + test('end-to-end via MessageDecoder routes ARINC 702 wildcard for H1', () => { + const decoder = new MessageDecoder(); + const result = decoder.decode({ label: 'H1', text: 'REQPOS037B' }); + expect(result.decoded).toBe(true); + expect(result.formatted.description).toBe('Request for Position Report'); + }); +}); diff --git a/lib/plugins/ARINC_702.ts b/lib/plugins/ARINC_702.ts index b784ffe..e16ea47 100644 --- a/lib/plugins/ARINC_702.ts +++ b/lib/plugins/ARINC_702.ts @@ -3,6 +3,10 @@ import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; import { Arinc702Helper } from '../utils/arinc_702_helper'; import { ResultFormatter } from '../utils/result_formatter'; +// Hoisted to module scope so the wildcard decode() doesn't recompile the +// pattern for every incoming message. +const NEWLINE_REGEX = /[\n\r]/g; + // TODO: come up with a better name as this decodes multiple labels export class Arinc702 extends DecoderPlugin { name = 'arinc-702'; @@ -17,7 +21,7 @@ export class Arinc702 extends DecoderPlugin { decodeResult.decoder.name = this.name; decodeResult.message = message; - const msg = message.text.replace(/\n|\r/g, ''); + const msg = message.text.replace(NEWLINE_REGEX, ''); // try to decode the entire message let decoded = false; diff --git a/lib/plugins/CBand.ts b/lib/plugins/CBand.ts index 8e2ec9c..06b5cc9 100644 --- a/lib/plugins/CBand.ts +++ b/lib/plugins/CBand.ts @@ -3,6 +3,15 @@ import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; import { MIAMCoreUtils } from '../utils/miam'; import { ResultFormatter } from '../utils/result_formatter'; +// C-Band puts a 10 char header in front of some message types: +// chars 0-3: message number ([A-Z]\d{2}[A-Z]) +// chars 4-5: airline code ([A-Z0-9]{2}) +// chars 6-9: flight number ([0-9]{4}) +// Hoisted to module scope so the pattern is allocated once instead of on +// every wildcard decode invocation. +const CBAND_HEADER = + /^(?[A-Z]\d{2}[A-Z])(?[A-Z0-9]{2})(?[0-9]{4})/; + export class CBand extends DecoderPlugin { name = 'c-band'; qualifiers() { @@ -12,39 +21,49 @@ export class CBand extends DecoderPlugin { } decode(message: Message, options: Options = {}): DecodeResult { - let decodeResult = this.defaultResult(); + const text = message.text; + // Cheap length check before running the regex — a C-Band header is + // exactly 10 chars and is followed by the wrapped payload. + if (text.length < 10) { + const result = this.defaultResult(); + result.decoder.name = this.name; + result.message = message; + return result; + } + + const cband = CBAND_HEADER.exec(text); + if (!cband?.groups) { + const result = this.defaultResult(); + result.decoder.name = this.name; + result.message = message; + return result; + } + + const decoded = this.decoder.decode( + { + label: message.label, + sublabel: message.sublabel, + text: text.substring(10), + }, + options, + ); + + const decodeResult = this.defaultResult(); decodeResult.decoder.name = this.name; decodeResult.message = message; - // C-Band puts a 10 char header in front of some message types - // First 4 chars are some kind of message number - // Last 6 chars are the flight number - let cband = message.text.match( - /^(?[A-Z]\d{2}[A-Z])(?[A-Z0-9]{2})(?[0-9]{4})/, - ); - if (cband?.groups) { - const messageText = message.text.substring(10); - const decoded = this.decoder.decode( - { - label: message.label, - sublabel: message.sublabel, - text: messageText, - }, - options, + if (decoded.decoded) { + ResultFormatter.flightNumber( + decodeResult, + cband.groups.airline + Number(cband.groups.number), ); - if (decoded.decoded) { - ResultFormatter.flightNumber( - decodeResult, - cband.groups.airline + Number(cband.groups.number), - ); - decodeResult.decoded = true; - decodeResult.decoder.decodeLevel = decoded.decoder.decodeLevel; - decodeResult.decoder.name = this.name + '-' + decoded.decoder.name; - decodeResult.raw = { ...decodeResult.raw, ...decoded.raw }; - decodeResult.formatted.description = decoded.formatted.description; - decodeResult.formatted.items.push(...decoded.formatted.items); - decodeResult.remaining = decoded.remaining; - } + decodeResult.decoded = true; + decodeResult.decoder.decodeLevel = decoded.decoder.decodeLevel; + decodeResult.decoder.name = this.name + '-' + decoded.decoder.name; + decodeResult.raw = { ...decodeResult.raw, ...decoded.raw }; + decodeResult.formatted.description = decoded.formatted.description; + decodeResult.formatted.items.push(...decoded.formatted.items); + decodeResult.remaining = decoded.remaining; } return decodeResult; } diff --git a/lib/plugins/Label_4A.ts b/lib/plugins/Label_4A.ts index 1916086..f8a1f0e 100644 --- a/lib/plugins/Label_4A.ts +++ b/lib/plugins/Label_4A.ts @@ -32,7 +32,8 @@ export class Label_4A extends DecoderPlugin { // ResultFormatter.altitude(decodeResult, Number(alt) * 100); ResultFormatter.unknownArr(decodeResult, fields.slice(8)); } else if (fields.length === 6) { - if (fields[0].match(/^[NS]/)) { + const f0 = fields[0]; + if (f0.length > 0 && (f0[0] === 'N' || f0[0] === 'S')) { // variant 2 ResultFormatter.position( decodeResult, diff --git a/lib/plugins/Label_80.ts b/lib/plugins/Label_80.ts index 5fc6885..da92d15 100644 --- a/lib/plugins/Label_80.ts +++ b/lib/plugins/Label_80.ts @@ -4,6 +4,10 @@ import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; import { CoordinateUtils } from '../utils/coordinate_utils'; import { ResultFormatter } from '../utils/result_formatter'; +const POS_REGEX = /^(?[NS])(?.+)(?[EW])(?.+)/; +const WS_REGEX = /\s+/; +const NEWLINE_REGEX = /\r?\n/; + // Airline Defined // 3N01 POSRPT export class Label_80 extends DecoderPlugin { @@ -22,7 +26,7 @@ export class Label_80 extends DecoderPlugin { 'Airline Defined Position Report', ); - const lines = message.text.split(/\r?\n/); + const lines = message.text.split(NEWLINE_REGEX); if (lines.length === 1 && lines[0].includes(',')) { this.parseCsvFormat(lines[0], decodeResult); } else { @@ -63,7 +67,7 @@ export class Label_80 extends DecoderPlugin { ResultFormatter.unknown(results, header, '/'); return; } - const msgInfo = fields[0].split(/\s+/); + const msgInfo = fields[0].split(WS_REGEX); if (msgInfo.length === 3) { ResultFormatter.unknownArr(results, msgInfo.slice(0, 2), ' '); ResultFormatter.flightNumber(results, msgInfo[2]); @@ -72,7 +76,7 @@ export class Label_80 extends DecoderPlugin { return; } - const otherInfo1 = fields[1].split(/\s+/); + const otherInfo1 = fields[1].split(WS_REGEX); if (otherInfo1.length === 2) { ResultFormatter.day(results, parseInt(otherInfo1[0], 10)); ResultFormatter.departureAirport(results, otherInfo1[1]); @@ -80,7 +84,7 @@ export class Label_80 extends DecoderPlugin { ResultFormatter.unknownArr(results, otherInfo1, ' '); } - const otherInfo2 = fields[2].split(/\s+/); + const otherInfo2 = fields[2].split(WS_REGEX); if (otherInfo2.length === 2) { ResultFormatter.arrivalAirport(results, otherInfo2[0]); ResultFormatter.tail(results, otherInfo2[1].replace('.', '')); @@ -94,7 +98,7 @@ export class Label_80 extends DecoderPlugin { } private parseTags(part: string, results: DecodeResult) { - const kvPair = part.split(/\s+/); + const kvPair = part.split(WS_REGEX); if (kvPair.length < 2) { ResultFormatter.unknown(results, part, '/'); return; @@ -103,10 +107,9 @@ export class Label_80 extends DecoderPlugin { const val = kvPair.slice(1).join(' '); switch (tag) { - case 'POS': + case 'POS': { // don't use decodeStringCoordinates because of different position format - const posRegex = /^(?[NS])(?.+)(?[EW])(?.+)/; - const posResult = val.match(posRegex); + const posResult = POS_REGEX.exec(val); const lat = Number(posResult?.groups?.lat) * (posResult?.groups?.latd === 'S' ? -1 : 1); @@ -119,6 +122,7 @@ export class Label_80 extends DecoderPlugin { }; ResultFormatter.position(results, position); break; + } case 'ALT': ResultFormatter.altitude(results, parseInt(val.replace('+', ''), 10)); break; @@ -153,10 +157,11 @@ export class Label_80 extends DecoderPlugin { DateTimeUtils.convertHHMMSSToTod(val), ); break; - case 'ETA': + case 'ETA': { const hhmm = val.split('.')[0].replace(':', ''); ResultFormatter.eta(results, DateTimeUtils.convertHHMMSSToTod(hhmm)); break; + } case 'HDG': ResultFormatter.heading(results, parseInt(val, 10)); break; @@ -183,7 +188,7 @@ export class Label_80 extends DecoderPlugin { if (csvParts.length !== 9) { return; } - const header = csvParts[0].trim().split(/\s+/); + const header = csvParts[0].trim().split(WS_REGEX); ResultFormatter.unknown(results, header[0], ' '); ResultFormatter.unknown(results, header[1], ' '); ResultFormatter.position( diff --git a/lib/plugins/Label_83.ts b/lib/plugins/Label_83.ts index 136b553..17029a8 100644 --- a/lib/plugins/Label_83.ts +++ b/lib/plugins/Label_83.ts @@ -4,6 +4,10 @@ import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; import { CoordinateUtils } from '../utils/coordinate_utils'; import { ResultFormatter } from '../utils/result_formatter'; +const WS_REGEX = /\s+/; +const ALL_WS_REGEX = /\s/g; +const ALL_DOTS_REGEX = /\./g; + export class Label_83 extends DecoderPlugin { name = 'label-83'; @@ -21,9 +25,9 @@ export class Label_83 extends DecoderPlugin { decodeResult.decoded = true; const text = message.text; - if (text.substring(0, 10) === '4DH3 ETAT2') { + if (text.startsWith('4DH3 ETAT2')) { // variant 2 - const fields = text.split(/\s+/); + const fields = text.split(WS_REGEX); if (fields[2].length > 5) { decodeResult.raw.day = fields[2].substring(5); } @@ -31,16 +35,16 @@ export class Label_83 extends DecoderPlugin { const subfields = fields[3].split('/'); ResultFormatter.departureAirport(decodeResult, subfields[0]); ResultFormatter.arrivalAirport(decodeResult, subfields[1]); - ResultFormatter.tail(decodeResult, fields[4].replace(/\./g, '')); + ResultFormatter.tail(decodeResult, fields[4].replace(ALL_DOTS_REGEX, '')); ResultFormatter.eta( decodeResult, DateTimeUtils.convertHHMMSSToTod(fields[6] + '00'), ); - } else if (text.substring(0, 5) === '001PR') { + } else if (text.startsWith('001PR')) { // variant 3 decodeResult.raw.day = text.substring(5, 7); const position = CoordinateUtils.decodeStringCoordinatesDecimalMinutes( - text.substring(13, 28).replace(/\./g, ''), + text.substring(13, 28).replace(ALL_DOTS_REGEX, ''), ); if (position) { ResultFormatter.position(decodeResult, position); @@ -48,16 +52,18 @@ export class Label_83 extends DecoderPlugin { ResultFormatter.altitude(decodeResult, Number(text.substring(28, 33))); ResultFormatter.unknown(decodeResult, text.substring(33)); } else { - const fields = text.replace(/\s/g, '').split(','); + const fields = text.replace(ALL_WS_REGEX, '').split(','); if (fields.length === 9) { // variant 1 ResultFormatter.departureAirport(decodeResult, fields[0]); ResultFormatter.arrivalAirport(decodeResult, fields[1]); decodeResult.raw.day = fields[2].substring(0, 2); decodeResult.raw.time = fields[2].substring(2); + // text was already whitespace-stripped above so the per-field + // .replace(/\s/g, '') is redundant. ResultFormatter.position(decodeResult, { - latitude: Number(fields[3].replace(/\s/g, '')), - longitude: Number(fields[4].replace(/\s/g, '')), + latitude: Number(fields[3]), + longitude: Number(fields[4]), }); ResultFormatter.altitude(decodeResult, Number(fields[5])); ResultFormatter.groundspeed(decodeResult, Number(fields[6])); diff --git a/lib/plugins/Label_B6.test.ts b/lib/plugins/Label_B6.test.ts new file mode 100644 index 0000000..b344f04 --- /dev/null +++ b/lib/plugins/Label_B6.test.ts @@ -0,0 +1,62 @@ +import { MessageDecoder } from '../MessageDecoder'; +import { Label_B6_Forwardslash } from './Label_B6'; + +describe('Label B6 /', () => { + let plugin: Label_B6_Forwardslash; + const message = { label: 'B6', text: '' }; + + beforeEach(() => { + const decoder = new MessageDecoder(); + plugin = new Label_B6_Forwardslash(decoder); + }); + + test('matches qualifiers', () => { + expect(plugin.decode).toBeDefined(); + expect(plugin.name).toBe('label-b6-forwardslash'); + expect(plugin.qualifiers).toBeDefined(); + expect(plugin.qualifiers()).toEqual({ + labels: ['B6'], + preambles: ['/'], + }); + }); + + test('extracts the CPDLC body after the leading slash as text', () => { + // The CPDLC body parser is not yet implemented, but the plugin + // should at least surface the body as the decoded text so that + // consumers don't lose it. + message.text = '/SOMECPDLCBODY'; + const result = plugin.decode(message); + + expect(result.decoder.name).toBe('label-b6-forwardslash'); + expect(result.formatted.description).toBe('CPDLC Message'); + expect(result.message).toBe(message); + expect(result.decoded).toBe(true); + expect(result.decoder.decodeLevel).toBe('partial'); + expect(result.raw.text).toBe('SOMECPDLCBODY'); + expect(result.formatted.items[0]).toEqual({ + type: 'text', + code: 'TEXT', + label: 'Text Message', + value: 'SOMECPDLCBODY', + }); + }); + + test('does not decode an empty body', () => { + message.text = '/'; + const result = plugin.decode(message); + + expect(result.decoded).toBe(false); + expect(result.decoder.decodeLevel).toBe('none'); + }); + + test('preamble filtering keeps non-/ messages from being routed here', () => { + const decoder = new MessageDecoder(); + const result = decoder.decode({ + label: 'B6', + text: 'NOT_A_CPDLC_BODY', + }); + // No B6 plugin matches a non-/ preamble, so the message is not decoded + // by Label_B6_Forwardslash. + expect(result.decoder.name).not.toBe('label-b6-forwardslash'); + }); +}); diff --git a/lib/plugins/Label_B6.ts b/lib/plugins/Label_B6.ts index 9e3dde3..73d2be5 100644 --- a/lib/plugins/Label_B6.ts +++ b/lib/plugins/Label_B6.ts @@ -1,5 +1,6 @@ import { DecoderPlugin } from '../DecoderPlugin'; import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; +import { ResultFormatter } from '../utils/result_formatter'; // CPDLC export class Label_B6_Forwardslash extends DecoderPlugin { @@ -13,15 +14,20 @@ export class Label_B6_Forwardslash extends DecoderPlugin { } decode(message: Message, options: Options = {}): DecodeResult { - const decodeResult = this.defaultResult(); - decodeResult.decoder.name = this.name; - decodeResult.formatted.description = 'CPDLC Message'; - decodeResult.message = message; + const decodeResult = this.initResult(message, 'CPDLC Message'); - if (options.debug) { - console.log('CPDLC: ' + message); + // Full ATN/PM-CPDLC parsing isn't implemented yet, but the body + // (everything after the leading "/") is at least preserved in the + // decoded output so consumers don't lose it. + const body = message.text.substring(1); + if (body.length > 0) { + ResultFormatter.text(decodeResult, body); + this.setDecodeLevel(decodeResult, true, 'partial'); + } else { + this.setDecodeLevel(decodeResult, false); } + this.debug(options, 'CPDLC body:', body); return decodeResult; } } diff --git a/lib/plugins/Label_ColonComma.test.ts b/lib/plugins/Label_ColonComma.test.ts new file mode 100644 index 0000000..66b643f --- /dev/null +++ b/lib/plugins/Label_ColonComma.test.ts @@ -0,0 +1,47 @@ +import { MessageDecoder } from '../MessageDecoder'; +import { Label_ColonComma } from './Label_ColonComma'; + +describe('Label :;', () => { + let plugin: Label_ColonComma; + const message = { label: ':;', text: '' }; + + beforeEach(() => { + const decoder = new MessageDecoder(); + plugin = new Label_ColonComma(decoder); + }); + + test('matches qualifiers', () => { + expect(plugin.decode).toBeDefined(); + expect(plugin.name).toBe('label-colon-comma'); + expect(plugin.qualifiers).toBeDefined(); + expect(plugin.qualifiers()).toEqual({ labels: [':;'] }); + }); + + test('decodes a frequency change in kHz to MHz', () => { + // 131550 kHz -> 131.55 MHz + message.text = '131550'; + const result = plugin.decode(message); + + expect(result.decoded).toBe(true); + expect(result.decoder.decodeLevel).toBe('full'); + expect(result.formatted.description).toBe( + 'Aircraft Transceiver Frequency Change', + ); + expect(result.raw.frequency).toBe(131.55); + expect(result.formatted.items).toHaveLength(1); + expect(result.formatted.items[0]).toEqual({ + type: 'frequency', + label: 'Frequency', + value: '131.55 MHz', + code: 'FREQ', + }); + }); + + test('end-to-end via MessageDecoder routes by label', () => { + const decoder = new MessageDecoder(); + const result = decoder.decode({ label: ':;', text: '129125' }); + expect(result.decoded).toBe(true); + expect(result.decoder.name).toBe('label-colon-comma'); + expect(result.raw.frequency).toBe(129.125); + }); +}); diff --git a/lib/plugins/Label_QP.test.ts b/lib/plugins/Label_QP.test.ts new file mode 100644 index 0000000..f07ac5d --- /dev/null +++ b/lib/plugins/Label_QP.test.ts @@ -0,0 +1,52 @@ +import { MessageDecoder } from '../MessageDecoder'; +import { Label_QP } from './Label_QP'; + +describe('Label QP', () => { + let plugin: Label_QP; + const message = { label: 'QP', text: '' }; + + beforeEach(() => { + const decoder = new MessageDecoder(); + plugin = new Label_QP(decoder); + }); + + test('matches qualifiers', () => { + expect(plugin.decode).toBeDefined(); + expect(plugin.name).toBe('label-qp'); + expect(plugin.qualifiers).toBeDefined(); + expect(plugin.qualifiers()).toEqual({ + labels: ['QP'], + }); + }); + + test('decodes a typical OUT report fully', () => { + message.text = 'KSFOKLAX1234'; + const result = plugin.decode(message); + + expect(result.decoded).toBe(true); + expect(result.decoder.decodeLevel).toBe('full'); + expect(result.formatted.description).toBe('OUT Report'); + expect(result.raw.departure_icao).toBe('KSFO'); + expect(result.raw.arrival_icao).toBe('KLAX'); + // 12:34:00 since midnight + expect(result.raw.out_time).toBe(12 * 3600 + 34 * 60); + expect(result.remaining.text).toBeFalsy(); + }); + + test('marks decode as partial when extra text remains', () => { + message.text = 'KSFOKLAX1234EXTRA'; + const result = plugin.decode(message); + + expect(result.decoded).toBe(true); + expect(result.decoder.decodeLevel).toBe('partial'); + expect(result.remaining.text).toBe('EXTRA'); + }); + + test('end-to-end via MessageDecoder routes by label', () => { + const decoder = new MessageDecoder(); + const result = decoder.decode({ label: 'QP', text: 'KSFOKLAX0900' }); + expect(result.decoded).toBe(true); + expect(result.decoder.name).toBe('label-qp'); + expect(result.raw.out_time).toBe(9 * 3600); + }); +}); diff --git a/lib/plugins/Label_QR.test.ts b/lib/plugins/Label_QR.test.ts new file mode 100644 index 0000000..a61c8ae --- /dev/null +++ b/lib/plugins/Label_QR.test.ts @@ -0,0 +1,51 @@ +import { MessageDecoder } from '../MessageDecoder'; +import { Label_QR } from './Label_QR'; + +describe('Label QR', () => { + let plugin: Label_QR; + const message = { label: 'QR', text: '' }; + + beforeEach(() => { + const decoder = new MessageDecoder(); + plugin = new Label_QR(decoder); + }); + + test('matches qualifiers', () => { + expect(plugin.decode).toBeDefined(); + expect(plugin.name).toBe('label-qr'); + expect(plugin.qualifiers).toBeDefined(); + expect(plugin.qualifiers()).toEqual({ + labels: ['QR'], + }); + }); + + test('decodes a typical ON report fully', () => { + message.text = 'EGLLEDDF2030'; + const result = plugin.decode(message); + + expect(result.decoded).toBe(true); + expect(result.decoder.decodeLevel).toBe('full'); + expect(result.formatted.description).toBe('ON Report'); + expect(result.raw.departure_icao).toBe('EGLL'); + expect(result.raw.arrival_icao).toBe('EDDF'); + expect(result.raw.on_time).toBe(20 * 3600 + 30 * 60); + expect(result.remaining.text).toBeFalsy(); + }); + + test('marks decode as partial when extra text remains', () => { + message.text = 'EGLLEDDF2030/X'; + const result = plugin.decode(message); + + expect(result.decoded).toBe(true); + expect(result.decoder.decodeLevel).toBe('partial'); + expect(result.remaining.text).toBe('/X'); + }); + + test('end-to-end via MessageDecoder routes by label', () => { + const decoder = new MessageDecoder(); + const result = decoder.decode({ label: 'QR', text: 'KJFKKBOS0000' }); + expect(result.decoded).toBe(true); + expect(result.decoder.name).toBe('label-qr'); + expect(result.raw.on_time).toBe(0); + }); +}); diff --git a/lib/plugins/Label_QS.test.ts b/lib/plugins/Label_QS.test.ts new file mode 100644 index 0000000..49a0725 --- /dev/null +++ b/lib/plugins/Label_QS.test.ts @@ -0,0 +1,51 @@ +import { MessageDecoder } from '../MessageDecoder'; +import { Label_QS } from './Label_QS'; + +describe('Label QS', () => { + let plugin: Label_QS; + const message = { label: 'QS', text: '' }; + + beforeEach(() => { + const decoder = new MessageDecoder(); + plugin = new Label_QS(decoder); + }); + + test('matches qualifiers', () => { + expect(plugin.decode).toBeDefined(); + expect(plugin.name).toBe('label-qs'); + expect(plugin.qualifiers).toBeDefined(); + expect(plugin.qualifiers()).toEqual({ + labels: ['QS'], + }); + }); + + test('decodes a typical IN report fully', () => { + message.text = 'KJFKKBOS1015'; + const result = plugin.decode(message); + + expect(result.decoded).toBe(true); + expect(result.decoder.decodeLevel).toBe('full'); + expect(result.formatted.description).toBe('IN Report'); + expect(result.raw.departure_icao).toBe('KJFK'); + expect(result.raw.arrival_icao).toBe('KBOS'); + expect(result.raw.in_time).toBe(10 * 3600 + 15 * 60); + expect(result.remaining.text).toBeFalsy(); + }); + + test('marks decode as partial when extra text remains', () => { + message.text = 'KJFKKBOS1015 STATUS=OK'; + const result = plugin.decode(message); + + expect(result.decoded).toBe(true); + expect(result.decoder.decodeLevel).toBe('partial'); + expect(result.remaining.text).toBe(' STATUS=OK'); + }); + + test('end-to-end via MessageDecoder routes by label', () => { + const decoder = new MessageDecoder(); + const result = decoder.decode({ label: 'QS', text: 'KSFOKLAX2345' }); + expect(result.decoded).toBe(true); + expect(result.decoder.name).toBe('label-qs'); + expect(result.raw.in_time).toBe(23 * 3600 + 45 * 60); + }); +}); diff --git a/lib/utils/coordinate_utils.test.ts b/lib/utils/coordinate_utils.test.ts new file mode 100644 index 0000000..290fe4e --- /dev/null +++ b/lib/utils/coordinate_utils.test.ts @@ -0,0 +1,108 @@ +import { CoordinateUtils } from './coordinate_utils'; + +describe('CoordinateUtils', () => { + describe('decodeStringCoordinates', () => { + test('decodes positive (N, E) coordinates without separator', () => { + // N12345 = 12.345°, E123456 = 123.456° + const result = CoordinateUtils.decodeStringCoordinates('N12345E123456'); + expect(result).toEqual({ latitude: 12.345, longitude: 123.456 }); + }); + + test('decodes negative (S, W) coordinates without separator', () => { + const result = CoordinateUtils.decodeStringCoordinates('S33456W077123'); + expect(result).toEqual({ latitude: -33.456, longitude: -77.123 }); + }); + + test('decodes coordinates with a space separator', () => { + const result = CoordinateUtils.decodeStringCoordinates('N12345 W023456'); + expect(result).toEqual({ latitude: 12.345, longitude: -23.456 }); + }); + + test('returns undefined for invalid latitude prefix', () => { + expect( + CoordinateUtils.decodeStringCoordinates('X12345E123456'), + ).toBeUndefined(); + }); + + test('returns undefined for invalid longitude prefix', () => { + expect( + CoordinateUtils.decodeStringCoordinates('N12345X123456'), + ).toBeUndefined(); + }); + }); + + describe('decodeStringCoordinatesDecimalMinutes', () => { + test('decodes degrees + decimal minutes (no separator)', () => { + // N38241 = 38° 24.1' = 38.401666...° N + // W081357 = 81° 35.7' = 81.595° W + const result = + CoordinateUtils.decodeStringCoordinatesDecimalMinutes('N38241W081357'); + expect(result).toBeDefined(); + expect(result!.latitude).toBeCloseTo(38.40166666666667, 10); + expect(result!.longitude).toBeCloseTo(-81.595, 10); + }); + + test('decodes degrees + decimal minutes (with space separator)', () => { + const result = + CoordinateUtils.decodeStringCoordinatesDecimalMinutes('S38241 E081357'); + expect(result).toBeDefined(); + expect(result!.latitude).toBeCloseTo(-38.40166666666667, 10); + expect(result!.longitude).toBeCloseTo(81.595, 10); + }); + + test('returns undefined for invalid prefixes', () => { + expect( + CoordinateUtils.decodeStringCoordinatesDecimalMinutes('Z38241E081357'), + ).toBeUndefined(); + expect( + CoordinateUtils.decodeStringCoordinatesDecimalMinutes('N38241Z081357'), + ).toBeUndefined(); + }); + }); + + describe('coordinateString', () => { + test('formats positive coordinates as N/E', () => { + expect( + CoordinateUtils.coordinateString({ + latitude: 12.345, + longitude: 67.89, + }), + ).toBe('12.345 N, 67.890 E'); + }); + + test('formats negative coordinates as S/W with absolute values', () => { + expect( + CoordinateUtils.coordinateString({ + latitude: -12.345, + longitude: -67.89, + }), + ).toBe('12.345 S, 67.890 W'); + }); + }); + + describe('getDirection', () => { + test('N and E return +1', () => { + expect(CoordinateUtils.getDirection('N')).toBe(1); + expect(CoordinateUtils.getDirection('E')).toBe(1); + }); + + test('S and W return -1', () => { + expect(CoordinateUtils.getDirection('S')).toBe(-1); + expect(CoordinateUtils.getDirection('W')).toBe(-1); + }); + + test('unknown directions return NaN', () => { + expect(CoordinateUtils.getDirection('X')).toBeNaN(); + }); + }); + + describe('dmsToDecimalDegrees', () => { + test('converts DMS to decimal degrees', () => { + // 12° 30' 36" = 12 + 30/60 + 36/3600 = 12.51 + expect(CoordinateUtils.dmsToDecimalDegrees(12, 30, 36)).toBeCloseTo( + 12.51, + 10, + ); + }); + }); +}); diff --git a/lib/utils/coordinate_utils.ts b/lib/utils/coordinate_utils.ts index 66e66f0..1f92465 100644 --- a/lib/utils/coordinate_utils.ts +++ b/lib/utils/coordinate_utils.ts @@ -9,11 +9,11 @@ export class CoordinateUtils { stringCoords: string, ): { latitude: number; longitude: number } | undefined { // format: N12345W123456 or N12345 W123456 - const firstChar = stringCoords.substring(0, 1); - let middleChar = stringCoords.substring(6, 7); + const firstChar = stringCoords.charAt(0); + let middleChar = stringCoords.charAt(6); let longitudeChars = stringCoords.substring(7, 13); - if (middleChar == ' ') { - middleChar = stringCoords.substring(7, 8); + if (middleChar === ' ') { + middleChar = stringCoords.charAt(7); longitudeChars = stringCoords.substring(8, 14); } if ( @@ -43,30 +43,34 @@ export class CoordinateUtils { stringCoords: string, ): { latitude: number; longitude: number } | undefined { // format: N12345W123456 or N12345 W123456 - const firstChar = stringCoords.substring(0, 1); - let middleChar = stringCoords.substring(6, 7); + const firstChar = stringCoords.charAt(0); + let middleChar = stringCoords.charAt(6); let longitudeChars = stringCoords.substring(7, 13); - if (middleChar == ' ') { - middleChar = stringCoords.substring(7, 8); + if (middleChar === ' ') { + middleChar = stringCoords.charAt(7); longitudeChars = stringCoords.substring(8, 14); } - const latDeg = Math.trunc(Number(stringCoords.substring(1, 6)) / 1000); - const latMin = (Number(stringCoords.substring(1, 6)) % 1000) / 10; - const lonDeg = Math.trunc(Number(longitudeChars) / 1000); - const lonMin = (Number(longitudeChars) % 1000) / 10; if ( - (firstChar === 'N' || firstChar === 'S') && - (middleChar === 'W' || middleChar === 'E') + (firstChar !== 'N' && firstChar !== 'S') || + (middleChar !== 'W' && middleChar !== 'E') ) { - return { - latitude: - (latDeg + latMin / 60) * CoordinateUtils.getDirection(firstChar), - longitude: - (lonDeg + lonMin / 60) * CoordinateUtils.getDirection(middleChar), - }; + return undefined; } - return undefined; + + const latRaw = Number(stringCoords.substring(1, 6)); + const lonRaw = Number(longitudeChars); + const latDeg = Math.trunc(latRaw / 1000); + const latMin = (latRaw % 1000) / 10; + const lonDeg = Math.trunc(lonRaw / 1000); + const lonMin = (lonRaw % 1000) / 10; + + return { + latitude: + (latDeg + latMin / 60) * CoordinateUtils.getDirection(firstChar), + longitude: + (lonDeg + lonMin / 60) * CoordinateUtils.getDirection(middleChar), + }; } public static coordinateString(coords: { latitude: number;