Files
TREK/client/tests/unit/mobile/trip/transportsModel.test.ts
T
Maurice 1b51181930 refactor(planner): share the bookings and transports list filters between desktop and phone
The bookings model moves from the phone tabs into components/Planner/bookings, so
both shells read the same sections, traveller predicate and type colours. The
phone bookings and transports tabs had the same traveller filter and folded
sections twice; that state now lives once in useReservationListFilter, which only
those two tabs use. The desktop panel keeps its own filters in useBookingsView
because it remembers them for the session.

The status switch and the delete on a booking card stay per shell. The desktop
hook renders its ConfirmDialog and awaits the delete, the phone asks in an
MConfirmSheet and lets the delete run on its own; sharing them would change
when the question closes.
2026-10-09 15:19:40 +02:00

186 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import { cardWhen, parseTransportMeta } from '../../../../src/mobile/screens/trip/tabs/transportsModel';
import { TRANSPORT_TYPE_COLOR, groupTransports } from '../../../../src/components/Planner/bookings/bookingsModel';
import { orderedEndpoints } from '../../../../src/utils/flightLegs';
import { buildDay, buildReservation } from '../../../helpers/factories';
import type { Day, Reservation } from '../../../../src/types';
// FE-MOB-TRPM-001 to FE-MOB-TRPM-017
function res(overrides: Partial<Reservation> = {}): Reservation {
return buildReservation({ type: 'flight', status: 'confirmed', ...overrides });
}
describe('transportsModel — metadata', () => {
it('FE-MOB-TRPM-001: exposes a chip colour per transport type', () => {
expect(TRANSPORT_TYPE_COLOR.flight).toBe('#3b82f6');
expect(TRANSPORT_TYPE_COLOR.transit).toBe('#7c3aed');
expect(TRANSPORT_TYPE_COLOR.transport_other).toBe(TRANSPORT_TYPE_COLOR.car);
expect(TRANSPORT_TYPE_COLOR.nope).toBeUndefined();
});
it('FE-MOB-TRPM-002: parses a JSON metadata string', () => {
const meta = parseTransportMeta(
res({ metadata: '{"airline":"LH","flight_number":"LH123","seat":"14A"}' }),
);
expect(meta).toEqual({ airline: 'LH', flight_number: 'LH123', seat: '14A' });
});
it('FE-MOB-TRPM-003: passes an already-parsed metadata object through', () => {
const parsed = { transit: { legs: [{ mode: 'subway', line: 'U2' }] } };
expect(parseTransportMeta({ ...res(), metadata: parsed } as unknown as Reservation)).toEqual(parsed);
});
it('FE-MOB-TRPM-004: returns an empty object for missing or broken metadata', () => {
expect(parseTransportMeta(res({ metadata: null }))).toEqual({});
expect(parseTransportMeta(res({ metadata: '' }))).toEqual({});
expect(parseTransportMeta(res({ metadata: '{not json' }))).toEqual({});
});
it('FE-MOB-TRPM-005: orderedEndpoints sorts waypoints by sequence without mutating', () => {
const endpoints = [
{ role: 'to' as const, sequence: 2, name: 'Rome', code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null },
{ role: 'from' as const, sequence: 0, name: 'Berlin', code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null },
{ role: 'stop' as const, sequence: 1, name: 'Munich', code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null },
];
const r = res({ endpoints });
expect(orderedEndpoints(r).map(e => e.name)).toEqual(['Berlin', 'Munich', 'Rome']);
expect(endpoints.map(e => e.name)).toEqual(['Rome', 'Berlin', 'Munich']);
expect(orderedEndpoints(res({ endpoints: undefined }))).toEqual([]);
});
it('FE-MOB-TRPM-006: orderedEndpoints treats a missing sequence as zero', () => {
const rome = { role: 'to' as const, sequence: 1, name: 'Rome', code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null };
const berlin = { role: 'from' as const, name: 'Berlin', code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null };
const pair = (list: unknown[]) =>
orderedEndpoints(res({ endpoints: list as unknown as Reservation['endpoints'] })).map(e => e.name);
expect(pair([rome, berlin])).toEqual(['Berlin', 'Rome']);
expect(pair([berlin, rome])).toEqual(['Berlin', 'Rome']);
});
});
describe('transportsModel — groupTransports', () => {
const days: Day[] = [
buildDay({ id: 100, date: '2026-07-01' }),
buildDay({ id: 101, date: '2026-07-02' }),
buildDay({ id: 102, date: '2026-07-03' }),
];
it('FE-MOB-TRPM-007: sorts by reservation date and time', () => {
const late = res({ id: 1, reservation_time: '2026-07-02T18:00' });
const early = res({ id: 2, reservation_time: '2026-07-02T06:30' });
const first = res({ id: 3, reservation_time: '2026-07-01T23:00' });
expect(groupTransports([late, early, first], days).confirmed.map(r => r.id)).toEqual([3, 2, 1]);
});
it('FE-MOB-TRPM-008: falls back to the linked day date, treating a date-only value as midnight', () => {
const viaDay = res({ id: 1, reservation_time: null, day_id: 100 });
const dateOnly = res({ id: 2, reservation_time: '2026-07-01' });
const withTime = res({ id: 3, reservation_time: '2026-07-01T08:00' });
// both undated-but-day-linked entries key on 00:00, so created_at breaks the tie
const sorted = groupTransports(
[
withTime,
{ ...dateOnly, created_at: '2025-01-02T00:00:00.000Z' },
{ ...viaDay, created_at: '2025-01-01T00:00:00.000Z' },
],
days,
).confirmed;
expect(sorted.map(r => r.id)).toEqual([1, 2, 3]);
});
it('FE-MOB-TRPM-009: a hotel keys on its accommodation start day, not its day_id', () => {
const hotel = res({
id: 1,
type: 'hotel',
reservation_time: null,
day_id: 102,
accommodation_start_day_id: 100,
});
const flight = res({ id: 2, reservation_time: '2026-07-02T09:00' });
expect(groupTransports([flight, hotel], days).confirmed.map(r => r.id)).toEqual([1, 2]);
});
it('FE-MOB-TRPM-010: a hotel without an accommodation start day still uses day_id', () => {
const hotel = res({ id: 1, type: 'hotel', reservation_time: null, day_id: 102 });
const flight = res({ id: 2, reservation_time: '2026-07-02T09:00' });
expect(groupTransports([flight, hotel], days).confirmed.map(r => r.id)).toEqual([2, 1]);
});
it('FE-MOB-TRPM-011: undated entries and entries on unknown days sink to the bottom', () => {
const dated = res({ id: 1, reservation_time: '2026-07-02T09:00' });
const undated = res({ id: 2, reservation_time: null, day_id: null, created_at: '2025-01-01T00:00:00.000Z' });
const unknownDay = res({ id: 3, reservation_time: null, day_id: 999, created_at: '2025-01-02T00:00:00.000Z' });
expect(groupTransports([undated, unknownDay, dated], days).confirmed.map(r => r.id)).toEqual([1, 2, 3]);
// same result whichever side of the comparison the undated entry lands on
expect(groupTransports([undated, dated], days).confirmed.map(r => r.id)).toEqual([1, 2]);
expect(groupTransports([dated, undated], days).confirmed.map(r => r.id)).toEqual([1, 2]);
});
it('FE-MOB-TRPM-012: splits confirmed, pending and automated transit', () => {
const confirmed = res({ id: 1, reservation_time: '2026-07-01T08:00' });
const pending = res({ id: 2, status: 'pending', reservation_time: '2026-07-01T09:00' });
const cancelled = res({ id: 3, status: 'cancelled', reservation_time: '2026-07-01T07:00' });
const transitConfirmed = res({ id: 4, type: 'transit', reservation_time: '2026-07-01T10:00' });
const transitPending = res({ id: 5, type: 'transit', status: 'pending', reservation_time: '2026-07-01T06:00' });
const groups = groupTransports([confirmed, pending, cancelled, transitConfirmed, transitPending], days);
expect(groups.confirmed.map(r => r.id)).toEqual([1]);
// anything not confirmed lands in pending, still chronological
expect(groups.pending.map(r => r.id)).toEqual([3, 2]);
// transit is peeled off regardless of status
expect(groups.transit.map(r => r.id)).toEqual([5, 4]);
});
it('FE-MOB-TRPM-013: an empty list yields three empty groups', () => {
expect(groupTransports([], days)).toEqual({ confirmed: [], pending: [], transit: [] });
expect(groupTransports([res({ id: 1, reservation_time: '2026-07-01T08:00' })], []).confirmed).toHaveLength(1);
});
it('FE-MOB-TRPM-014: entries without a created_at still sort deterministically', () => {
const a = { ...res({ id: 1, reservation_time: '2026-07-02T09:00' }), created_at: undefined };
const b = { ...res({ id: 2, reservation_time: '2026-07-02T09:00' }), created_at: undefined };
expect(groupTransports([a, b], days).confirmed.map(r => r.id)).toEqual([1, 2]);
});
});
describe('transportsModel — cardWhen', () => {
const t = (key: string, params?: Record<string, string | number | null>) => `${key}:${params?.n}`;
const day1 = buildDay({ id: 100, day_number: 1, title: null, date: '2026-07-01' });
const day3 = buildDay({ id: 102, day_number: 3, title: 'Coast', date: '2026-07-03' });
it('FE-MOB-TRPM-015: names the day or the day range and the time or time range', () => {
const r = res({ reservation_time: '2026-07-01T09:05', reservation_end_time: '2026-07-03T18:30' });
expect(cardWhen(r, day1, day3, t, 'en-US', '24h')).toEqual({
dayValue: 'dayplan.dayN:1 – Coast',
timeValue: '09:05 – 18:30',
});
expect(cardWhen(r, day1, day1, t, 'en-US', '24h').dayValue).toBe('dayplan.dayN:1');
});
it('FE-MOB-TRPM-016: without a day the booking date stands in, then a dash', () => {
expect(cardWhen(res({ reservation_time: '2026-07-04' }), undefined, undefined, t, 'en-US', '24h')).toEqual({
dayValue: 'Jul 4',
timeValue: '—',
});
expect(cardWhen(res({ reservation_time: null }), undefined, undefined, t, 'en-US', '24h')).toEqual({
dayValue: '—',
timeValue: '—',
});
});
it('FE-MOB-TRPM-017: a start time alone shows on its own, in the chosen clock', () => {
const r = res({ reservation_time: '2026-07-01T14:00', reservation_end_time: null });
expect(cardWhen(r, day1, undefined, t, 'en-US', '12h').timeValue).toBe('2:00 PM');
});
});