mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
7e7d13c021
A trip can now switch on roadtrip_hotel_bookends, a shared driving preference that is off until somebody sets it, so every trip drives exactly as before. Switched on, the planner in the browser and calculate_roadtrip on the server seat the hotel slept in at the start of the next day and tonight's hotel at the end of the day, both through seatNightBookends over the trip's stays in id order, with the earliest booking linked to each. A linked booking changes the hotel row and asks the router nothing. The switch sits in the desktop driving settings under the route line, and the phone's driving figures carry the same switch as the one control they offer; both write through the planner's own preference save, so a flip offline queues like any other. get_roadtrip_context reports the stays, calculate_roadtrip previews the switch through its settings, and the settings, corridor and day boundary tools say what it does and that missing means off.
578 lines
27 KiB
TypeScript
578 lines
27 KiB
TypeScript
/**
|
||
* 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 }[]) => ({
|
||
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,
|
||
});
|
||
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 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);
|
||
});
|
||
});
|