mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
85b5756e89
Ride minutes and late marks follow an edited timetable instead of the routing that fetched them; a landing day's join band reads as the ride it is rather than a nine hour drive of no length; the refuel offer is there on the road out of a terminal or a pick-up desk; the phone's stage map draws the ride; the share page numbers its stops the way the planner does; the PDF lists a day the way the day plan lists it, from both shells; a poster-less clip draws its play badge everywhere, not only in the editor; the hero title has room for its descenders and the list card keeps its badge clear; the release notice keeps its close control in reach when it stacks; a link chip's actions are thumb-sized and its delete asks first; the Google retry line and the Google-only switch appear only where Google answers.
652 lines
30 KiB
TypeScript
652 lines
30 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
destinationCount,
|
|
firstStopOfPlace,
|
|
legReroutable,
|
|
pickWarning,
|
|
roadtripRows,
|
|
stageClocks,
|
|
stageEnd,
|
|
stageOf,
|
|
upNextStop,
|
|
type RoadtripRow,
|
|
type StopRow,
|
|
} from './roadtripRowModel'
|
|
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
|
|
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
|
|
|
// FE-RTROW-001 to FE-RTROW-055
|
|
|
|
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
|
|
return {
|
|
assignmentId: name.length,
|
|
ownerDayId: 7,
|
|
ownerIndex: 0,
|
|
placeId: 100,
|
|
name,
|
|
lat: 48.1,
|
|
lng: 11.2,
|
|
time: null,
|
|
dwellMinutes: null,
|
|
legMode: null,
|
|
incomingLegMode: null,
|
|
stopType: null,
|
|
...over,
|
|
}
|
|
}
|
|
|
|
function entry(arrival: string | null, over: Partial<ScheduleEntry> = {}): ScheduleEntry {
|
|
return { arrival, departure: arrival, anchored: false, dayOffset: 0, ...over }
|
|
}
|
|
|
|
function seg(index: number): RouteSegment {
|
|
return {
|
|
mid: [48, 11],
|
|
from: [48, 11],
|
|
to: [49, 12],
|
|
distance: 1000 * (index + 1),
|
|
duration: 600,
|
|
walkingText: '2 h',
|
|
drivingText: '10 min',
|
|
distanceText: `${index + 1} km`,
|
|
}
|
|
}
|
|
|
|
function day(stops: RoadtripStop[], over: Partial<RoadtripDay> = {}): RoadtripDay {
|
|
return {
|
|
dayId: 7,
|
|
dayNumber: 1,
|
|
date: '2026-06-01',
|
|
title: null,
|
|
stops,
|
|
legs: stops.slice(0, -1).map((_, i) => seg(i)),
|
|
schedule: { entries: stops.map(() => entry(null)), warnings: [] },
|
|
legVias: [],
|
|
geometry: [],
|
|
distance: 0,
|
|
duration: 0,
|
|
driveWarnings: [],
|
|
dayWarning: null,
|
|
...over,
|
|
}
|
|
}
|
|
|
|
const night = (phase: 'start' | 'end' = 'end') =>
|
|
stop('Nacht', { automaticNight: { phase, fromDayNumber: 1 }, dwellMinutes: 480 })
|
|
|
|
const stopRows = (rows: RoadtripRow[]) => rows.filter((r): r is StopRow => r.kind === 'stop')
|
|
|
|
/** Ziel, Tankstelle, Ziel, automatische Nacht, Ziel. */
|
|
const mixedDay = () =>
|
|
day([stop('Bremen'), stop('Aral', { stopType: 'fuel' }), stop('Kassel'), night(), stop('Fulda')])
|
|
|
|
describe('roadtripRows numbering', () => {
|
|
it('FE-RTROW-001: only destinations take a number, a service stop and a night do not advance it', () => {
|
|
// Nummerierung ueber den Array-Index machte aus Kassel die drei und aus Fulda die fuenf.
|
|
const numbers = stopRows(roadtripRows(mixedDay())).map((r) => [r.stop.name, r.number])
|
|
expect(numbers).toEqual([
|
|
['Bremen', 1],
|
|
['Aral', null],
|
|
['Kassel', 2],
|
|
['Fulda', 3],
|
|
])
|
|
})
|
|
|
|
it('FE-RTROW-002: the service stop is a stop row, the automatic night is not', () => {
|
|
const rows = roadtripRows(mixedDay())
|
|
expect(rows.map((r) => r.kind)).toEqual(['stop', 'leg', 'stop', 'leg', 'stop', 'leg', 'auto', 'leg', 'stop'])
|
|
expect(stopRows(rows).map((r) => r.stop.name)).not.toContain('Nacht')
|
|
})
|
|
|
|
it('FE-RTROW-040: the placeholder leg into an automatic night is not a row at all', () => {
|
|
// `dayWindow.stationary()` puts one of these between the last stop of a day and the
|
|
// night that closes it: same coordinate at both ends, nothing measured. It is
|
|
// bookkeeping, not a drive, and as a pill it read "No route" under a stop the
|
|
// traveller had simply arrived at (and, before that was guarded, a bare " in ").
|
|
const stops = [stop('Hamburg'), night()]
|
|
const at: [number, number] = [53.54, 10.01]
|
|
const stationary: RouteSegment = {
|
|
mid: at, from: at, to: at, distance: 0, duration: 0,
|
|
walkingText: '', drivingText: '', distanceText: '',
|
|
}
|
|
const rows = roadtripRows(day(stops, { legs: [stationary] }))
|
|
|
|
expect(rows.map((r) => r.kind)).toEqual(['stop', 'auto'])
|
|
})
|
|
|
|
it('FE-RTROW-041: a night the traveller drove to keeps its leg', () => {
|
|
// A boundary dragged down the road ends the day somewhere else, and that IS a drive.
|
|
const stops = [stop('Hamburg'), night()]
|
|
const rows = roadtripRows(day(stops, { legs: [seg(0)] }))
|
|
|
|
expect(rows.map((r) => r.kind)).toEqual(['stop', 'leg', 'auto'])
|
|
})
|
|
|
|
it('FE-RTROW-042: a leg nothing has routed yet still gets its row, so it can say it is pending', () => {
|
|
const stops = [stop('Hamburg'), stop('Bremen')]
|
|
const rows = roadtripRows(day(stops, { legs: [undefined] }))
|
|
|
|
expect(rows.map((r) => r.kind)).toEqual(['stop', 'leg', 'stop'])
|
|
})
|
|
|
|
it('FE-RTROW-003: the automatic night carries no dwell and takes its time from the schedule', () => {
|
|
// Die Nacht traegt dwellMinutes 480, aber keine Zeile, die eine Aufenthaltsdauer zeigen koennte.
|
|
const rows = roadtripRows(
|
|
day([stop('Kassel'), night(), stop('Fulda')], {
|
|
schedule: { entries: [entry('18:00'), entry('22:30'), entry('09:15')], warnings: [] },
|
|
}),
|
|
)
|
|
expect(rows.find((r) => r.kind === 'auto')).toEqual({ kind: 'auto', phase: 'end', time: '22:30' })
|
|
expect(JSON.stringify(rows)).not.toContain('480')
|
|
})
|
|
|
|
it('FE-RTROW-004: a night that starts a day resumes it, anything else ends it', () => {
|
|
expect(roadtripRows(day([night('start'), stop('Fulda')])).find((r) => r.kind === 'auto')).toMatchObject({
|
|
phase: 'resume',
|
|
})
|
|
expect(roadtripRows(day([night('end'), stop('Fulda')])).find((r) => r.kind === 'auto')).toMatchObject({
|
|
phase: 'end',
|
|
})
|
|
})
|
|
|
|
it('FE-RTROW-005: exactly one leg between two stops and none after the last', () => {
|
|
const rows = roadtripRows(day([stop('A'), stop('B'), stop('C')]))
|
|
expect(rows.filter((r) => r.kind === 'leg')).toEqual([
|
|
{ kind: 'leg', index: 0, seg: seg(0), mode: null },
|
|
{ kind: 'leg', index: 1, seg: seg(1), mode: null },
|
|
])
|
|
expect(rows[rows.length - 1].kind).toBe('stop')
|
|
})
|
|
|
|
it('FE-RTROW-006: a leg is driven in the next stop incoming mode, else in this one own', () => {
|
|
const rows = roadtripRows(
|
|
day([
|
|
stop('A', { legMode: 'car' }),
|
|
stop('B', { incomingLegMode: 'ferry', legMode: 'train' }),
|
|
stop('C'),
|
|
stop('D'),
|
|
]),
|
|
)
|
|
const modes = rows.flatMap((r) => (r.kind === 'leg' ? [r.mode] : []))
|
|
expect(modes).toEqual(['ferry', 'train', null])
|
|
})
|
|
})
|
|
|
|
describe('roadtripRows dry points and spills', () => {
|
|
it('FE-RTROW-007: a dry point makes one row, directly after the leg it falls on', () => {
|
|
const rows = roadtripRows(
|
|
day([stop('A'), stop('B'), stop('C')], {
|
|
dryPoints: [{ legIndex: 1, intoLegKm: 42, sinceKm: 610, drivenMeters: 42000, lat: 48, lng: 11 }],
|
|
}),
|
|
)
|
|
expect(rows.map((r) => r.kind)).toEqual(['stop', 'leg', 'stop', 'leg', 'dry', 'stop'])
|
|
expect(rows[4]).toEqual({ kind: 'dry', legIndex: 1, intoLegKm: 42, sinceKm: 610 })
|
|
})
|
|
|
|
it('FE-RTROW-008: a dry point past the last leg is not drawn', () => {
|
|
// Nach dem letzten Stopp gibt es kein Bein, an dem die Warnung haengen koennte.
|
|
const rows = roadtripRows(
|
|
day([stop('A'), stop('B')], {
|
|
dryPoints: [{ legIndex: 1, intoLegKm: 5, sinceKm: 600, drivenMeters: 5000, lat: 48, lng: 11 }],
|
|
}),
|
|
)
|
|
expect(rows.some((r) => r.kind === 'dry')).toBe(false)
|
|
})
|
|
|
|
it('FE-RTROW-009: a spill sits before the stop it hangs on, with the day it came from', () => {
|
|
const rows = roadtripRows(
|
|
day([stop('A'), stop('B')], {
|
|
spills: [
|
|
{ at: 1, count: 1, fromDayNumber: 3, departure: '23:40', leg: undefined, fromStop: undefined, line: [] },
|
|
],
|
|
}),
|
|
)
|
|
expect(rows.map((r) => r.kind)).toEqual(['stop', 'leg', 'spill', 'stop'])
|
|
expect(rows[2]).toEqual({ kind: 'spill', fromDayNumber: 3, departs: '23:40', stops: [] })
|
|
})
|
|
})
|
|
|
|
describe('roadtripRows stop detail', () => {
|
|
it('FE-RTROW-010: the clock and the pin come from the schedule entry', () => {
|
|
const rows = stopRows(
|
|
roadtripRows(
|
|
day([stop('A'), stop('B'), stop('C')], {
|
|
schedule: {
|
|
entries: [entry('08:00'), entry('09:40', { departure: '10:10', anchored: true }), entry(null)],
|
|
warnings: [],
|
|
},
|
|
}),
|
|
),
|
|
)
|
|
expect(rows.map((r) => [r.time, r.pinned])).toEqual([
|
|
['08:00', false],
|
|
['09:40', true],
|
|
[null, false],
|
|
])
|
|
expect(rows[1].entry?.departure).toBe('10:10')
|
|
})
|
|
|
|
it('FE-RTROW-011: a stop shows only its own findings, and only the strongest of them', () => {
|
|
const rows = stopRows(
|
|
roadtripRows(
|
|
day([stop('A'), stop('B')], {
|
|
driveWarnings: [
|
|
{ index: 0, code: 'overnight' },
|
|
{ index: 1, code: 'leg', overMinutes: 20 },
|
|
{ index: 1, code: 'late', minutes: 15 },
|
|
],
|
|
}),
|
|
),
|
|
)
|
|
expect(rows[0].warning).toEqual({ index: 0, code: 'overnight' })
|
|
expect(rows[1].warning).toEqual({ index: 1, code: 'late', minutes: 15 })
|
|
})
|
|
|
|
it('FE-RTROW-012: dwell and the walk in from the road pass through, absent means null', () => {
|
|
const rows = stopRows(roadtripRows(day([stop('A', { dwellMinutes: 90, offRoadMeters: 240 }), stop('B')])))
|
|
expect([rows[0].dwellMinutes, rows[0].offRoadMeters]).toEqual([90, 240])
|
|
expect([rows[1].dwellMinutes, rows[1].offRoadMeters]).toEqual([null, null])
|
|
})
|
|
})
|
|
|
|
describe('pickWarning', () => {
|
|
it('FE-RTROW-013: late beats range beats leg beats overnight', () => {
|
|
const all: ScheduleWarning[] = [
|
|
{ index: 0, code: 'overnight' },
|
|
{ index: 0, code: 'leg', overMinutes: 30 },
|
|
{ index: 0, code: 'range', sinceKm: 700 },
|
|
{ index: 0, code: 'late', minutes: 20 },
|
|
]
|
|
expect(pickWarning(all)?.code).toBe('late')
|
|
expect(pickWarning(all.slice(0, 3))?.code).toBe('range')
|
|
expect(pickWarning(all.slice(0, 2))?.code).toBe('leg')
|
|
expect(pickWarning(all.slice(0, 1))?.code).toBe('overnight')
|
|
})
|
|
|
|
it('FE-RTROW-028: the order the findings arrive in does not decide', () => {
|
|
// Sonst haengt die Marke davon ab, in welcher Reihenfolge deriveDriveWarnings sie anhaengt.
|
|
const all: ScheduleWarning[] = [
|
|
{ index: 0, code: 'late', minutes: 20 },
|
|
{ index: 0, code: 'range', sinceKm: 700 },
|
|
{ index: 0, code: 'overnight' },
|
|
]
|
|
expect(pickWarning(all)?.code).toBe('late')
|
|
expect(pickWarning([...all].reverse())?.code).toBe('late')
|
|
})
|
|
|
|
it('FE-RTROW-014: nothing to report is null, not a placeholder', () => {
|
|
expect(pickWarning([])).toBeNull()
|
|
})
|
|
|
|
it('FE-RTROW-043: missing the time a stop is left at ranks right after being late for it', () => {
|
|
const missed: ScheduleWarning = { index: 0, code: 'missedLeave', minutes: 30 }
|
|
expect(pickWarning([{ index: 0, code: 'range', sinceKm: 700 }, missed])).toBe(missed)
|
|
expect(pickWarning([missed, { index: 0, code: 'late', minutes: 5 }])?.code).toBe('late')
|
|
})
|
|
})
|
|
|
|
describe('a stop left at a set time', () => {
|
|
it('FE-RTROW-044: its row carries the stay the time makes, not the one the place has', () => {
|
|
const d = day([stop('A'), stop('Bremen', { dwellMinutes: 30, leaveAt: '14:00' })], {
|
|
schedule: { entries: [entry('09:00'), entry('10:00', { departure: '14:00' })], warnings: [] },
|
|
})
|
|
const [, bremen] = stopRows(roadtripRows(d))
|
|
expect(bremen.dwellMinutes).toBe(240)
|
|
expect(bremen.departure).toBe('14:00')
|
|
})
|
|
})
|
|
|
|
describe('stageOf', () => {
|
|
const days = [day([stop('A')], { dayId: 11, dayNumber: 1 }), day([stop('B')], { dayId: 22, dayNumber: 2 })]
|
|
|
|
it('FE-RTROW-015: finds the day by its id, not by its position', () => {
|
|
expect(stageOf(days, 22)?.dayNumber).toBe(2)
|
|
})
|
|
|
|
it('FE-RTROW-016: no selection and an id that is gone both give null', () => {
|
|
expect(stageOf(days, null)).toBeNull()
|
|
expect(stageOf(days, 999)).toBeNull()
|
|
expect(stageOf([], 11)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('destinationCount', () => {
|
|
it('FE-RTROW-017: agrees with the highest number the rows handed out', () => {
|
|
const d = mixedDay()
|
|
const highest = stopRows(roadtripRows(d)).reduce((max, r) => Math.max(max, r.number ?? 0), 0)
|
|
expect(destinationCount(d)).toBe(3)
|
|
expect(destinationCount(d)).toBe(highest)
|
|
})
|
|
|
|
it('FE-RTROW-018: a day of nothing but services and nights counts none', () => {
|
|
expect(destinationCount(day([stop('Aral', { stopType: 'fuel' }), night()]))).toBe(0)
|
|
})
|
|
})
|
|
|
|
const timedDay = (arrivals: (string | null)[], stops: RoadtripStop[]) =>
|
|
day(stops, { schedule: { entries: arrivals.map((a) => entry(a)), warnings: [] } })
|
|
|
|
describe('upNextStop', () => {
|
|
const threeStops = () =>
|
|
timedDay(['09:00', '11:00', '13:00'], [stop('A'), stop('Aral', { stopType: 'fuel' }), stop('C')])
|
|
|
|
it('FE-RTROW-019: says nothing on a day that is not today, and nothing without a day', () => {
|
|
expect(upNextStop(threeStops(), 10 * 60, false)).toBeNull()
|
|
expect(upNextStop(null, 10 * 60, true)).toBeNull()
|
|
})
|
|
|
|
it('FE-RTROW-020: passes over the petrol stop and names the next destination', () => {
|
|
// Die Tankstelle um 11:00 liegt vor dem Ziel um 13:00 und ist trotzdem nie das naechste.
|
|
const next = upNextStop(threeStops(), 10 * 60, true)
|
|
expect(next?.row.stop.name).toBe('C')
|
|
expect(next?.minutesUntil).toBe(180)
|
|
})
|
|
|
|
it('FE-RTROW-021: a stop without a readable time is passed over', () => {
|
|
const d = timedDay([null, 'irgendwann', '13:00'], [stop('A'), stop('B'), stop('C')])
|
|
expect(upNextStop(d, 8 * 60, true)?.row.stop.name).toBe('C')
|
|
})
|
|
|
|
it('FE-RTROW-022: a stop due this very minute is still ahead', () => {
|
|
expect(upNextStop(threeStops(), 13 * 60, true)?.minutesUntil).toBe(0)
|
|
})
|
|
|
|
it('FE-RTROW-023: a stop whose time has just passed comes back as a delay', () => {
|
|
// Es gibt keine Positionsquelle, also ist "der Plan sagte 13:00, es ist 13:20"
|
|
// die staerkste ehrliche Aussage. Sie kommt als negatives minutesUntil.
|
|
expect(upNextStop(threeStops(), 13 * 60 + 20, true)?.minutesUntil).toBe(-20)
|
|
})
|
|
|
|
it('FE-RTROW-024: an hour past the last stop the day is over and nothing is next', () => {
|
|
expect(upNextStop(threeStops(), 14 * 60 + 1, true)).toBeNull()
|
|
})
|
|
|
|
it('FE-RTROW-025: a delay is only ever reported for the last stop, never a passed one', () => {
|
|
// 09:00 ist lange vorbei, 13:00 auch, gemeldet wird der Verzug auf den letzten.
|
|
const late = upNextStop(threeStops(), 13 * 60 + 30, true)
|
|
expect(late?.row.stop.name).toBe('C')
|
|
expect(late?.minutesUntil).toBe(-30)
|
|
})
|
|
|
|
it('FE-RTROW-026: another day, no day and a day without any clock name nothing', () => {
|
|
expect(upNextStop(timedDay(['09:00'], [stop('A')]), 12 * 60, false)).toBeNull()
|
|
expect(upNextStop(null, 12 * 60, true)).toBeNull()
|
|
expect(upNextStop(timedDay([null], [stop('A')]), 12 * 60, true)).toBeNull()
|
|
expect(upNextStop(timedDay(['nachmittags'], [stop('A')]), 12 * 60, true)).toBeNull()
|
|
})
|
|
|
|
it('FE-RTROW-027: a service stop is never the one named, even when it is next in line', () => {
|
|
// Die Tankstelle um 10:00 laege vorne, genannt wird trotzdem das Ziel um 11:00.
|
|
const d = timedDay(['09:00', '10:00', '11:00'], [stop('A'), stop('Aral', { stopType: 'fuel' }), stop('B')])
|
|
expect(upNextStop(d, 9 * 60 + 30, true)?.row.stop.name).toBe('B')
|
|
})
|
|
})
|
|
|
|
describe('stageClocks', () => {
|
|
it('FE-RTROW-029: starts at the first stop arrival and arrives at the last one, neither departure', () => {
|
|
// The reported stage: a pin at 10:00 with an hour and a half there headed the card as
|
|
// 11:30, a clock no row prints. The last stop leaves at 21:33, which is not an arrival either.
|
|
const d = day([stop('Hamburg Speicherstadt', { dwellMinutes: 90, time: '10:00' }), stop('Sanssouci', { dwellMinutes: 120 })], {
|
|
schedule: {
|
|
entries: [entry('10:00', { departure: '11:30', anchored: true }), entry('19:33', { departure: '21:33' })],
|
|
warnings: [],
|
|
},
|
|
})
|
|
const clocks = stageClocks(roadtripRows(d))
|
|
expect(clocks).toEqual({ start: '10:00', arrive: '19:33' })
|
|
expect(Object.values(clocks)).not.toContain('11:30')
|
|
expect(Object.values(clocks)).not.toContain('21:33')
|
|
})
|
|
|
|
it('FE-RTROW-030: a morning after an automatic night starts at the resume point, and a day end point arrives nowhere', () => {
|
|
const d = day([night('start'), stop('Fulda'), stop('Kassel'), night('end')], {
|
|
schedule: { entries: [entry('08:00'), entry('09:10', { departure: '10:00' }), entry('12:40'), entry('22:00')], warnings: [] },
|
|
})
|
|
expect(stageClocks(roadtripRows(d))).toEqual({ start: '08:00', arrive: '12:40' })
|
|
})
|
|
|
|
it('FE-RTROW-031: a spill band departure belongs to the day before and does not start this one', () => {
|
|
const d = day([stop('A'), stop('B')], {
|
|
spills: [{ at: 0, count: 1, fromDayNumber: 1, departure: '23:10', leg: undefined, fromStop: undefined, line: [] }],
|
|
schedule: { entries: [entry('00:40'), entry('02:00')], warnings: [] },
|
|
})
|
|
expect(stageClocks(roadtripRows(d))).toEqual({ start: '00:40', arrive: '02:00' })
|
|
})
|
|
|
|
it('FE-RTROW-032: untimed stops are skipped at both ends, deliberately, and a day with no clock at all gives nulls', () => {
|
|
// Skipping rather than dashing is a choice: a day whose first leg has no route yet still
|
|
// has one clock worth heading the card with, even when start and arrival are that one.
|
|
const partly = timedDay([null, '11:00', null], [stop('A'), stop('B'), stop('C')])
|
|
expect(stageClocks(roadtripRows(partly))).toEqual({ start: '11:00', arrive: '11:00' })
|
|
|
|
const untimed = timedDay([null, null], [stop('A'), stop('B')])
|
|
expect(stageClocks(roadtripRows(untimed))).toEqual({ start: null, arrive: null })
|
|
expect(stageClocks([])).toEqual({ start: null, arrive: null })
|
|
|
|
// A petrol stop is a place you drive to, so its arrival counts like any other.
|
|
const fuelFirst = timedDay(['07:50', '09:00'], [stop('Aral', { stopType: 'fuel' }), stop('B')])
|
|
expect(stageClocks(roadtripRows(fuelFirst))).toEqual({ start: '07:50', arrive: '09:00' })
|
|
})
|
|
|
|
it('FE-RTROW-033: follows the row order rather than the clock, for a missed pin and for an unsplit card past midnight', () => {
|
|
// A pin the drive cannot make keeps its own clock, so the last row can read earlier
|
|
// than the one above it. The arrival is still that last row's clock, not the latest one.
|
|
const missed = timedDay(
|
|
['11:00', '10:00'],
|
|
[stop('Harbour', { dwellMinutes: 60, time: '11:00' }), stop('Ferry', { time: '10:00' })],
|
|
)
|
|
expect(stageClocks(roadtripRows(missed))).toEqual({ start: '11:00', arrive: '10:00' })
|
|
|
|
// When a daily window cannot be kept, each stored day is timed on its own and never
|
|
// split: an evening stay can then head a card that ends on a pin the next morning. The
|
|
// figures still name the first and the last clock the chain prints.
|
|
const unsplit = timedDay(['18:30', '10:00'], [stop('Hotel', { dwellMinutes: 900 }), stop('Museum')])
|
|
expect(stageClocks(roadtripRows(unsplit))).toEqual({ start: '18:30', arrive: '10:00' })
|
|
})
|
|
})
|
|
|
|
describe('stageEnd', () => {
|
|
it('FE-RTROW-034: names the last stop the chain draws with its own arrival, not the automatic day end after it', () => {
|
|
// The reported bar: Kyoto Station reached at 12:40 sat beside 22:00, where the window closed.
|
|
const d = day([stop('Bremen'), stop('Aral', { stopType: 'fuel' }), stop('Kyoto Station'), night()], {
|
|
schedule: { entries: [entry('09:00'), entry('10:30'), entry('12:40'), entry('22:00')], warnings: [] },
|
|
})
|
|
const end = stageEnd(roadtripRows(d))
|
|
expect(end?.stop.name).toBe('Kyoto Station')
|
|
expect(end?.time).toBe('12:40')
|
|
|
|
// A service stop is still a stop the drive ends at, so it is the one named.
|
|
const fuelLast = timedDay(['09:00', '11:00'], [stop('A'), stop('Aral', { stopType: 'fuel' })])
|
|
expect(stageEnd(roadtripRows(fuelLast))?.stop.name).toBe('Aral')
|
|
})
|
|
|
|
it('FE-RTROW-035: a stage drawn with nothing but its night markers, and no rows at all, end nowhere', () => {
|
|
expect(stageEnd(roadtripRows(day([night('start'), night('end')])))).toBeNull()
|
|
expect(stageEnd([])).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('firstStopOfPlace', () => {
|
|
it('FE-RTROW-036: on one card, a place visited twice answers with its first visit and a night on it never does', () => {
|
|
// A loop day: out of the hotel in the morning, back to it at night.
|
|
const loop = day([
|
|
stop('Nacht', { placeId: 300, assignmentId: 90, automaticNight: { phase: 'start', fromDayNumber: 1 } }),
|
|
stop('Hotel', { placeId: 300, assignmentId: 1 }),
|
|
stop('Museum', { placeId: 301, assignmentId: 2 }),
|
|
stop('Hotel', { placeId: 300, assignmentId: 3 }),
|
|
])
|
|
expect(firstStopOfPlace([loop], 300)?.assignmentId).toBe(1)
|
|
expect(firstStopOfPlace([loop], 301)?.assignmentId).toBe(2)
|
|
})
|
|
|
|
it('FE-RTROW-037: across days it keeps day order, a card handed alone ignores the days before it, and no stop gives null', () => {
|
|
const monday = day([stop('Town', { placeId: 301, assignmentId: 11 })], { dayId: 7 })
|
|
const tuesday = day([stop('Town', { placeId: 301, assignmentId: 21 }), stop('Lake', { placeId: 302, assignmentId: 22 })], { dayId: 8 })
|
|
expect(firstStopOfPlace([monday, tuesday], 301)?.assignmentId).toBe(11)
|
|
expect(firstStopOfPlace([tuesday], 301)?.assignmentId).toBe(21)
|
|
expect(firstStopOfPlace([monday, tuesday], 302)?.assignmentId).toBe(22)
|
|
|
|
expect(firstStopOfPlace([monday, tuesday], 999)).toBeNull()
|
|
expect(firstStopOfPlace([], 301)).toBeNull()
|
|
// Only an automatic night sits on this position, which is not a stop anybody chose.
|
|
expect(firstStopOfPlace([day([stop('Nacht', { placeId: 303, automaticNight: { phase: 'end', fromDayNumber: 1 } })])], 303)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('legReroutable', () => {
|
|
it('FE-RTROW-038: a routed leg between two stops anybody chose can be offered other ways, a service stop included', () => {
|
|
const d = day([stop('Bremen'), stop('Aral', { stopType: 'fuel' }), stop('Kassel')])
|
|
expect(legReroutable(d, 0)).toBe(true)
|
|
expect(legReroutable(d, 1)).toBe(true)
|
|
})
|
|
|
|
it('FE-RTROW-039: no leg into or out of an automatic night, none without a route, none past either end', () => {
|
|
// Bremen, Kassel, automatic night, Fulda: the rail offers the first leg only.
|
|
const withNight = day([stop('Bremen'), stop('Kassel'), night(), stop('Fulda')])
|
|
expect(legReroutable(withNight, 0)).toBe(true)
|
|
// Into the marker where the window closed, and out of it again the morning after.
|
|
expect(legReroutable(withNight, 1)).toBe(false)
|
|
expect(legReroutable(withNight, 2)).toBe(false)
|
|
expect(legReroutable(day([night('start'), stop('Fulda')]), 0)).toBe(false)
|
|
|
|
// A leg the router has not answered for yet has nothing to weigh an offer against.
|
|
const unrouted = day([stop('A'), stop('B'), stop('C')], { legs: [seg(0)] })
|
|
expect(legReroutable(unrouted, 0)).toBe(true)
|
|
expect(legReroutable(unrouted, 1)).toBe(false)
|
|
|
|
// The last stop has no leg after it, whatever the legs array happens to hold.
|
|
const extra = day([stop('A'), stop('B')], { legs: [seg(0), seg(1)] })
|
|
expect(legReroutable(extra, 1)).toBe(false)
|
|
expect(legReroutable(extra, -1)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('roadtripRows with a ride (#2428)', () => {
|
|
const terminal = (name: string, role: 'departure' | 'arrival', at: string | null = '13:20') =>
|
|
stop(name, {
|
|
assignmentId: role === 'departure' ? -3000000014 : -3000000015,
|
|
placeId: -7,
|
|
carrier: { reservationId: 7, type: 'flight', role, title: 'LH 2020', code: role === 'departure' ? 'MUC' : 'HAM', at },
|
|
legMode: role === 'departure' ? 'flight' : null,
|
|
incomingLegMode: role === 'arrival' ? 'flight' : null,
|
|
})
|
|
const ride: RouteSegment = { ...seg(9), distance: 0, duration: 4200, mode: 'flight', distanceText: '', durationText: '1 h 10 min' }
|
|
const flightDay = () =>
|
|
day([stop('Bremen'), terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30'), stop('Hotel')], {
|
|
legs: [seg(0), ride, seg(2)],
|
|
})
|
|
|
|
it('FE-RTROW-045: a same-day ride is one row holding both terminals, unnumbered, and the numbering skips them', () => {
|
|
const rows = roadtripRows(flightDay())
|
|
expect(stopRows(rows).map(r => [r.stop.name, r.number])).toEqual([
|
|
['Bremen', 1],
|
|
['Hotel', 2],
|
|
])
|
|
const rideRow = rows.find(r => r.kind === 'ride')
|
|
expect(rideRow?.kind === 'ride' && [rideRow.departure.stop.name, rideRow.departure.number, rideRow.arrival.stop.name, rideRow.arrival.number])
|
|
.toEqual(['Munich Airport', null, 'Hamburg Airport', null])
|
|
expect(destinationCount(flightDay())).toBe(2)
|
|
})
|
|
|
|
it('FE-RTROW-046: the ride row stands where the departure stood and carries the booking; the road out of the arrival follows it as a leg', () => {
|
|
const rows = roadtripRows(flightDay())
|
|
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'ride', 'leg', 'stop'])
|
|
const rideRow = rows[2]
|
|
expect(rideRow.kind === 'ride' && rideRow.carrier.title).toBe('LH 2020')
|
|
expect(rideRow.kind === 'ride' && rideRow.seg).toBe(ride)
|
|
expect(rideRow.kind === 'ride' && rideRow.arrival.time).toBe(rideRow.kind === 'ride' ? rideRow.arrival.entry?.arrival ?? null : null)
|
|
// The leg after the ride is the arrival's, at the arrival's index.
|
|
expect(rows[3]).toMatchObject({ kind: 'leg', index: 2 })
|
|
})
|
|
|
|
it('FE-RTROW-049: a ride landing tomorrow leaves its departure as a lone stop row, and the arrival opens the next day as one', () => {
|
|
const rows = roadtripRows(day([stop('Bremen'), terminal('Munich Airport', 'departure')], { legs: [seg(0)] }))
|
|
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
|
|
const landing = roadtripRows(day([terminal('Hamburg Airport', 'arrival', '07:00'), stop('Hotel')], { legs: [seg(0)] }))
|
|
expect(landing.map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
|
|
expect(stopRows(landing).map(r => [r.stop.name, r.number])).toEqual([['Hamburg Airport', null], ['Hotel', 1]])
|
|
})
|
|
|
|
it('FE-RTROW-051: a hop, the desk beside the terminal, is no leg row and is offered no other ways', () => {
|
|
const hop: RouteSegment = { ...seg(0), distance: 120, duration: 45, distanceText: '0 km', durationText: '0 min' }
|
|
const d = day([stop('Sixt Airport'), stop('Hamburg Airport'), stop('Hotel')], { legs: [hop, seg(1)] })
|
|
expect(roadtripRows(d).map(r => r.kind)).toEqual(['stop', 'stop', 'leg', 'stop'])
|
|
expect(legReroutable(d, 0)).toBe(false)
|
|
expect(legReroutable(d, 1)).toBe(true)
|
|
// A note a plugin attached keeps the row, however short the hop.
|
|
const noted = day([stop('A'), stop('B')], { legs: [{ ...hop, noteText: '10 min charge' }] })
|
|
expect(roadtripRows(noted).map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
|
|
})
|
|
|
|
it('FE-RTROW-050: a hire car\'s desks are unnumbered stop rows the road runs through', () => {
|
|
const desk = (name: string, role: 'pickup' | 'return') =>
|
|
stop(name, {
|
|
assignmentId: role === 'pickup' ? -3000000018 : -3000000019,
|
|
placeId: -9,
|
|
carrier: { reservationId: 9, type: 'car', role, title: 'Sixt', code: null, at: '09:00' },
|
|
})
|
|
const rows = roadtripRows(day([desk('Sixt Hbf', 'pickup'), stop('Bremen'), desk('Sixt Airport', 'return')], { legs: [seg(0), seg(1)] }))
|
|
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop', 'leg', 'stop'])
|
|
expect(stopRows(rows).map(r => [r.stop.name, r.number])).toEqual([['Sixt Hbf', null], ['Bremen', 1], ['Sixt Airport', null]])
|
|
const d = day([desk('Sixt Hbf', 'pickup'), stop('Bremen'), desk('Sixt Airport', 'return')], { legs: [seg(0), seg(1)] })
|
|
expect(legReroutable(d, 0)).toBe(false)
|
|
expect(legReroutable(d, 1)).toBe(true)
|
|
})
|
|
|
|
it('FE-RTROW-047: the road into a departure terminal can be offered other ways, nothing leaving a terminal can', () => {
|
|
const d = flightDay()
|
|
expect(legReroutable(d, 0)).toBe(true)
|
|
expect(legReroutable(d, 1)).toBe(false)
|
|
expect(legReroutable(d, 2)).toBe(false)
|
|
})
|
|
|
|
it('FE-RTROW-048: a terminal never answers for a place, however its id reads', () => {
|
|
expect(firstStopOfPlace([flightDay()], -7)).toBeNull()
|
|
expect(firstStopOfPlace([flightDay()], 100)?.name).toBe('Bremen')
|
|
})
|
|
|
|
/** Bremen at 09:00, the airport at 12:20 for a 13:20 flight, landing at 14:30, nothing after. */
|
|
const endsOnRide = () =>
|
|
day([stop('Bremen'), terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30')], {
|
|
legs: [seg(0), ride],
|
|
schedule: { entries: [entry('09:00'), entry('12:20', { departure: '13:20' }), entry('14:30')], warnings: [] },
|
|
})
|
|
/** The flight first, the hotel after it. */
|
|
const opensOnRide = () =>
|
|
day([terminal('Munich Airport', 'departure', '06:00'), terminal('Hamburg Airport', 'arrival', '07:10'), stop('Hotel')], {
|
|
legs: [ride, seg(1)],
|
|
schedule: { entries: [entry('05:00', { departure: '06:00' }), entry('07:10'), entry('09:00')], warnings: [] },
|
|
})
|
|
|
|
it('FE-RTROW-052: a day that ends on a ride arrives at the arrival terminal, one that opens on a ride starts at the departure', () => {
|
|
// The head card printed the clock of the stop before the flight as where the day ends,
|
|
// while the ride block right below it printed the landing.
|
|
expect(stageClocks(roadtripRows(endsOnRide()))).toEqual({ start: '09:00', arrive: '14:30' })
|
|
expect(stageClocks(roadtripRows(opensOnRide()))).toEqual({ start: '05:00', arrive: '09:00' })
|
|
})
|
|
|
|
it('FE-RTROW-053: a day that is nothing but a ride still has both clocks', () => {
|
|
const rideOnly = day([terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30')], {
|
|
legs: [ride],
|
|
schedule: { entries: [entry('12:20', { departure: '13:20' }), entry('14:30')], warnings: [] },
|
|
})
|
|
expect(stageClocks(roadtripRows(rideOnly))).toEqual({ start: '12:20', arrive: '14:30' })
|
|
})
|
|
|
|
it('FE-RTROW-054: up next names the departure terminal before its check-in, and the arrival after take-off', () => {
|
|
// A lone terminal (a ride landing tomorrow) was already a candidate; the terminals
|
|
// inside a same-day ride were not, so the countdown skipped the airport.
|
|
expect(upNextStop(endsOnRide(), 10 * 60, true)?.row.stop.name).toBe('Munich Airport')
|
|
expect(upNextStop(endsOnRide(), 10 * 60, true)?.minutesUntil).toBe(140)
|
|
expect(upNextStop(endsOnRide(), 13 * 60 + 30, true)?.row.stop.name).toBe('Hamburg Airport')
|
|
expect(upNextStop(endsOnRide(), 15 * 60 + 1, true)?.minutesUntil).toBe(-31)
|
|
})
|
|
|
|
it('FE-RTROW-055: a stage that ends on a ride ends at its arrival terminal', () => {
|
|
const end = stageEnd(roadtripRows(endsOnRide()))
|
|
expect(end?.stop.name).toBe('Hamburg Airport')
|
|
expect(end?.time).toBe('14:30')
|
|
expect(stageEnd(roadtripRows(opensOnRide()))?.stop.name).toBe('Hotel')
|
|
})
|
|
})
|