Files
TREK/client/tests/unit/mobile/trip/planTimelineModel.test.ts
T
Maurice a910c2a837 fix: follow-ups from the review of the price, Immich, moving day and import fixes
Saved place price (#2471)
- A price above zero that would be left without a currency is refused
  with a 400, over REST and through update_collection_place alike. A list
  has no base currency, so such an amount was read in each viewer's own
  default currency and went into a trip as the trip's. A price sent alone
  keeps the stored currency, and a zero price or clearing both fields
  still goes through. The check sits in the service because the MCP tool
  spreads the contract's shape, where a refine would never arrive.
- An older row that holds an amount without a currency shows it as a
  bare number instead of in the viewer's own currency.
- The Hungarian hint uses the informal register of its file, which is
  now prettier clean.

Immich certificate switch (#2475)
- The switch trusts one server. Saving a different URL without sending
  it turns it off; saving the same URL keeps it as before. The wiki says
  so.

Moving day (#2476)
- Google Maps and CoMaps are left out only when there is nothing to hand
  over. A day with a single stop opens it as a pin again, on the desktop
  and on the phone, as it did before.
- Desktop: a moving day whose flight or train has no stations shows no
  route tools, since there is nothing left to draw. With stations they
  stay.
- Phone: the drive between two stays on a day without stops or bookings
  shows once at the top instead of again at the bottom.
- The export helpers take the carrier flags as one object instead of two
  booleans in a row.

Import card (#2477)
- The success and error marks use the theme tokens.
- The e2e test names its upload with a made-up booking number.

Tests: COLLECTIONS-SVC-132 and 133, COLLECTIONS-E2E-088,
update_collection_place with a price and no currency, FE-COMP-COLEXTRAS-013
(011 adjusted), IMMICH-TLS-010, IMMICH-103 extended,
FE-PLANNER-DAYPLAN-222 adjusted and 224, FE-MOB-PLTL-051, FE-MOB-PTLM-047,
FE-W4BGT-023 and 024 check the mark colours.
2026-09-23 17:41:26 +02:00

487 lines
24 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { Cloud, CloudLightning, CloudRain, CloudSnow, Sun, Wind } from 'lucide-react'
import {
breaksChronology, buildPlanRows, cityPillsForDay, findUpNext, getTransitMeta, hotelChipsForDay,
hotelLegsForDay, itemHasTime, parseReservationMeta, transportSubtitle, weatherIconFor,
type TransportEntry,
} from '../../../../src/mobile/screens/trip/plan/planTimelineModel'
import { getDisplayTimeForDay, type MergedItem } from '../../../../src/utils/dayMerge'
import { buildDayRouteRuns } from '../../../../src/components/Map/dayRoutePlan'
import { buildAssignment, buildDayNote, buildPlace, buildReservation } from '../../../helpers/factories'
import type {
Accommodation, Assignment, Day, DayNote, Reservation, RouteSegment, TranslationFn,
} from '../../../../src/types'
// FE-MOB-PTLM-001 to FE-MOB-PTLM-047
const DAYS = [
{ id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Old Town' },
{ id: 3, trip_id: 1, day_number: 3, date: '2026-05-03', title: null },
] as unknown as Day[]
const DAY2 = DAYS[1]
function place(id: number, name: string, lat: number | null, lng: number | null, time: string | null = null) {
return buildPlace({ id, name, lat, lng, place_time: time })
}
function assignment(id: number, order: number, p: ReturnType<typeof place>): Assignment {
return buildAssignment({ id, day_id: 2, order_index: order, place_id: p.id, place: p })
}
function seg(from: [number, number], to: [number, number], overrides: Partial<RouteSegment> = {}): RouteSegment {
return {
mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2],
from,
to,
distance: 1200,
duration: 600,
walkingText: '15 min',
drivingText: '3 min',
distanceText: '1.2 km',
...overrides,
}
}
const placeItem = (a: Assignment): MergedItem => ({ type: 'place', sortKey: a.order_index, data: a })
const noteItem = (n: DayNote): MergedItem => ({ type: 'note', sortKey: n.sort_order ?? 0, data: n })
const transportItem = (r: TransportEntry): MergedItem => ({ type: 'transport', sortKey: 0, data: r })
function accommodation(overrides: Partial<Accommodation>): Accommodation {
return {
id: 1,
trip_id: 1,
place_id: null,
start_day_id: 1,
end_day_id: 3,
check_in: null,
check_out: null,
place_name: 'Hotel Sacher',
place_lat: 48.2,
place_lng: 16.35,
...overrides,
} as unknown as Accommodation
}
describe('planTimelineModel — metadata parsing', () => {
it('FE-MOB-PTLM-001: returns an already-parsed metadata object untouched', () => {
const meta = { airline: 'LH', legs: [] }
expect(parseReservationMeta({ ...buildReservation(), metadata: meta } as unknown as Reservation)).toBe(meta)
})
it('FE-MOB-PTLM-002: parses a JSON metadata string', () => {
expect(parseReservationMeta(buildReservation({ metadata: '{"airline":"LH"}' }))).toEqual({ airline: 'LH' })
})
it('FE-MOB-PTLM-003: heals a double-encoded metadata string', () => {
const doubled = JSON.stringify(JSON.stringify({ airline: 'LH', flight_number: 'LH123' }))
expect(parseReservationMeta(buildReservation({ metadata: doubled }))).toEqual({
airline: 'LH', flight_number: 'LH123',
})
})
it('FE-MOB-PTLM-004: falls back to an empty object for broken, empty or scalar metadata', () => {
expect(parseReservationMeta(buildReservation({ metadata: '{not json' }))).toEqual({})
expect(parseReservationMeta(buildReservation({ metadata: '' }))).toEqual({})
expect(parseReservationMeta(buildReservation({ metadata: null }))).toEqual({})
expect(parseReservationMeta(buildReservation({ metadata: '5' }))).toEqual({})
// Double-encoded, with an inner payload that is broken or empty.
expect(parseReservationMeta(buildReservation({ metadata: JSON.stringify('{not json') }))).toEqual({})
expect(parseReservationMeta(buildReservation({ metadata: '""' }))).toEqual({})
})
it('FE-MOB-PTLM-005: reads transit metadata only from transit reservations with legs', () => {
const legs = [{ mode: 'subway', line: 'U2' }]
const transit = buildReservation({
type: 'transit', metadata: JSON.stringify({ transit: { legs, transfers: 1, duration: 900 } }),
})
expect(getTransitMeta(transit)).toEqual({ legs, transfers: 1, duration: 900 })
expect(getTransitMeta(buildReservation({ type: 'flight', metadata: JSON.stringify({ transit: { legs } }) }))).toBeNull()
expect(getTransitMeta(buildReservation({ type: 'transit', metadata: '{}' }))).toBeNull()
expect(getTransitMeta(buildReservation({ type: 'transit', metadata: '{"transit":{"legs":"nope"}}' }))).toBeNull()
})
})
describe('planTimelineModel — transportSubtitle', () => {
it('FE-MOB-PTLM-006: renders a synthetic leg as from → to', () => {
const res = { ...buildReservation({ type: 'flight' }), __leg: { index: 0, total: 2, from: 'FRA', to: 'IST' } } as TransportEntry
expect(transportSubtitle(res)).toBe('FRA → IST')
})
it('FE-MOB-PTLM-007: renders a leg without endpoints as an empty subtitle', () => {
const res = { ...buildReservation({ type: 'flight' }), __leg: { index: 1, total: 2, from: null, to: null } } as TransportEntry
expect(transportSubtitle(res)).toBe('')
})
it('FE-MOB-PTLM-008: joins airline, flight number and airports for a flight', () => {
const res = buildReservation({
type: 'flight',
metadata: JSON.stringify({ airline: 'LH', flight_number: 'LH123', departure_airport: 'FRA', arrival_airport: 'HND' }),
})
expect(transportSubtitle(res)).toBe('LH · LH123 · FRA → HND')
})
it('FE-MOB-PTLM-009: omits the airport pair when neither airport is known', () => {
const res = buildReservation({ type: 'flight', metadata: JSON.stringify({ airline: 'LH', flight_number: 'LH123' }) })
expect(transportSubtitle(res)).toBe('LH · LH123')
})
it('FE-MOB-PTLM-010: labels platform and seat for a train', () => {
const full = buildReservation({
type: 'train', metadata: JSON.stringify({ train_number: 'ICE 599', platform: '7', seat: '32A' }),
})
expect(transportSubtitle(full)).toBe('ICE 599 · Gl. 7 · Sitz 32A')
expect(transportSubtitle(buildReservation({ type: 'train', metadata: '{"train_number":"ICE 599"}' }))).toBe('ICE 599')
})
it('FE-MOB-PTLM-011: falls back to the location for any other type', () => {
expect(transportSubtitle(buildReservation({ type: 'bus', location: 'Central Station' }))).toBe('Central Station')
expect(transportSubtitle(buildReservation({ type: 'bus', location: null }))).toBe('')
})
})
describe('planTimelineModel — buildPlanRows', () => {
const museum = assignment(11, 0, place(101, 'Museum', 48.1, 16.1))
const park = assignment(12, 1, place(102, 'Park', 48.2, 16.2))
it('FE-MOB-PTLM-012: maps places, notes and transports to keyed rows', () => {
const note = buildDayNote({ id: 41, day_id: 2, text: 'Buy tickets' })
const bus = buildReservation({ id: 51, type: 'bus', day_id: 2, title: 'Bus 13A' })
const rows = buildPlanRows({
merged: [placeItem(museum), noteItem(note), transportItem(bus)],
reservations: [bus],
routeSegments: [],
dayId: 2,
})
expect(rows.map(r => [r.kind, r.key])).toEqual([
['place', 'pl-11'],
['note', 'note-41'],
['transport', 'tr-51'],
])
})
it('FE-MOB-PTLM-013: links a place row to the reservation booked on it', () => {
const dinner = buildReservation({ id: 52, type: 'restaurant', day_id: 2, assignment_id: 11 })
const rows = buildPlanRows({
merged: [placeItem(museum), placeItem(park)], reservations: [dinner], routeSegments: [], dayId: 2,
})
const [first, second] = rows.filter(r => r.kind === 'place')
expect(first.kind === 'place' && first.linkedReservations[0]).toBe(dinner)
expect(second.kind === 'place' && second.linkedReservations.length === 0).toBe(true)
})
it('FE-MOB-PTLM-014: keys a synthetic leg row by its leg index', () => {
const legRes = { ...buildReservation({ id: 60, type: 'flight', day_id: 2 }), __leg: { index: 1, total: 2, from: 'IST', to: 'NRT' } } as TransportEntry
const rows = buildPlanRows({ merged: [transportItem(legRes)], reservations: [], routeSegments: [], dayId: 2 })
expect(rows[0].key).toBe('tr-60-leg1')
})
it('FE-MOB-PTLM-015: recognises a transit booking as its own row kind', () => {
const transit = buildReservation({
id: 61, type: 'transit', day_id: 2,
metadata: JSON.stringify({ transit: { legs: [{ mode: 'subway', line: 'U2' }], transfers: 0 } }),
})
const rows = buildPlanRows({ merged: [transportItem(transit)], reservations: [], routeSegments: [], dayId: 2 })
expect(rows[0].kind).toBe('transit')
expect(rows[0].kind === 'transit' && rows[0].transit.legs).toHaveLength(1)
})
it('FE-MOB-PTLM-016: hides a car rental on the days between pickup and drop-off', () => {
const car = buildReservation({ id: 62, type: 'car', day_id: 1, end_day_id: 3 })
expect(buildPlanRows({ merged: [transportItem(car)], reservations: [], routeSegments: [], dayId: 2 })).toEqual([])
expect(buildPlanRows({ merged: [transportItem(car)], reservations: [], routeSegments: [], dayId: 1 })).toHaveLength(1)
})
it('FE-MOB-PTLM-043: hides a parking on the days between drop-off and pickup (#1937)', () => {
const parking = buildReservation({ id: 63, type: 'parking', day_id: 1, end_day_id: 3 })
expect(buildPlanRows({ merged: [transportItem(parking)], reservations: [], routeSegments: [], dayId: 2 })).toEqual([])
expect(buildPlanRows({ merged: [transportItem(parking)], reservations: [], routeSegments: [], dayId: 1 })).toHaveLength(1)
expect(buildPlanRows({ merged: [transportItem(parking)], reservations: [], routeSegments: [], dayId: 3 })).toHaveLength(1)
})
it('FE-MOB-PTLM-017: slots a connector between two located places and tags its origin', () => {
const leg = seg([48.1, 16.1], [48.2, 16.2])
const rows = buildPlanRows({
merged: [placeItem(museum), placeItem(park)], reservations: [], routeSegments: [leg], dayId: 2,
})
expect(rows.map(r => r.kind)).toEqual(['place', 'conn', 'place'])
const conn = rows[1]
expect(conn.kind === 'conn' && conn.seg).toBe(leg)
expect(conn.kind === 'conn' && conn.assignmentId).toBe(11)
expect(conn.key).toBe('conn-pl-11')
})
it('FE-MOB-PTLM-018: connects across an intervening note', () => {
const note = buildDayNote({ id: 41, day_id: 2 })
const rows = buildPlanRows({
merged: [placeItem(museum), noteItem(note), placeItem(park)],
reservations: [],
routeSegments: [seg([48.1, 16.1], [48.2, 16.2])],
dayId: 2,
})
expect(rows.map(r => r.kind)).toEqual(['place', 'conn', 'note', 'place'])
})
it('FE-MOB-PTLM-019: draws no connector when a transport is the hop between the places', () => {
const bus = buildReservation({ id: 51, type: 'bus', day_id: 2 })
const rows = buildPlanRows({
merged: [placeItem(museum), transportItem(bus), placeItem(park)],
reservations: [],
routeSegments: [seg([48.1, 16.1], [48.2, 16.2])],
dayId: 2,
})
expect(rows.map(r => r.kind)).toEqual(['place', 'transport', 'place'])
})
it('FE-MOB-PTLM-020: skips places without coordinates and unmatched segments', () => {
const nowhere = assignment(13, 2, place(103, 'Idea', null, null))
const rows = buildPlanRows({
merged: [placeItem(nowhere), placeItem(museum)],
reservations: [],
routeSegments: [seg([49.9, 17.9], [48.2, 16.2])],
dayId: 2,
})
expect(rows.map(r => r.kind)).toEqual(['place', 'place'])
})
it('FE-MOB-PTLM-021: consumes each segment only once', () => {
const again = assignment(14, 2, place(104, 'Museum again', 48.1, 16.1))
const parkAgain = assignment(15, 3, place(105, 'Park again', 48.2, 16.2))
const rows = buildPlanRows({
merged: [placeItem(museum), placeItem(park), placeItem(again), placeItem(parkAgain)],
reservations: [],
routeSegments: [seg([48.1, 16.1], [48.2, 16.2])],
dayId: 2,
})
// Only the first Museum → Park hop finds the single matching leg.
expect(rows.filter(r => r.kind === 'conn')).toHaveLength(1)
})
})
describe('planTimelineModel — hotel chips and legs', () => {
it('FE-MOB-PTLM-022: orders check-out before check-in before an ongoing stay, each chip naming its stay (#2210)', () => {
const chips = hotelChipsForDay(DAY2, DAYS, [
accommodation({ id: 1, start_day_id: 1, end_day_id: 3, place_name: 'Long Stay' }),
accommodation({ id: 2, start_day_id: 2, end_day_id: 3, place_name: 'New Hotel', check_in: '15:00', place_id: 102 }),
accommodation({ id: 3, start_day_id: 1, end_day_id: 2, place_name: 'Old Hotel', check_out: '11:00' }),
])
expect(chips).toEqual([
{ key: 'out-3', variant: 'checkout', name: 'Old Hotel', time: '11:00', accId: 3, placeId: null },
{ key: 'in-2', variant: 'checkin', name: 'New Hotel', time: '15:00', accId: 2, placeId: 102 },
{ key: 'stay-1', variant: 'stay', name: 'Long Stay', time: null, accId: 1, placeId: null },
])
})
it('FE-MOB-PTLM-023: falls back to the reservation title and drops unnamed stays', () => {
const chips = hotelChipsForDay(DAY2, DAYS, [
accommodation({ id: 4, place_name: null, reservation_title: 'Airbnb Wieden' }),
accommodation({ id: 5, place_name: null, reservation_title: null }),
])
expect(chips).toEqual([{ key: 'stay-4', variant: 'stay', name: 'Airbnb Wieden', time: null, accId: 4, placeId: null }])
})
it('FE-MOB-PTLM-042: leaves the chip time empty when the stay has no check-in or check-out', () => {
const chips = hotelChipsForDay(DAY2, DAYS, [
accommodation({ id: 9, start_day_id: 1, end_day_id: 2, place_name: 'Old Hotel' }),
accommodation({ id: 10, start_day_id: 2, end_day_id: 3, place_name: 'New Hotel' }),
])
expect(chips.map(c => [c.variant, c.time])).toEqual([['checkout', null], ['checkin', null]])
})
it('FE-MOB-PTLM-024: ignores accommodations outside the day range', () => {
expect(hotelChipsForDay(DAY2, DAYS, [accommodation({ id: 6, start_day_id: 1, end_day_id: 1 })])).toEqual([])
})
it('FE-MOB-PTLM-025: picks the hotel bookend legs out of the calculated segments', () => {
const hotel = accommodation({ id: 7, start_day_id: 1, end_day_id: 3, place_lat: 48.0, place_lng: 16.0 })
const out = seg([48.0, 16.0], [48.1, 16.1])
const back = seg([48.2, 16.2], [48.0, 16.0])
const legs = hotelLegsForDay(DAY2, DAYS, [hotel], [out, back, seg([48.1, 16.1], [48.2, 16.2])])
expect(legs.top).toEqual({ seg: out, name: 'Hotel Sacher' })
expect(legs.bottom).toEqual({ seg: back, name: 'Hotel Sacher' })
})
it('FE-MOB-PTLM-026: returns no legs when no segment touches the hotel', () => {
const hotel = accommodation({ id: 8, start_day_id: 1, end_day_id: 3, place_lat: 48.0, place_lng: 16.0 })
expect(hotelLegsForDay(DAY2, DAYS, [hotel], [seg([48.1, 16.1], [48.2, 16.2])])).toEqual({ top: null, bottom: null })
})
it('FE-MOB-PTLM-027: returns no legs without an accommodation on the day', () => {
expect(hotelLegsForDay(DAY2, DAYS, [], [seg([48.1, 16.1], [48.2, 16.2])])).toEqual({ top: null, bottom: null })
})
it('FE-MOB-PTLM-045: the evening leg is the last drive into the hotel, not the first (#2476)', () => {
// A stop planned on the hotel's own spot early in the day: the drive there reaches
// the hotel's coordinates first, but the day ends with the drive back from the park.
const hotel = accommodation({ id: 7, start_day_id: 1, end_day_id: 3, place_lat: 48.0, place_lng: 16.0 })
const out = seg([48.0, 16.0], [48.1, 16.1])
const toHotelSpot = seg([48.1, 16.1], [48.0, 16.0])
const onward = seg([48.0, 16.0], [48.2, 16.2])
const back = seg([48.2, 16.2], [48.0, 16.0])
const legs = hotelLegsForDay(DAY2, DAYS, [hotel], [out, toHotelSpot, onward, back])
expect(legs.top?.seg).toBe(out)
expect(legs.bottom?.seg).toBe(back)
})
describe('a moving day with a flight between the two stays (#2476)', () => {
// Day 2 checks out of Munich and into Hamburg; the flight sits between them.
const HOTEL_A = { lat: 48.137, lng: 11.575 }
const HOTEL_B = { lat: 53.551, lng: 9.993 }
const MUC = { lat: 48.353, lng: 11.786 }
const HAM = { lat: 53.63, lng: 9.988 }
const stays = [
accommodation({ id: 1, start_day_id: 1, end_day_id: 2, place_name: 'Hotel A', place_lat: HOTEL_A.lat, place_lng: HOTEL_A.lng }),
accommodation({ id: 2, start_day_id: 2, end_day_id: 3, place_name: 'Hotel B', place_lat: HOTEL_B.lat, place_lng: HOTEL_B.lng }),
]
const flight = (located: boolean) => buildReservation({
id: 7, type: 'flight', title: 'LH 2078', day_id: 2, end_day_id: 2,
reservation_time: '2026-05-02T15:15', reservation_end_time: '2026-05-02T17:20',
endpoints: located
? [
{ role: 'from', sequence: 0, name: 'MUC', code: null, ...MUC, timezone: null, local_date: null, local_time: null },
{ role: 'to', sequence: 1, name: 'HAM', code: null, ...HAM, timezone: null, local_date: null, local_time: null },
]
: [],
})
// What the connector calculation hands the timeline: one segment per pair of
// neighbouring waypoints in each drawn run.
const legsOf = (reservations: Reservation[]) => {
const runs = buildDayRouteRuns(2, {
days: DAYS, assignments: {}, reservations, accommodations: stays, optimizeFromAccommodation: true,
})
const segments = runs.flatMap(run => run.slice(1).map((p, i) => seg([run[i].lat, run[i].lng], [p.lat, p.lng])))
return hotelLegsForDay(DAY2, DAYS, stays, segments)
}
it('FE-MOB-PTLM-046: shows no drive from one hotel to the other, above or below the flight', () => {
// Saved without its airports, the flight leaves nothing to connect: the plan
// used to show the whole Munich to Hamburg drive twice, around the flight.
expect(legsOf([flight(false)])).toEqual({ top: null, bottom: null })
// With them, the morning drive goes to the departure airport and the evening
// one comes from the arrival airport.
const located = legsOf([flight(true)])
expect(located.top).toMatchObject({ name: 'Hotel A', seg: { from: [HOTEL_A.lat, HOTEL_A.lng], to: [MUC.lat, MUC.lng] } })
expect(located.bottom).toMatchObject({ name: 'Hotel B', seg: { from: [HAM.lat, HAM.lng], to: [HOTEL_B.lat, HOTEL_B.lng] } })
})
it('FE-MOB-PTLM-047: without a flight the one drive between the two stays shows once, not above and below', () => {
// No stop and no booking: the day is the drive from Munich to Hamburg (#1297).
// That one segment leaves the morning hotel and reaches the evening one.
const legs = legsOf([])
expect(legs.top).toMatchObject({ name: 'Hotel A', seg: { from: [HOTEL_A.lat, HOTEL_A.lng], to: [HOTEL_B.lat, HOTEL_B.lng] } })
expect(legs.bottom).toBeNull()
})
})
})
describe('planTimelineModel — cityPillsForDay', () => {
const t = ((key: string, params?: Record<string, string | number>) =>
params ? `${key}:${Object.values(params).join(',')}` : key) as unknown as TranslationFn
it('FE-MOB-PTLM-028: splits a transfer title into one pill per city', () => {
expect(cityPillsForDay({ ...DAY2, title: 'Tokyo → Kyoto ' } as Day, t)).toEqual(['Tokyo', 'Kyoto'])
})
it('FE-MOB-PTLM-029: keeps a plain title as a single pill', () => {
expect(cityPillsForDay(DAY2, t)).toEqual(['Old Town'])
})
it('FE-MOB-PTLM-030: falls back to the day number for blank or arrow-only titles', () => {
expect(cityPillsForDay({ ...DAY2, title: ' ' } as Day, t)).toEqual(['planner.dayN:2'])
expect(cityPillsForDay({ ...DAY2, title: ' → ' } as Day, t)).toEqual(['planner.dayN:2'])
expect(cityPillsForDay(undefined, t)).toEqual(['planner.dayN:0'])
})
})
describe('planTimelineModel — findUpNext', () => {
const early = assignment(11, 0, place(101, 'Breakfast', 48.1, 16.1, '09:00'))
const noon = assignment(12, 1, place(102, 'Museum', 48.2, 16.2, '11:00'))
const late = assignment(13, 2, place(103, 'Dinner', 48.3, 16.3, '19:00'))
it('FE-MOB-PTLM-031: returns null for a day without stops', () => {
expect(findUpNext(DAY2, [], new Date(2026, 4, 2, 10, 0))).toBeNull()
})
it('FE-MOB-PTLM-032: counts down to the next stop still ahead today', () => {
const up = findUpNext(DAY2, [late, early, noon], new Date(2026, 4, 2, 10, 15))
expect(up?.assignment.id).toBe(12)
expect(up?.minutesUntil).toBe(45)
})
it('FE-MOB-PTLM-033: shows nothing once the timed plan for today has run out', () => {
expect(findUpNext(DAY2, [early, noon, late], new Date(2026, 4, 2, 23, 30))).toBeNull()
})
it('FE-MOB-PTLM-044: shows nothing for a day that is already behind us', () => {
expect(findUpNext(DAY2, [late, noon, early], new Date(2026, 4, 3, 8, 0))).toBeNull()
})
it('FE-MOB-PTLM-034: never counts down on a day that is not today', () => {
const up = findUpNext(DAY2, [late, noon, early], new Date(2026, 4, 1, 8, 0))
expect(up?.assignment.id).toBe(11)
expect(up?.minutesUntil).toBeNull()
})
it('FE-MOB-PTLM-035: uses the manual order when no stop carries a time', () => {
const a = assignment(21, 1, place(201, 'Park', 48.1, 16.1))
const b = assignment(22, 0, place(202, 'Cafe', 48.2, 16.2))
const up = findUpNext(DAY2, [a, b], new Date(2026, 4, 2, 10, 0))
expect(up?.assignment.id).toBe(22)
expect(up?.minutesUntil).toBeNull()
})
it('FE-MOB-PTLM-036: treats a day without a date as not today', () => {
const up = findUpNext({ ...DAY2, date: null } as unknown as Day, [noon, early], new Date(2026, 4, 2, 10, 0))
expect(up?.assignment.id).toBe(11)
expect(findUpNext(undefined, [noon, early], new Date(2026, 4, 2, 10, 0))?.assignment.id).toBe(11)
})
})
describe('planTimelineModel — chronology helpers', () => {
it('FE-MOB-PTLM-037: detects which merged items carry a time', () => {
expect(itemHasTime(placeItem(assignment(11, 0, place(101, 'Museum', 48.1, 16.1, '09:00'))), 2)).toBe(true)
expect(itemHasTime(placeItem(assignment(12, 1, place(102, 'Park', 48.2, 16.2))), 2)).toBe(false)
expect(itemHasTime(noteItem(buildDayNote({ id: 41, time: '10:30' })), 2)).toBe(true)
expect(itemHasTime(noteItem(buildDayNote({ id: 42, time: null })), 2)).toBe(false)
})
it('FE-MOB-PTLM-038: reads a transport time through the per-day display time', () => {
const bus = buildReservation({ id: 51, type: 'bus', day_id: 2, reservation_time: '2026-05-02T08:00' })
expect(itemHasTime(transportItem(bus), 2)).toBe(true)
const car = buildReservation({ id: 52, type: 'car', day_id: 1, end_day_id: 3, reservation_time: '2026-05-01T08:00' })
expect(itemHasTime(transportItem(car), 2)).toBe(false)
})
it('FE-MOB-PTLM-039: reports an order that puts a later time before an earlier one', () => {
const nine = placeItem(assignment(11, 0, place(101, 'Breakfast', 48.1, 16.1, '09:00')))
const eleven = placeItem(assignment(12, 1, place(102, 'Museum', 48.2, 16.2, '11:00')))
expect(breaksChronology([nine, eleven], 2, getDisplayTimeForDay)).toBe(false)
expect(breaksChronology([eleven, nine], 2, getDisplayTimeForDay)).toBe(true)
})
it('FE-MOB-PTLM-040: lets untimed items sit anywhere between timed ones', () => {
const nine = placeItem(assignment(11, 0, place(101, 'Breakfast', 48.1, 16.1, '09:00')))
const free = placeItem(assignment(12, 1, place(102, 'Park', 48.2, 16.2)))
const eleven = noteItem(buildDayNote({ id: 41, time: '11:00' }))
expect(breaksChronology([nine, free, eleven], 2, getDisplayTimeForDay)).toBe(false)
const bus = transportItem(buildReservation({ id: 51, type: 'bus', day_id: 2, reservation_time: '2026-05-02T07:00' }))
const getDisplayTime = vi.fn(getDisplayTimeForDay)
expect(breaksChronology([nine, bus], 2, getDisplayTime)).toBe(true)
expect(getDisplayTime).toHaveBeenCalledWith(bus.data, 2)
})
})
describe('planTimelineModel — weatherIconFor', () => {
it('FE-MOB-PTLM-041: maps the known conditions and defaults to a cloud', () => {
expect(weatherIconFor('Clear')).toBe(Sun)
expect(weatherIconFor('Rain')).toBe(CloudRain)
expect(weatherIconFor('Thunderstorm')).toBe(CloudLightning)
expect(weatherIconFor('Snow')).toBe(CloudSnow)
expect(weatherIconFor('Haze')).toBe(Wind)
expect(weatherIconFor('Tornado')).toBe(Cloud)
expect(weatherIconFor(undefined)).toBe(Cloud)
})
})