Files
TREK/server/tests/unit/nest/roadtrip-plan.service.test.ts
T
Maurice e64ade010f fix(roadtrip): a flight day ends at the stay it lands at, and no drive runs where nobody drives
On a day a flight, train, ferry, cruise or bus leaves and lands, tonight's stay was seated
by its check-in time. A hotel in Munich checked into from 15:00 stood ahead of a 15:15 flight
out of Hamburg, so the drive went from the Munich hotel to Hamburg airport and reported the
flight as nine hours late. A check-in is the earliest arrival, not an appointment: such a
stay now waits behind the landing when it lies nearer the arrival than the departure, and
its check-in only holds it when the ride lands before the room is ready. A time typed on the
stop keeps its seat.

With the stays at both ends of a day switched on:
- a day with nothing but the next stay's check-in and a ride booked across it gets no drive
  from one hotel to the other, the same rule the day plan follows since #2476;
- a stay is never joined by road to a terminal more than 2000 km away. The check moved to
  shared/roadtrip so Days and the road trip read one copy.

A departure terminal's pin is dated: reaching it more than half a day late no longer moves
the flight to the next day without a word, it is a late warning with the full minutes.
A check-in evening also carries the stay's check-in time, as a label only.

calculate_roadtrip runs the same code and its description says all of the above.
2026-09-24 11:32:52 +02:00

