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:
@@ -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