diff --git a/package.json b/package.json index a34e87f..0fb6fee 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "postinstall": "nuxt prepare", "sharp:rebuild": "SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn rebuild sharp", "import:decision": "tsx --tsconfig tsconfig.scripts.json scripts/import-decision-tree.ts", - "test": "tsx --tsconfig tsconfig.tests.json --test server/utils/openai.test.ts" + "test": "tsx --tsconfig tsconfig.tests.json --test \"tests/**/*.test.ts\" \"server/**/*.test.ts\" \"shared/**/*.test.ts\"" }, "dependencies": { "@nuxt/image": "^2.0.0", diff --git a/tests/server/decisionSanitizer.test.ts b/tests/server/decisionSanitizer.test.ts new file mode 100644 index 0000000..96a6119 --- /dev/null +++ b/tests/server/decisionSanitizer.test.ts @@ -0,0 +1,100 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { + sanitizeAutoTrigger, + sanitizeNodeCondition, + sanitizeNodeTrigger, + sanitizeTransition, +} from '~~/server/utils/decisionSanitizer' + +describe('decisionSanitizer', () => { + it('throws when transition has no target', () => { + assert.throws( + () => sanitizeTransition({ type: 'ok' }), + /Transition target is required/ + ) + }) + + it('normalizes transition defaults, timer and auto trigger payloads', () => { + const transition = sanitizeTransition( + { + type: 'unknown', + target: 'STATE_B', + timer: { + afterSeconds: '12', + allowManualProceed: 'false', + }, + autoTrigger: { + type: 'telemetry', + parameter: 'speed_kts', + operator: '>=', + value: '130', + once: 'false', + }, + metadata: { + color: ' cyan ', + notes: ' keep speed ', + }, + }, + 4 + ) + + assert.equal(transition.type, 'next') + assert.equal(transition.order, 4) + assert.deepEqual(transition.timer, { afterSeconds: 12, allowManualProceed: false }) + assert.equal(transition.autoTrigger?.type, 'telemetry') + assert.equal(transition.autoTrigger?.parameter, 'speed_kts') + assert.equal(transition.autoTrigger?.operator, '>=') + assert.equal(transition.autoTrigger?.value, 130) + assert.equal(transition.autoTrigger?.once, false) + assert.equal(transition.metadata?.color, 'cyan') + assert.equal(transition.metadata?.notes, 'keep speed') + }) + + it('builds regex triggers and regex_not conditions', () => { + const trigger = sanitizeNodeTrigger( + { + type: 'regex', + pattern: 'roger', + patternFlags: 'i', + }, + 2 + ) + const condition = sanitizeNodeCondition( + { + type: 'regex_not', + pattern: 'hold short', + patternFlags: 'i', + }, + 3 + ) + + assert.equal(trigger.type, 'regex') + assert.equal(trigger.pattern, 'roger') + assert.equal(trigger.patternFlags, 'i') + assert.equal(trigger.order, 2) + assert.match(trigger.id, /^trigger_/) + + assert.equal(condition.type, 'regex_not') + assert.equal(condition.pattern, 'hold short') + assert.equal(condition.patternFlags, 'i') + assert.equal(condition.order, 3) + assert.match(condition.id, /^condition_/) + }) + + it('coerces variable auto trigger values', () => { + const trigger = sanitizeAutoTrigger({ + type: 'variable', + variable: 'is_ready', + operator: '==', + value: true, + once: false, + }) + + assert.equal(trigger?.type, 'variable') + assert.equal(trigger?.variable, 'is_ready') + assert.equal(trigger?.value, 'true') + assert.equal(trigger?.once, false) + }) +}) diff --git a/tests/server/validation.test.ts b/tests/server/validation.test.ts new file mode 100644 index 0000000..192437e --- /dev/null +++ b/tests/server/validation.test.ts @@ -0,0 +1,34 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { isValidEmail, validatePasswordStrength } from '~~/server/utils/validation' + +describe('validation utils', () => { + it('accepts valid email addresses and trims whitespace', () => { + assert.equal(isValidEmail(' pilot@example.com '), true) + assert.equal(isValidEmail('First.Last+tag@opsquawk.dev'), true) + }) + + it('rejects malformed email addresses', () => { + assert.equal(isValidEmail('pilot.example.com'), false) + assert.equal(isValidEmail('pilot@localhost'), false) + }) + + it('accepts strong passwords', () => { + const result = validatePasswordStrength('Abflug1234!') + assert.equal(result.valid, true) + assert.equal(result.message, undefined) + }) + + it('rejects passwords without special character', () => { + const result = validatePasswordStrength('Abflug12345') + assert.equal(result.valid, false) + assert.match(result.message ?? '', /special character/i) + }) + + it('rejects passwords with spaces', () => { + const result = validatePasswordStrength('Abflug 1234!') + assert.equal(result.valid, false) + assert.match(result.message ?? '', /cannot contain spaces/i) + }) +}) diff --git a/tests/shared/radioSpeech.test.ts b/tests/shared/radioSpeech.test.ts new file mode 100644 index 0000000..edc8ef6 --- /dev/null +++ b/tests/shared/radioSpeech.test.ts @@ -0,0 +1,40 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { + normalizeMetarPhrase, + normalizeRadioPhrase, + spellIcaoDigits, + spellIcaoLetters, + toIcaoPhonetic, +} from '~~/shared/utils/radioSpeech' + +describe('radioSpeech', () => { + it('spells ICAO digits and letters', () => { + assert.equal(spellIcaoDigits('120'), 'wun too zero') + assert.equal(spellIcaoLetters('eddf'), 'Echo Delta Delta Foxtrot') + assert.equal(toIcaoPhonetic('A3'), 'Alfa tree') + }) + + it('normalizes a combined phrase with callsign, runway, frequency and taxi route', () => { + const normalized = normalizeRadioPhrase('DLH359 contact 121.800, RWY 25R via N3 U4', { + expandCallsigns: true, + airlineMap: { DLH: 'Lufthansa' }, + }) + + assert.match(normalized, /Lufthansa tree fife niner/) + assert.match(normalized, /wun too wun decimal eight zero zero/) + assert.match(normalized, /runway too fife right/) + assert.match(normalized, /November tree Uniform four/) + }) + + it('normalizes SID suffix and METAR data', () => { + const sid = normalizeRadioPhrase('MARUN 7F') + const metar = normalizeMetarPhrase('EDDF 171450Z 28015G25KT 9999 -RA SCT025 BKN040 15/08 Q1013') + + assert.equal(sid, 'MARUN seven Foxtrot') + assert.match(metar, /wind too eight zero degrees/) + assert.match(metar, /gusting too fife knots/) + assert.match(metar, /QNH wun zero wun tree/) + }) +}) diff --git a/tests/shared/scenario.test.ts b/tests/shared/scenario.test.ts new file mode 100644 index 0000000..9d958d9 --- /dev/null +++ b/tests/shared/scenario.test.ts @@ -0,0 +1,68 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { + altitudeToWords, + createBaseScenario, + createScenarioSeries, + minutesToWords, + runwayToWords, +} from '~~/shared/learn/scenario' + +describe('scenario helpers', () => { + it('formats runway, minutes and altitude values', () => { + assert.equal(runwayToWords('25R'), 'two fife right') + assert.equal(minutesToWords(0), 'one minute') + assert.equal(minutesToWords(4.4), 'four minutes') + assert.equal(altitudeToWords(4500), 'four thousand fife zero zero') + }) + + it('creates a base scenario with critical data fields', () => { + const scenario = createBaseScenario() + + assert.match(scenario.callsign, /^[A-Z]{3}\d{3,4}$/) + assert.equal(scenario.metar.startsWith(`${scenario.airport.icao} `), true) + assert.equal(scenario.frequencies.length > 0, true) + assert.equal(typeof scenario.handoff.frequencyWords, 'string') + assert.equal(scenario.handoff.frequencyWords.length > 0, true) + }) +}) + +describe('createScenarioSeries', () => { + it('returns cloned cached data and isolates mutations', () => { + let sourceCalls = 0 + const series = createScenarioSeries(() => { + sourceCalls += 1 + return { + callsign: `SRC${sourceCalls}`, + nested: { value: 1 }, + } as any + }) + + const first = series() + const second = series() + + assert.equal(sourceCalls, 1) + assert.notStrictEqual(first, second) + assert.deepEqual(first, second) + + first.nested.value = 99 + const third = series() + assert.equal(third.nested.value, 1) + }) + + it('supports override scenarios and reset', () => { + const series = createScenarioSeries(() => ({ callsign: 'SRC1', nested: { value: 1 } } as any)) + + series.setScenario({ callsign: 'OVR1', nested: { value: 2 } } as any) + const fromOverride = series() + assert.equal(fromOverride.callsign, 'OVR1') + + fromOverride.nested.value = 999 + assert.equal(series().nested.value, 2) + + series.setScenario(null) + series.reset() + assert.equal(series().callsign, 'SRC1') + }) +}) diff --git a/tests/smoke/normalize.smoke.test.ts b/tests/smoke/normalize.smoke.test.ts new file mode 100644 index 0000000..3e15ddc --- /dev/null +++ b/tests/smoke/normalize.smoke.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { atcReplyPrompt, atcSeedPrompt, atcSystemPrompt, normalizeATC } from '~~/server/utils/normalize' + +describe('normalize smoke', () => { + it('builds prompt templates with key context', () => { + const systemPrompt = atcSystemPrompt({ regionHint: 'EUR' }) + const seedPrompt = atcSeedPrompt({ + airport: 'EDDF', + aircraft: 'A320', + type: 'IFR', + stand: 'V155', + dep: 'EHAM', + runway: '25R', + phase: 'taxi', + }) + const replyPrompt = atcReplyPrompt('DLH359 ready for departure RWY 25R', { + airport: 'EDDF', + runway: '25R', + phase: 'lineup', + lastFreq: '121.800', + }) + + assert.match(systemPrompt, /OUTPUT RULES \(STRICT\):/) + assert.match(seedPrompt, /Airport EDDF/) + assert.match(seedPrompt, /Phase: taxi/) + assert.match(replyPrompt, /Pilot said: "DLH359 ready for departure RWY 25R"/) + assert.match(replyPrompt, /Phase: lineup/) + }) + + it('normalizes ICAO phrase fragments for TTS pipeline', () => { + const normalized = normalizeATC('DLH359, taxi RWY 25R via N3 U4') + + assert.match(normalized, /Lufthansa tree fife niner/) + assert.match(normalized, /runway too fife right/) + assert.match(normalized, /November tree Uniform four/) + }) +})