667 lines
32 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.
/**
* RoadtripPlanService against the worker's real SQLite schema.
*
* The planning SELECT is the one place the server reads a visit's times for the road
* trip, and the tests beside the MCP tools hand the service its rows by hand, so a
* column the statement never selects would go unnoticed there. These run the statement.
*/
import { db } from '../../../src/db/database';
import type { McpContext } from '../../../src/nest-mcp';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RoadtripPlanService } from '../../../src/nest/roadtrip/roadtrip-plan.service';
import { RoadtripPlanningMcp } from '../../../src/nest/roadtrip/roadtrip-planning.mcp';
import { createDay, createDayAccommodation, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import type { RoadtripPreferences } from '@trek/shared';
import { bookendAssignmentId, type RoadtripStop } from '@trek/shared/roadtrip';
import { beforeEach, describe, expect, it, vi } from 'vitest';
/** Every leg an hour and 60 km, whatever it joins. */
function hourlyRouter() {
return {
profiles: () => ['driving'],
route: vi.fn(async (_user: number, _trip: number, _day: number, points: { lat: number; lng: number }[], _profile?: string) => ({
parts: points.slice(1).map(() => ({ distance: 60000, duration: 3600 })),
avoidMissed: [],
leg: {
line: points.map((p) => [p.lat, p.lng]),
vias: [],
seg: {
from: [points[0].lat, points[0].lng],
to: [points[points.length - 1].lat, points[points.length - 1].lng],
mid: [points[0].lat, points[0].lng],
distance: 60000 * (points.length - 1),
duration: 3600 * (points.length - 1),
mode: 'driving',
distanceText: '',
drivingText: '',
walkingText: '',
},
},
})),
};
}
function setup() {
const { user } = createUser(db);
const trip = createTrip(db, user.id);
const day = createDay(db, trip.id);
const visits = ['Hamburg', 'Lueneburg', 'Celle'].map((name, i) => {
const place = createPlace(db, trip.id, { name, lat: 53 - i * 0.3, lng: 10 });
db.prepare('UPDATE places SET duration_minutes = ? WHERE id = ?').run(i === 0 ? 0 : 30, place.id);
return createDayAssignment(db, day.id, place.id);
});
db.prepare("UPDATE day_assignments SET assignment_time = '09:00' WHERE id = ?").run(visits[0].id);
const plans = new RoadtripPlanService(
new DatabaseService(db),
{ getUserSettings: () => ({}) } as never,
{ read: () => ({}) } as never,
hourlyRouter() as never,
{ listForTrip: () => [], tracksForTrip: () => [] } as never,
{ list: () => [] } as never,
);
return { user, trip, visits, plans };
}
beforeEach(() => {
resetTestDb(db);
});
describe('a visit end time on the road trip', () => {
it('is read from the visit, and from the place when the visit has none', () => {
const { user, trip, visits, plans } = setup();
db.prepare("UPDATE day_assignments SET assignment_end_time = '14:00' WHERE id = ?").run(visits[1].id);
db.prepare(
"UPDATE places SET end_time = '18:00' WHERE id = (SELECT place_id FROM day_assignments WHERE id = ?)",
).run(visits[2].id);
const context = plans.context(trip.id, user.id);
expect(context.visits.map((v) => [v.name, v.end_time])).toEqual([
['Hamburg', null],
['Lueneburg', '14:00'],
['Celle', '18:00'],
]);
});
it('is when the drive leaves the stop, in place of its stay', async () => {
const { user, trip, visits, plans } = setup();
db.prepare("UPDATE day_assignments SET assignment_end_time = '14:00' WHERE id = ?").run(visits[1].id);
const { calculated } = await plans.calculate(trip.id, user.id);
const day = calculated.days[0];
expect(day.stops[1]).toMatchObject({ name: 'Lueneburg', leaveAt: '14:00' });
expect(day.schedule.entries.map((e) => [e.arrival, e.departure])).toEqual([
['09:00', '09:00'],
['10:00', '14:00'],
['15:00', '15:30'],
]);
expect(day.schedule.warnings).toEqual([]);
});
it('is reported when the drive gets there after it', async () => {
const { user, trip, visits, plans } = setup();
db.prepare("UPDATE day_assignments SET assignment_end_time = '09:30' WHERE id = ?").run(visits[1].id);
const { calculated } = await plans.calculate(trip.id, user.id);
expect(calculated.days[0].schedule.warnings).toEqual([{ index: 1, code: 'missedLeave', minutes: 30 }]);
expect(calculated.days[0].schedule.entries[1]).toMatchObject({ arrival: '10:00', departure: '10:00' });
});
it('leaves a stop without one to its stay', async () => {
const { user, trip, plans } = setup();
const { calculated } = await plans.calculate(trip.id, user.id);
expect(calculated.days[0].schedule.entries[1]).toMatchObject({ arrival: '10:00', departure: '10:30' });
expect(calculated.days[0].stops[1].leaveAt).toBeNull();
});
});
describe('a booked night on the road trip (#2410)', () => {
// The night flag comes off the planning SELECT (stay.id), not off a row the tests
// hand the service, so a column dropped from the statement would flip every stop
// to a night here and nowhere else.
it('marks the stop the night is booked at, and a day that is only its night stands among the days', async () => {
const { user, trip, plans } = setup();
const arrival = createDay(db, trip.id);
const hotel = createPlace(db, trip.id, { name: 'Rostock', lat: 54.09, lng: 12.1 });
createDayAssignment(db, arrival.id, hotel.id);
createDayAccommodation(db, trip.id, hotel.id, arrival.id, arrival.id, { check_in: '15:00' });
const { context, calculated } = await plans.calculate(trip.id, user.id);
expect(context.visits.map((v) => [v.name, v.stay_id !== null])).toEqual([
['Hamburg', false],
['Lueneburg', false],
['Celle', false],
['Rostock', true],
]);
expect(calculated.quietDays).toEqual([]);
expect(calculated.days.map((d) => d.dayId)).toContain(arrival.id);
const night = calculated.days.find((d) => d.dayId === arrival.id)!;
expect(night.stops.map((s) => [s.name, s.night])).toEqual([['Rostock', true]]);
expect(night.legs).toEqual([]);
expect(night.schedule.entries[0]).toMatchObject({ arrival: '15:00', anchored: true });
});
it('files a lone stop that is no night under the quiet days', async () => {
const { user, trip, plans } = setup();
const quiet = createDay(db, trip.id);
const museum = createPlace(db, trip.id, { name: 'Museum', lat: 54.09, lng: 12.1 });
createDayAssignment(db, quiet.id, museum.id);
const { calculated } = await plans.calculate(trip.id, user.id);
expect(calculated.days.map((d) => d.dayId)).not.toContain(quiet.id);
expect(calculated.quietDays.map((d) => d.dayId)).toEqual([quiet.id]);
});
});
describe('a booking the traveller rides (#2428)', () => {
function withFlight(dayId: number, tripId: number, over: { end_day_id?: number; dep?: string; arr?: string } = {}) {
const result = db
.prepare(
`INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time)
VALUES (?, ?, 'flight', ?, ?, ?, ?)`,
)
.run(tripId, 'LH 2020 HAM → MUC', dayId, over.end_day_id ?? dayId, over.dep ?? '13:20', over.arr ?? '14:30');
const id = Number(result.lastInsertRowid);
const endpoint = db.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)',
);
endpoint.run(id, 'from', 0, 'Hamburg Airport', 'HAM', 53.63, 9.99);
endpoint.run(id, 'to', 1, 'Munich Airport', 'MUC', 48.35, 11.78);
return id;
}
it('seats the terminals in the day and never asks the router for the ride', async () => {
const { user, trip, visits, plans } = setup();
db.prepare("UPDATE day_assignments SET assignment_time = '10:00' WHERE id = ?").run(visits[1].id);
// Pinned to the minute the drive reaches it anyway, so the clock alone seats the
// flight here. Untimed, Celle lies south of Lueneburg with the airport north of both,
// and the flight would go where it adds the least road (#2461, see below).
db.prepare("UPDATE day_assignments SET assignment_time = '15:30' WHERE id = ?").run(visits[2].id);
const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number };
const flightId = withFlight(day.day_id, trip.id);
const { context, calculated } = await plans.calculate(trip.id, user.id);
expect(context.carriers).toHaveLength(1);
expect(context.carriers[0].endpoints).toHaveLength(2);
const card = calculated.days[0];
// Behind Lueneburg (10:00), before Celle (15:30): the day plan's own seat.
expect(card.stops.map((s) => s.carrier?.role ?? s.name)).toEqual(['Hamburg', 'Lueneburg', 'departure', 'arrival', 'Celle']);
const departure = card.stops[2];
expect(departure.carrier).toMatchObject({ reservationId: flightId, type: 'flight', code: 'HAM', at: '13:20' });
expect(departure.assignmentId).toBeLessThan(-2_000_000_000);
// The ride is a leg of the booking's minutes with no road under it; the roads to
// and from the terminals were routed like any other leg.
expect(card.legs.map((l) => [l?.mode, l?.duration])).toEqual([
['driving', 3600],
['driving', 3600],
['flight', 70 * 60],
['driving', 3600],
]);
expect(card.legs[2]?.distance).toBe(0);
// Reached an hour ahead of the flight, off on the timetable, landed on the timetable.
expect(card.schedule.entries.map((e) => [e.arrival, e.departure])).toEqual([
['09:00', '09:00'],
['10:00', '10:30'],
['12:20', '13:20'],
['14:30', '14:30'],
['15:30', '16:00'],
]);
// The day's figures are the drive's: three roads of 60 km, no flight in them.
expect(card.distance).toBe(180_000);
expect(calculated.totalStops).toBe(3);
});
it('lets a booking without located terminals fall through, a hire car without a desk among them', async () => {
const { user, trip, visits, plans } = setup();
const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number };
db.prepare("INSERT INTO reservations (trip_id, title, type, day_id) VALUES (?, 'Hire car', 'car', ?)").run(trip.id, day.day_id);
db.prepare("INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, 'Somewhere', 'train', ?, '11:00')").run(trip.id, day.day_id);
db.prepare("INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, 'Cab', 'taxi', ?, '11:00')").run(trip.id, day.day_id);
const { context, calculated } = await plans.calculate(trip.id, user.id);
expect(context.carriers.map((c) => c.type).sort()).toEqual(['car', 'train']);
expect(calculated.days[0].stops.every((s) => !s.carrier)).toBe(true);
});
it("puts a hire car's desks on the road: the pick-up opens the day, the return closes it, one run through both", async () => {
const { user, trip, visits, plans } = setup();
db.prepare("UPDATE day_assignments SET assignment_time = '10:00' WHERE id = ?").run(visits[1].id);
db.prepare("UPDATE day_assignments SET assignment_time = '12:00' WHERE id = ?").run(visits[2].id);
const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number };
const result = db
.prepare(
`INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time)
VALUES (?, 'Sixt', 'car', ?, ?, '08:00', '18:00')`,
)
.run(trip.id, day.day_id, day.day_id);
const carId = Number(result.lastInsertRowid);
const endpoint = db.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)',
);
endpoint.run(carId, 'from', 0, 'Sixt Hauptbahnhof', null, 53.55, 10.0);
endpoint.run(carId, 'to', 1, 'Sixt Airport', 'HAM', 53.63, 9.99);
const router = hourlyRouter();
const service = new RoadtripPlanService(
new DatabaseService(db),
{ getUserSettings: () => ({}) } as never,
{ read: () => ({}) } as never,
router as never,
{ listForTrip: () => [], tracksForTrip: () => [] } as never,
{ list: () => [] } as never,
);
const { calculated } = await service.calculate(trip.id, user.id);
const card = calculated.days[0];
expect(card.stops.map((s) => s.carrier?.role ?? s.name)).toEqual(['pickup', 'Hamburg', 'Lueneburg', 'Celle', 'return']);
expect(card.stops[0].carrier).toMatchObject({ reservationId: carId, type: 'car', at: '08:00' });
expect(card.stops[4].carrier).toMatchObject({ role: 'return', code: 'HAM', at: '18:00' });
// One road, desk to desk: no ride, no seam, one routing run.
expect(router.route).toHaveBeenCalledTimes(1);
expect(card.legs.map((l) => l?.mode)).toEqual(['driving', 'driving', 'driving', 'driving']);
expect(card.schedule.entries[0]).toMatchObject({ arrival: '08:00', departure: '08:00' });
expect(card.distance).toBe(240_000);
expect(calculated.totalStops).toBe(3);
});
});
/**
* The crossing from the report behind #2461: Amsterdam and Newcastle on one day, neither
* with a clock, and a ferry from IJmuiden to the Port of Tyne. A day sailing here, so the
* drive on from the far pier ends before midnight and the one-day trip stays one card.
*/
describe('a ferry across the day, and one on no day (#2461)', () => {
function crossing() {
const { user } = createUser(db);
const trip = createTrip(db, user.id);
const day = createDay(db, trip.id);
for (const [name, lat, lng] of [
['Amsterdam', 52.3731, 4.8926],
['Newcastle', 54.9783, -1.6178],
] as const) {
createDayAssignment(db, day.id, createPlace(db, trip.id, { name, lat, lng }).id);
}
const router = hourlyRouter();
const plans = new RoadtripPlanService(
new DatabaseService(db),
{ getUserSettings: () => ({}) } as never,
{ read: () => ({}) } as never,
router as never,
{ listForTrip: () => [], tracksForTrip: () => [] } as never,
{ list: () => [] } as never,
);
return { user, trip, day, router, plans };
}
function withFerry(tripId: number, dayId: number | null, title: string, located = true): number {
const id = Number(
db
.prepare(
`INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time)
VALUES (?, ?, 'ferry', ?, ?, '09:30', '15:00')`,
)
.run(tripId, title, dayId, dayId).lastInsertRowid,
);
if (located) {
const endpoint = db.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)',
);
endpoint.run(id, 'from', 0, 'IJmuiden', null, 52.4581, 4.5879);
endpoint.run(id, 'to', 1, 'Port of Tyne', null, 54.9925, -1.4522);
}
return id;
}
it('drives to the pier and on from the far one, and never overland between the two shores', async () => {
const { user, trip, day, router, plans } = crossing();
withFerry(trip.id, day.id, 'IJmuiden to Newcastle');
const { calculated, undatedRides } = await plans.calculate(trip.id, user.id);
const card = calculated.days[0];
// By the clock alone the ferry closed the day: Amsterdam to Newcastle overland, back
// to IJmuiden, and only then the crossing.
expect(card.stops.map((s) => s.carrier?.role ?? s.name)).toEqual(['Amsterdam', 'departure', 'arrival', 'Newcastle']);
expect(card.legs.map((l) => l?.mode)).toEqual(['driving', 'ferry', 'driving']);
// Two roads, one to each side of the crossing, and nothing between the two cities.
expect(router.route).toHaveBeenCalledTimes(2);
expect(card.distance).toBe(120_000);
expect(undatedRides).toEqual([]);
});
it('names a located ride on no day, and the tool answers with the same list', async () => {
const { user, trip, day, plans } = crossing();
const undated = withFerry(trip.id, null, 'Ferry without a date');
withFerry(trip.id, day.id, 'Ferry on the day');
withFerry(trip.id, null, 'Ferry without terminals', false);
db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Dinner', 'restaurant')").run(trip.id);
const plan = await plans.calculate(trip.id, user.id);
expect(plan.undatedRides).toEqual([{ id: undated, type: 'ferry', title: 'Ferry without a date' }]);
// It is on the drive nowhere, which is why it is named.
expect(plan.context.carriers.map((c) => c.id)).not.toContain(undated);
expect(plan.calculated.days.flatMap((d) => d.stops).some((s) => s.carrier?.reservationId === undated)).toBe(false);
const tool = new RoadtripPlanningMcp(plans, {} as never, {} as never);
const answer = await tool.calculate({ tripId: trip.id, includeGeometry: false }, { userId: user.id } as McpContext);
const body = JSON.parse(answer.content[0].text as string);
expect(body.undatedRides).toEqual(plan.undatedRides);
expect(body.complete).toBe(true);
});
});
/**
* A booked night at both ends of the days around it, read from the planning SELECTs.
*
* The same two trips as the rule's own cases in shared (nightBookends.spec.ts, ROADTRIP-
* BOOKENDS-001..005) with the same expected days, so the server's plan and the browser's
* cannot drift apart: the stays come off `context.stays`, the rule is the shared one, and
* only the router is a stand-in. Off unless the trip switches it on.
*/
describe('a booked night at both ends of its days', () => {
const GETAWAY = { lat: -33.71, lng: 150.31 };
const WALLINGA = { lat: -34.1, lng: 150.9 };
const HOTEL = { lat: 45.07, lng: 7.68 };
function trip(settings: RoadtripPreferences = { roadtrip_hotel_bookends: true }) {
const { user } = createUser(db);
const created = createTrip(db, user.id);
const router = hourlyRouter();
const plans = new RoadtripPlanService(
new DatabaseService(db),
{ getUserSettings: () => ({}) } as never,
{ read: () => settings } as never,
router as never,
{ listForTrip: () => [], tracksForTrip: () => [] } as never,
{ list: () => [] } as never,
);
return { user, trip: created, router, plans };
}
const visit = (tripId: number, dayId: number, name: string, at: { lat: number; lng: number }) => {
const place = createPlace(db, tripId, { name, ...at });
db.prepare('UPDATE places SET duration_minutes = 0 WHERE id = ?').run(place.id);
return { place, assignment: createDayAssignment(db, dayId, place.id) };
};
const linkBooking = (tripId: number, stayId: number) =>
Number(
db
.prepare("INSERT INTO reservations (trip_id, title, type, accommodation_id) VALUES (?, 'Motel', 'hotel', ?)")
.run(tripId, stayId).lastInsertRowid,
);
const shape = (stops: RoadtripStop[]) =>
stops.map((s) => (s.bookend ? `${s.bookend.phase}:${s.bookend.accommodationId}` : s.name));
const lats = (router: ReturnType<typeof hourlyRouter>) =>
router.route.mock.calls.map((call) => call[3].map((p: { lat: number }) => p.lat));
/** A check-in with two places, then a transfer day that is only the next check-in. */
function cam(settings?: RoadtripPreferences) {
const t = trip(settings);
const [d1, d2, d3] = [createDay(db, t.trip.id), createDay(db, t.trip.id), createDay(db, t.trip.id)];
const getaway = visit(t.trip.id, d1.id, 'Getaway', GETAWAY);
visit(t.trip.id, d1.id, 'Lookout', { lat: -33.73, lng: 150.35 });
visit(t.trip.id, d1.id, 'Falls', { lat: -33.65, lng: 150.38 });
const wallinga = visit(t.trip.id, d2.id, 'Wallinga', WALLINGA);
const stayA = createDayAccommodation(db, t.trip.id, getaway.place.id, d1.id, d2.id, {
check_in: '14:00',
check_out: '10:00',
});
const stayB = createDayAccommodation(db, t.trip.id, wallinga.place.id, d2.id, d3.id, { check_in: '15:00' });
return { ...t, days: [d1, d2, d3], stayA, stayB };
}
/** Three nights in one hotel with two places on every day before the check-out. */
function simeon(settings?: RoadtripPreferences) {
const t = trip(settings);
const days = [1, 2, 3, 4].map(() => createDay(db, t.trip.id));
const h = visit(t.trip.id, days[0].id, 'H', HOTEL);
(
[
[0, 'P1', 45.1],
[0, 'P2', 45.2],
[1, 'P3', 45.3],
[1, 'P4', 45.4],
[2, 'P5', 45.5],
[2, 'P6', 45.6],
] as const
).forEach(([day, name, lat]) => visit(t.trip.id, days[day].id, name, { lat, lng: 7.5 }));
const stay = createDayAccommodation(db, t.trip.id, h.place.id, days[0].id, days[3].id, { check_in: '15:00' });
return { ...t, days, stay };
}
it('seats the check-in day back at the stay and starts the transfer day there, as the rule in shared does', async () => {
const { user, trip: created, router, plans, days, stayA } = cam();
const reservationId = linkBooking(created.id, stayA.id);
linkBooking(created.id, stayA.id);
const { calculated, failures } = await plans.calculate(created.id, user.id);
expect(calculated.days.map((d) => shape(d.stops))).toEqual([
['Getaway', 'Lookout', 'Falls', `evening:${stayA.id}`],
[`morning:${stayA.id}`, 'Wallinga'],
]);
const [back, out] = [calculated.days[0].stops[3], calculated.days[1].stops[0]];
expect(back.bookend).toEqual({
phase: 'evening',
accommodationId: stayA.id,
reservationId,
checkingOut: false,
checkingIn: true,
checkOut: null,
checkIn: '14:00',
});
expect(back).toMatchObject({ name: 'Getaway', lat: GETAWAY.lat, lng: GETAWAY.lng, stopType: 'hotel', time: null });
expect(back.assignmentId).toBe(bookendAssignmentId(days[0].id, 'evening'));
expect(out.bookend).toMatchObject({ phase: 'morning', checkingOut: true, checkOut: '10:00', reservationId });
// The check-out is a label: the drive leaves when it must to make the 15:00 check-in.
expect(calculated.days[1].schedule.entries.map((e) => [e.arrival, e.departure])).toEqual([
['14:00', '14:00'],
['15:00', expect.any(String)],
]);
// One run a day, the hotel legs inside it, and the check-out day is no card.
expect(lats(router)).toEqual([
[GETAWAY.lat, -33.73, -33.65, GETAWAY.lat],
[GETAWAY.lat, WALLINGA.lat],
]);
expect(calculated.days.map((d) => d.distance)).toEqual([180_000, 60_000]);
expect(calculated.totalStops).toBe(4);
expect(failures).toEqual([]);
// calculate_roadtrip hands the assistant the same stops.
const tool = new RoadtripPlanningMcp(plans, {} as never, {} as never);
const answer = await tool.calculate({ tripId: created.id, includeGeometry: false }, { userId: user.id } as McpContext);
const body = JSON.parse(answer.content[0].text as string);
expect(body.days[1].stops[0].bookend).toMatchObject({ phase: 'morning', accommodationId: stayA.id });
});
it('asks the router nothing for a night spent at one hotel when the days are connected', async () => {
const { user, trip: created, router, plans } = cam({ roadtrip_hotel_bookends: true, roadtrip_connect_days: true });
const { calculated } = await plans.calculate(created.id, user.id);
// The two day runs, and no seam from the Getaway to the Getaway.
expect(router.route).toHaveBeenCalledTimes(2);
expect(calculated.days[1].arrivingLeg).toBeFalsy();
expect(calculated.totalDistance).toBe(240_000);
});
it('starts and ends every day between the nights at the hotel, and leaves the check-out day undriven', async () => {
const { user, trip: created, router, plans, days, stay } = simeon();
const { calculated } = await plans.calculate(created.id, user.id);
expect(calculated.days.map((d) => shape(d.stops))).toEqual([
['H', 'P1', 'P2', `evening:${stay.id}`],
[`morning:${stay.id}`, 'P3', 'P4', `evening:${stay.id}`],
[`morning:${stay.id}`, 'P5', 'P6', `evening:${stay.id}`],
]);
expect(calculated.days[1].stops[0].bookend).toMatchObject({ checkingOut: false, checkingIn: false, checkOut: null });
// The evening keeps the index the next stored stop would have.
expect(calculated.days[1].stops[3].ownerIndex).toBe(2);
expect(calculated.days.map((d) => d.dayId)).not.toContain(days[3].id);
expect(router.route).toHaveBeenCalledTimes(3);
expect(lats(router)[1]).toEqual([HOTEL.lat, 45.3, 45.4, HOTEL.lat]);
});
it('drives out of the morning hotel the way the first place is reached from it, as the browser and the day plan do', async () => {
const { user, trip: created, router, plans } = simeon();
db.prepare(
"UPDATE day_assignments SET incoming_leg_transport_mode = 'walking' WHERE place_id = (SELECT id FROM places WHERE trip_id = ? AND name = 'P3')",
).run(created.id);
const { calculated } = await plans.calculate(created.id, user.id);
expect(calculated.days[1].stops[0]).toMatchObject({ legMode: 'walking', bookend: { phase: 'morning' } });
const runs = router.route.mock.calls.map((call) => [call[4], call[3].map((p: { lat: number }) => p.lat)]);
// The hotel's leg on foot on its own, the rest of the day in the day's own mode.
expect(runs).toContainEqual(['walking', [HOTEL.lat, 45.3]]);
expect(runs).toContainEqual(['driving', [45.3, 45.4, HOTEL.lat]]);
// The other mornings are reached no particular way and stay in one run.
expect(runs).toContainEqual(['driving', [HOTEL.lat, 45.5, 45.6, HOTEL.lat]]);
});
it('drives the stored days while the trip has it off, and a preview can switch it either way', async () => {
const off = simeon({});
const stored = await off.plans.calculate(off.trip.id, off.user.id);
expect(stored.calculated.days.map((d) => shape(d.stops))).toEqual([
['H', 'P1', 'P2'],
['P3', 'P4'],
['P5', 'P6'],
]);
const preview = await off.plans.calculate(off.trip.id, off.user.id, { roadtrip_hotel_bookends: true });
expect(preview.calculated.days[1].stops.map((s) => s.bookend?.phase ?? null)).toEqual(['morning', null, null, 'evening']);
expect(preview.preferences.roadtrip_hotel_bookends).toBe(true);
const on = simeon();
const without = await on.plans.calculate(on.trip.id, on.user.id, { roadtrip_hotel_bookends: false });
expect(without.calculated.days.flatMap((d) => d.stops).some((s) => s.bookend)).toBe(false);
});
it('reads every stay with its place, its nights and its earliest booking into the context', () => {
const { user, trip: created, plans, days, stayA, stayB } = cam();
const first = linkBooking(created.id, stayA.id);
linkBooking(created.id, stayA.id);
const context = plans.context(created.id, user.id);
expect(context.stays).toEqual([
{
id: stayA.id,
place_id: expect.any(Number),
start_day_id: days[0].id,
end_day_id: days[1].id,
check_in: '14:00',
check_out: '10:00',
place_name: 'Getaway',
place_lat: GETAWAY.lat,
place_lng: GETAWAY.lng,
reservation_id: first,
},
expect.objectContaining({ id: stayB.id, place_name: 'Wallinga', check_in: '15:00', reservation_id: null }),
]);
});
it('names the hotel in the failures of a run it pushes past the router’s waypoint limit', async () => {
const t = trip({});
const [d1, d2] = [createDay(db, t.trip.id), createDay(db, t.trip.id)];
for (let i = 0; i < 100; i++) visit(t.trip.id, d1.id, `Stop ${i}`, { lat: 40 + i * 0.01, lng: 5 });
const hotel = createPlace(db, t.trip.id, { name: 'Tonight', ...HOTEL });
createDayAccommodation(db, t.trip.id, hotel.id, d1.id, d2.id);
// A hundred stored stops are one run the router takes.
const stored = await t.plans.calculate(t.trip.id, t.user.id);
expect(stored.failures).toEqual([]);
// Tonight's hotel is the hundred and first waypoint.
const seated = await t.plans.calculate(t.trip.id, t.user.id, { roadtrip_hotel_bookends: true });
expect(seated.failures).toHaveLength(100);
expect(seated.failures[99]).toMatchObject({
toAssignmentId: bookendAssignmentId(d1.id, 'evening'),
reason: 'More than 100 waypoints in this run.',
});
expect(seated.failures[99].toAssignmentId).toBeLessThan(-6_000_000_000);
});
/**
* Trip 45 on the dev instance: a night in Hamburg, then LH 2078 to Munich at 15:15 on the
* day the Munich hotel is checked into from 15:00, the booking's slot seeded behind it.
*/
function trip45(settings?: RoadtripPreferences) {
const t = trip(settings);
const days = [1, 2, 3, 4, 5].map(() => createDay(db, t.trip.id));
const atlantic = visit(t.trip.id, days[0].id, 'Hotel Atlantic Hamburg', { lat: 53.5573, lng: 10.0056 });
const munich = visit(t.trip.id, days[2].id, 'Hotel Bayerischer Hof', { lat: 48.1403, lng: 11.5732 });
const stays = { check_in: '15:00', check_out: '11:00' };
const hamburgStay = createDayAccommodation(db, t.trip.id, atlantic.place.id, days[0].id, days[2].id, stays);
createDayAccommodation(db, t.trip.id, munich.place.id, days[2].id, days[4].id, stays);
const flight = Number(
db
.prepare(
`INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time, day_plan_position)
VALUES (?, 'LH 2078 HAM-MUC', 'flight', ?, ?, '2026-11-04T15:15', '2026-11-04T17:20', 0.5)`,
)
.run(t.trip.id, days[2].id, days[2].id).lastInsertRowid,
);
const endpoint = db.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)',
);
endpoint.run(flight, 'from', 0, 'Hamburg (HAM)', 'HAM', 53.630402, 9.98823);
endpoint.run(flight, 'to', 1, 'Munich (MUC)', 'MUC', 48.353802, 11.7861);
return { ...t, flightDay: days[2], hamburgStay };
}
it('flies the day of the Munich check-in out of Hamburg and ends it at the Munich hotel, with no road between the cities (trip 45)', async () => {
// Every run the router is asked for lies north of 53 or south of 49.
const oneCity = (router: ReturnType<typeof hourlyRouter>) =>
lats(router).every((run) => run.every((lat: number) => lat > 53) || run.every((lat: number) => lat < 49));
const off = trip45({});
const stored = await off.plans.calculate(off.trip.id, off.user.id);
const offDay = stored.calculated.days.find((d) => d.dayId === off.flightDay.id)!;
expect(shape(offDay.stops)).toEqual(['Hamburg (HAM)', 'Munich (MUC)', 'Hotel Bayerischer Hof']);
// At the airport by the check-in, at the hotel an hour after landing, and late for nothing.
expect(offDay.schedule.entries.map((e) => e.arrival)).toEqual(['14:15', '17:20', '18:20']);
expect(offDay.schedule.warnings).toEqual([]);
expect(oneCity(off.router)).toBe(true);
const on = trip45();
const seated = await on.plans.calculate(on.trip.id, on.user.id);
const onDay = seated.calculated.days.find((d) => d.dayId === on.flightDay.id)!;
expect(shape(onDay.stops)).toEqual([
`morning:${on.hamburgStay.id}`,
'Hamburg (HAM)',
'Munich (MUC)',
'Hotel Bayerischer Hof',
]);
expect(oneCity(on.router)).toBe(true);
expect(seated.failures).toEqual([]);
});
it('drives no road between two stays on a day a flight saved without its airports moves the traveller (#2476)', async () => {
const { user, trip: created, router, plans, days, stayA } = cam();
db.prepare(
"INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time) VALUES (?, 'Flight', 'flight', ?, ?, '12:00')",
).run(created.id, days[1].id, days[1].id);
const { calculated, failures } = await plans.calculate(created.id, user.id);
expect(calculated.days.map((d) => shape(d.stops))).toEqual([
['Getaway', 'Lookout', 'Falls', `evening:${stayA.id}`],
['Wallinga'],
]);
expect(lats(router)).toEqual([[GETAWAY.lat, -33.73, -33.65, GETAWAY.lat]]);
expect(failures).toEqual([]);
});
});