mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
8d78c988c3
A day that checks out of one hotel and into another, with nothing else planned, falls back to a drive from the first hotel to the second (#1297). A flight, train, ferry or coach booked on that day is the move itself, but one saved without its stations leaves no waypoint behind, so the day map, the trip overview and the phone plan all drew the full road between the two hotels, and the phone showed it above and below the flight (#2476). With its stations the day was already right. - The fallback drive between the two hotels is no longer drawn when any such booking is on the day, located or not. A day without a booking keeps it. No line beats a wrong one. - The phone plan takes the evening hotel leg from the last drive that reaches the hotel, not the first, so an earlier drive to the same spot no longer stands in for the one that closes the day. - Google Maps and CoMaps exports of such a day, which could only ever describe that same drive, return nothing, and on the desktop and the phone the two buttons are left out whenever fewer than two stops remain, instead of doing nothing on a tap. A hotel stop planned by hand ahead of the flight already sorts behind it through the ride seating of #2474; a test now pins that for the days view as well.
479 lines
24 KiB
TypeScript
479 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-046
|
|
|
|
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] } })
|
|
})
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|