flight-planner
Version:
Plan and route VFR flights
447 lines (446 loc) • 19.5 kB
JavaScript
import { metarFlightRule, metarFlightRuleColor, metarColorCode, metarCeiling, createMetarFromString, isMetarExpired, } from './metar.js';
import { formatCloud, formatPressure, formatTemperature, formatVisibility, formatWind } from './format.js';
import { FlightRules } from './metar.types.js';
import { jest } from '@jest/globals';
describe('Metar functions', () => {
describe('metarFlightRule', () => {
it('should return VFR for good weather conditions', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
visibility: 10000,
clouds: [{ quantity: 'FEW', height: 5000 }]
};
expect(metarFlightRule(metarData)).toBe(FlightRules.VFR);
});
it('should return LIFR for very poor visibility', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
visibility: 800,
};
expect(metarFlightRule(metarData)).toBe(FlightRules.LIFR);
});
it('should return LIFR for very low ceiling', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
clouds: [{ quantity: 'OVC', height: 300 }]
};
expect(metarFlightRule(metarData)).toBe(FlightRules.LIFR);
});
});
describe('metarFlightRuleColor', () => {
it('should return "green" for VFR', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
visibility: 10000,
clouds: [{ quantity: 'FEW', height: 5000 }]
};
expect(metarFlightRuleColor(metarData)).toBe('green');
});
it('should return "purple" for LIFR', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
visibility: 800,
};
expect(metarFlightRuleColor(metarData)).toBe('purple');
});
it('should return "red" for IFR', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
visibility: 4000,
clouds: [{ quantity: 'OVC', height: 700 }]
};
expect(metarFlightRuleColor(metarData)).toBe('red');
});
it('should return "blue" for MVFR', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
clouds: [{ quantity: 'OVC', height: 2500 }]
};
expect(metarFlightRuleColor(metarData)).toBe('blue');
});
});
describe('metarColorCode', () => {
it('should return "red" for very hazardous conditions', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 45 },
visibility: 1000,
};
expect(metarColorCode(metarData)).toBe('red');
});
it('should return "amber" for hazardous conditions', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 32 },
visibility: 1500,
};
expect(metarColorCode(metarData)).toBe('amber');
});
it('should return "yellow" for significant deterioration', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 22 },
clouds: [{ quantity: 'BKN', height: 650 }]
};
expect(metarColorCode(metarData)).toBe('yellow');
});
it('should return "blue" for minor deterioration', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 16 },
clouds: [{ quantity: 'BKN', height: 1400 }]
};
expect(metarColorCode(metarData)).toBe('blue');
});
it('should return "green" for normal operations', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
visibility: 8000,
clouds: [{ quantity: 'FEW', height: 3500 }]
};
expect(metarColorCode(metarData)).toBe('green');
});
it('should handle wind gusts correctly', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 15, gust: 35 },
visibility: 8000,
};
expect(metarColorCode(metarData)).toBe('yellow');
});
});
describe('formatMetarQNH', () => {
it('should return formatted QNH when present', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
qnh: 1013
};
expect(formatPressure(metarData.qnh)).toBe('1013 hPa');
});
});
describe('metarCeiling', () => {
it('should return "undefined" when no clouds are present', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
};
expect(metarCeiling(metarData)).toBeUndefined();
});
it('should return formatted ceiling when clouds are present', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
clouds: [{ quantity: 'BKN', height: 3000 }]
};
expect(metarCeiling(metarData)).toBe(3000);
});
});
describe('formatMetarWind', () => {
it('should return "Calm" when windDirection is not defined', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 0, speed: 0 },
};
expect(formatWind(metarData.wind)).toBe('Calm');
});
it('should return wind direction and speed when windDirection and windSpeed are defined', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
};
expect(formatWind(metarData.wind)).toBe('180° with 10 kt');
});
it('should include gusting wind speed when windGust is defined', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10, gust: 20 },
};
expect(formatWind(metarData.wind)).toBe('180° with 10 kt gusting 20 kt');
});
it('should include variable wind direction when windDirection is an array', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 350, speed: 12, directionMin: 340, directionMax: 360 },
};
expect(formatWind(metarData.wind)).toBe('350° with 12 kt variable between 340° and 360°');
});
});
describe('formatMetarVisibility', () => {
it('should return formatted visibility when present', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
visibility: 9999,
};
expect(formatVisibility(metarData.visibility)).toBe('10 km+');
});
// it('should handle CAVOK condition correctly', () => {
// const metarData: Metar = {
// station: 'TEST',
// observationTime: new Date(),
// raw: 'EGLL 291020Z 24015KT CAVOK 18/09 Q1022',
// wind: { direction: 240, speed: 15 },
// };
// expect(formatVisibility(metarData.visibility!)).toBe('10 km+');
// });
});
describe('formatMetarTemperature', () => {
it('should return formatted temperature when present', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
temperature: 23
};
expect(formatTemperature(metarData.temperature)).toBe('23°C');
});
});
describe('formatMetarDewpoint', () => {
it('should return formatted dew point when present', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
dewpoint: 15
};
expect(formatTemperature(metarData.dewpoint)).toBe('15°C');
});
});
describe('formatMetarClouds', () => {
it('should return formatted clouds when present', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
clouds: [{ quantity: 'BKN', height: 3000 }]
};
expect(formatCloud(metarData.clouds[0])).toBe('Broken at 3000 ft');
});
it('should return formatted clouds with multiple layers', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
clouds: [
{ quantity: 'BKN', height: 3000 },
{ quantity: 'OVC', height: 5000 }
]
};
expect(formatCloud(metarData.clouds[0])).toBe('Broken at 3000 ft');
expect(formatCloud(metarData.clouds[1])).toBe('Overcast at 5000 ft');
});
it('should return formatted clouds with different quantities', () => {
const metarData = {
station: 'TEST',
observationTime: new Date(),
raw: 'RAW DATA',
wind: { direction: 180, speed: 10 },
clouds: [
{ quantity: 'FEW', height: 1000 },
{ quantity: 'SCT', height: 2000 },
{ quantity: 'BKN', height: 3000 }
]
};
expect(formatCloud(metarData.clouds[0])).toBe('Few at 1000 ft');
expect(formatCloud(metarData.clouds[1])).toBe('Scattered at 2000 ft');
expect(formatCloud(metarData.clouds[2])).toBe('Broken at 3000 ft');
});
});
describe('createMetarFromString', () => {
it('should parse a simple METAR string correctly', () => {
const rawMetar = 'METAR EHAM 011025Z 24015KT 9999 SCT040 BKN080 18/12 Q1012 NOSIG';
const metar = createMetarFromString(rawMetar);
expect(metar.station).toBe('EHAM');
expect(metar.observationTime.getUTCDate()).toBe(1);
expect(metar.observationTime.getUTCHours()).toBe(10);
expect(metar.observationTime.getUTCMinutes()).toBe(25);
expect(metar.wind?.direction).toBe(240);
expect(metar.wind?.speed).toBe(15);
expect(metar.visibility).toBe(9999);
expect(metar.clouds).toEqual([
{ quantity: 'SCT', height: 4000 },
{ quantity: 'BKN', height: 8000 },
]);
expect(metar.temperature).toBe(18);
expect(metar.dewpoint).toBe(12);
expect(metar.qnh).toBe(1012);
expect(metar.raw).toContain('METAR EHAM 011025Z 24015KT 9999 SCT040 BKN080 18/12 Q1012 NOSIG');
});
it('should handle wind in MPS and visibility in SM', () => {
const rawMetar = 'METAR KLAX 011053Z 25008MPS 6SM FEW040 SCT060 OVC080 17/11 A2983 RMK AO2 SLP101';
const metar = createMetarFromString(rawMetar);
expect(metar.station).toBe('KLAX');
expect(metar.wind?.speed).toBeCloseTo(15.55, 1); // 8 MPS to knots
expect(metar.visibility).toBeCloseTo(9656.064, 1); // 6 SM to meters
expect(metar.qnh).toBeCloseTo(1010.14, 1); // 29.83 inHg to hPa
});
// it('should handle wind in KM/H', () => {
// const rawMetar = 'METAR CYYZ 011000Z 30020G30KMH 8000 -SN BKN015 OVC030 M05/M08 Q0995';
// const metar = createMetarFromString(rawMetar);
// expect(metar.station).toBe('CYYZ');
// expect(metar.wind?.speed).toBeCloseTo(10.8, 1); // 20 KM/H to knots
// expect(metar.wind?.gust).toBeCloseTo(16.2, 1); // 30 KM/H to knots
// });
it('should handle CAVOK', () => {
const rawMetar = 'METAR LFPG 011100Z 27010KT CAVOK 15/08 Q1018 NOSIG';
const metar = createMetarFromString(rawMetar);
expect(metar.station).toBe('LFPG');
expect(metar.visibility).toBe(9999);
expect(metar.clouds).toEqual([]);
});
it('should handle AUTO keyword', () => {
const rawMetar = 'METAR EDDF 011050Z AUTO 23012KT 9999 FEW030 17/11 Q1009 NOSIG';
const metar = createMetarFromString(rawMetar);
expect(metar.station).toBe('EDDF');
expect(metar.raw).toContain('AUTO');
});
it('should handle variable wind direction', () => {
const rawMetar = 'METAR LOWW 011120Z VRB03KT 9999 SCT050 19/10 Q1015 NOSIG';
const metar = createMetarFromString(rawMetar);
expect(metar.wind?.direction).toBeUndefined(); // VRB means variable
expect(metar.wind?.speed).toBe(3);
});
it('should handle wind variation', () => {
const rawMetar = 'METAR EGLL 011020Z 24015KT 200V280 9999 SCT030 BKN050 18/12 Q1012 BECMG 27020G30KT';
const metar = createMetarFromString(rawMetar);
expect(metar.wind?.direction).toBe(240);
expect(metar.wind?.speed).toBe(15);
expect(metar.wind?.directionMin).toBe(200);
expect(metar.wind?.directionMax).toBe(280);
});
});
describe('isMetarExpired', () => {
const baseTime = new Date('2025-06-01T10:00:00Z'); // June 1, 2025 10:00 UTC
const dummyWind = { direction: 0, speed: 0 };
it('should return false for a recent METAR using standard rules', () => {
const metarData = {
station: 'TEST',
observationTime: new Date('2025-06-01T09:30:00Z'), // 30 minutes ago
raw: 'METAR TEST ...',
wind: dummyWind,
};
// Mock current time to be baseTime
jest.useFakeTimers().setSystemTime(baseTime);
expect(isMetarExpired(metarData)).toBe(false);
jest.useRealTimers();
});
it('should return true for an old METAR using standard rules', () => {
const metarData = {
station: 'TEST',
observationTime: new Date('2025-06-01T08:50:00Z'), // 70 minutes ago
raw: 'METAR TEST ...',
wind: dummyWind,
};
jest.useFakeTimers().setSystemTime(baseTime);
expect(isMetarExpired(metarData)).toBe(true);
jest.useRealTimers();
});
it('should return false for a recent SPECI report using standard rules', () => {
const metarData = {
station: 'TEST',
observationTime: new Date('2025-06-01T09:45:00Z'), // 15 minutes ago
raw: 'SPECI TEST ...',
wind: dummyWind,
};
jest.useFakeTimers().setSystemTime(baseTime);
expect(isMetarExpired(metarData)).toBe(false);
jest.useRealTimers();
});
it('should return true for an old SPECI report using standard rules', () => {
const metarData = {
station: 'TEST',
observationTime: new Date('2025-06-01T09:25:00Z'), // 35 minutes ago
raw: 'SPECI TEST ...',
wind: dummyWind,
};
jest.useFakeTimers().setSystemTime(baseTime);
expect(isMetarExpired(metarData)).toBe(true);
jest.useRealTimers();
});
it('should use custom expiration time if provided', () => {
const metarData = {
station: 'TEST',
observationTime: new Date('2025-06-01T09:50:00Z'), // 10 minutes ago
raw: 'METAR TEST ...',
wind: dummyWind,
};
jest.useFakeTimers().setSystemTime(baseTime);
expect(isMetarExpired(metarData, { customMinutes: 5 })).toBe(true); // Expired after 5 mins
expect(isMetarExpired(metarData, { customMinutes: 15 })).toBe(false); // Not expired after 15 mins
jest.useRealTimers();
});
it('should default to 60 minutes if useStandardRules is false and no customMinutes', () => {
const metarData = {
station: 'TEST',
observationTime: new Date('2025-06-01T08:55:00Z'), // 65 minutes ago
raw: 'METAR TEST ...',
wind: dummyWind,
};
jest.useFakeTimers().setSystemTime(baseTime);
expect(isMetarExpired(metarData, { useStandardRules: false })).toBe(true);
const metarData2 = {
station: 'TEST',
observationTime: new Date('2025-06-01T09:05:00Z'), // 55 minutes ago
raw: 'METAR TEST ...',
wind: dummyWind,
};
expect(isMetarExpired(metarData2, { useStandardRules: false })).toBe(false);
jest.useRealTimers();
});
});
});