feat(tours): get_tour_plan reads the whole saved plan of a tour

An assistant reading a tour over MCP now gets everything the saved plan
holds, from the same reads the app uses, so its suggestions start from the
actual plan:

- legs lists every step from one control point to the next with how its
  line came to be (routed, via ferrata, drawn by hand, portage), keyed by
  the points' stable keys, and the length of a fixed leg's own line; a
  routed leg has no length stored apart and says so with null.
- line gives the saved line in brief: its point count, whether it carries
  heights, and a preview thinned to 200 points; null while the way waits
  to be planned.
- A city walk can be read for one of its days (dayId): each station then
  carries its visit status that day and run what is still open. Without a
  day no visit is told, so one day's ticks are never passed off as the
  walk's; dayId on another type or a day the walk is not on is refused.
- derived names every field worked out on reading rather than stored,
  including the control points an import without its own answers from the
  ends of its track.

Reading still writes nothing: no revision moves and no visit changes, and
drafts in a browser stay invisible. The wiki row of the tool says the same.
This commit is contained in:
Maurice
2026-10-20 12:11:13 +02:00
parent 6577595058
commit 19946039ad
7 changed files with 477 additions and 48 deletions
+145
View File
@@ -0,0 +1,145 @@
/**
* MCP-T01: how get_tour_plan lays one saved tour out for an assistant. The
* facts come from the reads the app uses (the tour detail, its days, the
* visits of a city walk on a day); this file only arranges them, so the plan
* an assistant reads is the plan the travellers saved and nothing here writes.
* Every number made here rather than stored is named in `derived`, so an
* assistant never takes a reckoning for a saved fact, and a value nobody
* stored stays null instead of being guessed.
*/
import type { WalkProgress } from './tour-visits.service';
import { computeTourMetrics, thinnedLine, type GeometryPoint } from './tours.helpers';
import { walkStationsOf, type TourDetailResponse, type TourFixedLegKind, type TourWaypoint } from '@trek/shared';
/** The most points of the saved line an answer carries: its shape without the thousands of points. */
export const TOUR_PLAN_LINE_POINTS = 200;
type VisitStatus = WalkProgress['stations'][number]['status'];
/** A control point as the plan answers it, with the name of the place it stands for where it stands for one. */
type PlanPoint = TourWaypoint & { linked_place_name?: string | null };
/**
* CW-10: a city walk station with every field it has spelled out, so an
* assistant reads a stay nobody entered as null and never as missing: null for
* an unknown stay, a normal importance and no known hours, false for an
* unprotected station. MCP-T01 adds its status on the day asked for, null
* while no day was named: a visit belongs to one day, never to the walk.
*/
function spelledOut<T extends PlanPoint>(station: T, status: VisitStatus | null) {
return {
...station,
key: station.key ?? null,
name: station.name ?? null,
stay_minutes: station.stay_minutes ?? null,
importance: station.importance ?? null,
protected: station.protected === true,
opening_hours: station.opening_hours ?? null,
visit_status: status,
};
}
/**
* The points of a tour as get_tour_plan answers them: a city walk's stations
* spelled out with their status on the day read, every other point as stored.
* A station without a key is planned, as the day plan shows it.
*/
export function planPoints<T extends PlanPoint>(tourType: string, points: T[], run: WalkProgress | null) {
const stations = new Set<T>(tourType === 'walk' ? walkStationsOf(points) : []);
const statusByKey = new Map((run?.stations ?? []).map((station) => [station.key, station.status] as const));
return points.map((point) => {
if (!stations.has(point)) return point;
if (!run) return spelledOut(point, null);
return spelledOut(point, (point.key && statusByKey.get(point.key)) || 'planned');
});
}
/** One leg of a tour, from a control point to the next, and how its line came to be. */
interface PlanLeg {
from_sequence: number;
to_sequence: number;
/** The keys of its two points, the same across saves; null for a point that has none. */
from_key: string | null;
to_key: string | null;
/** routed: planned by the router; ferrata, free and portage: a fixed line taken as it stands. */
kind: 'routed' | TourFixedLegKind;
/** The via ferrata's name where OpenStreetMap gives one. */
name: string | null;
/** Derived: the length of a fixed leg's own line; null for a routed leg, whose length is not stored apart. */
length_km: number | null;
}
/** Kilometres to ten metres: the precision the app shows, and no false digits for an assistant. */
function roundKm(km: number): number {
return Math.round(km * 100) / 100;
}
/** The legs between consecutive control points, each saying whether the router planned it or it is a fixed line. */
export function planLegs(points: readonly TourWaypoint[]): PlanLeg[] {
return points.slice(1).map((to, index) => {
const from = points[index] as TourWaypoint;
const leg = from.fixed_leg ?? null;
return {
from_sequence: from.sequence,
to_sequence: to.sequence,
from_key: from.key ?? null,
to_key: to.key ?? null,
kind: leg?.kind ?? 'routed',
name: leg?.kind === 'ferrata' ? leg.name : null,
length_km: leg ? roundKm(computeTourMetrics(leg.geometry).distanceKm) : null,
};
});
}
/** The saved line in brief: how many points it has, whether each carries a height, and its shape thinned. */
interface PlanLine {
point_count: number;
has_heights: boolean;
/** Derived: at most TOUR_PLAN_LINE_POINTS of its points, spread evenly with both ends kept. */
preview: GeometryPoint[];
}
/** The saved line of a tour, or null while it has none (a tour saved with its points only). */
export function planLine(line: readonly GeometryPoint[]): PlanLine | null {
if (line.length < 2) return null;
return {
point_count: line.length,
has_heights: computeTourMetrics(line).hasElevation,
preview: thinnedLine(line, TOUR_PLAN_LINE_POINTS),
};
}
/** The visits of a city walk on the day read: the day, its assignment, and what is still open. */
export function planRun(progress: WalkProgress) {
return { day_id: progress.day_id, assignment_id: progress.assignment_id, open: progress.open };
}
/** What get_tour_plan read besides the tour detail, to tell which of its fields were worked out. */
interface PlanExtras {
/** The control points are the two ends of the saved line: the tour stores none of its own. */
controlsFromTrack: boolean;
line: PlanLine | null;
run: WalkProgress | null;
}
/**
* The fields of this answer worked out when it was read rather than stored
* with the tour. The distance, heights and walking time of a tour are stored
* when it is saved, and the days are the trip's own assignments, so neither
* is listed here.
*/
export function derivedFields(detail: TourDetailResponse, { controlsFromTrack, line, run }: PlanExtras): string[] {
const type = detail.tour.tour_type;
return [
'tour.planned',
'tour.caution',
'tour.route_pending',
'provenance.editedLocally',
'legs[].length_km',
...(line ? ['line.preview'] : []),
...(controlsFromTrack ? ['waypoints'] : []),
...(type === 'walk' ? ['tour.station_count', 'tour.station_stay_minutes', 'tour.station_stays_unknown'] : []),
...(run ? ['run.open'] : []),
...(type === 'kayak' ? ['tour.paddle_check', 'obstacle_check'] : []),
];
}
+72 -41
View File
@@ -6,52 +6,63 @@ import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { TripAccessService } from '../trip-membership/trip-access.service';
import { TourGpxExportService } from './tour-gpx-export.service';
import { derivedFields, planLegs, planLine, planPoints, planRun } from './tour-plan-read';
import { TourPoiSearchService } from './tour-poi-search.service';
import { TourVisitsService } from './tour-visits.service';
import { TourVisitsService, type WalkProgress } from './tour-visits.service';
import type { GeometryPoint } from './tours.helpers';
import { ToursService } from './tours.service';
import { NotFoundException } from '@nestjs/common';
import {
idSchema,
tourPoiSearchRequestSchema,
walkStationsOf,
type TourDetailResponse,
type TourListItem,
type TourPoiSearchRequest,
type TourWaypoint,
} from '@trek/shared';
type TourDay = Pick<TourDayRow, 'day_id' | 'day_number' | 'date' | 'title'>;
const poiFields = tourPoiSearchRequestSchema.shape;
/**
* CW-10: a station of a city walk with every field it has spelled out, so an
* assistant reads a stay nobody entered as null and never as missing: null
* for an unknown stay, a normal importance and no known hours, false for an
* unprotected station. REST answers the stored fields as they are.
*/
function spelledOut<T extends TourWaypoint>(station: T) {
return {
...station,
key: station.key ?? null,
name: station.name ?? null,
stay_minutes: station.stay_minutes ?? null,
importance: station.importance ?? null,
protected: station.protected === true,
opening_hours: station.opening_hours ?? null,
};
}
/** The points of a tour as get_tour_plan answers them: a city walk's stations spelled out, every other point as stored. */
function planPoints<T extends TourWaypoint>(tourType: string, points: T[]) {
const stations = new Set<T>(tourType === 'walk' ? walkStationsOf(points) : []);
return points.map((point) => (stations.has(point) ? spelledOut(point) : point));
}
/** A tour as a client reads it: its list facts and the days of the trip it is on. */
function withDays(tour: TourListItem, days: ReadonlyMap<number, TourDay[]>) {
return { ...tour, days: days.get(tour.place_id) ?? [] };
}
/** What get_tour_plan reads of one saved tour, before a day's visits are added. */
interface PlanRead {
detail: TourDetailResponse;
days: ReadonlyMap<number, TourDay[]>;
line: GeometryPoint[];
/** The names of the places of the trip its control points stand for (PR1-10). */
names: ReadonlyMap<number, string>;
}
/**
* MCP-T01: the plan of one saved tour as an assistant reads it: the detail the
* app reads, its days, the legs between its control points, its saved line in
* brief, for a city walk the visits of the day asked for, and the fields that
* were worked out on reading rather than stored.
*/
function planOf({ detail, days, line, names }: PlanRead, run: WalkProgress | null) {
const named = detail.waypoints.map((point) =>
point.linked_place_id ? { ...point, linked_place_name: names.get(point.linked_place_id) ?? null } : point,
);
const saved = planLine(line);
// A tour that stores no control points answers the two ends of its line instead.
const controlsFromTrack = detail.tour.has_waypoints !== true && detail.waypoints.length > 0;
const isWalk = detail.tour.tour_type === 'walk';
return {
...detail,
tour: withDays(detail.tour, days),
waypoints: planPoints(detail.tour.tour_type, named, run),
legs: planLegs(detail.waypoints),
line: saved,
...(isWalk ? { run: run ? planRun(run) : null } : {}),
derived: derivedFields(detail, { controlsFromTrack, line: saved, run }),
};
}
/**
* P2D-MCP-01: the saved tours of a trip, read only. A tool reads what the
* tours list and the tour detail answer over REST, through the same service,
@@ -100,30 +111,50 @@ export class ToursMcp {
@Tool({
name: 'get_tour_plan',
description:
'Read one saved tour of a trip: its facts, its ordered control points (start, via and end; an import without its own shows the two ends of its track), where it came from (provenance) and the days it is on. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point whose fixed_leg is set leads to the next one along a line the router did not plan: kind ferrata follows a via ferrata, kind free is drawn by hand and was checked by nothing, kind horse_route (a ride only) follows a section of a signposted horse route from OpenStreetMap in the direction the rider chose (signposted: with or against the direction the route is signposted in, null where it is not signposted one way; access: the metres of each horse=* value its ways carried when it was taken, unknown where untagged). Being on a horse route is no permission to ride it. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (its own id, the same across saves; the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app) and opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known). How far a walk got on a day is get_city_walk_progress. The full track geometry is not included.',
inputSchema: { tripId: idSchema, placeId: idSchema.describe('The place id of the tour, as list_tours gives it') },
'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps and null for a routed leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts.',
inputSchema: {
tripId: idSchema,
placeId: idSchema.describe('The place id of the tour, as list_tours gives it'),
dayId: idSchema
.optional()
.describe('For a city walk only: the day whose visits to read, one of the days the tour is on'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'read' },
})
async getTourPlan({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) {
async getTourPlan(
{ tripId, placeId, dayId }: { tripId: number; placeId: number; dayId?: number },
ctx: McpContext,
) {
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
let read: PlanRead;
try {
const [detail, days] = await Promise.all([
this.tours.getTour(String(tripId), String(placeId)),
this.daysByTour(tripId),
]);
// A point standing for a place of the trip (PR1-10) says which, by name.
const names = await this.tours.linkedPlaceNames(tripId, detail.waypoints);
const named = detail.waypoints.map((point) =>
point.linked_place_id ? { ...point, linked_place_name: names.get(point.linked_place_id) ?? null } : point,
);
const waypoints = planPoints(detail.tour.tour_type, named);
return ok({ ...detail, waypoints, tour: withDays(detail.tour, days) });
read = await this.readPlan(tripId, placeId);
} catch (err: unknown) {
if (err instanceof NotFoundException) return errorResult('Tour not found.');
throw err;
}
if (dayId === undefined) return ok(planOf(read, null));
if (read.detail.tour.tour_type !== 'walk') {
return errorResult('Only a city walk keeps visits for a day: read this tour without dayId.');
}
try {
return ok(planOf(read, await this.visits.progress(tripId, placeId, dayId)));
} catch (err: unknown) {
if (err instanceof NotFoundException) return errorResult('This city walk is not on that day.');
throw err;
}
}
/** One saved tour as the app reads it, its days, its saved line and the places its points stand for. */
private async readPlan(tripId: number, placeId: number): Promise<PlanRead> {
const [detail, days, line] = await Promise.all([
this.tours.getTour(String(tripId), String(placeId)),
this.daysByTour(tripId),
this.tours.savedLine(tripId, placeId),
]);
return { detail, days, line, names: await this.tours.linkedPlaceNames(tripId, detail.waypoints) };
}
@Tool({
+8
View File
@@ -309,6 +309,14 @@ export class ToursService {
return await this.toursRepo.listTourDays(tripId);
}
/**
* MCP-T01: the saved line of a tour of this trip, as stored; empty for a
* place out of the trip and for a tour saved with its points only.
*/
async savedLine(tripId: number, placeId: number): Promise<GeometryPoint[]> {
return parseRouteGeometry((await this.placesRepo.findInTrip(placeId, tripId))?.route_geometry);
}
/** One saved tour plus the persisted routing controls needed by the editor. */
async getTour(tripId: string, placeId: string): Promise<TourDetailResponse> {
const tid = toRowId(tripId);
+2
View File
@@ -33,6 +33,8 @@ interface StationColumns {
shaping?: number;
/** The id of the place of the trip the point stands for. */
linkedPlace?: number | null;
/** The leg to the next point as a fixed line, as its JSON column stores it. */
fixed_leg?: string | null;
}
/**
@@ -0,0 +1,91 @@
import { derivedFields, planLegs, planLine, planPoints } from '../../../src/nest/tours/tour-plan-read';
import type { WalkProgress } from '../../../src/nest/tours/tour-visits.service';
import type { TourDetailResponse, TourWaypoint } from '@trek/shared';
import { describe, expect, it } from 'vitest';
// TOUR-PLAN-READ-001 to TOUR-PLAN-READ-005
/** The detail of a tour of this type, only as far as the plan reads it. */
function detailOf(tourType: TourDetailResponse['tour']['tour_type']): TourDetailResponse {
return { tour: { tour_type: tourType }, waypoints: [] } as unknown as TourDetailResponse;
}
const point = (sequence: number, extra: Partial<TourWaypoint> = {}): TourWaypoint => ({
lat: 48 + sequence * 0.01,
lng: 11,
role: 'via',
sequence,
...extra,
});
/** A walk on a day where the station keyed `seen` was visited. */
const run: WalkProgress = {
place_id: 1,
day_id: 2,
assignment_id: 3,
stations: [
{
position: 1,
key: 'seen',
name: null,
lat: 48,
lng: 11,
status: 'visited',
importance: 'normal',
protected: false,
stay_minutes: null,
opening_hours: null,
linked_place_id: null,
},
],
open: { stations: 1, stay_minutes_known: 0, stays_unknown: 1 },
walking_minutes: null,
};
describe('tour plan read (MCP-T01)', () => {
it('TOUR-PLAN-READ-001: a station without a key is planned on the day read, never another station of it', () => {
const points = [point(0, { key: 'seen' }), point(1), point(2, { key: 'other' })];
expect(planPoints('walk', points, run).map((station) => 'visit_status' in station && station.visit_status)).toEqual(
['visited', 'planned', 'planned'],
);
});
it('TOUR-PLAN-READ-002: the points of any other type stay as stored, whatever day is read', () => {
const points = [point(0, { key: 'seen' }), point(1)];
expect(planPoints('bike', points, run)).toEqual(points);
});
it('TOUR-PLAN-READ-003: a portage is a fixed leg of its own kind, and a single point has no leg', () => {
const portage = point(0, {
fixed_leg: {
kind: 'portage',
geometry: [
[48, 11],
[48.01, 11],
],
},
});
expect(planLegs([portage, point(1)])).toEqual([
expect.objectContaining({ kind: 'portage', name: null, length_km: 1.11 }),
]);
expect(planLegs([point(0)])).toEqual([]);
});
it('TOUR-PLAN-READ-004: a line of fewer than two points is no line', () => {
expect(planLine([])).toBeNull();
expect(planLine([[48, 11]])).toBeNull();
});
it('TOUR-PLAN-READ-005: a paddle tour names its check bench and checklist as worked out, a walk its station counts', () => {
const none = { controlsFromTrack: false, line: null, run: null };
expect(derivedFields(detailOf('kayak'), none)).toEqual(
expect.arrayContaining(['tour.paddle_check', 'obstacle_check']),
);
expect(derivedFields(detailOf('kayak'), none)).not.toContain('tour.station_count');
const walk = derivedFields(detailOf('walk'), none);
expect(walk).toEqual(expect.arrayContaining(['tour.station_count', 'tour.station_stays_unknown']));
expect(walk).not.toContain('tour.paddle_check');
expect(walk).not.toContain('run.open');
});
});
+158 -6
View File
@@ -12,6 +12,7 @@ import type { McpContext } from '../../../src/nest-mcp';
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
import { PlacesMcp } from '../../../src/nest/places/places.mcp';
import { TourLibraryMcp } from '../../../src/nest/tours/tour-library.mcp';
import { TOUR_PLAN_LINE_POINTS } from '../../../src/nest/tours/tour-plan-read';
import { TourVisitsService } from '../../../src/nest/tours/tour-visits.service';
import { ToursMcp } from '../../../src/nest/tours/tours.mcp';
import { ToursModule } from '../../../src/nest/tours/tours.module';
@@ -27,7 +28,7 @@ import {
} from '../../helpers/factories';
import { makeDayAssignment } from '../../helpers/factories/itinerary';
import { makePlace } from '../../helpers/factories/places';
import { countRows, insertRows, updateRows } from '../../helpers/factories/rows';
import { countRows, findRow, insertRows, updateRows } from '../../helpers/factories/rows';
import { addTourWaypoints, makeTour, markStationVisit } from '../../helpers/factories/tours';
import { resetTestDb } from '../../helpers/test-db';
import { createTestModule, type TestModule } from '../../helpers/test-module';
@@ -343,7 +344,8 @@ describe('ToursMcp', () => {
return entry.options.description ?? '';
};
expect(description(ToursMcp, 'listTours')).toMatch(/station_count.*route_pending/);
for (const field of ['key', 'stay_minutes', 'importance', 'protected', 'opening_hours']) {
const planFields = ['key', 'stay_minutes', 'importance', 'protected', 'opening_hours', 'visit_status', 'derived'];
for (const field of planFields) {
expect(description(ToursMcp, 'getTourPlan')).toContain(field);
}
expect(description(ToursMcp, 'getCityWalkProgress')).toMatch(/key .*opening hours/);
@@ -357,7 +359,157 @@ describe('ToursMcp', () => {
}
});
it('TOURS-MCP-015: list_tours names the winter label of a hike, and says it is only a label (HIK-01)', async () => {
/** MCP-T01: a hike at revision 4 over a saved line of 450 points, a routed leg, a via ferrata and a free leg. */
async function legsFixture() {
const base = fixture();
const orm = await createTestOrm(db);
const hike = await makePlace(orm, base.trip.id, { name: 'Crest' });
await makeTour(orm, hike.id, { revision: 4, distance: 12.5 });
const line = Array.from({ length: 450 }, (_, index) => [47 + index * 0.0001, 11, 900 + index]);
await updateRows(orm, Places, { id: hike.id }, { route_geometry: JSON.stringify(line) });
const ferrata = {
kind: 'ferrata',
ref: 'way/77',
name: 'Iron path',
geometry: [
[47.01, 11],
[47.02, 11],
],
};
const free = {
kind: 'free',
geometry: [
[47.02, 11],
[47.02, 11.01],
],
};
await addTourWaypoints(orm, hike.id, [
{ lat: 47, lng: 11, station_key: 'pt-start' },
{ lat: 47.01, lng: 11, station_key: 'pt-wall', fixed_leg: JSON.stringify(ferrata) },
{ lat: 47.02, lng: 11, station_key: 'pt-lake', fixed_leg: JSON.stringify(free) },
{ lat: 47.02, lng: 11.01, station_key: 'pt-end' },
]);
await orm.close();
return { ...base, hike };
}
it('TOURS-MCP-015: get_tour_plan answers each leg with how its line came to be, a fixed one with its own length', async () => {
const { member, trip, hike } = await legsFixture();
const plan = read(await mcp.getTourPlan({ tripId: trip.id, placeId: hike.id }, ctx(member.id)));
expect(plan.tour).toMatchObject({ place_id: hike.id, tour_type: 'hike', revision: 4, distance: 12.5 });
expect(plan.waypoints.map((point: { key: string }) => point.key)).toEqual([
'pt-start',
'pt-wall',
'pt-lake',
'pt-end',
]);
expect(plan.legs).toEqual([
expect.objectContaining({ from_key: 'pt-start', to_key: 'pt-wall', kind: 'routed', name: null, length_km: null }),
expect.objectContaining({ from_sequence: 1, to_sequence: 2, kind: 'ferrata', name: 'Iron path' }),
expect.objectContaining({ from_key: 'pt-lake', to_key: 'pt-end', kind: 'free', name: null }),
]);
expect(plan.legs[1].length_km).toBeCloseTo(1.11, 1);
expect(plan.legs[2].length_km).toBeCloseTo(0.76, 1);
// A hike carries no city walk fields at all.
expect('run' in plan).toBe(false);
expect(plan.waypoints.some((point: object) => 'visit_status' in point)).toBe(false);
});
it('TOURS-MCP-016: get_tour_plan gives the saved line in brief and names what it worked out, and reading moves no revision', async () => {
const { user, trip, hike } = await legsFixture();
const plan = read(await mcp.getTourPlan({ tripId: trip.id, placeId: hike.id }, ctx(user.id)));
expect(plan.line).toMatchObject({ point_count: 450, has_heights: true });
expect(plan.line.preview).toHaveLength(TOUR_PLAN_LINE_POINTS);
expect(plan.line.preview[0]).toEqual([47, 11, 900]);
expect(plan.line.preview.at(-1)).toEqual([47 + 449 * 0.0001, 11, 1349]);
expect(plan.derived).toEqual(expect.arrayContaining(['legs[].length_km', 'line.preview', 'tour.caution']));
// Stored facts are never listed as worked out: the points are its own, the distance was saved.
expect(plan.derived).not.toContain('waypoints');
expect(plan.derived).not.toContain('tour.distance');
expect(plan.derived.some((field: string) => field.startsWith('tour.station') || field === 'run.open')).toBe(false);
const orm = await createTestOrm(db);
expect((await findRow(orm, Tours, { place: hike.id }))?.revision).toBe(4);
await orm.close();
});
it('TOURS-MCP-017: an import without control points answers the ends of its track as worked out, a tour without a line none', async () => {
const { user, trip, ridge, gorge } = fixture();
const orm = await createTestOrm(db);
const track = [
[47, 11],
[47.005, 11.005],
[47.01, 11.01],
];
await updateRows(orm, Places, { id: ridge.id }, { route_geometry: JSON.stringify(track) });
await orm.close();
const plan = read(await mcp.getTourPlan({ tripId: trip.id, placeId: ridge.id }, ctx(user.id)));
expect(plan.waypoints.map((point: { lat: number; key?: string }) => [point.lat, point.key])).toEqual([
[47, undefined],
[47.01, undefined],
]);
expect(plan.legs).toEqual([
{ from_sequence: 0, to_sequence: 1, from_key: null, to_key: null, kind: 'routed', name: null, length_km: null },
]);
expect(plan.line).toEqual({ point_count: 3, has_heights: false, preview: track });
expect(plan.derived).toContain('waypoints');
const bare = read(await mcp.getTourPlan({ tripId: trip.id, placeId: gorge.id }, ctx(user.id)));
expect(bare).toMatchObject({ waypoints: [], legs: [], line: null });
expect(bare.derived).not.toContain('line.preview');
});
it('TOURS-MCP-018: a city walk read for one of its days carries the visits of that day, and of no other', async () => {
const { member, trip, day1, day2, walk, onDay1 } = await walkFixture();
const orm = await createTestOrm(db);
await makeDayAssignment(orm, day2.id, walk.id);
const before = await countRows(orm, TourStationVisits, {});
const plan = async (dayId?: number) =>
read(await mcp.getTourPlan({ tripId: trip.id, placeId: walk.id, dayId }, ctx(member.id)));
const statuses = (answer: { waypoints: Array<{ key: string; visit_status: string | null }> }) =>
answer.waypoints.map((point) => [point.key, point.visit_status]);
const first = await plan(day1.id);
expect(statuses(first)).toEqual([
['stop-cathedral', 'visited'],
['stop-market', 'skipped'],
['stop-park', 'planned'],
]);
expect(first.run).toEqual({
day_id: day1.id,
assignment_id: onDay1.id,
open: { stations: 1, stay_minutes_known: 0, stays_unknown: 1 },
});
expect(first.derived).toEqual(expect.arrayContaining(['tour.station_count', 'run.open']));
// The same walk on another day keeps its own visits: none were made there.
expect(statuses(await plan(day2.id)).map(([, status]) => status)).toEqual(['planned', 'planned', 'planned']);
// Without a day no visit is told, rather than the visits of one day passed off as the walk's.
const undated = await plan();
expect(undated.run).toBeNull();
expect(statuses(undated).map(([, status]) => status)).toEqual([null, null, null]);
expect(undated.derived).not.toContain('run.open');
expect(await countRows(orm, TourStationVisits, {})).toBe(before);
await orm.close();
});
it('TOURS-MCP-019: a day is read for a city walk only, and only for a day it is on', async () => {
const { user, trip, day1, day2, ridge, walk } = await walkFixture();
expect(read(await mcp.getTourPlan({ tripId: trip.id, placeId: ridge.id, dayId: day1.id }, ctx(user.id)))).toEqual({
error: 'Only a city walk keeps visits for a day: read this tour without dayId.',
});
expect(read(await mcp.getTourPlan({ tripId: trip.id, placeId: walk.id, dayId: day2.id }, ctx(user.id)))).toEqual({
error: 'This city walk is not on that day.',
});
const failure = new Error('database gone');
const progress = vi.spyOn(t.get(TourVisitsService), 'progress').mockRejectedValueOnce(failure);
try {
await expect(mcp.getTourPlan({ tripId: trip.id, placeId: walk.id, dayId: day1.id }, ctx(user.id))).rejects.toBe(
failure,
);
} finally {
progress.mockRestore();
}
});
it('TOURS-MCP-020: list_tours names the winter label of a hike, and says it is only a label (HIK-01)', async () => {
const { user, trip, ridge, gorge } = fixture();
const orm = await createTestOrm(db);
await updateRows(orm, Tours, { place: ridge.id }, { winter_activity: 'snowshoe' });
@@ -372,7 +524,7 @@ describe('ToursMcp', () => {
expect(description).toContain('never a statement that a way can be walked in winter');
});
it('TOURS-MCP-016: list_tours names a run with its profile and pace, and says its time is a plan at that pace only (RUN-01)', async () => {
it('TOURS-MCP-021: list_tours names a run with its profile and pace, and says its time is a plan at that pace only (RUN-01)', async () => {
const { user, trip, ridge, gorge } = fixture();
const orm = await createTestOrm(db);
await updateRows(
@@ -393,7 +545,7 @@ describe('ToursMcp', () => {
expect(description).toContain('never a recorded time');
});
it('TOURS-MCP-017: list_tours names a trail run as such, and its profile as a routing preference only (RUN-02)', async () => {
it('TOURS-MCP-022: list_tours names a trail run as such, and its profile as a routing preference only (RUN-02)', async () => {
const { user, trip, ridge } = fixture();
const orm = await createTestOrm(db);
await updateRows(
@@ -412,7 +564,7 @@ describe('ToursMcp', () => {
expect(description).toContain('never a statement that a way can be run');
});
it('TOURS-MCP-018: list_tours names a ski track with its discipline and technique, and says it is kept as recorded (SKI-02)', async () => {
it('TOURS-MCP-023: list_tours names a ski track with its discipline and technique, and says it is kept as recorded (SKI-02)', async () => {
const { user, trip, ridge, gorge } = fixture();
const orm = await createTestOrm(db);
await updateRows(
+1 -1
View File
@@ -238,7 +238,7 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how
| Tool | Description | Scope |
|---|---|---|
| `list_tours` | The saved tours of a trip: name, type (`hike`, `walk` for a city walk, `run`, `bike`, `kayak` for a paddle tour, `horse` for a ride, which has no riding time worked out, or `ski` for a ski track), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale and its route choices (`hiking_preferences`), the winter label of a hike (`winter_activity`: `winter_hike` or `snowshoe`; a label only, the route and its time are planned as for any hike, never a statement about winter conditions), the classification of a ski track (`ski_discipline`: `alpine`, `snowboard`, `cross_country` or `touring`; `ski_technique` for cross-country only: `classic`, `skating` or `unknown`; a ski track is imported from a GPX file and kept as recorded, never routed, has no ski time worked out and says nothing about snow, avalanches or open runs), the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), for a city walk how many stations it has (`station_count`), their stays as far as entered and how many have none, whether a tour still waits for its way to be planned (`route_pending`: saved with its points only, its distance and walking time unknown until someone opens it in the planner and saves it), the `revision` (raised by every saved change of the tour), and the days of the trip each tour is on | `trips:read` |
| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`), a stretch drawn by hand (`free`), which no router planned or checked, or on a ride a section of a signposted horse route (`horse_route`) in the direction chosen, with its signposting and the `horse=*` tags its ways carried; being on a route is no permission to ride it. A shaping point (`shaping: true`) only steers the route and is no stop. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The stations of a city walk carry every field spelled out: `key` (the same across saves), `name`, `stay_minutes` (`0` for a point only passed, `null` when not known), `importance` (`optional`, `must`, or `null` for normal), `protected` (a station a proposed change has to keep where it is; the travellers can still edit it) and `opening_hours` (as OpenStreetMap writes them, `null` when none is known). The full track geometry is not included | `trips:read` |
| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`), a stretch drawn by hand (`free`), which no router planned or checked, or on a ride a section of a signposted horse route (`horse_route`) in the direction chosen, with its signposting and the `horse=*` tags its ways carried; being on a route is no permission to ride it. A shaping point (`shaping: true`) only steers the route and is no stop. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The stations of a city walk carry every field spelled out: `key` (the same across saves), `name`, `stay_minutes` (`0` for a point only passed, `null` when not known), `importance` (`optional`, `must`, or `null` for normal), `protected` (a station a proposed change has to keep where it is; the travellers can still edit it) and `opening_hours` (as OpenStreetMap writes them, `null` when none is known). Every leg from one control point to the next is listed in `legs` with its kind (`routed`, `ferrata`, `free`, `portage` or `horse_route`) and, for a fixed leg, the length of its own line; a routed leg has no length of its own stored. `line` gives the saved line in brief: how many points it has, whether they carry heights, and a preview thinned to 200 points. Pass `dayId` with a city walk to read the visits of that day: each station then says `visit_status` (planned, visited or skipped) and `run` what is still open; without a day no visit is told. `derived` names every field worked out on reading rather than stored with the tour. Reading changes nothing, not even the revision. The full track geometry is not included | `trips:read` |
| `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` |
| `get_city_walk_progress` | How far a city walk got on one day it is on: every station in order with its key, its status that day (planned, visited or skipped, as checked off in the day plan), importance, protection, stay and opening hours, what is still open, and the walking time of the whole walk. Visits belong to that day only. Reading changes nothing | `trips:read` |
| `export_tour_gpx` | One saved tour as a GPX file: its whole saved line as one track, with the heights it carries and none it lacks, and a file name. A tour without a line cannot be exported | `trips:read` |