mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
dd2431db97
The router checks its request against the strict plan point, which has no stage_end, so every hike with a stage end was refused with "Unrecognized key: stage_end" before a way was planned. The points go to the router with their position, name and kind only.
1223 lines
52 KiB
TypeScript
1223 lines
52 KiB
TypeScript
/**
|
|
* HikePlanMcp: a hike planned with the traveller over MCP. The proposal
|
|
* reads only; the hike is created through ToursService's create (put on its
|
|
* day through AssignmentsService in the same write), changed through the
|
|
* route write the planner's edit uses, and a GPX text taken in through the
|
|
* tours' file import, all real here over a snapshot test db. The way is
|
|
* planned by HikeRoutingService, a stub in this suite (its own suite plans
|
|
* against a stubbed Valhalla); the places along it and the day's weather come
|
|
* from stubs of TourPoiSearchService and WeatherService. The suite takes its
|
|
* subject from the container and seeds through the ORM factories.
|
|
*/
|
|
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
|
|
import { Places } from '../../../src/db/entities/Places.entity';
|
|
import { TourTypes } from '../../../src/db/entities/TourTypes.entity';
|
|
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
|
|
import { Tours } from '../../../src/db/entities/Tours.entity';
|
|
import type { McpContext } from '../../../src/nest-mcp';
|
|
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
|
|
import { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
|
|
import { DomainError } from '../../../src/nest/common/domain-error';
|
|
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
|
|
import { HikePlanMcp } from '../../../src/nest/tours/hike-plan.mcp';
|
|
import { HikeRoutingService, type PlannedHike } from '../../../src/nest/tours/hike-routing.service';
|
|
import { TourPoiSearchService } from '../../../src/nest/tours/tour-poi-search.service';
|
|
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
|
import { WeatherService } from '../../../src/nest/weather/weather.service';
|
|
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
|
import { makeDayAssignment } from '../../helpers/factories/itinerary';
|
|
import { makePlace } from '../../helpers/factories/places';
|
|
import { countRows, findRow, findRows, updateRows } from '../../helpers/factories/rows';
|
|
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
|
|
import { makeDay, makeTrip } from '../../helpers/factories/trips';
|
|
import { makeUser } from '../../helpers/factories/users';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
|
import { MikroORM } from '@mikro-orm/core';
|
|
import {
|
|
HIKE_ALONG_CATEGORIES,
|
|
HIKE_ALONG_MAX,
|
|
HIKE_PLAN_LINE_POINTS,
|
|
HIKE_PLAN_MAX_WAYPOINTS,
|
|
hikeProposalSchema,
|
|
type GeometryPoint,
|
|
type HikePlanRequest,
|
|
type HikeSacSegment,
|
|
type HourlyEntry,
|
|
type TourPoi,
|
|
type TourPoiSearchResponse,
|
|
type UnroutedHikePlan,
|
|
type WeatherResult,
|
|
hikePlanRequestSchema,
|
|
} from '@trek/shared';
|
|
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// HIKE-MCP-001 to HIKE-MCP-023
|
|
|
|
const db = createSnapshotTestDb();
|
|
const plan = vi.fn<HikeRoutingService['plan']>();
|
|
const search = vi.fn<TourPoiSearchService['search']>();
|
|
const getDetailed = vi.fn<WeatherService['getDetailed']>();
|
|
let t: TestModule;
|
|
let mcp: HikePlanMcp;
|
|
const orm = () => t.get(MikroORM);
|
|
|
|
beforeAll(async () => {
|
|
t = await createTestModule({
|
|
db,
|
|
imports: [ToursModule],
|
|
overrides: [
|
|
{ provide: HikeRoutingService, useValue: { plan } },
|
|
{ provide: TourPoiSearchService, useValue: { search } },
|
|
{ provide: WeatherService, useValue: { getDetailed } },
|
|
],
|
|
});
|
|
mcp = t.get(HikePlanMcp);
|
|
});
|
|
beforeEach(() => {
|
|
resetTestDb(db);
|
|
plan.mockReset().mockImplementation(async (_userId, _tripId, request) => routed(request));
|
|
search.mockReset().mockResolvedValue(found([]));
|
|
getDetailed.mockReset().mockResolvedValue(day());
|
|
t.realtime.broadcastMock.mockReset();
|
|
});
|
|
afterAll(async () => {
|
|
await t.close();
|
|
db.close();
|
|
});
|
|
|
|
const ctx = (userId: number) => ({ userId }) as McpContext;
|
|
|
|
/** The JSON a tool answered, or the error text it refused with. */
|
|
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
|
|
const text = result.content[0]?.text ?? '';
|
|
return result.isError ? { error: text } : JSON.parse(text);
|
|
}
|
|
|
|
/** A way up 100 m a point, the router's answer with every height read. */
|
|
const LINE: GeometryPoint[] = [
|
|
[47.5, 11.1, 800],
|
|
[47.505, 11.1, 900],
|
|
[47.51, 11.1, 1000],
|
|
];
|
|
const GRADES: HikeSacSegment[] = [
|
|
{ from_m: 0, to_m: 600, sac: 2 },
|
|
{ from_m: 600, to_m: 1112, sac: 3 },
|
|
];
|
|
|
|
/** The stub's plan: the way above (or `line`) under the limit and preferences asked for, its legs as the router measured them. */
|
|
function routed(
|
|
request: HikePlanRequest,
|
|
line: GeometryPoint[] = LINE,
|
|
legs: number[] | null = [556, 556],
|
|
): PlannedHike {
|
|
return {
|
|
plan: {
|
|
kind: 'routed',
|
|
max_hiking_difficulty: request.max_hiking_difficulty ?? 2,
|
|
hiking_preferences: request.hiking_preferences ?? null,
|
|
distance_m: 1112,
|
|
ascent_m: 200,
|
|
descent_m: 0,
|
|
duration_minutes: 52,
|
|
duration_basis: 'din33466',
|
|
has_heights: true,
|
|
line,
|
|
profile: [
|
|
[0, 800],
|
|
[556, 900],
|
|
[1112, 1000],
|
|
],
|
|
grades: { segments: GRADES, highest: 3, unknown_share: 0 },
|
|
},
|
|
line,
|
|
snapped: legs ? [12, 3, 40] : null,
|
|
legs,
|
|
};
|
|
}
|
|
|
|
/** The stub's plan of the way above whose heights the router did not give. */
|
|
function flat(): PlannedHike {
|
|
const line = LINE.map(([lat, lng]): GeometryPoint => [lat, lng]);
|
|
return {
|
|
plan: {
|
|
kind: 'routed',
|
|
max_hiking_difficulty: 2,
|
|
hiking_preferences: null,
|
|
distance_m: 1112,
|
|
ascent_m: null,
|
|
descent_m: null,
|
|
duration_minutes: 17,
|
|
duration_basis: 'router',
|
|
has_heights: false,
|
|
line,
|
|
profile: [],
|
|
grades: null,
|
|
},
|
|
line,
|
|
snapped: [12, 3, 40],
|
|
legs: [556, 556],
|
|
};
|
|
}
|
|
|
|
function unrouted(why: UnroutedHikePlan['why'], position: number | null = null): PlannedHike {
|
|
return {
|
|
plan: { kind: 'unrouted', max_hiking_difficulty: 2, why, position, snapped_m: position === null ? null : 420 },
|
|
line: null,
|
|
snapped: null,
|
|
legs: null,
|
|
};
|
|
}
|
|
|
|
/** What the search for places along a way answers: these places, every kind searched. */
|
|
function found(pois: TourPoi[], unavailable: TourPoiSearchResponse['unavailable'] = []): TourPoiSearchResponse {
|
|
return { pois, truncated: false, partial: false, capped: false, unavailable };
|
|
}
|
|
|
|
/** A place the search found, `off` metres from the way. */
|
|
function poi(name: string, lat: number, off: number, category: TourPoi['category'] = 'viewpoint'): TourPoi {
|
|
const at = { lat, lng: 11.1 };
|
|
return {
|
|
id: `node:${name}`,
|
|
name,
|
|
...at,
|
|
category,
|
|
kind: `tourism=${category}`,
|
|
source: 'openstreetmap',
|
|
address: null,
|
|
website: null,
|
|
phone: null,
|
|
opening_hours: null,
|
|
distance_m: off,
|
|
along_m: 0,
|
|
};
|
|
}
|
|
|
|
/** A day's detailed weather: clear, sunrise 05:30, sunset 21:00, the hours `over` names changed. */
|
|
function day(type = 'forecast', over: Record<number, Partial<HourlyEntry>> = {}): WeatherResult {
|
|
const hourly = Array.from({ length: 24 }, (_, hour) => ({
|
|
hour,
|
|
temp: 12,
|
|
precipitation: 0,
|
|
precipitation_probability: 5,
|
|
main: 'Clear',
|
|
wind: 10,
|
|
humidity: 50,
|
|
...over[hour],
|
|
}));
|
|
// A forecast says the day's highest likelihood of rain beside its hours, as Open-Meteo's does.
|
|
const likelihood = type === 'forecast' ? { precipitation_probability_max: 5 } : {};
|
|
return { type, temp: 14, main: 'Clear', description: '', sunrise: '05:30', sunset: '21:00', ...likelihood, hourly };
|
|
}
|
|
|
|
const WAYPOINTS = [
|
|
{ lat: 47.5, lng: 11.1, name: 'Car park' },
|
|
{ lat: 47.505, lng: 11.1 },
|
|
{ lat: 47.51, lng: 11.1, name: 'Summit' },
|
|
];
|
|
|
|
const GPX = `<gpx>
|
|
<trk><name>Ridge</name><trkseg>
|
|
<trkpt lat="47.5" lon="11.1"><ele>800</ele></trkpt>
|
|
<trkpt lat="47.51" lon="11.1"><ele>1000</ele></trkpt>
|
|
</trkseg></trk>
|
|
</gpx>`;
|
|
|
|
async function fixture() {
|
|
const { user } = await makeUser(orm());
|
|
const { user: outsider } = await makeUser(orm());
|
|
const trip = await makeTrip(orm(), user.id);
|
|
return { user, outsider, trip };
|
|
}
|
|
|
|
type Fixture = Awaited<ReturnType<typeof fixture>>;
|
|
|
|
/** A hike created through the tool, as its answer reads. */
|
|
async function createdHike(f: Fixture, over: Record<string, unknown> = {}) {
|
|
return read(
|
|
await mcp.createHike({ tripId: f.trip.id, name: 'Herzogstand', waypoints: WAYPOINTS, ...over }, ctx(f.user.id)),
|
|
);
|
|
}
|
|
|
|
const events = () =>
|
|
t.realtime.broadcastMock.mock.calls.map(([tripId, event, , excluded]) => [tripId, event, excluded]);
|
|
|
|
describe('HikePlanMcp: the tools and their gates', () => {
|
|
it('HIKE-MCP-001: the proposal reads only, the create, change and import write, all on the Tours addon', async () => {
|
|
const options = (method: string) => {
|
|
const entry = getEntry(HikePlanMcp as unknown as ClassRef, method);
|
|
if (!entry || entry.kind !== 'tool') throw new Error(`${method} is no MCP tool`);
|
|
return entry.options;
|
|
};
|
|
const tools = ['proposeHike', 'createHike', 'updateTour', 'importTourGpx'].map(options);
|
|
expect(
|
|
tools.map((tool) => [tool.name, tool.annotations?.readOnlyHint, tool.annotations?.idempotentHint, tool.access]),
|
|
).toEqual([
|
|
['propose_hike', true, true, { group: 'trips', mode: 'read' }],
|
|
['create_hike', false, false, { group: 'trips', mode: 'write' }],
|
|
['update_tour', false, true, { group: 'trips', mode: 'write' }],
|
|
['import_tour_gpx', false, false, { group: 'trips', mode: 'write' }],
|
|
]);
|
|
expect(tools[0].description).toContain('variant_label');
|
|
expect(tools[0].description).toContain('never list coordinates');
|
|
expect(tools[1].description).toContain('route_pending');
|
|
expect(tools[2].description).toContain('expected_revision');
|
|
expect(tools[3].description).toContain('skipped');
|
|
const isAddonEnabled = vi.fn(async (id: string) => id === 'tours');
|
|
const gated = await Promise.all(tools.map((tool) => tool.when?.(ctx(1), { addons: { isAddonEnabled } })));
|
|
expect(gated).toEqual([true, true, true, true]);
|
|
expect(isAddonEnabled).toHaveBeenCalledWith('tours');
|
|
});
|
|
|
|
it('HIKE-MCP-002: an outsider is refused every tool, and the writes are refused without place_edit, as REST refuses them', async () => {
|
|
const f = await fixture();
|
|
const outsider = ctx(f.outsider.id);
|
|
const noAccess = { error: 'Trip not found or access denied.' };
|
|
expect(read(await mcp.proposeHike({ tripId: f.trip.id, name: 'X', waypoints: WAYPOINTS }, outsider))).toEqual(
|
|
noAccess,
|
|
);
|
|
expect(read(await mcp.createHike({ tripId: f.trip.id, name: 'X', waypoints: WAYPOINTS }, outsider))).toEqual(
|
|
noAccess,
|
|
);
|
|
expect(read(await mcp.updateTour({ tripId: f.trip.id, placeId: 1, expected_revision: 1 }, outsider))).toEqual(
|
|
noAccess,
|
|
);
|
|
expect(read(await mcp.importTourGpx({ tripId: f.trip.id, gpx: GPX, tour_type: 'hike' }, outsider))).toEqual(
|
|
noAccess,
|
|
);
|
|
|
|
const created = await createdHike(f);
|
|
const denied = vi.spyOn(t.get(McpToolGuardsService), 'hasTripPermission').mockResolvedValue(false);
|
|
try {
|
|
const own = ctx(f.user.id);
|
|
const refusals = [
|
|
await mcp.createHike({ tripId: f.trip.id, name: 'X', waypoints: WAYPOINTS }, own),
|
|
await mcp.updateTour(
|
|
{ tripId: f.trip.id, placeId: created.tour.place_id, expected_revision: 1, name: 'Y' },
|
|
own,
|
|
),
|
|
await mcp.importTourGpx({ tripId: f.trip.id, gpx: GPX, tour_type: 'hike' }, own),
|
|
];
|
|
expect(refusals.map(read)).toEqual(
|
|
Array(3).fill({ error: 'You do not have permission to perform this action on this trip.' }),
|
|
);
|
|
expect(denied).toHaveBeenCalledWith('place_edit', f.trip.id, f.user.id);
|
|
// The proposal only reads, and is open to anyone who sees the trip.
|
|
expect(read(await mcp.proposeHike({ tripId: f.trip.id, name: 'X', waypoints: WAYPOINTS }, own)).route).toEqual({
|
|
planned: true,
|
|
});
|
|
} finally {
|
|
denied.mockRestore();
|
|
}
|
|
expect(await countRows(orm(), Tours, {})).toBe(1);
|
|
expect((await findRow(orm(), Places, { id: created.tour.place_id }))?.name).toBe('Herzogstand');
|
|
});
|
|
});
|
|
|
|
describe('HikePlanMcp: propose_hike', () => {
|
|
it('HIKE-MCP-003: proposes the way with its measures, grades and where each point lies, and writes nothing', async () => {
|
|
const f = await fixture();
|
|
const proposal = read(
|
|
await mcp.proposeHike(
|
|
{
|
|
tripId: f.trip.id,
|
|
name: 'Herzogstand',
|
|
waypoints: WAYPOINTS,
|
|
max_hiking_difficulty: 3,
|
|
hiking_preferences: { avoid_steps: true },
|
|
variant_label: 'short and steep',
|
|
date: '2027-07-10',
|
|
start_time: '08:30',
|
|
},
|
|
ctx(f.user.id),
|
|
),
|
|
);
|
|
expect(hikeProposalSchema.parse(proposal)).toEqual(proposal);
|
|
expect(proposal).toMatchObject({
|
|
name: 'Herzogstand',
|
|
variant_label: 'short and steep',
|
|
route: { planned: true },
|
|
unreachable: null,
|
|
distance_m: 1112,
|
|
ascent_m: 200,
|
|
duration_minutes: 52,
|
|
duration_basis: 'din33466',
|
|
heights_known: true,
|
|
min_ele_m: 800,
|
|
max_ele_m: 1000,
|
|
sac: { highest: 3, segments: GRADES, unknown_share: 0 },
|
|
max_hiking_difficulty: 3,
|
|
hiking_preferences: { avoid_steps: true },
|
|
round_trip: false,
|
|
date: '2027-07-10',
|
|
start_time: '08:30',
|
|
});
|
|
expect(proposal.waypoints.map((point: { name: string | null; at_m: number }) => [point.name, point.at_m])).toEqual([
|
|
['Car park', 0],
|
|
[null, 556],
|
|
['Summit', 1112],
|
|
]);
|
|
// Read as text by every client that did not ask for the line: none of it is in the answer.
|
|
expect(Object.keys(proposal).filter((key) => key.includes('geometry'))).toEqual([]);
|
|
expect(plan).toHaveBeenCalledWith(
|
|
f.user.id,
|
|
f.trip.id,
|
|
{ waypoints: WAYPOINTS, max_hiking_difficulty: 3, hiking_preferences: { avoid_steps: true } },
|
|
{ grades: true },
|
|
);
|
|
expect(await countRows(orm(), Tours, {})).toBe(0);
|
|
expect(t.realtime.broadcastMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('HIKE-MCP-004: a round trip is planned back to its start, and a proposal without a way says why', async () => {
|
|
const f = await fixture();
|
|
// Up the way and back down it.
|
|
// Without the router's legs each point is placed on the line's nearest pass, in order.
|
|
plan.mockResolvedValueOnce(routed({ waypoints: WAYPOINTS }, [...LINE, ...LINE.slice(0, -1).reverse()], null));
|
|
const round = read(
|
|
await mcp.proposeHike(
|
|
{ tripId: f.trip.id, name: 'Loop', waypoints: WAYPOINTS.slice(0, 2), round_trip: true },
|
|
ctx(f.user.id),
|
|
),
|
|
);
|
|
expect(plan.mock.calls[0][2].waypoints).toEqual([WAYPOINTS[0], WAYPOINTS[1], WAYPOINTS[0]]);
|
|
expect(round.round_trip).toBe(true);
|
|
expect(round.waypoints.map((point: { name: string | null; at_m: number }) => [point.name, point.at_m])).toEqual([
|
|
['Car park', 0],
|
|
[null, 556],
|
|
['Car park', 2224],
|
|
]);
|
|
|
|
expect(round.waypoints.map((point: { snapped_m: number | null }) => point.snapped_m)).toEqual([null, null, null]);
|
|
|
|
plan.mockResolvedValueOnce(unrouted('point_off_the_way', 2));
|
|
const off = read(
|
|
await mcp.proposeHike(
|
|
{ tripId: f.trip.id, name: 'Off', waypoints: WAYPOINTS, date: '2027-07-10', include_geometry: true },
|
|
ctx(f.user.id),
|
|
),
|
|
);
|
|
expect(hikeProposalSchema.parse(off)).toEqual(off);
|
|
expect(off).toMatchObject({
|
|
route: { planned: false, why: 'point_off_the_way', position: 2 },
|
|
unreachable: { position: 2, snapped_m: 420 },
|
|
distance_m: null,
|
|
duration_minutes: null,
|
|
heights_known: false,
|
|
sac: null,
|
|
high_point: null,
|
|
along: null,
|
|
schedule: null,
|
|
warnings: [],
|
|
});
|
|
expect(off).not.toHaveProperty('line_geometry');
|
|
expect(off.waypoints.every((point: { at_m: number | null }) => point.at_m === null)).toBe(true);
|
|
// Without a way there is nothing to look along and no walking time to put on a day.
|
|
expect(search).toHaveBeenCalledTimes(1);
|
|
expect(getDetailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('HIKE-MCP-018: adds the line and the profile for drawing only when asked, within what a card draws', async () => {
|
|
const f = await fixture();
|
|
const asked = read(
|
|
await mcp.proposeHike(
|
|
{ tripId: f.trip.id, name: 'Herzogstand', waypoints: WAYPOINTS, include_geometry: true },
|
|
ctx(f.user.id),
|
|
),
|
|
);
|
|
expect(hikeProposalSchema.parse(asked)).toEqual(asked);
|
|
expect(asked.line_geometry).toEqual(LINE);
|
|
expect(asked.profile_geometry).toEqual([
|
|
[0, 800],
|
|
[556, 900],
|
|
[1112, 1000],
|
|
]);
|
|
expect(asked.line_geometry.length).toBeLessThanOrEqual(HIKE_PLAN_LINE_POINTS);
|
|
const left = read(
|
|
await mcp.proposeHike(
|
|
{ tripId: f.trip.id, name: 'Herzogstand', waypoints: WAYPOINTS, include_geometry: false },
|
|
ctx(f.user.id),
|
|
),
|
|
);
|
|
expect(left).not.toHaveProperty('line_geometry');
|
|
expect(left).not.toHaveProperty('profile_geometry');
|
|
// The flag is the tool's own: the plan is asked for the same way either way.
|
|
expect(plan.mock.calls.map((call) => call[2])).toEqual([
|
|
{ waypoints: WAYPOINTS, max_hiking_difficulty: undefined, hiking_preferences: undefined },
|
|
{ waypoints: WAYPOINTS, max_hiking_difficulty: undefined, hiking_preferences: undefined },
|
|
]);
|
|
});
|
|
|
|
it('HIKE-MCP-019: gives each point its place along the way by the legs, its minutes, arrival and snap, and the highest point', async () => {
|
|
const f = await fixture();
|
|
const waypoints = [WAYPOINTS[0], WAYPOINTS[1], { ...WAYPOINTS[2], poi_category: 'peak' as const }];
|
|
const proposal = read(
|
|
await mcp.proposeHike({ tripId: f.trip.id, name: 'Herzogstand', waypoints, start_time: '08:30' }, ctx(f.user.id)),
|
|
);
|
|
expect(proposal.waypoints).toEqual([
|
|
{
|
|
lat: 47.5,
|
|
lng: 11.1,
|
|
name: 'Car park',
|
|
poi_category: null,
|
|
at_m: 0,
|
|
snapped_m: 12,
|
|
minutes: 0,
|
|
arrival: '08:30',
|
|
stage_end: false,
|
|
},
|
|
{
|
|
lat: 47.505,
|
|
lng: 11.1,
|
|
name: null,
|
|
poi_category: null,
|
|
at_m: 556,
|
|
snapped_m: 3,
|
|
minutes: 26,
|
|
arrival: '08:56',
|
|
stage_end: false,
|
|
},
|
|
{
|
|
lat: 47.51,
|
|
lng: 11.1,
|
|
name: 'Summit',
|
|
poi_category: 'peak',
|
|
at_m: 1112,
|
|
snapped_m: 40,
|
|
minutes: 52,
|
|
arrival: '09:22',
|
|
stage_end: false,
|
|
},
|
|
]);
|
|
expect(proposal.high_point).toEqual({ lat: 47.51, lng: 11.1, ele_m: 1000, at_m: 1112 });
|
|
// No day and no date: the start and the arrivals stand, with no schedule to put them on.
|
|
expect(proposal).toMatchObject({ day_id: null, date: null, start_time: '08:30', schedule: null, warnings: [] });
|
|
|
|
// A way without heights has no highest point, and its legs weigh by their length.
|
|
plan.mockResolvedValueOnce(flat());
|
|
const level = read(
|
|
await mcp.proposeHike({ tripId: f.trip.id, name: 'Level', waypoints: WAYPOINTS }, ctx(f.user.id)),
|
|
);
|
|
expect(level.high_point).toBeNull();
|
|
expect(level.waypoints.map((point: { minutes: number }) => point.minutes)).toEqual([0, 9, 17]);
|
|
expect(level.waypoints.map((point: { arrival: string | null }) => point.arrival)).toEqual([null, null, null]);
|
|
});
|
|
|
|
it('HIKE-MCP-020: names the nearest summits, huts, water and viewpoints along the way, and none when not looked for', async () => {
|
|
const f = await fixture();
|
|
const near = Array.from({ length: HIKE_ALONG_MAX + 2 }, (_, index) =>
|
|
poi(`Place ${index}`, 47.51 - index * 0.0007, index * 10, index === 0 ? 'peak' : 'viewpoint'),
|
|
);
|
|
search.mockResolvedValueOnce(found([{ ...near[3], name: ' ' }, ...near.filter((_, index) => index !== 3)]));
|
|
const proposal = read(
|
|
await mcp.proposeHike({ tripId: f.trip.id, name: 'Herzogstand', waypoints: WAYPOINTS }, ctx(f.user.id)),
|
|
);
|
|
expect(search).toHaveBeenCalledWith({
|
|
tour_type: 'hike',
|
|
categories: [...HIKE_ALONG_CATEGORIES],
|
|
corridor_m: 250,
|
|
line: LINE.map(([lat, lng]) => [lat, lng]),
|
|
});
|
|
expect(proposal.along).toHaveLength(HIKE_ALONG_MAX);
|
|
// The two furthest from the way are left out, the rest in the order the way passes them.
|
|
expect(proposal.along.map((place: { name: string | null }) => place.name)).toEqual([
|
|
'Place 11',
|
|
'Place 10',
|
|
'Place 9',
|
|
'Place 8',
|
|
'Place 7',
|
|
'Place 6',
|
|
'Place 5',
|
|
'Place 4',
|
|
null,
|
|
'Place 2',
|
|
'Place 1',
|
|
'Place 0',
|
|
]);
|
|
expect(proposal.along.at(-1)).toEqual({
|
|
name: 'Place 0',
|
|
category: 'peak',
|
|
lat: 47.51,
|
|
lng: 11.1,
|
|
at_m: 1112,
|
|
off_m: 0,
|
|
});
|
|
|
|
search.mockClear();
|
|
const compared = read(
|
|
await mcp.proposeHike(
|
|
{ tripId: f.trip.id, name: 'Herzogstand', waypoints: WAYPOINTS, along: false },
|
|
ctx(f.user.id),
|
|
),
|
|
);
|
|
expect(compared.along).toBeNull();
|
|
expect(search).not.toHaveBeenCalled();
|
|
|
|
// A search that fails, or that could ask for no kind at all, is no answer of nothing there.
|
|
search.mockRejectedValueOnce(new Error('Overpass timed out'));
|
|
search.mockResolvedValueOnce(found([poi('Hut', 47.505, 20, 'shelter')], [...HIKE_ALONG_CATEGORIES]));
|
|
search.mockResolvedValueOnce(found([poi('Hut', 47.505, 20, 'shelter')], ['peak']));
|
|
const propose = async () =>
|
|
read(await mcp.proposeHike({ tripId: f.trip.id, name: 'Herzogstand', waypoints: WAYPOINTS }, ctx(f.user.id)));
|
|
// One after the other, so each takes the next search answer in turn.
|
|
const answers = [await propose(), await propose(), await propose()];
|
|
expect(answers.map((answer) => answer.along?.length ?? null)).toEqual([null, null, 1]);
|
|
});
|
|
|
|
it('HIKE-MCP-021: puts the hike on its day with the weather at its highest point, and answers without the weather', async () => {
|
|
const f = await fixture();
|
|
const tripDay = await makeDay(orm(), f.trip.id, { date: '2027-07-10' });
|
|
getDetailed.mockResolvedValueOnce(day('forecast', { 9: { main: 'Thunderstorm' } }));
|
|
const proposal = read(
|
|
await mcp.proposeHike(
|
|
{ tripId: f.trip.id, name: 'Herzogstand', waypoints: WAYPOINTS, day_id: tripDay.id, start_time: '08:30' },
|
|
ctx(f.user.id),
|
|
),
|
|
);
|
|
expect(hikeProposalSchema.parse(proposal)).toEqual(proposal);
|
|
expect(getDetailed).toHaveBeenCalledWith('47.51', '11.1', '2027-07-10', 'en');
|
|
expect(proposal).toMatchObject({ day_id: tripDay.id, date: '2027-07-10' });
|
|
expect(proposal.schedule).toMatchObject({
|
|
date: '2027-07-10',
|
|
day_id: tripDay.id,
|
|
start: '08:30',
|
|
arrive: '09:22',
|
|
next_day: false,
|
|
sunrise: '05:30',
|
|
sunset: '21:00',
|
|
latest_start: null,
|
|
weather: 'forecast',
|
|
});
|
|
expect(proposal.schedule.hours.map((hour: { hour: number }) => hour.hour)).toEqual([7, 8, 9, 10]);
|
|
expect(proposal.warnings).toEqual([{ kind: 'thunder', at: '09:00' }]);
|
|
|
|
// The highest point is asked to about a kilometre, so the variants of one hike share the day's weather.
|
|
plan.mockImplementationOnce(async (_userId, _tripId, request) =>
|
|
routed(request, [
|
|
[47.5, 11.1, 800],
|
|
[47.505, 11.1, 900],
|
|
[47.51234, 11.10987, 1000],
|
|
]),
|
|
);
|
|
await mcp.proposeHike(
|
|
{ tripId: f.trip.id, name: 'Variant', waypoints: WAYPOINTS, day_id: tripDay.id },
|
|
ctx(f.user.id),
|
|
);
|
|
expect(getDetailed).toHaveBeenLastCalledWith('47.51', '11.11', '2027-07-10', 'en');
|
|
|
|
// A date alone, the weather out of reach and a way without heights: asked at the start, answered without it.
|
|
plan.mockResolvedValueOnce(flat());
|
|
getDetailed.mockRejectedValueOnce(new Error('Open-Meteo API error'));
|
|
const unknown = read(
|
|
await mcp.proposeHike(
|
|
{ tripId: f.trip.id, name: 'Level', waypoints: WAYPOINTS, date: '2027-07-11' },
|
|
ctx(f.user.id),
|
|
),
|
|
);
|
|
expect(getDetailed).toHaveBeenLastCalledWith('47.5', '11.1', '2027-07-11', 'en');
|
|
expect(unknown.schedule).toMatchObject({ date: '2027-07-11', day_id: null, weather: null, hours: [] });
|
|
expect(unknown.warnings).toEqual([]);
|
|
|
|
// A day without a date has no schedule; a day of another trip is not found.
|
|
const undated = await makeDay(orm(), f.trip.id, { date: null });
|
|
getDetailed.mockClear();
|
|
const open = read(
|
|
await mcp.proposeHike(
|
|
{ tripId: f.trip.id, name: 'Open', waypoints: WAYPOINTS, day_id: undated.id },
|
|
ctx(f.user.id),
|
|
),
|
|
);
|
|
expect(open).toMatchObject({ day_id: undated.id, date: null, schedule: null });
|
|
expect(getDetailed).not.toHaveBeenCalled();
|
|
const other = await makeTrip(orm(), f.user.id);
|
|
const elsewhere = await makeDay(orm(), other.id, { date: '2027-07-10' });
|
|
await expect(
|
|
mcp.proposeHike(
|
|
{ tripId: f.trip.id, name: 'Elsewhere', waypoints: WAYPOINTS, day_id: elsewhere.id },
|
|
ctx(f.user.id),
|
|
),
|
|
).rejects.toThrow('Day not found');
|
|
});
|
|
|
|
it('HIKE-MCP-022: refuses a stage end at the start or the end of a hike, which ends no day', async () => {
|
|
const f = await fixture();
|
|
const [first, ...rest] = WAYPOINTS;
|
|
await expect(
|
|
mcp.proposeHike(
|
|
{ tripId: f.trip.id, name: 'Wrong', waypoints: [{ ...first, stage_end: true }, ...rest] },
|
|
ctx(f.user.id),
|
|
),
|
|
).rejects.toThrow('a stage ends at a stop between start and end');
|
|
});
|
|
|
|
it('HIKE-MCP-022b: the router gets the points without their stage ends, which its strict request refuses', async () => {
|
|
const f = await fixture();
|
|
const [first, middle, last] = WAYPOINTS;
|
|
await mcp.proposeHike(
|
|
{ tripId: f.trip.id, name: 'Two days', waypoints: [first, { ...middle, stage_end: true }, last] },
|
|
ctx(f.user.id),
|
|
);
|
|
const sent = plan.mock.calls.at(-1)?.[2];
|
|
expect(sent?.waypoints).toEqual(WAYPOINTS);
|
|
expect(hikePlanRequestSchema.safeParse(sent).success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('HikePlanMcp: create_hike', () => {
|
|
it('HIKE-MCP-005: creates the hike the way the planner saves one, with its whole line, time and limit, and tells the whole trip', async () => {
|
|
const f = await fixture();
|
|
const created = await createdHike(f, {
|
|
description: 'Up from the car park',
|
|
max_hiking_difficulty: 3,
|
|
hiking_preferences: { avoid_steps: true, avoid_climbs: false },
|
|
planned_duration_minutes: 240,
|
|
});
|
|
expect(created.route).toEqual({ planned: true });
|
|
expect(created.tour).toMatchObject({
|
|
name: 'Herzogstand',
|
|
description: 'Up from the car park',
|
|
tour_type: 'hike',
|
|
max_hiking_difficulty: 3,
|
|
hiking_preferences: { avoid_steps: true },
|
|
duration: 52,
|
|
planned_duration_minutes: 240,
|
|
elevation_gain: 200,
|
|
revision: 1,
|
|
route_pending: false,
|
|
});
|
|
expect(
|
|
created.waypoints.map((point: { role: string; sequence: number; name?: string | null }) => [
|
|
point.role,
|
|
point.sequence,
|
|
point.name ?? null,
|
|
]),
|
|
).toEqual([
|
|
['start', 0, 'Car park'],
|
|
['via', 1, null],
|
|
['end', 2, 'Summit'],
|
|
]);
|
|
const placeId = created.tour.place_id;
|
|
const place = await findRow(orm(), Places, { id: placeId });
|
|
expect(JSON.parse(String(place?.route_geometry))).toEqual(LINE);
|
|
// A hike being saved reads no grades: nothing of them is kept.
|
|
expect(plan.mock.calls[0][3]).toEqual({ grades: false });
|
|
// Made in one write, its place stamped once: an undo tells a change later from it by its updated_at.
|
|
expect(place?.updated_at).toBe(place?.created_at);
|
|
// A tour sent with no socket to leave out: the tab that asked shows it too.
|
|
expect(events()).toEqual([
|
|
[String(f.trip.id), 'tours:changed', undefined],
|
|
[String(f.trip.id), 'place:created', undefined],
|
|
]);
|
|
});
|
|
|
|
it('HIKE-MCP-006: saves the hike with its points only where no way was planned, and refuses a switched-off hike', async () => {
|
|
const f = await fixture();
|
|
plan.mockResolvedValueOnce(unrouted('router_unavailable'));
|
|
const created = await createdHike(f);
|
|
expect(created.route).toEqual({ planned: false, why: 'router_unavailable', position: null });
|
|
expect(created.tour).toMatchObject({ route_pending: true, distance: null, duration: null });
|
|
const place = await findRow(orm(), Places, { id: created.tour.place_id });
|
|
expect([place?.route_geometry, place?.lat, place?.lng]).toEqual([null, 47.5, 11.1]);
|
|
|
|
// A way whose heights the router did not give is not kept either: the planner saves none without them.
|
|
plan.mockResolvedValueOnce(flat());
|
|
const level = await createdHike(f, { name: 'Level' });
|
|
expect(level.route).toEqual({ planned: false, why: 'router_unavailable', position: null });
|
|
expect(level.tour).toMatchObject({ route_pending: true, distance: null, elevation_gain: null });
|
|
expect((await findRow(orm(), Places, { id: level.tour.place_id }))?.route_geometry).toBeNull();
|
|
|
|
await updateRows(orm(), TourTypes, { key: 'hike' }, { enabled: 0 });
|
|
try {
|
|
await expect(createdHike(f)).rejects.toThrow('Tour type is not available');
|
|
} finally {
|
|
await updateRows(orm(), TourTypes, { key: 'hike' }, { enabled: 1 });
|
|
}
|
|
expect(await countRows(orm(), Tours, {})).toBe(2);
|
|
});
|
|
|
|
it('HIKE-MCP-013: refuses a hike whose way the planner could not plan either, and writes nothing', async () => {
|
|
const f = await fixture();
|
|
const refusals: Array<[UnroutedHikePlan['why'], number | null, string]> = [
|
|
['no_valhalla', null, 'TREK has no Valhalla router here'],
|
|
['no_way', null, 'No way within the hiking limit joins these points'],
|
|
['point_off_the_way', 2, 'Point 2 is further than 300 m from any way'],
|
|
['too_long', null, 'The way is too long to plan as one hike'],
|
|
];
|
|
// One create after the other, each planned by the next answer of the stub.
|
|
const refused = await refusals.reduce<Promise<unknown[]>>(async (before, [why, position]) => {
|
|
const done = await before;
|
|
plan.mockResolvedValueOnce(unrouted(why, position));
|
|
return [...done, await createdHike(f).catch((err: unknown) => err)];
|
|
}, Promise.resolve([]));
|
|
refused.forEach((err, index) => {
|
|
const [why, , words] = refusals[index];
|
|
expect(err, why).toBeInstanceOf(DomainError);
|
|
expect(err, why).toMatchObject({ code: 'hike_not_routed', publicMessage: expect.stringContaining(words) });
|
|
expect(err instanceof DomainError && err.publicMessage.endsWith(' Nothing was saved.'), why).toBe(true);
|
|
expect(err instanceof DomainError && err.getStatus(), why).toBe(422);
|
|
});
|
|
expect(await countRows(orm(), Tours, {})).toBe(0);
|
|
expect(await countRows(orm(), Places, { trip: f.trip.id })).toBe(0);
|
|
expect(t.realtime.broadcastMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('HIKE-MCP-022: puts a hike created for a day on that day in the same write, its start kept, and tells the trip after', async () => {
|
|
const f = await fixture();
|
|
const tripDay = await makeDay(orm(), f.trip.id, { date: '2027-07-10' });
|
|
const created = await createdHike(f, { day_id: tripDay.id, start_time: '08:00' });
|
|
expect(created.route).toEqual({ planned: true });
|
|
expect(created.assignment).toEqual({ id: expect.any(Number), day_id: tripDay.id });
|
|
const visit = await findRow(orm(), DayAssignments, { id: created.assignment.id });
|
|
expect(visit).toMatchObject({ day_id: tripDay.id, place_id: created.tour.place_id, assignment_time: '08:00' });
|
|
// As the planner's save and its drop onto the day tell it, to every tab, once both were saved.
|
|
expect(events()).toEqual([
|
|
[String(f.trip.id), 'tours:changed', undefined],
|
|
[String(f.trip.id), 'place:created', undefined],
|
|
[String(f.trip.id), 'assignment:created', undefined],
|
|
]);
|
|
const added = t.realtime.broadcastMock.mock.calls[2]?.[2] as { assignment: { id: number; place_id: number } };
|
|
expect(added.assignment).toMatchObject({ id: created.assignment.id, place_id: created.tour.place_id });
|
|
|
|
// Without a day there is no visit, and a start alone changes nothing.
|
|
t.realtime.broadcastMock.mockReset();
|
|
const loose = await createdHike(f, { name: 'Loose', start_time: '09:00' });
|
|
expect(loose.assignment).toBeNull();
|
|
expect(events().map(([, event]) => event)).toEqual(['tours:changed', 'place:created']);
|
|
expect(await countRows(orm(), DayAssignments, {})).toBe(1);
|
|
|
|
// A start before a visit the day already has puts the day in time order, told after the visit.
|
|
const second = await makeDay(orm(), f.trip.id, { date: '2027-07-11' });
|
|
const lunchPlace = await makePlace(orm(), f.trip.id, { name: 'Lunch' });
|
|
const lunch = await makeDayAssignment(orm(), second.id, lunchPlace.id, { assignment_time: '10:00' });
|
|
t.realtime.broadcastMock.mockReset();
|
|
const early = await createdHike(f, { name: 'Early', day_id: second.id, start_time: '08:00' });
|
|
expect(events()).toEqual([
|
|
[String(f.trip.id), 'tours:changed', undefined],
|
|
[String(f.trip.id), 'place:created', undefined],
|
|
[String(f.trip.id), 'assignment:created', undefined],
|
|
[String(f.trip.id), 'assignment:reordered', undefined],
|
|
]);
|
|
expect(t.realtime.broadcastMock.mock.calls[3]?.[2]).toEqual({
|
|
dayId: second.id,
|
|
orderedIds: [early.assignment.id, lunch.id],
|
|
});
|
|
|
|
// Roads drawn on the day that the new order moved are told last, as REST tells them.
|
|
const assignments = t.get(AssignmentsService);
|
|
const realUpdate = assignments.updateTime.bind(assignments);
|
|
const vias = { dayId: second.id, vias: [] };
|
|
const timed = vi
|
|
.spyOn(assignments, 'updateTime')
|
|
.mockImplementationOnce(async (id, start, end) => ({ ...(await realUpdate(id, start, end)), vias }));
|
|
t.realtime.broadcastMock.mockReset();
|
|
try {
|
|
await createdHike(f, { name: 'Earlier', day_id: second.id, start_time: '07:00' });
|
|
} finally {
|
|
timed.mockRestore();
|
|
}
|
|
expect(events().map(([, event]) => event)).toEqual([
|
|
'tours:changed',
|
|
'place:created',
|
|
'assignment:created',
|
|
'assignment:reordered',
|
|
'roadtripVia:changed',
|
|
]);
|
|
expect(t.realtime.broadcastMock.mock.calls[4]?.[2]).toEqual(vias);
|
|
});
|
|
|
|
it('HIKE-MCP-023: writes nothing where the day cannot take the hike, nor without day_edit', async () => {
|
|
const f = await fixture();
|
|
const own = ctx(f.user.id);
|
|
const other = await makeTrip(orm(), f.user.id);
|
|
const elsewhere = await makeDay(orm(), other.id);
|
|
await expect(createdHike(f, { day_id: elsewhere.id })).rejects.toThrow('Day not found');
|
|
expect(plan).not.toHaveBeenCalled();
|
|
|
|
const tripDay = await makeDay(orm(), f.trip.id);
|
|
const failing = vi
|
|
.spyOn(t.get(AssignmentsService), 'createAssignment')
|
|
.mockRejectedValueOnce(new Error('Tour is already assigned to this day'));
|
|
await expect(createdHike(f, { day_id: tripDay.id })).rejects.toThrow('Tour is already assigned to this day');
|
|
failing.mockRestore();
|
|
// One write: the tour the visit failed for is gone with it, and nobody was told of it.
|
|
expect(await countRows(orm(), Tours, {})).toBe(0);
|
|
expect(await countRows(orm(), Places, { trip: f.trip.id })).toBe(0);
|
|
expect(t.realtime.broadcastMock).not.toHaveBeenCalled();
|
|
|
|
const guards = t.get(McpToolGuardsService);
|
|
const denied = vi.spyOn(guards, 'hasTripPermission').mockImplementation(async (action) => action !== 'day_edit');
|
|
try {
|
|
expect(
|
|
read(await mcp.createHike({ tripId: f.trip.id, name: 'X', waypoints: WAYPOINTS, day_id: tripDay.id }, own)),
|
|
).toEqual({
|
|
error: 'You do not have permission to perform this action on this trip.',
|
|
});
|
|
expect(denied).toHaveBeenCalledWith('day_edit', f.trip.id, f.user.id);
|
|
// Without a day the hike is the trip's places alone, which place_edit covers.
|
|
expect(
|
|
read(await mcp.createHike({ tripId: f.trip.id, name: 'Y', waypoints: WAYPOINTS }, own)).assignment,
|
|
).toBeNull();
|
|
} finally {
|
|
denied.mockRestore();
|
|
}
|
|
expect(await countRows(orm(), Tours, {})).toBe(1);
|
|
expect(await countRows(orm(), DayAssignments, {})).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('HikePlanMcp: update_tour', () => {
|
|
it('HIKE-MCP-007: a new name keeps the way, raises the revision and tells the whole trip', async () => {
|
|
const f = await fixture();
|
|
const created = await createdHike(f);
|
|
plan.mockClear();
|
|
t.realtime.broadcastMock.mockReset();
|
|
const changed = read(
|
|
await mcp.updateTour(
|
|
{
|
|
tripId: f.trip.id,
|
|
placeId: created.tour.place_id,
|
|
expected_revision: 1,
|
|
name: 'Herzogstand loop',
|
|
description: 'Now with a description',
|
|
planned_duration_minutes: 300,
|
|
},
|
|
ctx(f.user.id),
|
|
),
|
|
);
|
|
expect(plan).not.toHaveBeenCalled();
|
|
expect(changed).toMatchObject({ replanned: false, route: { planned: true } });
|
|
expect(changed.tour).toMatchObject({
|
|
name: 'Herzogstand loop',
|
|
description: 'Now with a description',
|
|
revision: 2,
|
|
duration: 52,
|
|
planned_duration_minutes: 300,
|
|
max_hiking_difficulty: 2,
|
|
});
|
|
expect(changed.waypoints.map((point: { key: string }) => point.key)).toEqual(
|
|
created.waypoints.map((point: { key: string }) => point.key),
|
|
);
|
|
const place = await findRow(orm(), Places, { id: created.tour.place_id });
|
|
expect(JSON.parse(String(place?.route_geometry))).toEqual(LINE);
|
|
expect(events()).toEqual([
|
|
[String(f.trip.id), 'tours:changed', undefined],
|
|
[String(f.trip.id), 'place:updated', undefined],
|
|
]);
|
|
});
|
|
|
|
it('HIKE-MCP-008: new points, a new limit or new preferences plan the way anew; a stale revision is refused', async () => {
|
|
const f = await fixture();
|
|
const created = await createdHike(f);
|
|
const placeId = created.tour.place_id;
|
|
const own = ctx(f.user.id);
|
|
|
|
plan.mockClear();
|
|
const harder = read(
|
|
await mcp.updateTour({ tripId: f.trip.id, placeId, expected_revision: 1, max_hiking_difficulty: 4 }, own),
|
|
);
|
|
expect(harder).toMatchObject({ replanned: true, route: { planned: true } });
|
|
expect(harder.tour).toMatchObject({ max_hiking_difficulty: 4, revision: 2 });
|
|
expect(plan.mock.calls[0][2]).toEqual({
|
|
waypoints: WAYPOINTS.map(({ lat, lng }) => ({ lat, lng })),
|
|
max_hiking_difficulty: 4,
|
|
hiking_preferences: null,
|
|
});
|
|
|
|
plan.mockClear();
|
|
const gentler = read(
|
|
await mcp.updateTour(
|
|
{ tripId: f.trip.id, placeId, expected_revision: 2, hiking_preferences: { avoid_climbs: true } },
|
|
own,
|
|
),
|
|
);
|
|
expect(gentler.tour).toMatchObject({ hiking_preferences: { avoid_climbs: true }, revision: 3 });
|
|
expect(plan).toHaveBeenCalledTimes(1);
|
|
|
|
const moved = [WAYPOINTS[0], { lat: 47.52, lng: 11.1, name: 'Heimgarten' }];
|
|
const repointed = read(
|
|
await mcp.updateTour({ tripId: f.trip.id, placeId, expected_revision: 3, waypoints: moved }, own),
|
|
);
|
|
expect(repointed.waypoints.map((point: { name?: string }) => point.name)).toEqual(['Car park', 'Heimgarten']);
|
|
expect(repointed.tour.revision).toBe(4);
|
|
expect(await countRows(orm(), TourWaypoints, { place: placeId })).toBe(2);
|
|
|
|
await expect(
|
|
mcp.updateTour({ tripId: f.trip.id, placeId, expected_revision: 3, name: 'Stale' }, own),
|
|
).rejects.toThrow('This tour was changed in the meantime');
|
|
expect((await findRow(orm(), Places, { id: placeId }))?.name).toBe('Herzogstand');
|
|
expect((await findRow(orm(), Tours, { place: placeId }))?.revision).toBe(4);
|
|
});
|
|
|
|
it('HIKE-MCP-009: a new way that cannot be planned leaves the hike as it was; one still waiting for its way is planned', async () => {
|
|
const f = await fixture();
|
|
const created = await createdHike(f);
|
|
const placeId = created.tour.place_id;
|
|
plan.mockResolvedValueOnce(unrouted('no_way'));
|
|
await expect(
|
|
mcp.updateTour({ tripId: f.trip.id, placeId, expected_revision: 1, max_hiking_difficulty: 1 }, ctx(f.user.id)),
|
|
).rejects.toThrow('No way within the hiking limit joins these points');
|
|
expect(await findRow(orm(), Tours, { place: placeId })).toMatchObject({ revision: 1, max_hiking_difficulty: 2 });
|
|
|
|
// A hike saved while the router was away is planned on its next change, a rename alone included.
|
|
plan.mockResolvedValueOnce(unrouted('router_unavailable'));
|
|
const pending = await createdHike(f, { name: 'Pending' });
|
|
plan.mockResolvedValueOnce(unrouted('router_unavailable'));
|
|
const still = read(
|
|
await mcp.updateTour(
|
|
{ tripId: f.trip.id, placeId: pending.tour.place_id, expected_revision: 1, name: 'Still pending' },
|
|
ctx(f.user.id),
|
|
),
|
|
);
|
|
expect(still).toMatchObject({ replanned: true, route: { planned: false, why: 'router_unavailable' } });
|
|
expect(still.tour).toMatchObject({ name: 'Still pending', route_pending: true, revision: 2 });
|
|
const planned = read(
|
|
await mcp.updateTour(
|
|
{ tripId: f.trip.id, placeId: pending.tour.place_id, expected_revision: 2, name: 'Planned' },
|
|
ctx(f.user.id),
|
|
),
|
|
);
|
|
expect(planned).toMatchObject({ replanned: true, route: { planned: true } });
|
|
expect(planned.tour).toMatchObject({ route_pending: false, duration: 52, revision: 3 });
|
|
});
|
|
|
|
it('HIKE-MCP-010: changes only a hike planned in TREK, and says which tour it cannot find', async () => {
|
|
const f = await fixture();
|
|
const own = ctx(f.user.id);
|
|
const walk = await makePlace(orm(), f.trip.id, { name: 'Old town' });
|
|
await makeTour(orm(), walk.id, { tourTypeRef: 'walk', max_hiking_difficulty: null });
|
|
await expect(
|
|
mcp.updateTour({ tripId: f.trip.id, placeId: walk.id, expected_revision: 1, name: 'X' }, own),
|
|
).rejects.toThrow('Only a hike is changed here');
|
|
|
|
// An import is a recorded track: renamed elsewhere, planned over only with new points.
|
|
const track = await makePlace(orm(), f.trip.id, {
|
|
name: 'Recorded',
|
|
route_geometry: JSON.stringify(LINE),
|
|
});
|
|
await makeTour(orm(), track.id, { origin: 'gpx', max_hiking_difficulty: null, duration: null });
|
|
await expect(
|
|
mcp.updateTour({ tripId: f.trip.id, placeId: track.id, expected_revision: 1, name: 'X' }, own),
|
|
).rejects.toThrow('a recorded track without points of its own');
|
|
const plannedOver = read(
|
|
await mcp.updateTour({ tripId: f.trip.id, placeId: track.id, expected_revision: 1, waypoints: WAYPOINTS }, own),
|
|
);
|
|
expect(plannedOver.tour).toMatchObject({ has_waypoints: true, has_recorded_track: true, max_hiking_difficulty: 2 });
|
|
|
|
// A section drawn by hand is planned around in the planner only.
|
|
const ferrata = await makePlace(orm(), f.trip.id, { name: 'Iron path', route_geometry: JSON.stringify(LINE) });
|
|
await makeTour(orm(), ferrata.id, { duration: null });
|
|
const free = {
|
|
kind: 'free',
|
|
geometry: [
|
|
[47.505, 11.1],
|
|
[47.51, 11.1],
|
|
],
|
|
};
|
|
await addTourWaypoints(orm(), ferrata.id, [
|
|
{ lat: 47.5, lng: 11.1 },
|
|
{ lat: 47.505, lng: 11.1, fixed_leg: JSON.stringify(free) },
|
|
{ lat: 47.51, lng: 11.1 },
|
|
]);
|
|
await expect(
|
|
mcp.updateTour({ tripId: f.trip.id, placeId: ferrata.id, expected_revision: 1, max_hiking_difficulty: 3 }, own),
|
|
).rejects.toThrow('a section drawn by hand');
|
|
// New points would drop the section with the point it starts at.
|
|
await expect(
|
|
mcp.updateTour({ tripId: f.trip.id, placeId: ferrata.id, expected_revision: 1, waypoints: WAYPOINTS }, own),
|
|
).rejects.toThrow('a section drawn by hand');
|
|
// A rename keeps it whole, its unknown time included.
|
|
const renamed = read(
|
|
await mcp.updateTour({ tripId: f.trip.id, placeId: ferrata.id, expected_revision: 1, name: 'Iron way' }, own),
|
|
);
|
|
expect(renamed.tour).toMatchObject({ name: 'Iron way', duration: null, revision: 2 });
|
|
expect(renamed.waypoints[1].fixed_leg).toMatchObject({ kind: 'free' });
|
|
|
|
expect(read(await mcp.updateTour({ tripId: f.trip.id, placeId: 999999, expected_revision: 1 }, own))).toEqual({
|
|
error: 'Tour not found.',
|
|
});
|
|
const other = await makeTrip(orm(), f.user.id);
|
|
await expect(
|
|
mcp.updateTour({ tripId: other.id, placeId: 1, expected_revision: 1, name: 'X' }, ctx(f.outsider.id)),
|
|
).resolves.toMatchObject({ isError: true });
|
|
});
|
|
|
|
it('HIKE-MCP-014: refuses a change based on an older revision before the router is asked', async () => {
|
|
const f = await fixture();
|
|
const created = await createdHike(f);
|
|
const placeId = created.tour.place_id;
|
|
read(await mcp.updateTour({ tripId: f.trip.id, placeId, expected_revision: 1, name: 'Renamed' }, ctx(f.user.id)));
|
|
plan.mockClear();
|
|
t.realtime.broadcastMock.mockReset();
|
|
const stale = await mcp
|
|
.updateTour({ tripId: f.trip.id, placeId, expected_revision: 1, max_hiking_difficulty: 5 }, ctx(f.user.id))
|
|
.catch((err: unknown) => err);
|
|
expect(stale).toMatchObject({
|
|
publicMessage: 'This tour was changed in the meantime',
|
|
code: 'tour_revision_conflict',
|
|
});
|
|
expect(stale instanceof DomainError && stale.getStatus()).toBe(409);
|
|
expect(plan).not.toHaveBeenCalled();
|
|
expect(t.realtime.broadcastMock).not.toHaveBeenCalled();
|
|
expect(await findRow(orm(), Tours, { place: placeId })).toMatchObject({ revision: 2, max_hiking_difficulty: 2 });
|
|
});
|
|
|
|
it('HIKE-MCP-015: a new way without its heights is not saved over a way; a hike waiting for one keeps waiting', async () => {
|
|
const f = await fixture();
|
|
const created = await createdHike(f);
|
|
const placeId = created.tour.place_id;
|
|
plan.mockResolvedValueOnce(flat());
|
|
await expect(
|
|
mcp.updateTour({ tripId: f.trip.id, placeId, expected_revision: 1, max_hiking_difficulty: 3 }, ctx(f.user.id)),
|
|
).rejects.toThrow(
|
|
'for the way or for its heights; try again in a moment. Nothing was saved and the hike keeps its way.',
|
|
);
|
|
expect(plan.mock.calls.at(-1)?.[3]).toEqual({ grades: false });
|
|
const place = await findRow(orm(), Places, { id: placeId });
|
|
expect(JSON.parse(String(place?.route_geometry))).toEqual(LINE);
|
|
expect(await findRow(orm(), Tours, { place: placeId })).toMatchObject({ revision: 1, max_hiking_difficulty: 2 });
|
|
|
|
plan.mockResolvedValueOnce(unrouted('router_unavailable'));
|
|
const pending = await createdHike(f, { name: 'Pending' });
|
|
plan.mockResolvedValueOnce(flat());
|
|
const still = read(
|
|
await mcp.updateTour(
|
|
{ tripId: f.trip.id, placeId: pending.tour.place_id, expected_revision: 1, name: 'Still pending' },
|
|
ctx(f.user.id),
|
|
),
|
|
);
|
|
expect(still).toMatchObject({ replanned: true, route: { planned: false, why: 'router_unavailable' } });
|
|
expect(still.tour).toMatchObject({ name: 'Still pending', route_pending: true });
|
|
expect((await findRow(orm(), Places, { id: pending.tour.place_id }))?.route_geometry).toBeNull();
|
|
});
|
|
|
|
it('HIKE-MCP-016: refuses new points for a hike with stages, and a way through more points than it plans', async () => {
|
|
const f = await fixture();
|
|
const own = ctx(f.user.id);
|
|
const staged = await makePlace(orm(), f.trip.id, { name: 'Two days', route_geometry: JSON.stringify(LINE) });
|
|
await makeTour(orm(), staged.id, { duration: 52 });
|
|
await addTourWaypoints(orm(), staged.id, [
|
|
{ lat: 47.5, lng: 11.1 },
|
|
{ lat: 47.505, lng: 11.1, stage_end: 1 },
|
|
{ lat: 47.51, lng: 11.1 },
|
|
]);
|
|
await expect(
|
|
mcp.updateTour({ tripId: f.trip.id, placeId: staged.id, expected_revision: 1, waypoints: WAYPOINTS }, own),
|
|
).rejects.toThrow('This hike is split into stages over several days');
|
|
// A new limit plans the same points anew, the end of the first stage kept.
|
|
const harder = read(
|
|
await mcp.updateTour(
|
|
{ tripId: f.trip.id, placeId: staged.id, expected_revision: 1, max_hiking_difficulty: 4 },
|
|
own,
|
|
),
|
|
);
|
|
expect(harder.tour).toMatchObject({ max_hiking_difficulty: 4, revision: 2, stage_count: 2 });
|
|
expect(harder.waypoints.map((point: { stage_end?: boolean }) => point.stage_end === true)).toEqual([
|
|
false,
|
|
true,
|
|
false,
|
|
]);
|
|
|
|
// A hike drawn in the planner through more points than a change plans through here.
|
|
const long = await makePlace(orm(), f.trip.id, { name: 'Long', route_geometry: JSON.stringify(LINE) });
|
|
await makeTour(orm(), long.id);
|
|
await addTourWaypoints(
|
|
orm(),
|
|
long.id,
|
|
Array.from({ length: HIKE_PLAN_MAX_WAYPOINTS + 1 }, (_, k) => ({ lat: 47.5 + k / 1000, lng: 11.1 })),
|
|
);
|
|
plan.mockClear();
|
|
await expect(
|
|
mcp.updateTour({ tripId: f.trip.id, placeId: long.id, expected_revision: 1, max_hiking_difficulty: 3 }, own),
|
|
).rejects.toThrow(`This hike has more than ${HIKE_PLAN_MAX_WAYPOINTS} points`);
|
|
expect(plan).not.toHaveBeenCalled();
|
|
// New points plan it all the same.
|
|
const repointed = read(
|
|
await mcp.updateTour({ tripId: f.trip.id, placeId: long.id, expected_revision: 1, waypoints: WAYPOINTS }, own),
|
|
);
|
|
expect(repointed.waypoints).toHaveLength(3);
|
|
});
|
|
|
|
it('HIKE-MCP-017: new points keep the link and the name of a saved point they stand on', async () => {
|
|
const f = await fixture();
|
|
const own = ctx(f.user.id);
|
|
const hut = await makePlace(orm(), f.trip.id, { name: 'Herzogstandhaus', lat: 47.505, lng: 11.1 });
|
|
const hike = await makePlace(orm(), f.trip.id, { name: 'Linked', route_geometry: JSON.stringify(LINE) });
|
|
await makeTour(orm(), hike.id, { duration: 52 });
|
|
await addTourWaypoints(orm(), hike.id, [
|
|
{ lat: 47.5, lng: 11.1 },
|
|
{ lat: 47.505, lng: 11.1, name: 'Hut', linkedPlace: hut.id },
|
|
{ lat: 47.51, lng: 11.1 },
|
|
]);
|
|
const changed = read(
|
|
await mcp.updateTour(
|
|
{
|
|
tripId: f.trip.id,
|
|
placeId: hike.id,
|
|
expected_revision: 1,
|
|
waypoints: [
|
|
{ lat: 47.5, lng: 11.1 },
|
|
{ lat: 47.50505, lng: 11.1 },
|
|
{ lat: 47.52, lng: 11.1, name: 'Heimgarten' },
|
|
],
|
|
},
|
|
own,
|
|
),
|
|
);
|
|
expect(
|
|
changed.waypoints.map((point: { name?: string | null; linked_place_id?: number | null }) => [
|
|
point.name ?? null,
|
|
point.linked_place_id ?? null,
|
|
]),
|
|
).toEqual([
|
|
[null, null],
|
|
['Hut', hut.id],
|
|
['Heimgarten', null],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('HikePlanMcp: import_tour_gpx', () => {
|
|
it('HIKE-MCP-011: takes in each track of a GPX text as a hike, as the file import does, and tells the whole trip', async () => {
|
|
const f = await fixture();
|
|
const imported = read(
|
|
await mcp.importTourGpx({ tripId: f.trip.id, gpx: GPX, file_name: 'ridge', tour_type: 'hike' }, ctx(f.user.id)),
|
|
);
|
|
expect(imported).toMatchObject({ caution: false, skipped: 0 });
|
|
expect(imported.tours).toHaveLength(1);
|
|
expect(imported.tours[0]).toMatchObject({ name: 'Ridge', tour_type: 'hike', origin: 'gpx', elevation_gain: 200 });
|
|
const tour = await findRow(orm(), Tours, { place: imported.tours[0].place_id });
|
|
expect(tour?.origin_file_name).toBe('ridge.gpx');
|
|
expect(events()).toEqual([
|
|
[String(f.trip.id), 'tours:changed', undefined],
|
|
[String(f.trip.id), 'place:created', undefined],
|
|
]);
|
|
|
|
// The same track again is skipped, as the file import skips it.
|
|
const again = read(
|
|
await mcp.importTourGpx(
|
|
{ tripId: f.trip.id, gpx: GPX, file_name: 'ridge.GPX', tour_type: 'hike' },
|
|
ctx(f.user.id),
|
|
),
|
|
);
|
|
expect(again).toMatchObject({ tours: [], skipped: 1 });
|
|
});
|
|
|
|
it('HIKE-MCP-012: refuses a file without a track and fields a type does not take, and writes nothing', async () => {
|
|
const f = await fixture();
|
|
const own = ctx(f.user.id);
|
|
await expect(
|
|
mcp.importTourGpx({ tripId: f.trip.id, gpx: '<gpx><wpt lat="1" lon="2"/></gpx>', tour_type: 'hike' }, own),
|
|
).rejects.toThrow('No track or route found in GPX file');
|
|
await expect(mcp.importTourGpx({ tripId: f.trip.id, gpx: GPX, tour_type: 'bike' }, own)).rejects.toThrow(
|
|
'a bike import needs bike_profile',
|
|
);
|
|
const bike = read(
|
|
await mcp.importTourGpx({ tripId: f.trip.id, gpx: GPX, tour_type: 'bike', bike_profile: 'gravel' }, own),
|
|
);
|
|
expect(bike.tours[0]).toMatchObject({ tour_type: 'bike', bike_profile: 'gravel' });
|
|
const rows = await findRows(orm(), Tours, {});
|
|
expect(rows.map((row) => row.origin_file_name)).toEqual(['import.gpx']);
|
|
});
|
|
});
|