mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(roadtrip): a day after a booked night starts at the stay, a day before one ends there
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.
This commit is contained in:
@@ -356,8 +356,10 @@ export class AccommodationsService {
|
||||
*
|
||||
* Only the check-in day gets a stop, even for a fortnight's stay: that is the
|
||||
* day you drive there, and it is exactly what the road-trip side writes for a
|
||||
* night it books itself. The later nights ride on the stay row, which is where
|
||||
* the rail reads check-out from.
|
||||
* night it books itself. The later nights ride on the stay row: a trip that
|
||||
* starts and ends its days at the stay seats the hotel at the edges of the days
|
||||
* around them from it (`seatNightBookends`), and reads the check-out there as a
|
||||
* label only.
|
||||
*
|
||||
* Runs inside the caller's transaction.
|
||||
*/
|
||||
|
||||
@@ -37,7 +37,7 @@ export class DayBoundariesMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'set_day_boundary',
|
||||
description: 'Override a road trip day ending at a visit or a fraction along the driving leg between consecutive visits. Daily travel times must be enabled in the planner. Fixed visit times stay protected and conflicts are shown. Pass null to restore the automatic day ending.',
|
||||
description: 'Override a road trip day ending at a visit or a fraction along the driving leg between consecutive visits. Daily travel times must be enabled in the planner. Fixed visit times stay protected and conflicts are shown. With roadtrip_hotel_bookends on, a boundary between two stops a booked night separates is ignored: the night ends the day. Pass null to restore the automatic day ending.',
|
||||
inputSchema: { tripId: z.number().int().positive(), dayNumber: z.number().int().min(1).max(MAX_TRIP_DAYS), boundary: roadtripDayBoundarySchema.nullable() },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'trips', mode: 'write' }, when,
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
carrierLegsFor,
|
||||
carrierSeam,
|
||||
foldRouteRun,
|
||||
hotelBookendsOn,
|
||||
isStationaryJoin,
|
||||
splitIntoRuns,
|
||||
spillChains,
|
||||
@@ -20,8 +21,10 @@ import {
|
||||
formatDistance,
|
||||
formatDurationShort,
|
||||
seatCarrierStops,
|
||||
seatNightBookends,
|
||||
undatedRides,
|
||||
viasOnLeg,
|
||||
type BookendStay,
|
||||
type CarrierBooking,
|
||||
type CarrierSeam,
|
||||
type DistanceUnit,
|
||||
@@ -79,6 +82,13 @@ interface VisitRow {
|
||||
stop_type: string | null;
|
||||
fill_percent: number | null;
|
||||
}
|
||||
/**
|
||||
* A booked night as the road trip reads it: the stay, the days it spans, where it is and
|
||||
* the earliest booking linked to it. The rule reads these for the hotel at the edges of
|
||||
* the days around the night (`seatNightBookends`); get_roadtrip_context reports them as
|
||||
* they are, with the check-in beside the check-out.
|
||||
*/
|
||||
type StayRow = BookendStay & { check_in: string | null };
|
||||
const stopKey = (s: RoadtripStop) =>
|
||||
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`;
|
||||
|
||||
@@ -110,9 +120,21 @@ export class RoadtripPlanService {
|
||||
WHERE d.trip_id = ? ORDER BY d.day_number, a.order_index, a.created_at`,
|
||||
tripId,
|
||||
);
|
||||
// Every stay of the trip in id order, the order the rule reads them in, so a browser
|
||||
// holding them in another order still picks the same hotel where two overlap. The
|
||||
// booking is the earliest linked one, the one the planner opens from the hotel's row.
|
||||
const stays = this.db.all<StayRow>(
|
||||
`SELECT a.id, a.place_id, a.start_day_id, a.end_day_id, a.check_in, a.check_out,
|
||||
p.name AS place_name, p.lat AS place_lat, p.lng AS place_lng,
|
||||
(SELECT MIN(r.id) FROM reservations r WHERE r.accommodation_id = a.id) AS reservation_id
|
||||
FROM day_accommodations a LEFT JOIN places p ON p.id = a.place_id
|
||||
WHERE a.trip_id = ? ORDER BY a.id`,
|
||||
tripId,
|
||||
);
|
||||
return {
|
||||
days,
|
||||
visits,
|
||||
stays,
|
||||
carriers: this.carriers(tripId),
|
||||
settings: this.preferences.read(tripId),
|
||||
profiles: this.router.profiles(),
|
||||
@@ -200,7 +222,7 @@ export class RoadtripPlanService {
|
||||
throw new HttpException({ error: 'Day end must be later than day start.' }, 400);
|
||||
const seams = context.carriers.map((booking) => carrierSeam(booking)).filter((seam): seam is CarrierSeam => seam !== null);
|
||||
const dayNumberOf = (dayId: number): number => context.days.find((d) => d.id === dayId)?.day_number ?? 0;
|
||||
const plan: PlanDay[] = context.days.map((day) => {
|
||||
const stored: PlanDay[] = context.days.map((day) => {
|
||||
const visits = context.visits.filter((v) => v.day_id === day.id && v.lat !== null && v.lng !== null);
|
||||
const stops = visits.map(
|
||||
(v, index): RoadtripStop => ({
|
||||
@@ -239,6 +261,10 @@ export class RoadtripPlanService {
|
||||
),
|
||||
};
|
||||
});
|
||||
// A day after a booked night starts at the stay and a day before one ends there, by
|
||||
// the rule the planner runs in the browser, and only while the trip (or the preview's
|
||||
// own settings) has it switched on. Everything below reads the seated days.
|
||||
const plan = hotelBookendsOn(preferences) ? seatNightBookends(stored, context.days, context.stays) : stored;
|
||||
const allLegs: Record<string, RoutedLeg> = {};
|
||||
const snapByDay: Record<number, Record<string, SnappedWaypoint>> = {};
|
||||
const missedByDay: Record<number, RouteAvoidClass[]> = {};
|
||||
|
||||
@@ -36,7 +36,7 @@ export class RoadtripPlanningMcp {
|
||||
@Tool({
|
||||
name: 'get_roadtrip_context',
|
||||
description:
|
||||
'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks, manual day endings and the carrier bookings (flight, train, ferry, cruise, bus) that seam the drive at their terminals, plus the hire cars whose pick-up and return desks stand on it. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.',
|
||||
'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks, manual day endings and the carrier bookings (flight, train, ferry, cruise, bus) that seam the drive at their terminals, plus the hire cars whose pick-up and return desks stand on it. stays lists every booked stay with its check-in and check-out day, its place and the earliest linked reservation (reservation_id). Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.',
|
||||
inputSchema: { tripId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
@@ -49,7 +49,7 @@ export class RoadtripPlanningMcp {
|
||||
@Tool({
|
||||
name: 'calculate_roadtrip',
|
||||
description:
|
||||
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. A flight, train, ferry, cruise or bus booking that is on none of the days of the trip (saved without a date, or on a day the trip no longer has) is not on the drive at all, although its terminals are located: undatedRides lists each one (id, type, title) so it can be given a date within the trip with the reservation tools, after which the road ends at its terminal. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
|
||||
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. A flight, train, ferry, cruise or bus booking that is on none of the days of the trip (saved without a date, or on a day the trip no longer has) is not on the drive at all, although its terminals are located: undatedRides lists each one (id, type, title) so it can be given a date within the trip with the reservation tools, after which the road ends at its terminal. With roadtrip_hotel_bookends switched on (missing means off), a day after a booked night starts at that stay and a day before one ends there. Such stops carry `bookend` {phase, accommodationId, reservationId, checkingOut, checkingIn, checkOut}, stopType hotel, the real placeId of the stay and a synthetic negative assignmentId that belongs to no assignment: they cannot be reordered, edited or given via points, and `failures` may name them. The check-out is a label, never a departure time. A day between two nights in the same stay with no visit has no drive. Pass settings {roadtrip_hotel_bookends:true} to preview the drive from and to the stays without saving it. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
|
||||
inputSchema: roadtripPlanRequestSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
@@ -93,7 +93,7 @@ export class RoadtripPlanningMcp {
|
||||
@Tool({
|
||||
name: 'search_roadtrip_corridor',
|
||||
description:
|
||||
'Search for fuel, charging, rest areas, campsites, food, sights or accommodation along a calculated roadtrip day, without a browser. Uses the routed road, not a straight line, and leaves out the ride between the two terminals of a flight, train, ferry, cruise or bus booking: no rectangle is searched under it and nothing found there is a hit. Returns distance along the route and distance from it. Search rectangles are paged in batches of six; continue with nextOffset until null for full coverage. Optional name, socket family and minimum power filter the matches; missing charging details mean unknown. Does not add stops. Add a chosen place and assign it to the appropriate stored day, preserving any via anchors. Routing failures refuse the corridor search rather than searching an invented line.',
|
||||
'Search for fuel, charging, rest areas, campsites, food, sights or accommodation along a calculated roadtrip day, without a browser. Uses the routed road, not a straight line, and leaves out the ride between the two terminals of a flight, train, ferry, cruise or bus booking: no rectangle is searched under it and nothing found there is a hit. Returns distance along the route and distance from it. Search rectangles are paged in batches of six; continue with nextOffset until null for full coverage. Optional name, socket family and minimum power filter the matches; missing charging details mean unknown. Does not add stops. Add a chosen place and assign it to the appropriate stored day, preserving any via anchors. With roadtrip_hotel_bookends on, the road of the day includes the drive from the stay slept in and to the stay of that night; a place found there belongs at the start or the end of the stored day. Routing failures refuse the corridor search rather than searching an invented line.',
|
||||
inputSchema: roadtripCorridorRequestSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
|
||||
@@ -35,7 +35,7 @@ export class RoadtripPreferencesMcp {
|
||||
@Tool({
|
||||
name: 'get_roadtrip_settings',
|
||||
description:
|
||||
'Read the shared driving preferences for one trip, including daily start and end times, day-ending behavior, vehicle specifications, range, fill percentage, driving limits, avoided road classes and route display options. Every member of this trip sees the same preferences. Missing numbers mean no limit, missing daily times disable automatic scheduling, missing vehicle means unspecified, and missing end mode means route. Range and consumption always use kilometres, litres and kWh. Instance URLs and credentials are not exposed.',
|
||||
'Read the shared driving preferences for one trip, including daily start and end times, day-ending behavior, vehicle specifications, range, fill percentage, driving limits, avoided road classes and route display options. Every member of this trip sees the same preferences. Missing numbers mean no limit, missing daily times disable automatic scheduling, missing vehicle means unspecified, and missing end mode means route. Missing roadtrip_hotel_bookends means off. Range and consumption always use kilometres, litres and kWh. Instance URLs and credentials are not exposed.',
|
||||
inputSchema: { tripId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
@@ -49,7 +49,7 @@ export class RoadtripPreferencesMcp {
|
||||
@Tool({
|
||||
name: 'update_roadtrip_settings',
|
||||
description:
|
||||
'Change the shared driving preferences for this trip. Changes apply to all its travellers and require permission to edit days. Read get_roadtrip_settings first for relative changes. Daily times use HH:mm; clear either with an empty string to disable automatic daily scheduling. Fixed visit times retain priority. Numeric zero clears a limit. Vehicle specifications take precedence over manual range when complete; clear the relevant specifications to use a manual range. Avoid classes are a comma-separated selection of toll,motorway,ferry. Unspecified fields remain unchanged. End mode route pauses at the cutoff; stop ends at the last reachable visit. Per-visit end_day and dragged boundaries are edited with set_assignment_end_day and set_day_boundary. This never changes instance routing URLs or credentials.',
|
||||
'Change the shared driving preferences for this trip. Changes apply to all its travellers and require permission to edit days. Read get_roadtrip_settings first for relative changes. Daily times use HH:mm; clear either with an empty string to disable automatic daily scheduling. Fixed visit times retain priority. Numeric zero clears a limit. Vehicle specifications take precedence over manual range when complete; clear the relevant specifications to use a manual range. Avoid classes are a comma-separated selection of toll,motorway,ferry. Unspecified fields remain unchanged. End mode route pauses at the cutoff; stop ends at the last reachable visit. Set roadtrip_hotel_bookends true to start and end each day at the stay: a day after a booked night starts where it was slept, a day before one ends there. False, like missing, drives each day from its first stored stop, as before. Per-visit end_day and dragged boundaries are edited with set_assignment_end_day and set_day_boundary. This never changes instance routing URLs or credentials.',
|
||||
inputSchema: { tripId: z.number().int().positive(), settings: roadtripPreferencesUpdateSchema },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
|
||||
@@ -253,6 +253,42 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
|
||||
db.prepare('DELETE FROM trip_members WHERE trip_id = 5 AND user_id = 2').run();
|
||||
}
|
||||
});
|
||||
it('ROADTRIP-E2E-013: the stay switch is off until it is set, and round-trips as a boolean', async () => {
|
||||
db.prepare("DELETE FROM roadtrip_preferences WHERE trip_id = 5 AND key = 'roadtrip_hotel_bookends'").run();
|
||||
const before = await request(server).get('/api/trips/5/roadtrip/preferences').set('Cookie', cookie()).expect(200);
|
||||
expect(before.body.preferences).not.toHaveProperty('roadtrip_hotel_bookends');
|
||||
|
||||
const saved = await request(server)
|
||||
.put('/api/trips/5/roadtrip/preferences')
|
||||
.set('Cookie', cookie())
|
||||
.send({ roadtrip_hotel_bookends: true })
|
||||
.expect(200);
|
||||
expect(saved.body.preferences.roadtrip_hotel_bookends).toBe(true);
|
||||
const after = await request(server).get('/api/trips/5/roadtrip/preferences').set('Cookie', cookie()).expect(200);
|
||||
expect(after.body.preferences.roadtrip_hotel_bookends).toBe(true);
|
||||
|
||||
await request(server)
|
||||
.put('/api/trips/5/roadtrip/preferences')
|
||||
.set('Cookie', cookie())
|
||||
.send({ roadtrip_hotel_bookends: false })
|
||||
.expect(200);
|
||||
expect(
|
||||
db.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = 5 AND key = 'roadtrip_hotel_bookends'").get(),
|
||||
).toEqual({ value: 'false' });
|
||||
});
|
||||
it('ROADTRIP-E2E-014: a stay switch that is not a boolean is a 400, not a stored row', async () => {
|
||||
db.prepare("DELETE FROM roadtrip_preferences WHERE trip_id = 5 AND key = 'roadtrip_hotel_bookends'").run();
|
||||
for (const value of ['true', 1, null]) {
|
||||
await request(server)
|
||||
.put('/api/trips/5/roadtrip/preferences')
|
||||
.set('Cookie', cookie())
|
||||
.send({ roadtrip_hotel_bookends: value })
|
||||
.expect(400);
|
||||
}
|
||||
expect(
|
||||
db.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = 5 AND key = 'roadtrip_hotel_bookends'").get(),
|
||||
).toBeUndefined();
|
||||
});
|
||||
it('refuses strangers and invalid daily windows', async () => {
|
||||
await request(server).get('/api/trips/5/roadtrip/preferences').set('Cookie', sessionCookie(2)).expect(404);
|
||||
await request(server)
|
||||
|
||||
@@ -83,6 +83,8 @@ import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service
|
||||
import { makeNotificationsService, makeNotificationPreferencesService } from './notifications';
|
||||
import { AddonsService } from '../../src/nest/addons/addons.service';
|
||||
import { RoadtripMcp } from '../../src/nest/roadtrip/roadtrip.mcp';
|
||||
import { RoadtripPreferencesMcp } from '../../src/nest/roadtrip/roadtrip-preferences.mcp';
|
||||
import { RoadtripPreferencesService } from '../../src/nest/roadtrip/roadtrip-preferences.service';
|
||||
import { RoadtripService } from '../../src/nest/roadtrip/roadtrip.service';
|
||||
import { notificationsStub } from './notifications';
|
||||
import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.service';
|
||||
@@ -223,6 +225,7 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService, guards),
|
||||
new DaysMcp(daysService, authService, guards),
|
||||
new RoadtripMcp(new RoadtripService(dbService, realtimeService), dbService, guards, authService, addonsService),
|
||||
new RoadtripPreferencesMcp(new RoadtripPreferencesService(dbService, realtimeService), authService, addonsService, dbService, guards),
|
||||
new FilesMcp(new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage), authService, guards),
|
||||
new AccommodationsMcp(accommodationsService, dbService, placesService, authService, guards),
|
||||
new AssignmentsMcp(assignmentsService, daysService, authService, guards),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The five road-trip MCP tools.
|
||||
* The five road-trip MCP tools, and the shared driving settings beside them.
|
||||
*
|
||||
* They had no test at all, and the shared harness seeds the addon disabled, so
|
||||
* `when: roadtripAddonOn` kept them from ever attaching: the handler bodies had
|
||||
@@ -315,6 +315,47 @@ describe('road-trip MCP tools', () => {
|
||||
expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_vias').get()).toEqual({ c: 0 });
|
||||
});
|
||||
|
||||
it('MCP-ROADTRIP-012: the stay switch is off until it is set, and round-trips through the settings tools', async () => {
|
||||
const { user, trip } = scenario();
|
||||
const stored = () =>
|
||||
testDb.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = ? AND key = 'roadtrip_hotel_bookends'").get(trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const before = parseToolResult(await h.client.callTool({ name: 'get_roadtrip_settings', arguments: { tripId: trip.id } })) as {
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
// Missing means off: nothing is written for a trip that never touched it.
|
||||
expect(before.settings).not.toHaveProperty('roadtrip_hotel_bookends');
|
||||
expect(stored()).toBeUndefined();
|
||||
|
||||
const saved = await h.client.callTool({
|
||||
name: 'update_roadtrip_settings',
|
||||
arguments: { tripId: trip.id, settings: { roadtrip_hotel_bookends: true } },
|
||||
});
|
||||
expect(saved.isError).toBeFalsy();
|
||||
const after = parseToolResult(await h.client.callTool({ name: 'get_roadtrip_settings', arguments: { tripId: trip.id } })) as {
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
expect(after.settings.roadtrip_hotel_bookends).toBe(true);
|
||||
});
|
||||
expect(stored()).toEqual({ value: 'true' });
|
||||
});
|
||||
|
||||
it('MCP-ROADTRIP-013: a stay switch that is not a boolean is refused, and nothing is stored', async () => {
|
||||
const { user, trip } = scenario();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
for (const value of ['yes', 1, null]) {
|
||||
const res = await h.client.callTool({
|
||||
name: 'update_roadtrip_settings',
|
||||
arguments: { tripId: trip.id, settings: { roadtrip_hotel_bookends: value } },
|
||||
});
|
||||
expect(res.isError, String(value)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_preferences WHERE trip_id = ?').get(trip.id)).toEqual({ c: 0 });
|
||||
});
|
||||
|
||||
it('MCP-ROADTRIP-009: removing one that is not on the day is refused, not silently ignored', async () => {
|
||||
const { user, trip, day } = scenario();
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { RoadtripPlanService } from '../../../src/nest/roadtrip/roadtrip-plan.se
|
||||
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';
|
||||
|
||||
@@ -358,3 +360,218 @@ describe('a ferry across the day, and one on no day (#2461)', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,9 +52,13 @@ function setup() {
|
||||
stop_type: null,
|
||||
fill_percent: null,
|
||||
}));
|
||||
// The trip's stays, none unless a case books one.
|
||||
const stays: Record<string, unknown>[] = [];
|
||||
const db = {
|
||||
canAccessTrip: vi.fn(() => true),
|
||||
all: vi.fn((sql: string) => (sql.includes('FROM day_assignments') ? visits : days)),
|
||||
all: vi.fn((sql: string) =>
|
||||
sql.includes('FROM day_assignments') ? visits : sql.includes('FROM day_accommodations') ? stays : days,
|
||||
),
|
||||
};
|
||||
const router = {
|
||||
profiles: () => ['driving'],
|
||||
@@ -88,7 +92,7 @@ function setup() {
|
||||
roadtrip as never,
|
||||
boundaries as never,
|
||||
);
|
||||
return { preferenceDb, preferences, store, realtime, db, plans, router, visits, days, boundaries };
|
||||
return { preferenceDb, preferences, store, realtime, db, plans, router, visits, days, boundaries, stays };
|
||||
}
|
||||
|
||||
describe('roadtrip preferences', () => {
|
||||
@@ -134,6 +138,23 @@ describe('roadtrip preferences', () => {
|
||||
await mcp.update({ tripId: 10, settings: { roadtrip_range_km: 300 } }, ctx);
|
||||
expect(s.preferenceDb.run).not.toHaveBeenCalled();
|
||||
});
|
||||
it('saves the switch that starts and ends each day at the stay, through the tool too, and reads it back', async () => {
|
||||
const s = setup();
|
||||
expect(s.preferences.read(10).roadtrip_hotel_bookends).toBeUndefined();
|
||||
const mcp = new RoadtripPreferencesMcp(
|
||||
s.preferences,
|
||||
{ isDemoUser: () => false } as never,
|
||||
{} as never,
|
||||
s.db as never,
|
||||
{ hasTripPermission: () => true } as never,
|
||||
);
|
||||
const res = await mcp.update({ tripId: 10, settings: { roadtrip_hotel_bookends: true } }, ctx);
|
||||
expect(JSON.parse(res.content[0].text as string).settings.roadtrip_hotel_bookends).toBe(true);
|
||||
expect(s.preferences.read(10).roadtrip_hotel_bookends).toBe(true);
|
||||
s.preferences.update(10, { roadtrip_hotel_bookends: false });
|
||||
expect(s.preferences.read(10).roadtrip_hotel_bookends).toBe(false);
|
||||
expect(roadtripPreferencesUpdateSchema.safeParse({ roadtrip_hotel_bookends: 'yes' }).success).toBe(false);
|
||||
});
|
||||
it('tells the assistant why a window was refused instead of the exception class name', async () => {
|
||||
// The service refuses an inverted window with the `{ error }` body the route
|
||||
// sends verbatim; left to the SDK the tool would answer "Http Exception".
|
||||
@@ -169,6 +190,29 @@ describe('browser-independent roadtrip calculation', () => {
|
||||
expect(s.preferences.read(10).roadtrip_day_end).toBe('10:00');
|
||||
expect(s.preferenceDb.run).not.toHaveBeenCalled();
|
||||
});
|
||||
it('previews a day that ends at tonight’s stay without saving the switch, and leaves it off otherwise', async () => {
|
||||
const s = setup();
|
||||
s.stays.push({
|
||||
id: 7,
|
||||
place_id: 70,
|
||||
start_day_id: 1,
|
||||
end_day_id: 2,
|
||||
check_in: '15:00',
|
||||
check_out: '10:00',
|
||||
place_name: 'Tonight',
|
||||
place_lat: 48.5,
|
||||
place_lng: 4,
|
||||
reservation_id: null,
|
||||
});
|
||||
const stored = await s.plans.calculate(10, 5);
|
||||
expect(stored.calculated.days.flatMap((d) => d.stops).some((stop) => stop.bookend)).toBe(false);
|
||||
|
||||
const preview = await s.plans.calculate(10, 5, { roadtrip_hotel_bookends: true });
|
||||
const hotel = preview.calculated.days.flatMap((d) => d.stops).find((stop) => stop.bookend);
|
||||
expect(hotel).toMatchObject({ name: 'Tonight', bookend: { phase: 'evening', accommodationId: 7, checkingIn: true } });
|
||||
expect(s.preferences.read(10).roadtrip_hotel_bookends).toBeUndefined();
|
||||
expect(s.preferenceDb.run).not.toHaveBeenCalled();
|
||||
});
|
||||
it('checks trip access before reading or routing', async () => {
|
||||
const s = setup();
|
||||
s.db.canAccessTrip.mockReturnValue(false);
|
||||
@@ -332,6 +376,31 @@ describe('Roadtrip MCP registration and search', () => {
|
||||
registry.attach(registrar as never, { ...ctx, scopes: null });
|
||||
expect(names).toEqual([]);
|
||||
});
|
||||
it('tells the assistant the stay switch is off until it is set, on every tool the switch changes', () => {
|
||||
const s = setup();
|
||||
const registry = createTestRegistry(
|
||||
[
|
||||
new RoadtripPreferencesMcp(s.preferences, {} as never, { isAddonEnabled: () => true } as never, s.db as never, {} as never),
|
||||
new RoadtripPlanningMcp(s.plans, {} as never, { isAddonEnabled: () => true } as never),
|
||||
],
|
||||
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
|
||||
);
|
||||
const descriptions: Record<string, string> = {};
|
||||
registry.attach(
|
||||
{
|
||||
registerTool: (name: string, config: Record<string, unknown>) => {
|
||||
descriptions[name] = String(config.description);
|
||||
},
|
||||
} as never,
|
||||
{ ...ctx, scopes: null },
|
||||
);
|
||||
expect(descriptions.get_roadtrip_settings).toContain('Missing roadtrip_hotel_bookends means off.');
|
||||
expect(descriptions.update_roadtrip_settings).toContain('Set roadtrip_hotel_bookends true to start and end each day at the stay');
|
||||
expect(descriptions.calculate_roadtrip).toContain('With roadtrip_hotel_bookends switched on (missing means off)');
|
||||
expect(descriptions.calculate_roadtrip).toContain('{roadtrip_hotel_bookends:true}');
|
||||
expect(descriptions.get_roadtrip_context).toContain('stays lists every booked stay');
|
||||
expect(descriptions.search_roadtrip_corridor).toContain('With roadtrip_hotel_bookends on');
|
||||
});
|
||||
it('returns corridor sources, truncation and matching brands without adding stops', async () => {
|
||||
const plans = {
|
||||
calculate: vi.fn(async () => ({
|
||||
|
||||
@@ -5,6 +5,8 @@ import { ChargingMcp } from '../../../src/nest/roadtrip/charging.mcp';
|
||||
import { RoadtripHazardsMcp } from '../../../src/nest/roadtrip/roadtrip-hazards.mcp';
|
||||
import { GoogleRouteMcp } from '../../../src/nest/roadtrip/google-route.mcp';
|
||||
import { DayBoundariesMcp } from '../../../src/nest/roadtrip/day-boundaries.mcp';
|
||||
import { createTestRegistry } from '../../../src/nest-mcp';
|
||||
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy';
|
||||
|
||||
const ctx = { userId: 5 } as McpContext;
|
||||
describe('roadtrip read tools', () => {
|
||||
@@ -69,6 +71,18 @@ describe('roadtrip read tools', () => {
|
||||
expect(realtime.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tells the assistant that a boundary over a booked night is ignored while the stay switch is on', () => {
|
||||
const tool = new DayBoundariesMcp({} as never, {} as never, {} as never, {} as never, {} as never, { isAddonEnabled: () => true } as never);
|
||||
const registry = createTestRegistry([tool], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess });
|
||||
const descriptions: Record<string, string> = {};
|
||||
registry.attach(
|
||||
{ registerTool: (name: string, config: Record<string, unknown>) => { descriptions[name] = String(config.description); } } as never,
|
||||
{ ...ctx, scopes: null },
|
||||
);
|
||||
expect(descriptions.set_day_boundary).toContain(
|
||||
'With roadtrip_hotel_bookends on, a boundary between two stops a booked night separates is ignored: the night ends the day.',
|
||||
);
|
||||
});
|
||||
it('does not expose manual boundaries to nonmembers', async () => {
|
||||
const boundaries = { list: vi.fn(() => [{ day_number: 1 }]) };
|
||||
const db = { canAccessTrip: vi.fn(() => false) };
|
||||
|
||||
Reference in New Issue
Block a user