Files
TREK/client/src/native/widgetFlight.test.ts
T
Maurice 27f12c2245 feat(native): a flight widget and a boarding-pass Live Activity
A new Flight widget on iOS (small to large and the lock screen) and Android
shows the trip's next flight or the one in the air: both airports, the arc
with the aircraft, a countdown the system keeps running, and the delay. It
follows the feed's phase, so a late departure is not flown along the arc.
Around a flight the travel day's Live Activity becomes a boarding pass with
a running bar and countdown, in the Dynamic Island too.
2026-11-13 15:29:21 +01:00

144 lines
5.2 KiB
TypeScript

// FE-NATIVE-WIDGETFLIGHT-001 to FE-NATIVE-WIDGETFLIGHT-008
import type { FlightLiveLeg } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { pickWidgetLeg, widgetFlight } from './widgetFlight';
import { liveFlightOf } from './widgets';
const DEP = Date.parse('2026-06-11T11:50:00Z');
const ARR = Date.parse('2026-06-11T20:35:00Z');
const H = 3_600_000;
const CLOCK = { locale: 'en-GB', timeFormat: '24h' };
function leg(over: Partial<FlightLiveLeg> = {}): FlightLiveLeg {
return {
reservation_id: 6,
leg_index: 0,
callsign: 'DLH400',
flight_number: 'LH 400',
service_date: '2026-06-11',
origin: { code: 'FRA', name: 'Frankfurt', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin' },
destination: { code: 'JFK', name: 'New York JFK', lat: 40.64, lng: -73.78, timezone: 'America/New_York' },
phase: 'scheduled',
scheduled_departure: new Date(DEP).toISOString(),
scheduled_arrival: new Date(ARR).toISOString(),
departed_at: null,
landed_at: null,
estimated_arrival: null,
departure_delay_min: null,
arrival_delay_min: null,
progress: null,
distance_total_km: null,
distance_remaining_km: null,
position: null,
aircraft: null,
last_seen_at: null,
fetched_at: null,
stale: false,
source: null,
...over,
};
}
describe('pickWidgetLeg', () => {
it('FE-NATIVE-WIDGETFLIGHT-001: prefers the flight in the air', () => {
const ahead = leg({ reservation_id: 7 });
const flying = leg({ reservation_id: 8, phase: 'airborne' });
expect(pickWidgetLeg([ahead, flying], DEP)?.reservation_id).toBe(8);
});
it('FE-NATIVE-WIDGETFLIGHT-002: takes the earliest within a week and drops one more than two hours landed', () => {
const later = leg({
reservation_id: 9,
scheduled_departure: new Date(DEP + 48 * H).toISOString(),
scheduled_arrival: new Date(ARR + 48 * H).toISOString(),
});
expect(pickWidgetLeg([later, leg()], DEP - H)?.reservation_id).toBe(6);
expect(pickWidgetLeg([leg()], ARR + 3 * H)).toBeNull();
expect(pickWidgetLeg([leg()], DEP - 8 * 24 * H)).toBeNull();
});
});
describe('widgetFlight', () => {
it('FE-NATIVE-WIDGETFLIGHT-003: sends the schedule in seconds and the clock at each airport', () => {
const flight = widgetFlight([leg()], DEP - H, '/trips/1', CLOCK);
expect(flight).toMatchObject({
number: 'LH 400',
from: 'FRA',
to: 'JFK',
dep: DEP / 1000,
arr: ARR / 1000,
depTime: '13:50',
arrTime: '16:35',
path: '/trips/1?booking=6',
});
expect(flight?.arrExpected).toBeUndefined();
expect(flight?.landed).toBeUndefined();
});
it('FE-NATIVE-WIDGETFLIGHT-004: adds what the feed observed while the flight is in the air', () => {
const flight = widgetFlight(
[
leg({
phase: 'airborne',
departed_at: new Date(DEP + 20 * 60_000).toISOString(),
estimated_arrival: new Date(ARR + 15 * 60_000).toISOString(),
arrival_delay_min: 15,
aircraft: { registration: 'D-AIXA', type: 'A359' },
last_seen_at: new Date(DEP + 2 * H).toISOString(),
}),
],
DEP + 2 * H,
'/trips/1',
CLOCK
);
expect(flight).toMatchObject({
depActual: (DEP + 20 * 60_000) / 1000,
arrExpected: (ARR + 15 * 60_000) / 1000,
arrTimeExpected: '16:50',
delay: 15,
aircraft: 'A359 D-AIXA',
});
});
it('FE-NATIVE-WIDGETFLIGHT-005: says landed once touched down, and nothing without a flight', () => {
expect(
widgetFlight([leg({ phase: 'landed', landed_at: new Date(ARR).toISOString() })], ARR + H, '/trips/1', CLOCK)
?.landed
).toBe(true);
expect(widgetFlight([], DEP, '/trips/1', CLOCK)).toBeUndefined();
});
});
describe('liveFlightOf', () => {
it('FE-NATIVE-WIDGETFLIGHT-006: holds the Live Activity from three hours before take-off to an hour after touchdown', () => {
const flight = widgetFlight([leg()], DEP - H, '/trips/1', CLOCK);
const at = (ms: number) => liveFlightOf(flight, ms / 1000);
expect(at(DEP - 4 * H)).toBeUndefined();
expect(at(DEP - 2 * H)).toMatchObject({
number: 'LH 400',
from: 'FRA',
to: 'JFK',
depTime: '13:50',
arrTime: '16:35',
});
expect(at(ARR + 2 * H)).toBeUndefined();
expect(liveFlightOf({ ...(flight as NonNullable<typeof flight>), landed: true }, (DEP + H) / 1000)).toBeUndefined();
expect(liveFlightOf(undefined, DEP / 1000)).toBeUndefined();
});
});
describe('widget phase', () => {
it('FE-NATIVE-WIDGETFLIGHT-007: a late departure goes along as the expected one, and the flight in the air says so', () => {
const late = widgetFlight([leg({ departure_delay_min: 40 })], DEP, '/trips/1', CLOCK);
expect(late?.depExpected).toBe(DEP / 1000 + 40 * 60);
expect(late?.airborne).toBeUndefined();
expect(widgetFlight([leg({ phase: 'airborne' })], DEP + H, '/trips/1', CLOCK)?.airborne).toBe(true);
expect(liveFlightOf(late, DEP / 1000)?.dep).toBe(DEP / 1000 + 40 * 60);
});
it('FE-NATIVE-WIDGETFLIGHT-008: a flight the feed lost in the air stops being picked two hours after arrival', () => {
expect(pickWidgetLeg([leg({ phase: 'airborne' })], ARR + 3 * H)).toBeNull();
});
});