feat(tours): plan, create and change hikes on the server, for the assistant too

The hiking costing moves to shared, so the planner and the server ask the
router the same way. The road trip router learns to plan a hike on foot
with that costing (Valhalla only: OSRM knows no trail difficulty), to read
heights along a line in chunks and to read the OSM ways a line follows,
all through its one paced queue. A hike router builds on it: the way, its
heights, distance, climb and DIN walking time, and the SAC grades of its
stretches from the trail grade service, each part best effort.

New MCP tools in HikePlanMcp, in the assistant's tours group:
- propose_hike plans a hike without saving it.
- create_hike saves a confirmed hike through the planner's own creation;
  a router that does not answer leaves it pending for the planner, a way
  that cannot be planned is refused and nothing is saved.
- update_tour changes a saved hike against its revision, keeping linked
  stations and refusing what would silently drop drawn legs or stages.
- import_tour_gpx takes a GPX file's text in as tours, like the import.
Same permission checks as REST, broadcasts from the services.

In the assistant a new hike or city walk is an "Add tour" card with its
route and difficulty, undone through delete_place while the tour is
untouched, and a hike being planned shows as a live step.
This commit is contained in:
Maurice
2026-10-22 00:03:12 +00:00
parent 24b7e607c3
commit aea37d9914
71 changed files with 5041 additions and 124 deletions
@@ -84,6 +84,9 @@ const VERB_LOOK: Record<AssistantChangeVerbKey, { Icon: typeof Check; tone: Tone
addReservation: { Icon: Ticket, tone: 'success' },
importBooking: { Icon: Ticket, tone: 'success' },
rememberNote: { Icon: NotebookPen, tone: 'info' },
addTour: { Icon: MapPinPlus, tone: 'success' },
updateTour: { Icon: PencilLine, tone: 'info' },
importTour: { Icon: Plus, tone: 'success' },
genericCreate: { Icon: Plus, tone: 'success' },
genericUpdate: { Icon: PencilLine, tone: 'info' },
genericRemove: { Icon: Trash2, tone: 'danger' },
@@ -1,5 +1,5 @@
/**
* FE-AI-MODEL-001 to FE-AI-MODEL-010
* FE-AI-MODEL-001 to FE-AI-MODEL-012
*
* The pure half of the assistant's views: how a stored message reaches
* @assistant-ui, what each error offers, how a reading tool is named while it
@@ -150,6 +150,15 @@ describe('toolStepKey', () => {
expect(liveStepText('search_nearby_places', null)).toEqual({ key: 'assistant.step.nearby' });
expect(liveStepText('list_places', 'Kyoto')).toEqual({ key: 'assistant.step.reading' });
});
it('FE-AI-MODEL-012: a hike being planned says so, and names its goal once the step names one', () => {
expect(toolStepKey('propose_hike')).toBe('assistant.step.planningHike');
expect(liveStepText('propose_hike', 'Herzogstand')).toEqual({
key: 'assistant.step.planningHikeTo',
params: { place: 'Herzogstand' },
});
expect(liveStepText('propose_hike', null)).toEqual({ key: 'assistant.step.planningHike' });
});
});
describe('suggestions', () => {
@@ -167,6 +167,10 @@ const STEPS: Partial<Record<string, StepLook>> = {
get_weather: { stepKey: 'assistant.step.weather', live: { liveKey: 'assistant.step.weatherAt', param: 'place' } },
list_todos: { stepKey: 'assistant.step.todos' },
get_settlement_summary: { stepKey: 'assistant.step.costs' },
propose_hike: {
stepKey: 'assistant.step.planningHike',
live: { liveKey: 'assistant.step.planningHikeTo', param: 'place' },
},
search_help: { stepKey: 'assistant.step.help' },
read_booking_file: { stepKey: 'assistant.step.readingBooking' },
};
@@ -288,4 +288,5 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
stay: { labelKey: 'assistant.change.label.stay' },
members: { labelKey: 'assistant.change.label.members' },
route: { labelKey: 'assistant.change.label.route' },
difficulty: { labelKey: 'assistant.change.label.difficulty' },
};
@@ -17,9 +17,10 @@ const ICON: Record<HikingPreferenceKey, ComponentType<{ size?: number; strokeWid
/**
* MF-08: what a hike asks the router for besides its difficulty limit, as
* switches under it. Only preferences the router was seen to honour are here
* (hikingCostingOptions says which and how they were checked), each with what
* it really does in a line of its own; the info dot says they weigh the way
* and give way where there is none other, and that none of them grades a path.
* (hikingCostingOptions in @trek/shared says which and how they were checked),
* each with what it really does in a line of its own; the info dot says they
* weigh the way and give way where there is none other, and that none of them
* grades a path.
* Switching one plans the route again, as one step of the history.
*/
export function HikingPreferencesField({
@@ -1,24 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { HikingPreferencesField } from './HikingPreferencesField';
import { hikingCosting, hikingCostingOptions } from './hikingCosting';
describe('hikingCostingOptions (MF-08, RTE-01)', () => {
it('asks the router only for what was switched on, in the options it was checked with', () => {
expect(hikingCostingOptions(null)).toEqual({});
expect(hikingCostingOptions({ avoid_steps: false })).toEqual({});
expect(hikingCostingOptions({ avoid_climbs: true, avoid_steps: true, avoid_ferries: true })).toEqual({
use_hills: 0.2,
step_penalty: 600,
use_ferry: 0,
});
});
it('weighs the whole tour and one of its sections alike: the limit with the preferences (RTE-02)', () => {
expect(hikingCosting()).toEqual({ max_hiking_difficulty: 2 });
expect(hikingCosting(4, { avoid_steps: true })).toEqual({ max_hiking_difficulty: 4, step_penalty: 600 });
});
});
describe('HikingPreferencesField (MF-08, RTE-01)', () => {
it('shows the three preferences in order with what each does, and switching one keeps the others', () => {
@@ -1,11 +1,13 @@
import type {
BikeProfile,
HikingPreferences,
RunProfile,
SkiColourScheme,
SkiRunDifficulty,
SkiSegmentKind,
TourFixedLeg,
import {
hikingCosting,
TOURS_MAX_HIKING_DIFFICULTY,
type BikeProfile,
type HikingPreferences,
type RunProfile,
type SkiColourScheme,
type SkiRunDifficulty,
type SkiSegmentKind,
type TourFixedLeg,
} from '@trek/shared'
import type { Waypoint } from '../../../types'
import {
@@ -20,7 +22,6 @@ import {
type ValhallaNoPath,
type ValhallaRun,
} from '../../Map/valhallaRoute'
import { hikingCosting, TOURS_MAX_HIKING_DIFFICULTY } from './hikingCosting'
import { densifyLine, lineLengthM } from './tourFixedLegs'
import { MAX_TOUR_SNAP_M, type TourRouteGap, type TourRouteUnreachable } from './tourRouteOutcome'
@@ -1,8 +1,12 @@
import type { HikingPreferences, PlannableTourType, TourMaxHikingDifficulty } from '@trek/shared';
import {
hikingCosting,
type HikingPreferences,
type PlannableTourType,
type TourMaxHikingDifficulty,
} from '@trek/shared';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { CostingOptions } from '../../Map/valhallaRoute';
import { pinAlternative } from '../../Roadtrip/alternativePins';
import { hikingCosting } from './hikingCosting';
import { askOtherWays, routeSection, type OtherWaysAnswer } from './legVariantRouting';
import {
comparedLegOf,
@@ -1,8 +1,12 @@
import type { HikingPreferences, PlannableTourType, TourMaxHikingDifficulty } from '@trek/shared';
import {
hikingCosting,
type HikingPreferences,
type PlannableTourType,
type TourMaxHikingDifficulty,
} from '@trek/shared';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { randomId } from '../../../utils/randomId';
import type { CostingOptions } from '../../Map/valhallaRoute';
import { hikingCosting } from './hikingCosting';
import { findRoundHikes, type RoundSearchReport } from './roundHikeRouting';
import {
canStartRound,
@@ -220,6 +220,34 @@ function boxArea(value: unknown): BoxArea | null {
return { type: 'bbox', south, west, north, east };
}
/** How far the box round a hike's points reaches past them, in degrees, so points on one line still make a box. */
const HIKE_BOX_MARGIN = 0.005;
/**
* Where a hike is being planned: named by its goal, the last point after the
* start that has a name, and the box round its points.
*/
function hikeLook(args: Row): { label: string | null; area: AssistantActivityArea | null } {
const points = Array.isArray(args.waypoints) ? args.waypoints.map(recordOf) : [];
const goal = points
.slice(1)
.map((point) => clipped(point.name, MAX_LABEL_CHARS))
.filter((name): name is string => name !== null)
.pop();
const lats = points.map((point) => finite(point.lat)).filter(isLat);
const lngs = points.map((point) => finite(point.lng)).filter(isLng);
const area =
lats.length === points.length && lngs.length === points.length && points.length > 0
? boxArea({
south: Math.min(...lats) - HIKE_BOX_MARGIN,
west: Math.min(...lngs) - HIKE_BOX_MARGIN,
north: Math.max(...lats) + HIKE_BOX_MARGIN,
east: Math.max(...lngs) + HIKE_BOX_MARGIN,
})
: null;
return { label: goal ?? null, area };
}
/** The name and the area of a step that looks somewhere, as its arguments say. */
function lookOf(
toolName: string,
@@ -236,6 +264,7 @@ function lookOf(
const centre = area ? nearbyName(card, (area.south + area.north) / 2, (area.west + area.east) / 2) : null;
return { label: centre, area };
}
if (toolName === 'propose_hike') return hikeLook(args);
if (toolName === 'search_place') {
const bias = recordOf(args.locationBias);
const area = pointArea(bias.lat, bias.lng, bias.radius, BIAS_RADIUS_M);
@@ -111,11 +111,13 @@ export function checkDenylist(names: Iterable<string>): string[] {
export const ASSISTANT_TOOL_GROUPS: Readonly<Record<string, { about: string; controllers: readonly string[] }>> = {
tours: {
about:
'Tours: hikes, bike tours, city walks, paddling and ski routes; plan, create, change and export them, ' +
'their packing, the tour library and imports from Google Maps.',
'Tours: hikes, bike tours, city walks, paddling and ski routes; plan, create, change and export them ' +
'(hikes with their heights and SAC grades), their packing, the tour library and imports from GPX files ' +
'and Google Maps.',
controllers: [
'ToursMcp',
'CityWalkMcp',
'HikePlanMcp',
'TourChangeMcp',
'TourChangeUndoMcp',
'TourLibraryMcp',
@@ -96,6 +96,11 @@ export const ASSISTANT_WRITE_TOOLS: Readonly<Record<string, AssistantWriteTool>>
create_budget_item: { verbKey: 'addBudgetItem', entity: 'budget' },
update_budget_item: { verbKey: 'updateBudgetItem', entity: 'budget' },
create_reservation: { verbKey: 'addReservation', entity: 'reservation' },
// A tour is a place with a route: its id is its place's.
create_hike: { verbKey: 'addTour', entity: 'tour' },
create_city_walk: { verbKey: 'addTour', entity: 'tour' },
update_tour: { verbKey: 'updateTour', entity: 'tour' },
import_tour_gpx: { verbKey: 'importTour', entity: 'tour' },
};
/**
@@ -195,6 +200,13 @@ function idIn(value: unknown, key: string): number | null {
return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null;
}
/** The place id of a tour a create answered with (`{ tour: { place_id } }`). */
function tourIdIn(value: unknown): number | null {
const tour = value && typeof value === 'object' ? (value as Record<string, unknown>).tour : undefined;
const id = tour && typeof tour === 'object' ? (tour as Record<string, unknown>).place_id : undefined;
return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null;
}
function argId(args: ToolArgs, key: string): number | null {
const id = args[key];
return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null;
@@ -213,6 +225,15 @@ interface AssistantUndo {
args: (args: ToolArgs, result: unknown) => ToolArgs | null;
}
/** A created tour taken back with its place, which takes its route and points along. */
const DELETE_CREATED_TOUR: AssistantUndo = {
tool: 'delete_place',
args: (_args, result) => {
const placeId = tourIdIn(result);
return placeId ? { placeId } : null;
},
};
export const ASSISTANT_UNDO_MAP: Readonly<Record<string, AssistantUndo>> = {
create_place: {
tool: 'delete_place',
@@ -265,4 +286,6 @@ export const ASSISTANT_UNDO_MAP: Readonly<Record<string, AssistantUndo>> = {
return itemId ? { itemId } : null;
},
},
create_hike: DELETE_CREATED_TOUR,
create_city_walk: DELETE_CREATED_TOUR,
};
@@ -65,9 +65,29 @@ function placeUnchanged(result: Row, snapshot: TripSnapshot): boolean {
return !plannedElsewhere && !referenced;
}
/**
* A created tour, unchanged and on no day: its place still has the name and
* description it was created with, has not been written since it was made,
* and nothing plans, books or pays for it. Every write of a tour's route or
* name (a change over MCP, the planner's save, a rename) stamps its place's
* `updated_at`, which the create left equal to `created_at`; the revision is
* not in the snapshot. A tour planned on a day since is kept too.
*/
function tourUnchanged(result: Row, snapshot: TripSnapshot): boolean {
const tour = recordOf(result.tour);
const current = byId(rowsOf(recordOf(snapshot.places).places), tour.place_id);
const untouched = typeof current?.created_at === 'string' && current.updated_at === current.created_at;
return (
untouched &&
placeUnchanged({ place: { id: tour.place_id, name: tour.name, description: tour.description ?? null } }, snapshot)
);
}
const CHECKS: Readonly<Record<string, (result: Row, snapshot: TripSnapshot) => boolean>> = {
create_place: placeUnchanged,
create_and_assign_place: placeUnchanged,
create_hike: tourUnchanged,
create_city_walk: tourUnchanged,
assign_place_to_day: (result, snapshot) => {
const created = recordOf(result.assignment);
return same(created, byId(assignments(snapshot), created.id), STABLE_FIELDS.assignment);
@@ -0,0 +1,51 @@
import type { AssistantChangeLabelKey, AssistantChangeRow } from '@trek/shared';
/**
* The pieces every change card is written with: a value cut to what a row
* shows, an argument read as an id, and the rows themselves. Shared by the
* cards of the trip's own tools (tool-call-preview.helpers.ts) and of the
* tours (tour-call-preview.helpers.ts).
*/
type Args = Record<string, unknown>;
/** Longest value a row shows; URLs keep their start, so the host stays visible. */
const MAX_VALUE_CHARS = 160;
const CHECKED = '✓';
export const UNCHECKED = '✗';
export function clip(value: string): string {
const line = value.replace(/\s+/g, ' ').trim();
return line.length > MAX_VALUE_CHARS ? `${line.slice(0, MAX_VALUE_CHARS - 1)}…` : line;
}
export function id(args: Args, key: string): number | null {
const value = args[key];
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
}
export function text(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'boolean') return value ? CHECKED : UNCHECKED;
return clip(String(value));
}
export class Rows {
readonly rows: AssistantChangeRow[] = [];
add(labelKey: AssistantChangeLabelKey, after: string): this {
if (after !== '') this.rows.push({ op: 'add', labelKey, before: null, after });
return this;
}
change(labelKey: AssistantChangeLabelKey, before: string | null, after: string): this {
this.rows.push({ op: 'change', labelKey, before: before || null, after });
return this;
}
move(labelKey: AssistantChangeLabelKey, before: string, after: string): this {
this.rows.push({ op: 'move', labelKey, before: before || null, after });
return this;
}
}
@@ -9,13 +9,14 @@ import {
writeToolOf,
} from './assistant-tools.constants';
import type { ToolHint } from './assistant-tools.service';
import { clip, id, Rows, text, UNCHECKED } from './preview-rows.helpers';
import { describeGeneric } from './tool-call-generic.helpers';
import { TOUR_DESCRIBERS } from './tour-call-preview.helpers';
import type {
AssistantApplied,
AssistantChangeEffect,
AssistantChangeLabelKey,
AssistantChangePreview,
AssistantChangeRow,
} from '@trek/shared';
/**
@@ -35,23 +36,8 @@ export interface PreviewContext {
hintOf?: (toolName: string) => ToolHint | null;
}
/** Longest value a row shows; URLs keep their start, so the host stays visible. */
const MAX_VALUE_CHARS = 160;
const MAX_ROWS = 12;
const CHECKED = '✓';
const UNCHECKED = '✗';
function clip(value: string): string {
const line = value.replace(/\s+/g, ' ').trim();
return line.length > MAX_VALUE_CHARS ? `${line.slice(0, MAX_VALUE_CHARS - 1)}…` : line;
}
function id(args: Args, key: string): number | null {
const value = args[key];
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
}
function has(args: Args, key: string): boolean {
return args[key] !== undefined;
}
@@ -107,12 +93,6 @@ function money(ctx: PreviewContext, amount: number, currency: string | null): st
return new Intl.NumberFormat(localeOf(ctx)).format(amount);
}
function text(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'boolean') return value ? CHECKED : UNCHECKED;
return clip(String(value));
}
function openingHours(ctx: PreviewContext, value: unknown): string {
if (!Array.isArray(value)) return '';
const weekday = new Intl.DateTimeFormat(localeOf(ctx), { weekday: 'short', timeZone: 'UTC' });
@@ -132,25 +112,6 @@ function memberNames(ctx: PreviewContext, ids: unknown): string {
return clip(ids.map((memberId) => ctx.card.members.get(Number(memberId)) ?? `#${String(memberId)}`).join(', '));
}
class Rows {
readonly rows: AssistantChangeRow[] = [];
add(labelKey: AssistantChangeLabelKey, after: string): this {
if (after !== '') this.rows.push({ op: 'add', labelKey, before: null, after });
return this;
}
change(labelKey: AssistantChangeLabelKey, before: string | null, after: string): this {
this.rows.push({ op: 'change', labelKey, before: before || null, after });
return this;
}
move(labelKey: AssistantChangeLabelKey, before: string, after: string): this {
this.rows.push({ op: 'move', labelKey, before: before || null, after });
return this;
}
}
function coordinates(args: Args): string {
const { lat, lng } = args;
return typeof lat === 'number' && typeof lng === 'number' ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : '';
@@ -273,7 +234,7 @@ interface Described {
placeIds: number[];
}
type Describer = (ctx: PreviewContext, args: Args) => Described;
export type Describer = (ctx: PreviewContext, args: Args) => Described;
/** Who shares an expense: the uneven split's members, else the equal one's. */
function splitOf(args: Args): unknown {
@@ -430,6 +391,7 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
};
},
[IMPORT_BOOKING_TOOL]: describeBooking,
...TOUR_DESCRIBERS,
};
/** A held write of the model's tools or of the server's own (a booking from a file). */
@@ -518,14 +480,28 @@ export function describeToolCall(
const RESULT_KEYS = ['place', 'assignment', 'note', 'item', 'reservation'] as const;
const ARG_KEYS = ['placeId', 'assignmentId', 'noteId', 'itemId'] as const;
function createdId(result: unknown): number | null {
if (!result || typeof result !== 'object') return null;
/**
* The tours an answer names: a tour created or changed (`tour`), or those a
* file brought in (`tours`). A tour's id is its place's.
*/
function tourIds(result: Args): number[] {
const tours = Array.isArray(result.tours) ? result.tours : [result.tour];
return tours.flatMap((tour: unknown) => {
const placeId = tour && typeof tour === 'object' ? id(tour as Args, 'place_id') : null;
return placeId === null ? [] : [placeId];
});
}
function createdIds(result: unknown): number[] {
if (!result || typeof result !== 'object') return [];
const tours = tourIds(result as Args);
if (tours.length > 0) return tours;
for (const key of RESULT_KEYS) {
const created = (result as Args)[key];
const value = created && typeof created === 'object' ? id(created as Args, 'id') : null;
if (value !== null) return value;
if (value !== null) return [value];
}
return null;
return [];
}
/** The day a created booking landed on, when its answer names one. */
@@ -550,11 +526,12 @@ export function appliedChange(
): AssistantApplied | null {
// A tool without a card of its own touched something the client has no view for.
const tool = writeTool(writeToolOf(toolName, args)) ?? { entity: 'other' as const };
const touched = createdId(result) ?? ARG_KEYS.map((key) => id(args, key)).find((value) => value !== null) ?? null;
const created = createdIds(result);
const named = ARG_KEYS.map((key) => id(args, key)).filter((value): value is number => value !== null);
return {
toolCallId,
entity: tool.entity,
ids: touched === null ? [] : [touched],
ids: created.length > 0 ? created : named.slice(0, 1),
dayId: dayId ?? bookedDay(result),
undoUntil,
};
@@ -0,0 +1,94 @@
import { clip, id, Rows, text } from './preview-rows.helpers';
import type { Describer, PreviewContext } from './tool-call-preview.helpers';
import { TOURS_MAX_HIKING_DIFFICULTY } from '@trek/shared';
/**
* The change cards of the tours an assistant creates and changes: a hike, a
* city walk, a change to a saved hike, tours taken in from a GPX file. The
* route reads as the names of its points in order, a hike's limit as its SAC
* grade (T1 to T6), a planned time in hours and minutes.
*/
type Args = Record<string, unknown>;
function recordOf(value: unknown): Args {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Args) : {};
}
/** A point's name: its own, else the name of the trip's place it stands for. */
function pointName(ctx: PreviewContext, point: unknown): string {
const record = recordOf(point);
const linked = id(record, 'linked_place_id');
return text(record.name) || (linked === null ? '' : (ctx.card.places.get(linked)?.name ?? ''));
}
/** "Car park → Hut → Summit": the named points in order, a round trip back to its start. */
function routeOf(ctx: PreviewContext, points: unknown, roundTrip: unknown): string {
if (!Array.isArray(points)) return '';
const names = points.map((point: unknown) => pointName(ctx, point));
if (roundTrip === true && names.length > 0) names.push(names[0]);
return clip(names.filter((name) => name !== '').join(' → '));
}
/** The trip's places a tour's points stand for. */
function linkedPlaceIds(ctx: PreviewContext, points: unknown): number[] {
if (!Array.isArray(points)) return [];
const ids = points.map((point: unknown) => id(recordOf(point), 'linked_place_id'));
return [...new Set(ids.filter((placeId): placeId is number => placeId !== null && ctx.card.places.has(placeId)))];
}
/** A hike's limit as its SAC grade, T1 to T6. */
function grade(value: unknown): string {
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 6 ? `T${value}` : '';
}
/** Minutes as hours and minutes, "5:30 h". */
function hours(value: unknown): string {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) return '';
return `${Math.floor(value / 60)}:${String(value % 60).padStart(2, '0')} h`;
}
/** The first name a GPX text gives, a track's or the file's own. */
function gpxName(gpx: unknown): string {
if (typeof gpx !== 'string') return '';
const open = gpx.indexOf('<name>');
const close = open < 0 ? -1 : gpx.indexOf('</name>', open);
return close < 0 ? '' : text(gpx.slice(open + '<name>'.length, close));
}
export const TOUR_DESCRIBERS: Readonly<Record<string, Describer>> = {
create_hike: (ctx, args) => {
const rows = new Rows()
.add('route', routeOf(ctx, args.waypoints, args.round_trip))
.add('difficulty', grade(args.max_hiking_difficulty ?? TOURS_MAX_HIKING_DIFFICULTY))
.add('time', hours(args.planned_duration_minutes))
.add('description', text(args.description));
return { subject: text(args.name), rows, dayId: null, placeIds: [] };
},
create_city_walk: (ctx, args) => {
const rows = new Rows().add('route', routeOf(ctx, args.stations, false)).add('description', text(args.description));
return { subject: text(args.name), rows, dayId: null, placeIds: linkedPlaceIds(ctx, args.stations) };
},
update_tour: (ctx, args) => {
const placeId = id(args, 'placeId');
const current = placeId === null ? undefined : ctx.card.places.get(placeId);
const rows = new Rows();
if (args.name !== undefined) rows.change('name', current?.name ?? null, text(args.name));
if (args.description !== undefined)
rows.change('description', current?.description ?? null, text(args.description));
rows
.add('route', routeOf(ctx, args.waypoints, false))
.add('difficulty', grade(args.max_hiking_difficulty))
.add('time', hours(args.planned_duration_minutes));
return {
subject: current?.name ?? text(args.name),
rows,
dayId: null,
placeIds: placeId === null ? [] : [placeId],
};
},
import_tour_gpx: (_ctx, args) => {
const rows = new Rows().add('type', text(args.tour_type));
return { subject: text(args.file_name) || gpxName(args.gpx) || 'GPX', rows, dayId: null, placeIds: [] };
},
};
@@ -2,12 +2,23 @@ import { Plugins } from '../../db/entities/Plugins.entity';
import type { PluginsRepository } from '../../db/repositories/Plugins.repository';
import { readCappedJson, discardBody } from '../../utils/cappedFetch';
import { safeFetchAdminConfigured } from '../../utils/ssrfGuard';
import { logWarn } from '../audit/audit-log.logger';
import { normalize, declaredProfiles } from '../plugins/contributions/plugin-route-normalize';
import { pluginsEnabled } from '../plugins/kill-switch';
import { PluginHooks } from '../plugins/plugin-hooks.service';
import { SettingsService } from '../settings/settings.service';
import {
HEIGHT_CHUNK_SIZE,
heightBody,
heightsOf,
TRACE_TIMEOUT_MS,
traceBody,
tracedWaysOf,
type TracedWay,
} from './valhalla-hike.helpers';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import type { GeometryPoint } from '@trek/shared';
import {
formatDurationShort,
haversineKm,
@@ -16,6 +27,7 @@ import {
type SnappedWaypoint,
} from '@trek/shared/roadtrip';
import { createHash } from 'node:crypto';
import { z } from 'zod';
const coordinate = z.tuple([z.number().min(-180).max(180), z.number().min(-90).max(90)]);
@@ -88,6 +100,79 @@ export interface RouteOptions {
* boat said. A city walk asks this; a road trip day never does.
*/
footOnly?: boolean;
/**
* A hike's own pedestrian costing (its hiking limit and preferences, as
* `hikingCosting` builds them), sent to Valhalla as the planner in the
* browser sends it. Only Valhalla knows a hiking limit: OSRM's foot profile
* takes a T5 ridge as readily as a forest road. So a walk asked with one is
* asked of Valhalla alone and fails rather than fall back to OSRM, with a
* `RouteRefusal` of `no_valhalla` where there is no Valhalla to ask.
*/
costingOptions?: Readonly<Record<string, number>>;
}
export type { TracedWay };
/**
* Why a route asked of Valhalla has none, as opposed to a router that did not
* answer: no Valhalla to ask (an own OSRM without one), Valhalla's own word
* that no way joins the points (`no_way`: points in regions no edge connects,
* a point with no edge near it, or no path between them, within the hiking
* limit where one was asked), or that the way is longer than it plans for
* one costing (`too_long`). Asking again gives the same answer.
*/
export class RouteRefusal extends Error {
constructor(
readonly why: 'no_valhalla' | 'no_way' | 'too_long',
message: string,
) {
super(message);
this.name = 'RouteRefusal';
}
}
/**
* Valhalla's refusals that are about the way rather than the host: locations
* in regions no edge connects (170), no edge near a location (171), no path
* (442), and a way past the instance's distance limit for the costing (154).
*/
const REFUSAL_CODES: ReadonlyMap<number, 'no_way' | 'too_long'> = new Map([
[154, 'too_long'],
[170, 'no_way'],
[171, 'no_way'],
[442, 'no_way'],
]);
const refusalSchema = z.object({ error_code: z.number() });
/** The most of a refusal's body that is read to tell which one it is. */
const REFUSAL_MAX_BYTES = 64 * 1024;
/** Valhalla's word in a router's 400 that the way cannot be had, or null where the 400 says something else. */
async function refusalOf(response: Response): Promise<'no_way' | 'too_long' | null> {
const refusal = refusalSchema.safeParse(await readCappedJson(response, REFUSAL_MAX_BYTES));
return refusal.success ? (REFUSAL_CODES.get(refusal.data.error_code) ?? null) : null;
}
/** How often a piece of a line whose heights could not be read is asked again, as the planner asks a route again. */
const HEIGHT_RETRIES = 1;
/** How long a line's heights are kept, as long as a route is: a proposal and the hike created from it ask once. */
const HEIGHTS_TTL_MS = 300_000;
/** The most lines whose heights are kept; a line of ten thousand points keeps that many numbers. */
const HEIGHTS_CACHE_MAX = 20;
/** The heights of a line kept under: who asked, the Valhalla asked, and the line itself as a digest. */
function heightsKey(userId: number, valhalla: string, line: ReadonlyArray<readonly [number, number]>): string {
const digest = createHash('sha256').update(JSON.stringify(line)).digest('hex');
return `${userId} ${valhalla} ${digest}`;
}
/** The routers a user's settings name: their own OSRM (blank for the public hosts) and the Valhalla to ask, if any. */
function routersOf(settings: Record<string, unknown>): { base: string; valhalla: string } {
const base =
typeof settings.routing_base_url === 'string' ? settings.routing_base_url.trim().replace(/\/+$/, '') : '';
const configuredValhalla =
typeof settings.valhalla_base_url === 'string' ? settings.valhalla_base_url.trim().replace(/\/+$/, '') : '';
// An own OSRM without a Valhalla means no Valhalla at all, as in the browser (`valhallaBase`).
return { base, valhalla: configuredValhalla || (base ? '' : 'https://valhalla1.openstreetmap.de') };
}
/** Valhalla's maneuver type for boarding a ferry. */
@@ -154,6 +239,7 @@ export class RoadtripRouterService {
private nextRequest = 0;
private queue: Promise<void> = Promise.resolve();
private cache = new Map<string, { at: number; route: RoadtripRoute }>();
private heightsCache = new Map<string, { at: number; heights: Array<number | null> }>();
constructor(
private readonly settings: SettingsService,
@@ -174,7 +260,7 @@ export class RoadtripRouterService {
return ['driving', 'walking', 'cycling', ...plugin];
}
private async request(url: string, body?: unknown): Promise<unknown> {
private async request(url: string, body?: unknown, timeoutMs = 20000): Promise<unknown> {
const before = this.queue;
let release!: () => void;
this.queue = new Promise<void>((resolve) => {
@@ -186,12 +272,17 @@ export class RoadtripRouterService {
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
this.nextRequest = Date.now() + 1100;
const response = await safeFetchAdminConfigured(url, {
signal: AbortSignal.timeout(20000),
signal: AbortSignal.timeout(timeoutMs),
headers: { 'User-Agent': 'TREK Roadtrip', 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
...(body ? { method: 'POST', body: JSON.stringify(body) } : {}),
});
if (!response.ok) {
discardBody(response);
// A 400 from Valhalla says why: a way that does not exist is no failure of the host.
if (response.status !== 400) discardBody(response);
else {
const why = await refusalOf(response);
if (why) throw new RouteRefusal(why, 'Routing provider returned 400');
}
throw new Error(`Routing provider returned ${response.status}`);
}
const payload = await readCappedJson<unknown>(response, 8 * 1024 * 1024);
@@ -213,6 +304,7 @@ export class RoadtripRouterService {
): Promise<RoadtripRoute> {
const settings = await this.settings.getUserSettings(userId);
const footOnly = options.footOnly === true && profile === 'walking';
const hiking = profile === 'walking' ? options.costingOptions : undefined;
const key = JSON.stringify([
userId,
tripId,
@@ -223,6 +315,7 @@ export class RoadtripRouterService {
settings.routing_base_url,
settings.valhalla_base_url,
...(footOnly ? ['foot-only'] : []),
...(hiking ? [{ hiking }] : []),
]);
const cached = this.cache.get(key);
if (!profile.startsWith('plugin:') && cached && Date.now() - cached.at < 300000) return cached.route;
@@ -252,13 +345,14 @@ export class RoadtripRouterService {
parts = route.legs;
} else {
const mode = profile === 'walking' || profile === 'cycling' ? profile : 'driving';
const base =
typeof settings.routing_base_url === 'string' ? settings.routing_base_url.trim().replace(/\/+$/, '') : '';
const configuredValhalla =
typeof settings.valhalla_base_url === 'string' ? settings.valhalla_base_url.trim().replace(/\/+$/, '') : '';
const valhalla = configuredValhalla || (base ? '' : 'https://valhalla1.openstreetmap.de');
const { base, valhalla } = routersOf(settings);
let weighted = false;
if (footOnly && valhalla) {
if (hiking) {
if (!valhalla) throw new RouteRefusal('no_valhalla', 'No Valhalla is set to plan a hike with');
const legs = await this.valhallaLegs(valhalla, points, 'pedestrian', { ...hiking });
({ line, distance, duration, parts, snapped } = routeOfLegs(points, legs));
weighted = true;
} else if (footOnly && valhalla) {
try {
const legs = await this.valhallaLegs(valhalla, points, 'pedestrian', { use_ferry: 0 }, true);
({ line, distance, duration, parts, snapped } = routeOfLegs(points, legs));
@@ -401,4 +495,97 @@ export class RoadtripRouterService {
if (legs.length !== points.length - 1) throw new Error('Routing response has mismatched legs');
return legs;
}
/** The Valhalla a user's routes are asked of, or null where there is none to ask. */
private async valhallaFor(userId: number): Promise<string | null> {
return routersOf(await this.settings.getUserSettings(userId)).valhalla || null;
}
/**
* The height of every point of a line, from Valhalla's /height, the way the
* planner in the browser reads it (enrichTourElevations): in pieces of 500
* points that overlap by one, one after another through the paced queue.
* A point the terrain data has no height for stays `[lat, lng]`. A piece
* whose question fails is asked once more; one that fails again ends the
* reading, and the points after it stay without heights. A line read whole
* is kept for five minutes, so a hike created from a proposal does not ask
* the host again. Null where there is no Valhalla to ask, or nothing to
* measure.
*/
async heights(userId: number, line: ReadonlyArray<readonly [number, number]>): Promise<GeometryPoint[] | null> {
const valhalla = await this.valhallaFor(userId);
if (!valhalla || line.length < 2) return null;
const key = heightsKey(userId, valhalla, line);
const kept = this.heightsCache.get(key);
const heights =
kept && Date.now() - kept.at < HEIGHTS_TTL_MS ? kept.heights : await this.readHeights(key, valhalla, line);
return line.map(([lat, lng], index): GeometryPoint => {
const height = heights[index];
return height === null ? [lat, lng] : [lat, lng, height];
});
}
/** The heights of a line asked piece by piece, kept under `key` where every piece was answered. */
private async readHeights(
key: string,
valhalla: string,
line: ReadonlyArray<readonly [number, number]>,
): Promise<Array<number | null>> {
const heights: Array<number | null> = line.map(() => null);
const step = HEIGHT_CHUNK_SIZE - 1;
const offsets = Array.from({ length: Math.ceil((line.length - 1) / step) }, (_, index) => index * step);
// One piece after another through the queue; the first that fails twice ends the reading.
const whole = await offsets.reduce<Promise<boolean>>(async (going, offset) => {
if (!(await going)) return false;
const read = await this.pieceHeights(valhalla, line.slice(offset, offset + HEIGHT_CHUNK_SIZE), offset);
// The point a piece shares with the one before keeps the height it already has.
read?.forEach((height, index) => {
if (index > 0 || heights[offset] === null) heights[offset + index] = height;
});
return read !== null;
}, Promise.resolve(true));
if (whole) {
this.heightsCache.set(key, { at: Date.now(), heights });
const [oldest] = this.heightsCache.keys();
if (this.heightsCache.size > HEIGHTS_CACHE_MAX && oldest !== undefined) this.heightsCache.delete(oldest);
}
return heights;
}
/**
* The heights of one piece of a line, starting at `offset`, asked again
* `retries` times where its question fails, or null where it failed every time.
*/
private async pieceHeights(
valhalla: string,
piece: ReadonlyArray<readonly [number, number]>,
offset: number,
retries = HEIGHT_RETRIES,
): Promise<Array<number | null> | null> {
let why: string;
try {
const read = heightsOf(await this.request(`${valhalla}/height`, heightBody(piece)), piece.length);
if (read) return read;
why = 'the answer does not name every point';
} catch (err: unknown) {
why = err instanceof Error ? err.message : String(err);
}
if (retries > 0) return await this.pieceHeights(valhalla, piece, offset, retries - 1);
logWarn(`[Router] heights stop at point ${offset}: ${why}`);
return null;
}
/**
* The OpenStreetMap ways under a line, in the order they are walked, each
* with the metres of the line it runs along: Valhalla's trace_attributes,
* matched on foot with every grade of path open, the line thinned to at
* most 4000 points as the planner thins it. Throws where there is no
* Valhalla or it gives no answer: a description is an addition beside a
* route that already exists, and its caller decides what its absence means.
*/
async wayIds(userId: number, line: ReadonlyArray<readonly [number, number]>): Promise<TracedWay[]> {
const valhalla = await this.valhallaFor(userId);
if (!valhalla) throw new RouteRefusal('no_valhalla', 'No Valhalla is set to describe a line with');
return tracedWaysOf(await this.request(`${valhalla}/trace_attributes`, traceBody(line), TRACE_TIMEOUT_MS));
}
}
@@ -0,0 +1,103 @@
import type { GeometryPoint } from '@trek/shared';
import { z } from 'zod';
/**
* What the router asks Valhalla about a hike besides its way, and how it
* reads the answers: the heights along a line (/height) and the OpenStreetMap
* ways under it (/trace_attributes). The request shapes are the planner's in
* the browser (enrichTourElevations and valhallaTraceAttributes on the
* client), so a hike planned on the server is measured as one planned there.
* Pure: the requests themselves go through the router's one paced queue.
*/
/** Points one /height question carries; a longer line is asked in pieces that overlap by one point. */
export const HEIGHT_CHUNK_SIZE = 500;
/** The most points a description of a line asks about; a longer line is thinned evenly, its ends kept. */
const TRACE_MAX_POINTS = 4000;
/** How long a description may take before it counts as failed: the matcher is slower than the router. */
export const TRACE_TIMEOUT_MS = 30_000;
/** The most edges a description is read with; a line of 4000 points has far fewer. */
const TRACE_MAX_EDGES = 20_000;
type LinePoint = GeometryPoint | readonly [number, number];
/** The /height question for one piece of a line: every point, and the distance along it asked back. */
export function heightBody(piece: readonly LinePoint[]) {
return { shape: piece.map(([lat, lng]) => ({ lat, lon: lng })), range: true };
}
/**
* /height with `range` answers `[metres along, height]` per point; the height
* is null where the terrain data has none.
*/
const heightAnswerSchema = z.object({
range_height: z.array(z.tuple([z.number(), z.number().nullable()])).max(HEIGHT_CHUNK_SIZE),
});
/**
* The height of each point of a piece, null where there is none, or null for
* the whole piece when the answer is no answer of that kind or does not name
* every point it was asked about.
*/
export function heightsOf(answer: unknown, expected: number): Array<number | null> | null {
const parsed = heightAnswerSchema.safeParse(answer);
if (!parsed.success || parsed.data.range_height.length !== expected) return null;
return parsed.data.range_height.map(([, height]) => height);
}
/** The line thinned evenly to at most `TRACE_MAX_POINTS`, its last point always kept. */
function traceShape(line: readonly LinePoint[]) {
const step = Math.max(1, Math.ceil(line.length / TRACE_MAX_POINTS));
return line
.filter((_, index) => index % step === 0 || index === line.length - 1)
.map(([lat, lng]) => ({ lat, lon: lng }));
}
/**
* The trace_attributes question for a hike's line: matched on foot with every
* grade of path open, since a hike may run on a T6 ridge the matcher must not
* refuse (matching is no routing: the limit the hike was planned with plays
* no part), asking only for the way behind each edge and the edge's length.
*/
export function traceBody(line: readonly LinePoint[]) {
return {
shape: traceShape(line),
costing: 'pedestrian',
costing_options: { pedestrian: { max_hiking_difficulty: 6 } },
shape_match: 'map_snap',
filters: { attributes: ['edge.way_id', 'edge.length'], action: 'include' },
};
}
const traceAnswerSchema = z.object({
edges: z
.array(
z.object({
way_id: z.number().int().nonnegative().optional(),
/** Kilometres. */
length: z.number().nonnegative().optional(),
}),
)
.max(TRACE_MAX_EDGES),
});
/** One stretch of a described line: the OpenStreetMap way it runs on, where the router names one, and its length. */
export interface TracedWay {
wayId: number | null;
metres: number;
}
/**
* The ways under a line in the order they are walked. An edge without a
* length is left out, since it cannot be placed along the line; one without a
* way keeps its length, so the stretches still add up to the line.
*/
export function tracedWaysOf(answer: unknown): TracedWay[] {
const { edges } = traceAnswerSchema.parse(answer);
return edges.flatMap((edge) =>
edge.length === undefined
? []
: [{ wayId: edge.way_id !== undefined && edge.way_id > 0 ? edge.way_id : null, metres: edge.length * 1000 }],
);
}
+285
View File
@@ -0,0 +1,285 @@
import { addStretch, metresAlong } from './hike-routing.helpers';
import type { PlannedHike } from './hike-routing.service';
import type { TourCreateCommand } from './tours.service';
import {
haversineMetres,
HIKE_PROPOSAL_SAC_SEGMENTS,
hikingPreferencesOf,
TOUR_MAX_SNAP_M,
type GeometryPoint,
type HikeCreateRequest,
type HikePlan,
type HikePlanPoint,
type HikeProposal,
type HikeProposalSac,
type HikeProposeRequest,
type HikeRouteOutcome,
type HikeSacSegment,
type HikeTrailGrades,
type TourWaypoint,
type UnroutedHikePlan,
} from '@trek/shared';
/**
* The pure halves of a hike an assistant plans over MCP (HikePlanService):
* the points it is planned through, the proposal shown from a plan, the way
* a saved hike keeps from a plan, and the write a created hike makes.
*/
/** The points a hike is planned through: as asked, and for a round trip back to its start. */
export function hikePointsOf(waypoints: readonly HikePlanPoint[], roundTrip: boolean | undefined): HikePlanPoint[] {
const [start] = waypoints;
return roundTrip === true && start ? [...waypoints, { ...start }] : [...waypoints];
}
/** Whether the way was planned, and why not, as the answers name it. */
export function routeOutcomeOf(plan: HikePlan): HikeRouteOutcome {
return plan.kind === 'routed' ? { planned: true } : { planned: false, why: plan.why, position: plan.position };
}
/** The points as a tour keeps them: in order, the first the start and the last the end, with their names. */
export function hikeWaypointsOf(points: readonly HikePlanPoint[]): TourWaypoint[] {
const last = points.length - 1;
return points.map(({ lat, lng, name }, sequence) => ({
lat,
lng,
...(name ? { name } : {}),
role: sequence === 0 ? 'start' : sequence === last ? 'end' : 'via',
sequence,
}));
}
/** The way a saved hike keeps from a plan: its whole line and walking time, or none, and how the planning went. */
export interface KeptWay {
line: GeometryPoint[] | null;
seconds: number | null;
route: HikeRouteOutcome;
}
/**
* The way a hike is saved with from its plan, as the planner saves one: the
* whole line with the height of every point, and its walking time. A way
* whose heights could not all be read is not kept: the planner saves no
* hike without them, and the router that did not answer for them is asked
* again when the hike is next planned, so it reads as `router_unavailable`.
*/
export function keptWayOf({ plan, line }: PlannedHike): KeptWay {
if (plan.kind === 'routed' && plan.has_heights && line) {
return { line, seconds: plan.duration_minutes * 60, route: { planned: true } };
}
const route: HikeRouteOutcome =
plan.kind === 'routed' ? { planned: false, why: 'router_unavailable', position: null } : routeOutcomeOf(plan);
return { line: null, seconds: null, route };
}
/**
* The write of a hike created from its points, the way the planner's save
* writes one: the way it keeps (the whole line with its heights and its
* walking time) and its routing limit. Without a way it is saved with its
* points only, and the planner plans the way when it is opened.
*/
export function hikeCreateCommandOf(
request: HikeCreateRequest,
points: readonly HikePlanPoint[],
way: KeptWay,
limit: number,
): TourCreateCommand {
return {
name: request.name,
description: request.description,
tour_type: 'hike',
route_geometry: way.line,
duration_seconds: way.seconds,
max_hiking_difficulty: limit,
hiking_preferences: hikingPreferencesOf(request.hiking_preferences),
...(request.planned_duration_minutes !== undefined
? { planned_duration_minutes: request.planned_duration_minutes }
: {}),
waypoints: hikeWaypointsOf(points),
};
}
/** How near a new point of a hike stands to a saved one to be the same point, in metres. */
const SAME_POINT_M = 25;
/** The saved point nearest to `point`, where one stands within SAME_POINT_M of it. */
function savedPointAt(point: { lat: number; lng: number }, saved: readonly TourWaypoint[]): TourWaypoint | undefined {
let nearest: TourWaypoint | undefined;
let nearestGap = Number.POSITIVE_INFINITY;
for (const old of saved) {
const gap = haversineMetres(point.lat, point.lng, old.lat, old.lng);
if (gap <= SAME_POINT_M && gap < nearestGap) {
nearest = old;
nearestGap = gap;
}
}
return nearest;
}
/**
* New points as a hike keeps them, in order. A point that stands where a
* saved one stood (within SAME_POINT_M, the nearest) is that point still: it
* keeps its link to the trip's place it stands for, and its name where the
* new one gives none. A saved point no new one stands on goes, with its link.
*/
export function repointedWaypoints(points: readonly HikePlanPoint[], saved: readonly TourWaypoint[]): TourWaypoint[] {
return hikeWaypointsOf(points).map((point) => {
const same = savedPointAt(point, saved);
if (!same) return point;
const name = point.name ?? same.name;
return {
...point,
...(name ? { name } : {}),
...(same.linked_place_id ? { linked_place_id: same.linked_place_id } : {}),
};
});
}
/**
* How much nearer than the nearest pass a later one may be and still lose to
* an earlier one: a way that comes by a point twice (a summit walked up and
* down the same path) places it on the first pass.
*/
const PASS_SLACK_M = 30;
/**
* Where each point lies along the line, in metres: at the line's nearest
* point at or after the one before it, so the points keep their order and a
* round trip's end lies at the end of the way, not at its start.
*/
export function positionsAlong(line: readonly GeometryPoint[], points: readonly HikePlanPoint[]): number[] {
const along = metresAlong(line);
let from = 0;
return points.map(({ lat, lng }) => {
const gaps = line.slice(from).map(([pointLat, pointLng]) => haversineMetres(lat, lng, pointLat, pointLng));
const nearest = gaps.reduce((low, gap) => Math.min(low, gap), Number.POSITIVE_INFINITY);
from += Math.max(
0,
gaps.findIndex((gap) => gap <= nearest + PASS_SLACK_M),
);
return Math.round(along[from]);
});
}
/** The lowest and highest point of a line with heights, in whole metres. */
export function heightRangeOf(line: readonly GeometryPoint[]): { min: number; max: number } | null {
const heights = line.flatMap((point) => (point.length === 3 ? [point[2]] : []));
if (heights.length === 0 || heights.length !== line.length) return null;
return {
min: Math.round(heights.reduce((low, height) => Math.min(low, height))),
max: Math.round(heights.reduce((high, height) => Math.max(high, height))),
};
}
/**
* Neighbouring stretches joined in pairs, each pair taking the harder of its
* grades, so a joined stretch never reads easier than a path in it. Only
* stretches that touch are paired unless `acrossGaps`: a stretch of unknown
* grade between two stays unknown, rather than read as either's grade.
*/
function pairedUp(segments: readonly HikeSacSegment[], acrossGaps: boolean): HikeSacSegment[] {
const paired: HikeSacSegment[] = [];
let held: HikeSacSegment | null = null;
for (const segment of segments) {
if (held && (acrossGaps || held.to_m === segment.from_m)) {
addStretch(paired, { from_m: held.from_m, to_m: segment.to_m, sac: Math.max(held.sac, segment.sac) });
held = null;
} else {
if (held) addStretch(paired, held);
held = segment;
}
}
if (held) addStretch(paired, held);
return paired;
}
/**
* The SAC grades a proposal shows: at most HIKE_PROPOSAL_SAC_SEGMENTS
* stretches. Where a long way holds more, touching neighbours are joined in
* pairs, the harder grade kept, until they fit; the stretches of unknown grade
* between stay as they are. Only a way with more graded stretches than that,
* each apart from the next, has them joined over the gaps as well. How much
* of the line is unknown stays as measured.
*/
export function proposalSacOf(grades: HikeTrailGrades | null): HikeProposalSac | null {
if (!grades) return null;
let segments = grades.segments.reduce<HikeSacSegment[]>(addStretch, []);
while (segments.length > HIKE_PROPOSAL_SAC_SEGMENTS) {
const touching = pairedUp(segments, false);
segments = touching.length < segments.length ? touching : pairedUp(segments, true);
}
return { highest: grades.highest, segments, unknown_share: grades.unknown_share };
}
/** Where a point lies further from any way than a tour allows: which one, and how far the router had to move it. */
function unreachableOf(plan: HikePlan): HikeProposal['unreachable'] {
if (plan.kind !== 'unrouted' || plan.position === null || plan.snapped_m === null) return null;
return { position: plan.position, snapped_m: plan.snapped_m };
}
/**
* The proposal of a hike as a plan gives it: the way and its measures, its
* profile and grades, each point with where it lies along the way, and the
* request's own words given back. Without a way the points are still shown,
* with why there is none.
*/
export function hikeProposalOf(
request: HikeProposeRequest,
points: readonly HikePlanPoint[],
{ plan, line }: PlannedHike,
): HikeProposal {
const routed = plan.kind === 'routed' ? plan : null;
const positions = routed && line ? positionsAlong(line, points) : null;
const heights = routed?.has_heights && line ? heightRangeOf(line) : null;
return {
name: request.name ?? null,
variant_label: request.variant_label ?? null,
route: routeOutcomeOf(plan),
unreachable: unreachableOf(plan),
distance_m: routed?.distance_m ?? null,
ascent_m: routed?.ascent_m ?? null,
descent_m: routed?.descent_m ?? null,
duration_minutes: routed?.duration_minutes ?? null,
duration_basis: routed?.duration_basis ?? null,
heights_known: heights !== null,
min_ele_m: heights?.min ?? null,
max_ele_m: heights?.max ?? null,
line: routed?.line ?? [],
profile: routed?.profile ?? [],
sac: proposalSacOf(routed?.grades ?? null),
waypoints: points.map(({ lat, lng, name }, index) => ({
lat,
lng,
name: name || null,
at_m: positions?.[index] ?? null,
})),
max_hiking_difficulty: plan.max_hiking_difficulty,
hiking_preferences: routed ? routed.hiking_preferences : hikingPreferencesOf(request.hiking_preferences),
round_trip: request.round_trip === true,
date: request.date ?? null,
start_time: request.start_time ?? null,
};
}
/** Why no way was planned, in words that say what to do about it. */
const UNPLANNED: Readonly<Record<UnroutedHikePlan['why'], (position: number | null) => string>> = {
no_valhalla: () =>
'TREK has no Valhalla router here to plan a hike with (a routing server of its own knows no hiking limit).',
router_unavailable: () => 'The router did not answer, for the way or for its heights; try again in a moment.',
no_way: () => 'No way within the hiking limit joins these points: raise the limit or move the points.',
point_off_the_way: (position) =>
`Point ${position ?? ''} is further than ${TOUR_MAX_SNAP_M} m from any way: move it onto a path.`,
too_long: () => 'The way is too long to plan as one hike: split it into shorter hikes.',
};
type Unplanned = Pick<UnroutedHikePlan, 'why' | 'position'>;
/** The refusal of a hike whose way could not be planned for a reason the planner would meet too: nothing is saved. */
export function unplannedCreateMessage({ why, position }: Unplanned): string {
return `${UNPLANNED[why](position)} Nothing was saved.`;
}
/** The refusal of a change whose new way could not be planned: the hike keeps the way it has. */
export function unplannedChangeMessage({ why, position }: Unplanned): string {
return `${UNPLANNED[why](position)} Nothing was saved and the hike keeps its way.`;
}
+116
View File
@@ -0,0 +1,116 @@
import { ADDON_IDS } from '../../addons';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import {
McpController,
Tool,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
errorResult,
ok,
type McpContext,
} from '../../nest-mcp';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { TripAccessService } from '../trip-membership/trip-access.service';
import { HikePlanService } from './hike-plan.service';
import { NotFoundException } from '@nestjs/common';
import {
hikeCreateRequestSchema,
hikeProposeRequestSchema,
idSchema,
tourGpxImportRequestSchema,
tourUpdateRequestSchema,
type HikeCreateRequest,
type HikeProposeRequest,
type TourGpxImportRequest,
type TourUpdateRequest,
} from '@trek/shared';
/**
* A hike planned with the traveller over MCP. The proposal reads only, so a
* way turned down changes nothing and several can be compared; the hike is
* created, changed or taken in from a GPX file once the traveller said yes,
* through the writes the planner and the file import use, with 'place_edit'
* on the trip as REST asks (ToursController for a create and an edit,
* ToursImportController for the file). Those writes tell the trip about the
* tour (tours:changed with place:created or place:updated) without leaving
* out any socket, so the tab that asked shows it too. A refusal of the
* service is a DomainError, which the registry answers as an error result
* with the text REST sends.
*/
@McpController()
export class HikePlanMcp {
constructor(
private readonly hikes: HikePlanService,
private readonly access: TripAccessService,
private readonly guards: McpToolGuardsService,
// Read by the addon gate of every tool here.
readonly addons: AddonsService,
) {}
@Tool({
name: 'propose_hike',
description:
"Plan a hike for a trip and show it to the traveller, without saving anything. The way on foot runs through waypoints in order (the start, the points on the way such as a hut or a summit, the end; with round_trip it comes back to the start) and takes no path harder than max_hiking_difficulty on the SAC scale (1 for T1 hiking to 6 for T6 difficult alpine hiking, 2 when left out), avoiding steep climbs, steps or ferries where there is a way round when hiking_preferences asks so. TREK's router plans it as the tour planner does. The answer gives distance_m, ascent_m and descent_m, the walking time without breaks (duration_minutes: by DIN 33466, the rule hiking signposts use, where the heights are known, duration_basis din33466; else the router's, duration_basis router), min_ele_m and max_ele_m, the SAC grades OpenStreetMap records along the way (sac: highest, the graded stretches in metres along the way, and unknown_share, the part with no grade, which is unknown and never easy), every point with at_m, where it lies along the way, and the line and height profile for display. Where no way was planned, route says why: no_valhalla (this TREK has no router that knows hiking grades), router_unavailable (try again later), no_way (nothing within the limit joins the points: a higher limit or other points may), point_off_the_way (unreachable names the point and how far it is from any way) or too_long (longer than one hike is planned for: split it into shorter hikes). To compare ways, propose each one with its own variant_label, such as 'short and steep' or 'by the lake'; date and start_time are given back for the schedule. The chat shows the route, its profile and grades as a card: never list coordinates or the profile, sum the hike up in a sentence and say which grade it reaches. Propose first and create the hike with create_hike only after the traveller said yes.",
inputSchema: { tripId: idSchema, ...hikeProposeRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'read' },
})
async proposeHike({ tripId, ...request }: { tripId: number } & HikeProposeRequest, ctx: McpContext) {
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
return ok(await this.hikes.propose(ctx.userId, tripId, request));
}
@Tool({
name: 'create_hike',
description:
"Create a hike in a trip from points the traveller confirmed, usually the waypoints of a proposal of propose_hike with its max_hiking_difficulty, hiking_preferences and round_trip. It is saved the way the planner saves a new hike: a tour of type hike with its points in order (start, points on the way, end), the way on foot planned by the same router as the proposal and kept whole, with its heights where every point has one, its distance, ascent, descent and walking time, its hiking limit and preferences, and planned_duration_minutes (the time out, breaks included) where given. Where the router did not answer, for the way or its heights (route planned false, why router_unavailable), the hike is saved with its points only (route_pending true) and TREK plans the way when someone opens it in the planner; no straight line is stored. Any other reason the proposal names (no_valhalla, no_way, point_off_the_way, too_long) refuses the hike with why, and nothing is saved. The answer is the tour as list_tours reads it (tour.place_id is the tour's id, its revision 1) and route. Put it on a day with assign_place_to_day, link it to the packing list with link_tour_packing, export it with export_tour_gpx, change it with update_tour. Needs place editing permission on the trip. Create it only after the traveller said yes.",
inputSchema: { tripId: idSchema.describe('The trip to create the hike in'), ...hikeCreateRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'write' },
})
async createHike({ tripId, ...request }: { tripId: number } & HikeCreateRequest, ctx: McpContext) {
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
return ok(await this.hikes.create(ctx.userId, tripId, request));
}
@Tool({
name: 'update_tour',
description:
"Change a saved hike (tour_type hike) against the revision it was read at: expected_revision is tour.revision from get_tour_plan or list_tours. A new name, description or planned_duration_minutes keeps the hike's way. New waypoints (start to end; a round trip ends on its start), a new max_hiking_difficulty or new hiking_preferences plan the way anew as propose_hike plans it, and so does a hike still waiting for its way (route_pending); propose the new way to the traveller first. A hike changed since that revision is refused as a conflict: read it again and show the traveller what changed. Where the new way cannot be planned, or not with its heights, nothing is saved and the hike keeps its way; the error says why. A new point standing where a saved one stood (within 25 m) keeps that point's link to a place of the trip and its name. A hike with a section drawn by hand (a via ferrata, a free line, a ferry) or split into stages takes new points only in the planner, and the hand-drawn one no new limit or preferences either: those changes are refused here. A recorded track without points of its own (an import) takes only new waypoints, which plan it over and keep the recording. Any other type is refused: change a city walk with prepare_tour_change and save_tour_change. The hike stays on its days. The answer is the tour as saved, with its new revision, route and replanned. Needs place editing permission on the trip. Change it only after the traveller said yes.",
inputSchema: { tripId: idSchema, ...tourUpdateRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_WRITE,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'write' },
})
async updateTour({ tripId, ...request }: { tripId: number } & TourUpdateRequest, ctx: McpContext) {
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
try {
return ok(await this.hikes.update(ctx.userId, tripId, request));
} catch (err: unknown) {
if (err instanceof NotFoundException) return errorResult('Tour not found.');
throw err;
}
}
@Tool({
name: 'import_tour_gpx',
description:
"Import the tracks and routes of a GPX file as tours of a trip, one tour each, as the planner's file import does: gpx is the file's text (its XML as it is, up to 1,000,000 characters) and file_name the name the travellers know it by. Without a type each one is a hike; a bike import names bike_profile, a run run_profile, a paddle import boat_type and water_context, a ski import ski_discipline (and for cross-country ski_technique). A track already in the trip under the same name and start is skipped and counted in skipped. The answer lists the tours created (each tour's place_id is its id), caution (a track without heights, its climb unknown) and skipped. A file without a single track or route is refused. Needs place editing permission on the trip. Import only a file the traveller gave and asked to import.",
inputSchema: { tripId: idSchema, ...tourGpxImportRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'write' },
})
async importTourGpx({ tripId, ...request }: { tripId: number } & TourGpxImportRequest, ctx: McpContext) {
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
return ok(await this.hikes.importGpx(tripId, request));
}
}
+243
View File
@@ -0,0 +1,243 @@
import { badRequest, DomainError } from '../common/domain-error';
import { UnitOfWork } from '../database/unit-of-work';
import {
hikeCreateCommandOf,
hikePointsOf,
hikeProposalOf,
keptWayOf,
repointedWaypoints,
unplannedChangeMessage,
unplannedCreateMessage,
type KeptWay,
} from './hike-plan.helpers';
import { HikeRoutingService, type PlannedHike } from './hike-routing.service';
import { noTrackFound } from './tour-file-import.helpers';
import { tourRevisionConflict } from './tour-revision';
import { ToursService, type ImportGpxAsTourResult, type TourCreateCommand } from './tours.service';
import { Injectable } from '@nestjs/common';
import {
HIKE_PLAN_MAX_WAYPOINTS,
hikingPreferencesOf,
tourGpxImportFieldsSchema,
TOURS_MAX_HIKING_DIFFICULTY,
type HikeCreateRequest,
type HikePlanPoint,
type HikeProposal,
type HikeProposeRequest,
type HikingPreferences,
type TourCreateResponse,
type TourDetailResponse,
type TourGpxImportRequest,
type HikeRouteOutcome,
type TourUpdateRequest,
type TourWaypoint,
} from '@trek/shared';
/** What a hike created over MCP answers: the tour as the planner's save answers it, and whether its way was planned. */
export type HikeCreated = TourCreateResponse & { route: HikeRouteOutcome };
/** What a change answers: the tour as now saved, whether its way was planned anew, and how that went. */
export type HikeChanged = TourDetailResponse & { route: HikeRouteOutcome; replanned: boolean };
/** The name an import keeps when the request gives none, and the ending that has the text read as GPX. */
const GPX_NAME = 'import.gpx';
function gpxFileName(name: string | undefined): string {
if (!name) return GPX_NAME;
return name.toLowerCase().endsWith('.gpx') ? name : `${name}.gpx`;
}
/** Whether two sets of preferences ask the router for the same, each as a hike keeps it. */
function samePreferences(a: HikingPreferences | null, b: HikingPreferences | null): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
/** A hike whose way cannot be planned: refused with why, as a 422 the tools answer with its text. */
function notRouted(message: string): DomainError {
return new DomainError(422, message, { code: 'hike_not_routed' });
}
/** A section of the hike's way drawn by hand, which the planner alone plans around. */
const HAND_DRAWN =
'This hike has a section drawn by hand (a via ferrata, a free line or a ferry) that only the planner plans around: change its way there. A new name, description or planned time is changed here.';
/**
* Why new points are refused for a saved hike: a section drawn by hand or the
* ends of its stages would go with the points they belong to, and a stage
* that goes would leave its day. Null where new points take nothing away.
*/
function repointingRefusal(saved: readonly TourWaypoint[]): string | null {
if (saved.some((point) => point.fixed_leg)) return HAND_DRAWN;
if (saved.some((point) => point.stage_end)) {
return 'This hike is split into stages over several days, which new points would end: change its points in the planner. A new limit or new preferences plan it anew here, its stages kept.';
}
return null;
}
/**
* A hike planned with the traveller over MCP, end to end on the server: the
* proposal reads only and may be asked for as often as ways are compared;
* the hike is created once the traveller said yes, through the creation the
* planner's save uses (one write, the trip told); a saved hike is changed
* through the route write the planner's edit uses, against the revision it
* was read at; a GPX file's text is taken in through the tours' file import.
* The way is planned by HikeRoutingService under the hike's limit and
* preferences. Refusals are DomainErrors, which REST and the MCP registry
* answer with their text.
*/
@Injectable()
export class HikePlanService {
constructor(
private readonly uow: UnitOfWork,
private readonly routing: HikeRoutingService,
private readonly tours: ToursService,
) {}
/** The way through the points; `grades` for a proposal only, which shows them. */
private async plan(
userId: number,
tripId: number,
points: HikePlanPoint[],
limit: number | undefined,
preferences: HikingPreferences | null | undefined,
grades: boolean,
): Promise<PlannedHike> {
return await this.routing.plan(
userId,
tripId,
{ waypoints: points, max_hiking_difficulty: limit, hiking_preferences: preferences },
{ grades },
);
}
async propose(userId: number, tripId: number, request: HikeProposeRequest): Promise<HikeProposal> {
const points = hikePointsOf(request.waypoints, request.round_trip);
const { max_hiking_difficulty: limit, hiking_preferences: preferences } = request;
const planned = await this.plan(userId, tripId, points, limit, preferences, true);
return hikeProposalOf(request, points, planned);
}
/**
* The hike created from its points with the way planned on the server and
* kept whole with its heights. Where the router did not answer, for the way
* or for its heights, it is saved with its points only (route_pending), and
* the planner plans the way when it is opened. Any other reason stands in
* the planner as well (no Valhalla, no way within the limit, a point off
* every way, a way too long), so the hike is refused and nothing is saved.
*/
async create(userId: number, tripId: number, request: HikeCreateRequest): Promise<HikeCreated> {
const points = hikePointsOf(request.waypoints, request.round_trip);
const { max_hiking_difficulty: limit, hiking_preferences: preferences } = request;
const planned = await this.plan(userId, tripId, points, limit, preferences, false);
const way = keptWayOf(planned);
if ('why' in way.route && way.route.why !== 'router_unavailable') {
throw notRouted(unplannedCreateMessage(way.route));
}
const command = hikeCreateCommandOf(request, points, way, planned.plan.max_hiking_difficulty);
const created = await this.tours.createTour(String(tripId), command);
return { ...created, route: way.route };
}
/**
* A saved hike changed against the revision it was read at; a change based
* on an older one is refused before the router is asked (the write checks it
* again). New points, a new limit or new preferences plan the way anew, and
* so does a hike still waiting for its way; a name, a description or a
* planned time alone keep the way it has. A new way that cannot be planned,
* or not with its heights, refuses the change rather than drop the way the
* hike has; a hike still waiting for its way keeps waiting. New points keep
* the links of the saved points they stand on, and are refused for a hike
* with a section drawn by hand or with stages, which they would take away.
* Only a hike planned in TREK takes it: a recorded track without points of
* its own is planned over only with points given, and every other type is
* changed in the planner (a city walk through prepare_tour_change and
* save_tour_change).
*/
async update(userId: number, tripId: number, request: TourUpdateRequest): Promise<HikeChanged> {
const { placeId } = request;
const saved = await this.tours.getTour(String(tripId), String(placeId));
const { tour } = saved;
if (tour.tour_type !== 'hike') {
throw badRequest(
'Only a hike is changed here. A city walk is changed with prepare_tour_change and save_tour_change, any other tour in the planner.',
);
}
if (tour.revision !== undefined && tour.revision !== request.expected_revision) {
throw tourRevisionConflict(tour.revision);
}
if (tour.has_waypoints !== true && request.waypoints === undefined) {
throw badRequest(
'This hike is a recorded track without points of its own: give waypoints to plan it over (the recorded track is kept), or rename it with update_place.',
);
}
const repointing = request.waypoints ? repointingRefusal(saved.waypoints) : null;
if (repointing) throw badRequest(repointing);
const kept = hikingPreferencesOf(tour.hiking_preferences);
// A recorded track keeps no limit of its own; planned over, it takes the planner's.
const keptLimit = tour.max_hiking_difficulty ?? TOURS_MAX_HIKING_DIFFICULTY;
const limit = request.max_hiking_difficulty ?? keptLimit;
const preferences =
request.hiking_preferences === undefined ? kept : hikingPreferencesOf(request.hiking_preferences);
const line = await this.tours.savedLine(tripId, placeId);
const replan =
request.waypoints !== undefined ||
line.length === 0 ||
limit !== keptLimit ||
!samePreferences(preferences, kept);
let way: KeptWay;
if (replan) {
// New points were refused above for such a hike; a new limit or new preferences are refused here.
if (saved.waypoints.some((point) => point.fixed_leg)) throw badRequest(HAND_DRAWN);
if (!request.waypoints && saved.waypoints.length > HIKE_PLAN_MAX_WAYPOINTS) {
throw badRequest(
`This hike has more than ${HIKE_PLAN_MAX_WAYPOINTS} points, more than its way is planned through here: change it in the planner, or give new waypoints.`,
);
}
const points = request.waypoints ?? saved.waypoints.map(({ lat, lng }) => ({ lat, lng }));
way = keptWayOf(await this.plan(userId, tripId, points, limit, preferences, false));
if ('why' in way.route && line.length > 0) throw notRouted(unplannedChangeMessage(way.route));
} else {
way = { line, seconds: tour.duration == null ? null : tour.duration * 60, route: { planned: true } };
}
const command: TourCreateCommand = {
name: request.name ?? tour.name,
...(request.description !== undefined ? { description: request.description } : {}),
tour_type: 'hike',
route_geometry: way.line,
duration_seconds: way.seconds,
max_hiking_difficulty: limit,
hiking_preferences: preferences,
...(request.planned_duration_minutes !== undefined
? { planned_duration_minutes: request.planned_duration_minutes }
: {}),
waypoints: request.waypoints ? repointedWaypoints(request.waypoints, saved.waypoints) : saved.waypoints,
expected_revision: request.expected_revision,
};
const write = await this.tours.routeWrite(command);
const drops = await this.uow.transactional(() => this.tours.replaceRoute(tripId, placeId, write));
const detail = await this.tours.announceRouteUpdate(String(tripId), placeId, drops);
return { ...detail, route: way.route, replanned: replan };
}
/**
* A GPX file's text taken in as tours, one for each of its tracks and
* routes, through the tours' file import, with the fields the multipart
* import takes beside the file and the same refusals.
*/
async importGpx(tripId: number, { gpx, file_name, ...fields }: TourGpxImportRequest): Promise<ImportGpxAsTourResult> {
const checked = tourGpxImportFieldsSchema.safeParse(fields);
if (!checked.success) throw badRequest(checked.error.issues.map((issue) => issue.message).join('; '));
const fileName = gpxFileName(file_name);
const imported = await this.tours.importGpxAsTour(
String(tripId),
Buffer.from(gpx, 'utf8'),
fileName,
undefined,
checked.data,
);
if (!imported) throw noTrackFound(fileName);
return imported;
}
}
@@ -0,0 +1,132 @@
import type { TracedWay } from '../roadtrip/roadtrip-router.service';
import { thinnedLine } from './tours.helpers';
import {
haversineMetres,
type GeometryPoint,
type HikeSacSegment,
type HikeTrailGrades,
type SacStep,
} from '@trek/shared';
/**
* The pure halves of a hike planned on the server (HikeRoutingService): the
* routed line as one line, its height profile for display, and the SAC
* grades of the ways under it laid onto the line as stretches. The measures
* along a line and the joining of stretches are here once, for the proposal
* (hike-plan.helpers) as well.
*/
type HeightPoint = [number, number, number];
function hasHeight(point: GeometryPoint): point is HeightPoint {
return point.length === 3;
}
/**
* The router's line with every point that repeats the one before it left
* out: each leg starts on the point the one before ended at, and the planner
* in the browser joins its legs the same way. A line that would be left with
* a single point stays as it was.
*/
export function joinedLine(line: ReadonlyArray<readonly [number, number]>): Array<[number, number]> {
const kept: Array<[number, number]> = [];
for (const [lat, lng] of line) {
const last = kept[kept.length - 1];
if (!last || last[0] !== lat || last[1] !== lng) kept.push([lat, lng]);
}
return kept.length >= 2 ? kept : line.map(([lat, lng]): [number, number] => [lat, lng]);
}
/** The line with its heights where every point has one, else as it was without any. */
export function heightsOrNone(
flat: ReadonlyArray<[number, number]>,
measured: readonly GeometryPoint[] | null,
): GeometryPoint[] {
return measured && measured.length === flat.length && measured.every(hasHeight) ? [...measured] : [...flat];
}
/** Metres along the line at each of its points, the first at 0. */
export function metresAlong(line: readonly GeometryPoint[]): number[] {
let along = 0;
return line.map(([lat, lng], index) => {
const before = line[index - 1];
if (before) along += haversineMetres(before[0], before[1], lat, lng);
return along;
});
}
/**
* The height profile for display: `[metres along the line, height]` at most
* `most` times, spread evenly over the points, the first and the last kept.
* Empty where the line has no heights.
*/
export function profileOf(line: readonly GeometryPoint[], most: number): Array<[number, number]> {
const measured = line.filter(hasHeight);
if (measured.length < 2 || measured.length !== line.length) return [];
const along = metresAlong(measured);
const samples = measured.map(([, , height], index): [number, number] => [along[index], height]);
return thinnedLine(samples, most).map(([metres, height]): [number, number] => [
Math.round(metres),
Math.round(height),
]);
}
/**
* A stretch added after the ones before it: joined to the last where it has
* the same grade and starts where that one ends, so a stretch of unknown
* grade between two of one grade keeps them apart. Changes `stretches`.
*/
export function addStretch(stretches: HikeSacSegment[], stretch: HikeSacSegment): HikeSacSegment[] {
const last = stretches[stretches.length - 1];
if (last && last.sac === stretch.sac && last.to_m === stretch.from_m) last.to_m = stretch.to_m;
else stretches.push({ ...stretch });
return stretches;
}
/** The ways under the line OpenStreetMap could grade, each once, in the order they are walked, at most `limit`. */
export function gradableWayIds(ways: readonly TracedWay[], limit: number): number[] {
const ids = new Set<number>();
for (const way of ways) {
if (ids.size === limit) break;
if (way.wayId !== null) ids.add(way.wayId);
}
return [...ids];
}
/**
* The SAC grades laid onto the line: each way's grade over the stretch it
* runs along, neighbours of one grade joined, how much of the line has none,
* and the hardest grade found. The described line is matched by the router
* and so a little longer or shorter than the planned one; its stretches are
* scaled onto the planned length, so they meet the height profile. A way
* without a grade, or one no source could be asked about, is unknown, never
* easy.
*/
export function gradesAlong(
ways: readonly TracedWay[],
steps: ReadonlyMap<number, SacStep>,
lengthM: number,
): HikeTrailGrades {
const traced = ways.reduce((sum, way) => sum + way.metres, 0);
const scale = traced > 0 && lengthM > 0 ? lengthM / traced : 1;
const segments: HikeSacSegment[] = [];
let at = 0;
let unknown = 0;
for (const way of ways) {
const from = at;
at += way.metres * scale;
const sac = way.wayId === null ? undefined : steps.get(way.wayId);
if (sac === undefined) unknown += at - from;
else if (at > from) addStretch(segments, { from_m: from, to_m: at, sac });
}
const total = traced * scale;
return {
segments: segments.map((segment) => ({
...segment,
from_m: Math.round(segment.from_m),
to_m: Math.round(segment.to_m),
})),
highest: segments.length > 0 ? Math.max(...segments.map(({ sac }) => sac)) : null,
unknown_share: total > 0 ? Math.min(1, Math.round((unknown / total) * 1000) / 1000) : 1,
};
}
@@ -0,0 +1,181 @@
import { logWarn } from '../audit/audit-log.logger';
import { badRequest } from '../common/domain-error';
import { RoadtripRouterService, RouteRefusal, type RoadtripRoute } from '../roadtrip/roadtrip-router.service';
import { gradableWayIds, gradesAlong, heightsOrNone, joinedLine, profileOf } from './hike-routing.helpers';
import { TourTrailGradeService } from './tour-trail-grade.service';
import { thinnedLine } from './tours.helpers';
import { Injectable } from '@nestjs/common';
import {
computeTourMetrics,
HIKE_MAX_LINE_POINTS,
HIKE_PLAN_LINE_POINTS,
HIKE_PLAN_PROFILE_SAMPLES,
hikePlanRequestSchema,
hikingCosting,
hikingPreferencesOf,
hikingTimeMinutes,
sacStepOf,
TOUR_MAX_SNAP_M,
TOUR_TRAIL_GRADE_MAX_WAYS,
TOURS_MAX_HIKING_DIFFICULTY,
type GeometryPoint,
type HikePlan,
type HikePlanRequest,
type HikeTrailGrades,
type SacStep,
type UnroutedHikePlan,
} from '@trek/shared';
/**
* The day the router is asked for. A hike's way does not depend on the day:
* the router only keys its cache by one and hands it to a routing plugin,
* which a hike never asks.
*/
const ANY_DAY = 0;
/** A hike as planned: what the plan shows, and the whole line a tour created from it keeps. */
export interface PlannedHike {
plan: HikePlan;
/**
* Every point of the routed line, each with its height or none with one;
* null where no way was planned.
*/
line: GeometryPoint[] | null;
}
/** What a plan asks for besides the way and its heights. */
export interface HikePlanOptions {
/**
* Whether the SAC grades are read (one more question to the router and the
* trail grades): a proposal shows them, a hike being saved keeps none.
*/
grades: boolean;
}
/** What went wrong, for the log. */
function reasonOf(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** Why a router that gave no way gave none, in the plan's words. */
function whyNot(err: unknown): UnroutedHikePlan['why'] {
return err instanceof RouteRefusal ? err.why : 'router_unavailable';
}
/**
* A hike planned on the server the way the tour planner plans one in the
* browser: the way on foot through its points under its hiking limit and
* preferences (Valhalla only, through the router the road trip and the city
* walks use, so its one paced queue and its choice of Valhalla hold), a point
* the router had to move further onto a way than a tour allows named instead
* of taken, the heights along the line, its length, climb, descent and the
* walking time by the signposts' rule, and the SAC grades OpenStreetMap
* records for the ways under it. Heights and grades are best effort: without
* heights the line stays flat and its climb unknown, without grades the plan
* says they could not be read. A way of more points than HIKE_MAX_LINE_POINTS
* is not measured: its heights alone would hold the router's queue up for
* every user. Writes nothing.
*/
@Injectable()
export class HikeRoutingService {
constructor(
private readonly router: RoadtripRouterService,
private readonly trailGrades: TourTrailGradeService,
) {}
async plan(
userId: number,
tripId: number,
request: HikePlanRequest,
{ grades: withGrades }: HikePlanOptions = { grades: true },
): Promise<PlannedHike> {
// The tools check their own requests; a change plans through the points a hike has saved, held here to the same.
const checked = hikePlanRequestSchema.safeParse(request);
if (!checked.success) throw badRequest(checked.error.issues.map((issue) => issue.message).join('; '));
const limit = request.max_hiking_difficulty ?? TOURS_MAX_HIKING_DIFFICULTY;
const preferences = hikingPreferencesOf(request.hiking_preferences);
const unrouted = (why: UnroutedHikePlan['why'], position: number | null = null, snapped: number | null = null) => ({
plan: { kind: 'unrouted' as const, max_hiking_difficulty: limit, why, position, snapped_m: snapped },
line: null,
});
let route: RoadtripRoute;
try {
const points = request.waypoints.map(({ lat, lng }) => ({ lat, lng }));
const costingOptions = hikingCosting(limit, preferences);
route = await this.router.route(userId, tripId, ANY_DAY, points, 'walking', [], { costingOptions });
} catch (err: unknown) {
if (!(err instanceof RouteRefusal)) logWarn(`[Hike] no way for trip ${tripId}: ${reasonOf(err)}`);
return unrouted(whyNot(err));
}
const snapped = route.snapped ?? [];
const off = snapped.findIndex((point) => point.meters > TOUR_MAX_SNAP_M);
if (off >= 0) return unrouted('point_off_the_way', off + 1, Math.round(snapped[off].meters));
const flat = joinedLine(route.leg.line);
if (flat.length > HIKE_MAX_LINE_POINTS) return unrouted('too_long');
const lengthM = computeTourMetrics(flat).distanceKm * 1000;
const [line, grades] = await Promise.all([
this.withHeights(userId, flat),
withGrades ? this.gradesOf(userId, flat, lengthM) : null,
]);
const metrics = computeTourMetrics(line);
const ascent = metrics.elevationGainM;
const descent = metrics.elevationLossM;
const heights = ascent !== null && descent !== null;
return {
plan: {
kind: 'routed',
max_hiking_difficulty: limit,
hiking_preferences: preferences,
distance_m: Math.round(lengthM),
ascent_m: heights ? Math.round(ascent) : null,
descent_m: heights ? Math.round(descent) : null,
duration_minutes: heights
? hikingTimeMinutes(metrics.distanceKm, ascent, descent)
: Math.round(route.leg.seg.duration / 60),
duration_basis: heights ? 'din33466' : 'router',
has_heights: heights,
line: thinnedLine(line, HIKE_PLAN_LINE_POINTS),
profile: profileOf(line, HIKE_PLAN_PROFILE_SAMPLES),
grades,
},
line,
};
}
/** The line with the height of every point, or as it was where the heights could not all be read. */
private async withHeights(userId: number, flat: Array<[number, number]>): Promise<GeometryPoint[]> {
try {
return heightsOrNone(flat, await this.router.heights(userId, flat));
} catch (err: unknown) {
logWarn(`[Hike] no heights: ${reasonOf(err)}`);
return heightsOrNone(flat, null);
}
}
/**
* The SAC grades along the line: the ways under it from the router, their
* grades from OpenStreetMap. Null where either could not be read at all.
*/
private async gradesOf(
userId: number,
flat: Array<[number, number]>,
lengthM: number,
): Promise<HikeTrailGrades | null> {
try {
const ways = await this.router.wayIds(userId, flat);
if (ways.length === 0) return null;
const ids = gradableWayIds(ways, TOUR_TRAIL_GRADE_MAX_WAYS);
const steps = new Map<number, SacStep>();
if (ids.length > 0) {
const answer = await this.trailGrades.grades({ way_ids: ids });
if (answer.unanswered.length === ids.length) return null;
for (const way of answer.ways) if (way.sac_scale) steps.set(way.id, sacStepOf(way.sac_scale));
}
return gradesAlong(ways, steps, lengthM);
} catch (err: unknown) {
logWarn(`[Hike] no trail grades: ${reasonOf(err)}`);
return null;
}
}
}
+14 -4
View File
@@ -33,6 +33,9 @@ import { CityWalkMcp } from './city-walk.mcp';
import { CollectionTourTakeOverController, CollectionToursController } from './collection-tours.controller';
import { CollectionToursMcp } from './collection-tours.mcp';
import { CollectionToursService } from './collection-tours.service';
import { HikePlanMcp } from './hike-plan.mcp';
import { HikePlanService } from './hike-plan.service';
import { HikeRoutingService } from './hike-routing.service';
import { TourActivitiesController } from './tour-activities.controller';
import { TourActivitiesMcp } from './tour-activities.mcp';
import { TourActivitiesService } from './tour-activities.service';
@@ -90,9 +93,12 @@ import { Module } from '@nestjs/common';
* link followed by the narrow resolver there (CW-06). A city walk an assistant
* prepares from a briefing reads the day and places of the trip through the
* tours' own repositories, is planned on foot by RoadtripModule's router and
* is created through ToursService (CW-11). A change to a saved city walk an
* assistant prepares for review is laid onto its saved points and planned on
* foot by the same router, and saves nothing (MCP-T02); once the traveller
* is created through ToursService (CW-11). A hike an assistant plans with the
* traveller is proposed, created, changed or taken in from a GPX file through
* the same writes, its way planned on the server by HikeRoutingService. A
* change to a saved city walk an assistant prepares for review is laid onto
* its saved points and planned on foot by the same router, and saves nothing
* (MCP-T02); once the traveller
* confirmed it, it is saved through the tour's own write, which records the
* step for a later undo (MCP-T03). The recorded tracks assigned to a tour
* (ACT-01) are read with its detail and written through
@@ -126,7 +132,8 @@ import { Module } from '@nestjs/common';
WandererModule,
// The obstacle data a paddle tour's checklist reads (PAD-06, PAD-09).
PaddleObstaclesModule,
// A city walk prepared over MCP is planned on foot by the router the road trip uses (CW-11).
// A city walk prepared over MCP is planned on foot by the router the road trip uses (CW-11),
// and a hike planned on the server under its hiking limit, with its heights and the ways under it.
RoadtripModule,
// A saved change takes the stages a tour no longer has off their days (MDT-02).
AssignmentsDomainModule,
@@ -190,6 +197,8 @@ import { Module } from '@nestjs/common';
TourWalkImportService,
CityWalkFootWayService,
CityWalkBriefingService,
HikeRoutingService,
HikePlanService,
TourChangePreviewService,
TourChangeSaveService,
TourChangeUndoService,
@@ -203,6 +212,7 @@ import { Module } from '@nestjs/common';
TourLibraryMcp,
TourWalkImportMcp,
CityWalkMcp,
HikePlanMcp,
TourChangeMcp,
TourChangeUndoMcp,
TourActivitiesMcp,
+329
View File
@@ -0,0 +1,329 @@
/**
* Tours e2e for a hike planned with an assistant over MCP: proposed, created,
* put on a day, pointed at the packing list, exported as GPX and changed,
* every tool called through a real MCP session on the whole app (buildApp,
* the real registry with its addon gates, scopes and error mapper, the real
* services and a migrated temp SQLite). The way is planned by the road
* trip's router with its paced queue; only the routing host behind
* safeFetchAdminConfigured is a stub answering /route, /height and
* /trace_attributes as Valhalla does, and the trail grades are a spy on the
* container's TourTrailGradeService, so nothing leaves the process. The
* router's pacing runs on the test's clock during a call, moved on until the
* call is done, so the suite does not wait it out. The broadcasts are spied
* on the container's RealtimeService.
*/
import { buildApp } from '../../src/bootstrap';
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
import { Places } from '../../src/db/entities/Places.entity';
import { Tours } from '../../src/db/entities/Tours.entity';
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
import { unchangedSinceApplied } from '../../src/nest/assistant/assistant-undo.helpers';
import { withRequestContext } from '../../src/nest/database/request-context';
import { TourTrailGradeService } from '../../src/nest/tours/tour-trail-grade.service';
import { safeFetchAdminConfigured } from '../../src/utils/ssrfGuard';
import { countRows, findRow } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
import { makeDay, makeTrip } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { spyOnRealtime, type RealtimeSpies } from '../helpers/fake-realtime';
import { valhallaRoute } from '../helpers/valhalla';
import { MikroORM } from '@mikro-orm/core';
import { Client } from '@modelcontextprotocol/sdk/client/index';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
import type { INestApplication } from '@nestjs/common';
import { hikeProposalSchema, type TourTrailGrade } from '@trek/shared';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/utils/ssrfGuard', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/utils/ssrfGuard')>()),
safeFetchAdminConfigured: vi.fn(),
}));
let app: INestApplication;
let orm: MikroORM;
let broadcasts: RealtimeSpies;
/** A point `k` steps of 0.0005° north of 47.6° N on 11.3° E, about 56 m a step. */
const at = (k: number): [number, number] => [47.6 + k / 2000, 11.3];
const stepsOf = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, index) => at(from + index));
/** The height under a point: 40 m a step up. */
const heightAt = (lat: number) => Math.round(800 + (lat - 47.6) * 80_000);
const WAYPOINTS = [
{ lat: at(0)[0], lng: 11.3, name: 'Car park' },
{ lat: at(10)[0], lng: 11.3, name: 'Hut' },
{ lat: at(20)[0], lng: 11.3, name: 'Summit' },
];
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
/** The routing host: two legs up to the summit, the height of every point, two ways under the line. */
function valhalla() {
vi.mocked(safeFetchAdminConfigured).mockImplementation(async (url, init) => {
const body = JSON.parse(String(init?.body ?? '{}')) as { shape?: Array<{ lat: number; lon: number }> };
if (url.endsWith('/route')) {
return json(
valhallaRoute([
{ line: stepsOf(0, 10), km: 0.556, seconds: 600 },
{ line: stepsOf(10, 20), km: 0.556, seconds: 720 },
]),
);
}
if (url.endsWith('/height')) {
return json({ range_height: (body.shape ?? []).map((point, index) => [index, heightAt(point.lat)]) });
}
if (url.endsWith('/trace_attributes')) {
return json({
edges: [
{ way_id: 501, length: 0.556 },
{ way_id: 502, length: 0.556 },
],
});
}
return json({}, 404);
});
}
const grade = (id: number, sac: TourTrailGrade['sac_scale']): TourTrailGrade => ({
id,
mtb_scale: null,
mtb_scale_uphill: null,
mtb_scale_imba: null,
sac_scale: sac,
sac_scale_unclear: null,
surface: null,
highway: 'path',
});
/** What the routing host was asked since the counts were last taken, by path. */
let askedBefore = 0;
function askedSince(): string[] {
const calls = vi.mocked(safeFetchAdminConfigured).mock.calls.slice(askedBefore);
askedBefore += calls.length;
return calls.map(([url]) => new URL(String(url)).pathname);
}
/** The test's clock moved on a tenth of a second at a time, each with a turn of the real event loop, until `done`. */
async function moveOn(done: () => boolean): Promise<void> {
if (done()) return;
await vi.advanceTimersByTimeAsync(100);
await moveOn(done);
}
/**
* A call moved through to its end on the test's clock: the router waits about
* a second between two questions to the host, here no time at all.
*/
async function settled<T>(pending: Promise<T>): Promise<T> {
let done = false;
const outcome = pending.then(
(value) => {
done = true;
return { value };
},
(error: unknown) => {
done = true;
return { error };
},
);
await moveOn(() => done);
const result = await outcome;
if ('error' in result) throw result.error;
return result.value;
}
/** One tool call as `userId` over an MCP session of its own, with every scope; the JSON it answered or its error. */
async function callTool(userId: number, name: string, args: Record<string, unknown>) {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
try {
return await settled(callOnce(userId, name, args));
} finally {
vi.useRealTimers();
}
}
async function callOnce(userId: number, name: string, args: Record<string, unknown>) {
const server = createLocalMcpServer('TREK hike e2e');
const around = (_info: unknown, call: () => unknown): unknown => withRequestContext(orm, call);
await withRequestContext(orm, () =>
app
.get(McpRegistryService)
.attach(
server,
{ userId, scopes: null, isStaticToken: false },
{ include: (kind, tool) => kind === 'tool' && tool === name, around },
),
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'hike-e2e', version: '1.0.0' });
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
const result = await client.callTool({ name, arguments: args });
const content = Array.isArray(result.content) ? (result.content as Array<{ text?: string }>) : [];
const text = content[0]?.text ?? '';
return result.isError === true ? { error: text } : JSON.parse(text);
} finally {
await client.close();
await server.close();
}
}
/** The trip sends as `[event, excluded socket]`, emptied after each read. */
function sent(): Array<[string, unknown]> {
const calls = broadcasts.broadcast.mock.calls.map((call): [string, unknown] => [String(call[1]), call[3]]);
broadcasts.broadcast.mockClear();
return calls;
}
beforeAll(async () => {
app = await buildApp();
orm = app.get(MikroORM);
broadcasts = spyOnRealtime(app);
await setAddonEnabled(orm, 'tours', true);
await setAddonEnabled(orm, 'packing', true);
vi.spyOn(app.get(TourTrailGradeService), 'grades').mockResolvedValue({
ways: [grade(501, 'hiking'), grade(502, 'mountain_hiking')],
unanswered: [],
});
valhalla();
}, 60_000);
afterAll(async () => {
await app?.close();
});
describe('a hike planned with an assistant over MCP', () => {
it('is proposed, created, put on a day, packed for, exported and changed, the whole trip told of each write', async () => {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { title: 'Alps' });
const day = await makeDay(orm, trip.id, { day_number: 1, date: '2027-07-10' });
// The proposal: the way, its heights and grades, and nothing written.
const proposal = await callTool(user.id, 'propose_hike', {
tripId: trip.id,
name: 'Up to the summit',
waypoints: WAYPOINTS,
max_hiking_difficulty: 2,
variant_label: 'by the hut',
date: '2027-07-10',
start_time: '08:30',
});
expect(hikeProposalSchema.parse(proposal)).toEqual(proposal);
expect(proposal).toMatchObject({
route: { planned: true },
variant_label: 'by the hut',
distance_m: 1112,
ascent_m: 800,
heights_known: true,
min_ele_m: 800,
max_ele_m: 1600,
duration_basis: 'din33466',
sac: { highest: 2, unknown_share: 0 },
date: '2027-07-10',
start_time: '08:30',
});
expect(proposal.waypoints.map((point: { at_m: number }) => point.at_m)).toEqual([0, 556, 1112]);
expect(await countRows(orm, Tours, {})).toBe(0);
expect(sent()).toEqual([]);
expect(askedSince()).toEqual(['/route', '/height', '/trace_attributes']);
// The hike the traveller said yes to, with its whole line and its heights.
const created = await callTool(user.id, 'create_hike', {
tripId: trip.id,
name: 'Up to the summit',
waypoints: WAYPOINTS,
max_hiking_difficulty: 2,
});
expect(created.route).toEqual({ planned: true });
expect(created.tour).toMatchObject({
name: 'Up to the summit',
tour_type: 'hike',
max_hiking_difficulty: 2,
elevation_gain: 800,
duration: proposal.duration_minutes,
revision: 1,
route_pending: false,
});
const placeId: number = created.tour.place_id;
const line = JSON.parse(String((await findRow(orm, Places, { id: placeId }))?.route_geometry)) as number[][];
expect(line).toHaveLength(21);
expect(line.every((point) => point.length === 3)).toBe(true);
expect(sent()).toEqual([
['tours:changed', undefined],
['place:created', undefined],
]);
// The way and its heights as the proposal had them, kept for five minutes, and no grades: the host is not asked.
expect(askedSince()).toEqual([]);
// Just made, the hike can be taken back: the trip as the undo reads it has it as it was created.
const summary = await callTool(user.id, 'get_trip_summary', { tripId: trip.id });
const places = await callTool(user.id, 'list_places', { tripId: trip.id });
expect(unchangedSinceApplied('create_hike', created, { summary, places })).toBe(true);
// On the day, pointed at the packing list, exported with its heights.
const assigned = await callTool(user.id, 'assign_place_to_day', { tripId: trip.id, dayId: day.id, placeId });
expect(assigned.assignment).toBeTruthy();
expect(await countRows(orm, DayAssignments, { place: placeId })).toBe(1);
sent();
const packed = await callTool(user.id, 'link_tour_packing', { tripId: trip.id, placeId, category: null });
expect(packed.packing_link).toMatchObject({ category: null });
expect(sent().map(([event]) => event)).toEqual(['tours:changed']);
const exported = await callTool(user.id, 'export_tour_gpx', { tripId: trip.id, placeId });
expect(exported.gpx).toContain('Up to the summit');
expect(exported.gpx).toContain('<ele>');
// A harder limit plans the way anew; the hike stays on its day.
const changed = await callTool(user.id, 'update_tour', {
tripId: trip.id,
placeId,
expected_revision: 1,
max_hiking_difficulty: 3,
name: 'Summit by the ridge',
});
expect(changed).toMatchObject({ replanned: true, route: { planned: true } });
expect(changed.tour).toMatchObject({ name: 'Summit by the ridge', max_hiking_difficulty: 3, revision: 2 });
expect(sent()).toEqual([
['tours:changed', undefined],
['place:updated', undefined],
]);
expect(await countRows(orm, DayAssignments, { place: placeId })).toBe(1);
// The way under the new limit; its heights are the same line's, and a change reads no grades.
expect(askedSince()).toEqual(['/route']);
// A change based on the old revision is refused and writes nothing.
const stale = await callTool(user.id, 'update_tour', {
tripId: trip.id,
placeId,
expected_revision: 1,
name: 'Stale',
});
expect(stale).toEqual({ error: 'This tour was changed in the meantime' });
expect((await findRow(orm, Places, { id: placeId }))?.name).toBe('Summit by the ridge');
expect(sent()).toEqual([]);
expect(askedSince()).toEqual([]);
});
it('takes a GPX text in as a hike, and refuses the tools to someone outside the trip', async () => {
const { user } = await makeUser(orm);
const { user: outsider } = await makeUser(orm);
const trip = await makeTrip(orm, user.id);
const gpx =
'<gpx><trk><name>Ridge</name><trkseg><trkpt lat="47.6" lon="11.3"><ele>800</ele></trkpt>' +
'<trkpt lat="47.61" lon="11.3"><ele>1000</ele></trkpt></trkseg></trk></gpx>';
const imported = await callTool(user.id, 'import_tour_gpx', { tripId: trip.id, gpx, file_name: 'ridge.gpx' });
expect(imported).toMatchObject({ caution: false, skipped: 0 });
expect(imported.tours[0]).toMatchObject({ name: 'Ridge', tour_type: 'hike', elevation_gain: 200 });
expect(sent()).toEqual([
['tours:changed', undefined],
['place:created', undefined],
]);
const refused = { error: 'Trip not found or access denied.' };
expect(await callTool(outsider.id, 'propose_hike', { tripId: trip.id, waypoints: WAYPOINTS })).toEqual(refused);
expect(await callTool(outsider.id, 'import_tour_gpx', { tripId: trip.id, gpx })).toEqual(refused);
expect(await callTool(user.id, 'import_tour_gpx', { tripId: trip.id, gpx, tour_type: 'kayak' })).toMatchObject({
error: expect.stringContaining('a paddle import needs boat_type'),
});
});
});
+40
View File
@@ -0,0 +1,40 @@
/**
* Valhalla's answers as the router reads them, for suites that stub the
* routing host behind safeFetchAdminConfigured.
*/
function encodeValue(value: number): string {
let remaining = value < 0 ? ~(value << 1) : value << 1;
let encoded = '';
while (remaining >= 32) {
encoded += String.fromCharCode((32 | (remaining & 31)) + 63);
remaining >>>= 5;
}
return encoded + String.fromCharCode(remaining + 63);
}
/** A line as Valhalla encodes a leg's shape: a polyline at six decimals. */
export function valhallaPolyline(line: ReadonlyArray<readonly [number, number]>): string {
let lat = 0;
let lng = 0;
return line
.map(([nextLat, nextLng]) => {
const [y, x] = [Math.round(nextLat * 1e6), Math.round(nextLng * 1e6)];
const step = encodeValue(y - lat) + encodeValue(x - lng);
[lat, lng] = [y, x];
return step;
})
.join('');
}
/** Valhalla's /route answer: one leg per line, each with its length in kilometres and its time in seconds. */
export function valhallaRoute(legs: ReadonlyArray<{ line: Array<[number, number]>; km: number; seconds: number }>) {
return {
trip: {
legs: legs.map(({ line, km, seconds }) => ({
shape: valhallaPolyline(line),
summary: { length: km, time: seconds },
})),
},
};
}
@@ -287,6 +287,44 @@ describe('activityOf', () => {
expect(activityOf('list_places', 'l', {}, card)).toBeNull();
expect(activityOf('search_place', 'x'.repeat(65), { query: 'a' }, card)).toBeNull();
});
it('ASTCRD-024: a hike being planned is named by its goal and shows the box round its points', () => {
const waypoints = [
{ lat: 47.6, lng: 11.3, name: 'Car park' },
{ lat: 47.61, lng: 11.31, name: 'Summit' },
{ lat: 47.62, lng: 11.3 },
];
const activity = activityOf('propose_hike', 'h', { waypoints, round_trip: true }, card);
expect(assistantActivitySchema.safeParse(activity).success).toBe(true);
expect(activity?.label).toBe('Summit');
expect(activity?.area).toMatchObject({ type: 'bbox' });
if (activity?.area?.type !== 'bbox') throw new Error('no box');
const { south, west, north, east } = activity.area;
[south, west, north, east].forEach((edge, index) =>
expect(edge).toBeCloseTo([47.595, 11.295, 47.625, 11.315][index], 9),
);
// Points all on one line still make a box; a start alone names no goal.
const line = [
{ lat: 47.6, lng: 11.3, name: 'Car park' },
{ lat: 47.6, lng: 11.31 },
];
expect(activityOf('propose_hike', 'h', { waypoints: line }, card)).toMatchObject({
label: null,
area: { type: 'bbox' },
});
expect(activityOf('propose_hike', 'h', { waypoints: [{ lat: 'north', lng: 11 }, ...line] }, card)).toEqual({
toolCallId: 'h',
label: 'Car park',
area: null,
});
expect(activityOf('propose_hike', 'h', {}, card)).toBeNull();
const far = [line[0], { lat: 50, lng: 11.3, name: 'Far away' }];
expect(activityOf('propose_hike', 'h', { waypoints: far }, card)).toEqual({
toolCallId: 'h',
label: 'Far away',
area: null,
});
});
});
describe('mentionItems', () => {
@@ -13,6 +13,7 @@ import {
ASSISTANT_DENIED_TOOLS,
ASSISTANT_TOOL_GROUPS,
checkDenylist,
groupOf,
isOfferedTool,
} from '../../../../src/nest/assistant/assistant-tool-offer';
import {
@@ -122,6 +123,28 @@ describe('the assistant tool catalog', () => {
}
});
it('ASTTOOL-017: the hike tools come with the tours, the proposal runs at once and the writes are cards of a tour', () => {
const hikeTools = ['propose_hike', 'create_hike', 'update_tour', 'import_tour_gpx'];
const entries = hikeTools.map((name) => registry.list().find((e) => e.kind === 'tool' && e.name === name));
expect(entries.map((entry) => entry && [entry.className, groupOf(entry)])).toEqual(
hikeTools.map(() => ['HikePlanMcp', 'tours']),
);
expect(hikeTools.map((name) => listed.get(name)?.annotations?.readOnlyHint === true)).toEqual([
true,
false,
false,
false,
]);
expect(
['create_hike', 'create_city_walk', 'update_tour', 'import_tour_gpx'].map((name) => ASSISTANT_WRITE_TOOLS[name]),
).toEqual([
{ verbKey: 'addTour', entity: 'tour' },
{ verbKey: 'addTour', entity: 'tour' },
{ verbKey: 'updateTour', entity: 'tour' },
{ verbKey: 'importTour', entity: 'tour' },
]);
});
it('ASTTOOL-005: the assistant scopes are real scopes', () => {
const allowed = new Set(
registry
@@ -121,3 +121,66 @@ describe('ASSISTANT_UNDO_MAP', () => {
expect(ASSISTANT_UNDO_MAP.create_day_note.args({ dayId: 31 }, { note: { id: 0 } })).toBeNull();
});
});
describe('a created tour', () => {
/** A hike as list_places has it: a place with its name and description, stamped once when it was made. */
const MADE = '2026-10-21 10:00:00';
const HIKE = {
id: 64,
name: 'Herzogstand',
description: null,
lat: 47.6,
lng: 11.3,
created_at: MADE,
updated_at: MADE,
};
const created = { tour: { place_id: 64, name: 'Herzogstand', description: null }, waypoints: [] };
it('ASTUND-005: is taken back while it has its name and words and nothing plans, books or pays for it', () => {
const tours = snapshot({ places: [PLACE, HIKE] });
for (const tool of ['create_hike', 'create_city_walk']) {
expect(unchangedSinceApplied(tool, created, tours), tool).toBe(true);
}
// A description the tour was created without reads as none on both sides.
expect(unchangedSinceApplied('create_hike', { tour: { place_id: 64, name: 'Herzogstand' } }, tours)).toBe(true);
});
it('ASTUND-006: stays once it was renamed, changed, put on a day, booked or deleted', () => {
expect(
unchangedSinceApplied('create_hike', created, snapshot({ places: [PLACE, { ...HIKE, name: 'Heimgarten' }] })),
).toBe(false);
// A new way, limit or planned time (update_tour, the planner's save) leaves name and words as they were.
const rerouted = { ...HIKE, updated_at: '2026-10-21 10:04:00' };
for (const tool of ['create_hike', 'create_city_walk']) {
expect(unchangedSinceApplied(tool, created, snapshot({ places: [PLACE, rerouted] })), tool).toBe(false);
}
// A row that does not say when it was made cannot say it is unchanged.
const { created_at: _made, ...unstamped } = HIKE;
expect(unchangedSinceApplied('create_hike', created, snapshot({ places: [PLACE, unstamped] }))).toBe(false);
expect(
unchangedSinceApplied(
'create_hike',
created,
snapshot({ places: [PLACE, HIKE], days: [{ id: 31, assignments: [{ id: 305, place_id: 64 }] }] }),
),
).toBe(false);
expect(
unchangedSinceApplied(
'create_city_walk',
created,
snapshot({ places: [PLACE, HIKE], summary: { budget: { items: [{ id: 72, place_id: 64 }] } } }),
),
).toBe(false);
expect(unchangedSinceApplied('create_hike', created, snapshot())).toBe(false);
});
it('ASTUND-011: is taken back by deleting its place, named in the answer', () => {
for (const tool of ['create_hike', 'create_city_walk']) {
expect(ASSISTANT_UNDO_MAP[tool].tool).toBe('delete_place');
expect(ASSISTANT_UNDO_MAP[tool].args({}, created)).toEqual({ placeId: 64 });
expect(ASSISTANT_UNDO_MAP[tool].args({}, { tour: { place_id: 0 } })).toBeNull();
expect(ASSISTANT_UNDO_MAP[tool].args({}, { tour: 'odd' })).toBeNull();
expect(ASSISTANT_UNDO_MAP[tool].args({}, 'Tour not found.')).toBeNull();
}
});
});
@@ -395,3 +395,124 @@ describe('appliedChange', () => {
});
});
});
describe('the cards of tours', () => {
it('ASTPRV-023: a new hike names its route by its points, its grade, its planned time and its words', () => {
const preview = describe_('create_hike', {
name: 'Herzogstand',
description: 'Up from the car park',
waypoints: [
{ lat: 47.6, lng: 11.3, name: 'Car park' },
{ lat: 47.61, lng: 11.3 },
{ lat: 47.62, lng: 11.3, name: 'Summit' },
],
round_trip: true,
max_hiking_difficulty: 3,
planned_duration_minutes: 330,
});
expect(assistantChangePreviewSchema.safeParse(preview).success).toBe(true);
expect(preview).toMatchObject({ verbKey: 'addTour', subject: 'Herzogstand', undoable: true, placeIds: [] });
expect(preview.rows).toEqual([
{ op: 'add', labelKey: 'route', before: null, after: 'Car park → Summit → Car park' },
{ op: 'add', labelKey: 'difficulty', before: null, after: 'T3' },
{ op: 'add', labelKey: 'time', before: null, after: '5:30 h' },
{ op: 'add', labelKey: 'description', before: null, after: 'Up from the car park' },
]);
// Left without a limit, the hike is planned at T2, and the card says so.
const plain = describe_('create_hike', {
name: 'Walk',
waypoints: [
{ lat: 1, lng: 1 },
{ lat: 2, lng: 2 },
],
});
expect(plain.rows).toEqual([{ op: 'add', labelKey: 'difficulty', before: null, after: 'T2' }]);
expect(
describe_('create_hike', { name: 'Odd', max_hiking_difficulty: 9, planned_duration_minutes: -5 }).rows,
).toEqual([]);
});
it('ASTPRV-024: a new city walk names its stations, a linked one by its place, and points at the places it uses', () => {
const preview = describe_('create_city_walk', {
name: 'Market morning',
stations: [
{ lat: 35, lng: 135, name: 'Hotel' },
{ lat: 35.01, lng: 135, linked_place_id: 12 },
{ lat: 35.02, lng: 135, linked_place_id: 99 },
'odd',
],
});
expect(preview).toMatchObject({ verbKey: 'addTour', subject: 'Market morning', undoable: true, placeIds: [12] });
expect(preview.rows).toEqual([{ op: 'add', labelKey: 'route', before: null, after: 'Hotel → Nishiki Market' }]);
});
it('ASTPRV-025: a changed hike is named by its place and shows what it becomes', () => {
const preview = describe_('update_tour', {
placeId: 12,
expected_revision: 2,
name: 'Market loop',
description: null,
waypoints: [
{ lat: 35, lng: 135, name: 'Gate' },
{ lat: 35.01, lng: 135, name: 'Shrine' },
],
max_hiking_difficulty: 2,
planned_duration_minutes: 90,
});
expect(preview).toMatchObject({
verbKey: 'updateTour',
subject: 'Nishiki Market',
undoable: false,
placeIds: [12],
});
expect(preview.rows).toEqual([
{ op: 'change', labelKey: 'name', before: 'Nishiki Market', after: 'Market loop' },
{ op: 'change', labelKey: 'description', before: null, after: '' },
{ op: 'add', labelKey: 'route', before: null, after: 'Gate → Shrine' },
{ op: 'add', labelKey: 'difficulty', before: null, after: 'T2' },
{ op: 'add', labelKey: 'time', before: null, after: '1:30 h' },
]);
expect(describe_('update_tour', { placeId: 77, name: 'Gone' })).toMatchObject({ subject: 'Gone', placeIds: [77] });
expect(describe_('update_tour', {})).toMatchObject({ subject: '', rows: [], placeIds: [] });
});
it('ASTPRV-026: tours from a file are named by the file, else by the first name in it', () => {
const gpx = '<gpx><trk><name>Ridge walk</name></trk></gpx>';
expect(describe_('import_tour_gpx', { gpx, file_name: 'ridge.gpx', tour_type: 'bike' })).toMatchObject({
verbKey: 'importTour',
subject: 'ridge.gpx',
rows: [{ op: 'add', labelKey: 'type', before: null, after: 'bike' }],
undoable: false,
});
expect(describe_('import_tour_gpx', { gpx }).subject).toBe('Ridge walk');
expect(describe_('import_tour_gpx', { gpx: '<gpx><name>Open' }).subject).toBe('GPX');
expect(describe_('import_tour_gpx', { gpx: '<gpx/>' }).subject).toBe('GPX');
});
it('ASTPRV-027: a tour is named by its place in the receipt, every tour a file brought in too', () => {
expect(appliedChange('create_hike', 'tc', {}, { tour: { place_id: 61 }, waypoints: [] }, null, null)).toEqual({
toolCallId: 'tc',
entity: 'tour',
ids: [61],
dayId: null,
undoUntil: null,
});
expect(appliedChange('update_tour', 'tc', { placeId: 61 }, { tour: { place_id: 61 } }, null, null)).toMatchObject({
entity: 'tour',
ids: [61],
});
expect(
appliedChange(
'import_tour_gpx',
'tc',
{},
{ tours: [{ place_id: 62 }, { place_id: 63 }, {}], skipped: 0 },
null,
null,
),
).toMatchObject({ entity: 'tour', ids: [62, 63] });
expect(appliedChange('import_tour_gpx', 'tc', {}, { tours: [], skipped: 1 }, null, null)).toMatchObject({
ids: [],
});
});
});
@@ -0,0 +1,311 @@
/**
* The pure halves of a hike planned over MCP: the points a hike is planned
* through, the proposal shown from a plan, the write a created hike makes and
* the words of a change whose way could not be planned.
*/
import {
heightRangeOf,
hikeCreateCommandOf,
hikePointsOf,
hikeProposalOf,
hikeWaypointsOf,
keptWayOf,
positionsAlong,
proposalSacOf,
repointedWaypoints,
routeOutcomeOf,
unplannedChangeMessage,
unplannedCreateMessage,
} from '../../../src/nest/tours/hike-plan.helpers';
import type { PlannedHike } from '../../../src/nest/tours/hike-routing.service';
import {
HIKE_PROPOSAL_SAC_SEGMENTS,
hikeProposalSchema,
type GeometryPoint,
type HikeSacSegment,
type RoutedHikePlan,
} from '@trek/shared';
import { describe, expect, it } from 'vitest';
// HIKE-PLAN-001 to HIKE-PLAN-011
/** A point `k` thousandths of a degree north of 47° N on 11° E, about 111 m a step. */
const at = (k: number): [number, number] => [47 + k / 1000, 11];
const START = { lat: 47, lng: 11, name: 'Car park' };
const SUMMIT = { lat: 47.004, lng: 11, name: 'Summit' };
const ROUTED: RoutedHikePlan = {
kind: 'routed',
max_hiking_difficulty: 3,
hiking_preferences: { avoid_steps: true },
distance_m: 445,
ascent_m: 40,
descent_m: 0,
duration_minutes: 12,
duration_basis: 'din33466',
has_heights: true,
line: [
[47, 11, 600],
[47.004, 11, 640],
],
profile: [
[0, 600],
[445, 640],
],
grades: { segments: [{ from_m: 0, to_m: 445, sac: 3 }], highest: 3, unknown_share: 0 },
};
const LINE: GeometryPoint[] = [0, 1, 2, 3, 4].map((k) => [...at(k), 600 + 10 * k]);
describe('the points and the write of a hike', () => {
it('HIKE-PLAN-001: a round trip comes back to its start; a hike one way ends where it was asked to', () => {
expect(hikePointsOf([START, SUMMIT], true)).toEqual([START, SUMMIT, START]);
expect(hikePointsOf([START, SUMMIT], false)).toEqual([START, SUMMIT]);
expect(hikePointsOf([START, SUMMIT], undefined)).toEqual([START, SUMMIT]);
expect(hikeWaypointsOf([START, { lat: 47.002, lng: 11, name: '' }, SUMMIT])).toEqual([
{ lat: 47, lng: 11, name: 'Car park', role: 'start', sequence: 0 },
{ lat: 47.002, lng: 11, role: 'via', sequence: 1 },
{ lat: 47.004, lng: 11, name: 'Summit', role: 'end', sequence: 2 },
]);
});
it('HIKE-PLAN-002: a created hike keeps the whole line, the walking time and the limit; without a way its points only', () => {
const planned: PlannedHike = { plan: ROUTED, line: LINE };
const request = { name: 'Summit', waypoints: [START, SUMMIT], hiking_preferences: { avoid_steps: true } };
expect(hikeCreateCommandOf(request, [START, SUMMIT], keptWayOf(planned), 3)).toEqual({
name: 'Summit',
description: undefined,
tour_type: 'hike',
route_geometry: LINE,
duration_seconds: 720,
max_hiking_difficulty: 3,
hiking_preferences: { avoid_steps: true },
waypoints: hikeWaypointsOf([START, SUMMIT]),
});
const unplanned: PlannedHike = {
plan: { kind: 'unrouted', max_hiking_difficulty: 2, why: 'no_way', position: null, snapped_m: null },
line: null,
};
expect(
hikeCreateCommandOf(
{ ...request, planned_duration_minutes: 300, hiking_preferences: null },
[START, SUMMIT],
keptWayOf(unplanned),
2,
),
).toMatchObject({
route_geometry: null,
duration_seconds: null,
max_hiking_difficulty: 2,
hiking_preferences: null,
planned_duration_minutes: 300,
});
expect(routeOutcomeOf(unplanned.plan)).toEqual({ planned: false, why: 'no_way', position: null });
expect(routeOutcomeOf(ROUTED)).toEqual({ planned: true });
});
it('HIKE-PLAN-009: keeps a way only with the height of every point, and reads one without as a router that did not answer', () => {
expect(keptWayOf({ plan: ROUTED, line: LINE })).toEqual({ line: LINE, seconds: 720, route: { planned: true } });
const flat: PlannedHike = {
plan: { ...ROUTED, has_heights: false, ascent_m: null, descent_m: null, duration_basis: 'router' },
line: [at(0), at(4)],
};
const none = { line: null, seconds: null, route: { planned: false, why: 'router_unavailable', position: null } };
expect(keptWayOf(flat)).toEqual(none);
// A plan that says it has heights but came without its line keeps none either.
expect(keptWayOf({ plan: ROUTED, line: null })).toEqual(none);
const off: PlannedHike = {
plan: { kind: 'unrouted', max_hiking_difficulty: 2, why: 'point_off_the_way', position: 2, snapped_m: 400 },
line: null,
};
expect(keptWayOf(off)).toEqual({
line: null,
seconds: null,
route: { planned: false, why: 'point_off_the_way', position: 2 },
});
});
it('HIKE-PLAN-010: new points keep the link and the name of a saved point they stand on, and only of that one', () => {
const saved = [
{ lat: 47, lng: 11, role: 'start' as const, sequence: 0, name: 'Car park', linked_place_id: 7 },
{ lat: 47.002, lng: 11, role: 'via' as const, sequence: 1, name: 'Hut', linked_place_id: 8 },
{ lat: 47.004, lng: 11, role: 'end' as const, sequence: 2, linked_place_id: null },
];
const repointed = repointedWaypoints(
[
// 11 m from the car park, a little further from nothing else.
{ lat: 47.0001, lng: 11 },
// 33 m from the hut: another point.
{ lat: 47.0023, lng: 11 },
{ lat: 47.004, lng: 11, name: 'Summit' },
{ lat: 47.002, lng: 11, name: 'Hut terrace' },
],
saved,
);
expect(repointed).toEqual([
{ lat: 47.0001, lng: 11, name: 'Car park', linked_place_id: 7, role: 'start', sequence: 0 },
{ lat: 47.0023, lng: 11, role: 'via', sequence: 1 },
{ lat: 47.004, lng: 11, name: 'Summit', role: 'via', sequence: 2 },
{ lat: 47.002, lng: 11, name: 'Hut terrace', linked_place_id: 8, role: 'end', sequence: 3 },
]);
expect(repointedWaypoints([START, SUMMIT], [])).toEqual(hikeWaypointsOf([START, SUMMIT]));
});
});
describe('a proposal from a plan', () => {
it('HIKE-PLAN-003: places each point along the line in order, a way back to the start at its end', () => {
const outAndBack: GeometryPoint[] = [...LINE, ...LINE.slice(0, -1).reverse()];
expect(positionsAlong(outAndBack, [START, SUMMIT, START])).toEqual([0, 445, 890]);
// A point a little off the line lies where the line passes it first.
expect(positionsAlong(LINE, [START, { lat: 47.002, lng: 11.0001 }, SUMMIT])).toEqual([0, 222, 445]);
});
it('HIKE-PLAN-004: reads the lowest and highest point only where every point has a height', () => {
expect(heightRangeOf(LINE)).toEqual({ min: 600, max: 640 });
expect(heightRangeOf([at(0), at(1)])).toBeNull();
expect(heightRangeOf([[...at(0), 600], at(1)])).toBeNull();
expect(heightRangeOf([])).toBeNull();
});
it('HIKE-PLAN-005: keeps the grades as they are, neighbours of one grade joined, where they fit', () => {
expect(proposalSacOf(null)).toBeNull();
const segments: HikeSacSegment[] = [
{ from_m: 0, to_m: 100, sac: 2 },
{ from_m: 100, to_m: 200, sac: 2 },
{ from_m: 250, to_m: 300, sac: 2 },
{ from_m: 300, to_m: 400, sac: 4 },
];
expect(proposalSacOf({ segments, highest: 4, unknown_share: 0.125 })).toEqual({
highest: 4,
segments: [
{ from_m: 0, to_m: 200, sac: 2 },
{ from_m: 250, to_m: 300, sac: 2 },
{ from_m: 300, to_m: 400, sac: 4 },
],
unknown_share: 0.125,
});
});
it('HIKE-PLAN-011: joins only stretches that touch while that is enough, the unknown ones between kept unknown', () => {
// Pairs of touching stretches with a gap of unknown grade after each pair.
const pairs = Array.from({ length: HIKE_PROPOSAL_SAC_SEGMENTS }, (_, index) => [
{ from_m: index * 30, to_m: index * 30 + 10, sac: 1 },
{ from_m: index * 30 + 10, to_m: index * 30 + 20, sac: 4 },
]).flat();
const sac = proposalSacOf({ segments: pairs, highest: 4, unknown_share: 0.33 });
expect(sac?.segments).toHaveLength(HIKE_PROPOSAL_SAC_SEGMENTS);
expect(sac?.segments.slice(0, 2)).toEqual([
{ from_m: 0, to_m: 20, sac: 4 },
{ from_m: 30, to_m: 50, sac: 4 },
]);
expect(sac?.unknown_share).toBe(0.33);
// Stretches each apart from the next leave no other way than to join them over the gaps.
const apart = Array.from({ length: HIKE_PROPOSAL_SAC_SEGMENTS + 1 }, (_, index) => ({
from_m: index * 20,
to_m: index * 20 + 10,
sac: index === 1 ? 5 : 2,
}));
const joined = proposalSacOf({ segments: apart, highest: 5, unknown_share: 0.5 })?.segments;
expect(joined?.length).toBeLessThanOrEqual(HIKE_PROPOSAL_SAC_SEGMENTS);
expect(joined?.[0]).toEqual({ from_m: 0, to_m: 30, sac: 5 });
});
it('HIKE-PLAN-006: joins too many stretches in pairs, each the harder of its grades, until they fit', () => {
const count = 2 * HIKE_PROPOSAL_SAC_SEGMENTS + 1;
const segments = Array.from({ length: count }, (_, index) => ({
from_m: index * 10,
to_m: index * 10 + 10,
sac: index % 2 === 0 ? 1 : 3,
}));
const sac = proposalSacOf({ segments, highest: 3, unknown_share: 0 });
expect(sac?.segments.length).toBeLessThanOrEqual(HIKE_PROPOSAL_SAC_SEGMENTS);
expect(sac?.segments[0]).toEqual({ from_m: 0, to_m: (count - 1) * 10, sac: 3 });
expect(sac?.segments.at(-1)).toEqual({ from_m: (count - 1) * 10, to_m: count * 10, sac: 1 });
expect(sac?.segments.every((segment) => segment.sac >= 1)).toBe(true);
});
it('HIKE-PLAN-007: a proposal gives the way and the request back; one without a way names why and keeps its points', () => {
const request = {
name: 'Summit',
waypoints: [START, { lat: 47.002, lng: 11 }, SUMMIT],
variant_label: 'direct',
date: '2027-07-10',
start_time: '08:00',
hiking_preferences: { avoid_steps: true },
};
const points = hikePointsOf(request.waypoints, false);
const routed = hikeProposalOf(request, points, { plan: ROUTED, line: LINE });
expect(hikeProposalSchema.parse(routed)).toEqual(routed);
expect(routed).toMatchObject({
name: 'Summit',
variant_label: 'direct',
heights_known: true,
min_ele_m: 600,
max_ele_m: 640,
hiking_preferences: { avoid_steps: true },
round_trip: false,
date: '2027-07-10',
start_time: '08:00',
});
expect(routed.waypoints.map((point) => [point.name, point.at_m])).toEqual([
['Car park', 0],
[null, 222],
['Summit', 445],
]);
const flat = hikeProposalOf({ waypoints: request.waypoints }, points, {
plan: { ...ROUTED, has_heights: false, ascent_m: null, descent_m: null, grades: null },
line: [at(0), at(2), at(4)],
});
expect(flat).toMatchObject({ heights_known: false, min_ele_m: null, sac: null, name: null, date: null });
const off = hikeProposalOf({ waypoints: request.waypoints, hiking_preferences: { avoid_climbs: true } }, points, {
plan: { kind: 'unrouted', max_hiking_difficulty: 2, why: 'point_off_the_way', position: 3, snapped_m: 512 },
line: null,
});
expect(hikeProposalSchema.parse(off)).toEqual(off);
expect(off).toMatchObject({
route: { planned: false, why: 'point_off_the_way', position: 3 },
unreachable: { position: 3, snapped_m: 512 },
distance_m: null,
line: [],
profile: [],
hiking_preferences: { avoid_climbs: true },
});
expect(off.waypoints.map((point) => point.at_m)).toEqual([null, null, null]);
// A refusal without a point, and one whose distance is not known, names none.
for (const plan of [
{ kind: 'unrouted' as const, max_hiking_difficulty: 2, why: 'no_way' as const, position: null, snapped_m: null },
{
kind: 'unrouted' as const,
max_hiking_difficulty: 2,
why: 'point_off_the_way' as const,
position: 2,
snapped_m: null,
},
]) {
expect(hikeProposalOf({ waypoints: request.waypoints }, points, { plan, line: null }).unreachable).toBeNull();
}
});
it('HIKE-PLAN-008: a hike or a change whose way cannot be planned says why, and that nothing was saved', () => {
const kept = 'Nothing was saved and the hike keeps its way.';
expect(unplannedChangeMessage({ why: 'no_valhalla', position: null })).toContain('no Valhalla router');
expect(unplannedChangeMessage({ why: 'router_unavailable', position: null })).toContain('did not answer');
expect(unplannedChangeMessage({ why: 'no_way', position: null })).toContain('raise the limit');
expect(unplannedChangeMessage({ why: 'too_long', position: null })).toBe(
`The way is too long to plan as one hike: split it into shorter hikes. ${kept}`,
);
expect(unplannedCreateMessage({ why: 'no_way', position: null })).toBe(
'No way within the hiking limit joins these points: raise the limit or move the points. Nothing was saved.',
);
expect(unplannedChangeMessage({ why: 'point_off_the_way', position: 2 })).toBe(
`Point 2 is further than 300 m from any way: move it onto a path. ${kept}`,
);
expect(unplannedChangeMessage({ why: 'point_off_the_way', position: null })).toContain('Point is further');
for (const why of ['no_valhalla', 'router_unavailable', 'no_way'] as const) {
expect(unplannedChangeMessage({ why, position: null }).endsWith(kept)).toBe(true);
}
});
});
@@ -0,0 +1,778 @@
/**
* HikePlanMcp: a hike planned with the traveller over MCP. The proposal
* reads only; the hike is created through ToursService.createTour, 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 suite takes its subject from the
* container and seeds through the ORM factories.
*/
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 { 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 { ToursModule } from '../../../src/nest/tours/tours.module';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { makePlace } from '../../helpers/factories/places';
import { countRows, findRow, findRows, updateRows } from '../../helpers/factories/rows';
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
import { 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_PLAN_MAX_WAYPOINTS,
hikeProposalSchema,
type GeometryPoint,
type HikePlanRequest,
type HikeSacSegment,
type UnroutedHikePlan,
} from '@trek/shared';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
// HIKE-MCP-001 to HIKE-MCP-017
const db = createSnapshotTestDb();
const plan = vi.fn<HikeRoutingService['plan']>();
let t: TestModule;
let mcp: HikePlanMcp;
const orm = () => t.get(MikroORM);
beforeAll(async () => {
t = await createTestModule({
db,
imports: [ToursModule],
overrides: [{ provide: HikeRoutingService, useValue: { plan } }],
});
mcp = t.get(HikePlanMcp);
});
beforeEach(() => {
resetTestDb(db);
plan.mockReset().mockImplementation(async (_userId, _tripId, request) => routed(request));
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. */
function routed(request: HikePlanRequest, line: GeometryPoint[] = LINE): 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,
};
}
/** 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,
};
}
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,
};
}
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, 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, 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],
]);
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.
plan.mockResolvedValueOnce(routed({ waypoints: WAYPOINTS }, [...LINE, ...LINE.slice(0, -1).reverse()]));
const round = read(
await mcp.proposeHike({ tripId: f.trip.id, 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],
]);
plan.mockResolvedValueOnce(unrouted('point_off_the_way', 2));
const off = read(await mcp.proposeHike({ tripId: f.trip.id, waypoints: WAYPOINTS }, 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,
line: [],
sac: null,
});
expect(off.waypoints.every((point: { at_m: number | null }) => point.at_m === null)).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();
});
});
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']);
});
});
@@ -0,0 +1,188 @@
/**
* The pure halves of a hike planned on the server: the routed line as one
* line, the heights all or none, the height profile for display and the SAC
* grades laid onto the line.
*/
import {
addStretch,
gradableWayIds,
gradesAlong,
heightsOrNone,
joinedLine,
metresAlong,
profileOf,
} from '../../../src/nest/tours/hike-routing.helpers';
import type { HikeSacSegment, SacStep } from '@trek/shared';
import { describe, expect, it } from 'vitest';
// HIKE-HELP-001 to HIKE-HELP-008
describe('the routed line of a hike', () => {
it('HIKE-HELP-001: drops a point that repeats the one before it, and keeps a line that would be left with one point', () => {
expect(
joinedLine([
[47, 11],
[47.1, 11],
[47.1, 11],
[47.2, 11],
]),
).toEqual([
[47, 11],
[47.1, 11],
[47.2, 11],
]);
expect(
joinedLine([
[47, 11],
[47, 11],
]),
).toEqual([
[47, 11],
[47, 11],
]);
});
it('HIKE-HELP-002: takes the heights only where every point of the line has one', () => {
const flat: Array<[number, number]> = [
[47, 11],
[47.1, 11],
];
expect(
heightsOrNone(flat, [
[47, 11, 600],
[47.1, 11, 650],
]),
).toEqual([
[47, 11, 600],
[47.1, 11, 650],
]);
expect(
heightsOrNone(flat, [
[47, 11, 600],
[47.1, 11],
]),
).toEqual(flat);
expect(heightsOrNone(flat, [[47, 11, 600]])).toEqual(flat);
expect(heightsOrNone(flat, null)).toEqual(flat);
});
it('HIKE-HELP-003: draws the profile in whole metres along the line, and none without heights', () => {
const line: Array<[number, number, number]> = [
[47, 11, 600.4],
[47.001, 11, 650.6],
[47.002, 11, 640],
];
expect(profileOf(line, 200)).toEqual([
[0, 600],
[111, 651],
[222, 640],
]);
expect(profileOf(line, 2)).toEqual([
[0, 600],
[222, 640],
]);
expect(
profileOf(
[
[47, 11, 600],
[47.001, 11],
],
200,
),
).toEqual([]);
expect(profileOf([[47, 11, 600]], 200)).toEqual([]);
});
});
describe('the SAC grades along a hike', () => {
const T2 = 2 as SacStep;
const T3 = 3 as SacStep;
it('HIKE-HELP-004: asks for each graded way once, in the order walked, up to the limit', () => {
const ways = [
{ wayId: 5, metres: 10 },
{ wayId: null, metres: 10 },
{ wayId: 5, metres: 10 },
{ wayId: 7, metres: 10 },
{ wayId: 9, metres: 10 },
];
expect(gradableWayIds(ways, 10)).toEqual([5, 7, 9]);
expect(gradableWayIds(ways, 2)).toEqual([5, 7]);
});
it('HIKE-HELP-005: joins neighbours of one grade, keeps grades apart where an unknown way lies between', () => {
const ways = [
{ wayId: 1, metres: 100 },
{ wayId: 2, metres: 50 },
{ wayId: 3, metres: 25 },
{ wayId: 4, metres: 25 },
{ wayId: 5, metres: 0 },
{ wayId: 6, metres: 50 },
];
const steps = new Map([
[1, T2],
[2, T2],
[4, T3],
[5, T2],
[6, T3],
]);
expect(gradesAlong(ways, steps, 250)).toEqual({
// A way of no length between two of one grade does not part them.
segments: [
{ from_m: 0, to_m: 150, sac: 2 },
{ from_m: 175, to_m: 250, sac: 3 },
],
highest: 3,
unknown_share: 0.1,
});
});
it('HIKE-HELP-006: lays the described stretches onto the planned length', () => {
const grades = gradesAlong(
[
{ wayId: 1, metres: 90 },
{ wayId: null, metres: 10 },
],
new Map([[1, T2]]),
110,
);
expect(grades).toEqual({ segments: [{ from_m: 0, to_m: 99, sac: 2 }], highest: 2, unknown_share: 0.1 });
// A planned length of nothing leaves the stretches as described.
expect(gradesAlong([{ wayId: 1, metres: 90 }], new Map([[1, T2]]), 0).segments).toEqual([
{ from_m: 0, to_m: 90, sac: 2 },
]);
});
it('HIKE-HELP-007: reads a line with nothing graded, or of no length, as unknown throughout', () => {
expect(gradesAlong([{ wayId: 1, metres: 40 }], new Map(), 40)).toEqual({
segments: [],
highest: null,
unknown_share: 1,
});
expect(gradesAlong([{ wayId: 1, metres: 0 }], new Map([[1, T2]]), 40)).toEqual({
segments: [],
highest: null,
unknown_share: 1,
});
expect(gradesAlong([], new Map(), 40)).toEqual({ segments: [], highest: null, unknown_share: 1 });
});
it('HIKE-HELP-008: measures a line point by point, and joins a stretch only to a touching one of its grade', () => {
expect(
metresAlong([
[47, 11],
[47.001, 11, 600],
[47.002, 11],
]).map(Math.round),
).toEqual([0, 111, 222]);
expect(metresAlong([])).toEqual([]);
const given: HikeSacSegment = { from_m: 100, to_m: 150, sac: 2 };
const stretches = [{ from_m: 0, to_m: 100, sac: 2 }];
expect(addStretch(stretches, given)).toEqual([{ from_m: 0, to_m: 150, sac: 2 }]);
// Another grade, or a gap of unknown grade between, keeps a stretch apart; the one given is never changed.
expect(addStretch(stretches, { from_m: 150, to_m: 160, sac: 3 })).toHaveLength(2);
expect(addStretch(stretches, { from_m: 170, to_m: 180, sac: 3 })).toHaveLength(3);
expect(given).toEqual({ from_m: 100, to_m: 150, sac: 2 });
});
});
@@ -0,0 +1,405 @@
/**
* HikeRoutingService: a hike planned on the server the way the tour planner
* plans one in the browser. The router is the real one the road trip and the
* city walks use, with its paced queue; only the routing host behind
* safeFetchAdminConfigured is a stub, answering /route, /height and
* /trace_attributes as Valhalla does, and the trail grades come from a stub
* of TourTrailGradeService. The suite takes its subject from the container.
*/
import { DomainError } from '../../../src/nest/common/domain-error';
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
import { SettingsService } from '../../../src/nest/settings/settings.service';
import { HikeRoutingService, type PlannedHike } from '../../../src/nest/tours/hike-routing.service';
import { TourTrailGradeService } from '../../../src/nest/tours/tour-trail-grade.service';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { safeFetchAdminConfigured } from '../../../src/utils/ssrfGuard';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { valhallaRoute } from '../../helpers/valhalla';
import {
HIKE_MAX_LINE_POINTS,
HIKE_PLAN_LINE_POINTS,
HIKE_PLAN_MAX_WAYPOINTS,
HIKE_PLAN_PROFILE_SAMPLES,
hikePlanSchema,
type HikePlanRequest,
type RoutedHikePlan,
type TourTrailGrade,
type TourTrailGradeResponse,
type UnroutedHikePlan,
} from '@trek/shared';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../src/utils/ssrfGuard', () => ({ safeFetchAdminConfigured: vi.fn() }));
// HIKE-ROUTE-001 to HIKE-ROUTE-015
const db = createSnapshotTestDb();
const grades = vi.fn<TourTrailGradeService['grades']>();
const getUserSettings = vi.fn<(userId: number) => Promise<Record<string, unknown>>>();
let t: TestModule;
let hikes: HikeRoutingService;
beforeAll(async () => {
t = await createTestModule({
db,
imports: [ToursModule],
overrides: [
{ provide: TourTrailGradeService, useValue: { grades } },
{ provide: SettingsService, useValue: { getUserSettings } },
],
});
hikes = t.get(HikeRoutingService);
});
afterAll(async () => {
await t.close();
db.close();
});
/** A point of the stub's world: `k` steps of a ten-thousandth of a degree north of 47° N on 11° E, about 11 m each. */
const at = (k: number, lng = 11): [number, number] => [47 + k / 1e4, lng];
const stepsOf = (from: number, to: number, lng = 11) =>
Array.from({ length: to - from + 1 }, (_, index) => at(from + index, lng));
/** The height under a point: up 10 m a step to the top at step 10, then down 5 m a step. */
const heightAt = (lat: number) => {
const k = Math.round((lat - 47) * 1e4);
return k <= 10 ? 600 + 10 * k : 700 - 5 * (k - 10);
};
/** Two legs through three points, the second starting on the point the first ended at. */
const TWO_LEGS = valhallaRoute([
{ line: stepsOf(0, 10), km: 0.111, seconds: 150 },
{ line: stepsOf(10, 20), km: 0.111, seconds: 180 },
]);
const WAYPOINTS = [0, 10, 20].map((k) => ({ lat: at(k)[0], lng: 11 }));
/** The ways under the line, 220 m of them against the 222.4 m planned. */
const TRACE = {
edges: [
{ way_id: 101, length: 0.1 },
{ way_id: 102, length: 0.05 },
{ length: 0.02 },
{ way_id: 103, length: 0.03 },
{ way_id: 104, length: 0.02 },
],
};
const grade = (id: number, sac: TourTrailGrade['sac_scale'], surface: string | null = null): TourTrailGrade => ({
id,
mtb_scale: null,
mtb_scale_uphill: null,
mtb_scale_imba: null,
sac_scale: sac,
sac_scale_unclear: null,
surface,
highway: 'path',
});
const GRADES: TourTrailGradeResponse = {
ways: [
grade(101, 'mountain_hiking'),
grade(102, 'mountain_hiking'),
grade(103, 'alpine_hiking'),
grade(104, null, 'gravel'),
],
unanswered: [],
};
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
interface Host {
route?: () => Response;
height?: (shape: Array<{ lat: number; lon: number }>) => Response;
trace?: () => Response;
}
/** The routing host, answering each of its three questions as Valhalla does unless a case says otherwise. */
function valhalla(host: Host = {}) {
vi.mocked(safeFetchAdminConfigured)
.mockReset()
.mockImplementation(async (url, init) => {
const body = JSON.parse(String(init?.body ?? '{}')) as { shape?: Array<{ lat: number; lon: number }> };
const shape = body.shape ?? [];
if (url.endsWith('/route')) return host.route ? host.route() : json(TWO_LEGS);
if (url.endsWith('/height'))
return host.height
? host.height(shape)
: json({ range_height: shape.map((point, index) => [index, heightAt(point.lat)]) });
if (url.endsWith('/trace_attributes')) return host.trace ? host.trace() : json(TRACE);
return json({}, 404);
});
}
const asked = (path: string) =>
vi.mocked(safeFetchAdminConfigured).mock.calls.filter(([url]) => String(url).endsWith(path));
const bodyOf = (path: string) => JSON.parse(String(asked(path)[0]?.[1]?.body)) as Record<string, unknown>;
let trip = 0;
/**
* A plan for a user and a trip of their own, so the router's caches (routes
* by trip, heights by user) never answer one case with another's.
*/
async function plan(request: Partial<HikePlanRequest> = {}, grades = true) {
trip += 1;
const pending = hikes.plan(trip, trip, { waypoints: WAYPOINTS, ...request }, { grades });
await vi.runAllTimersAsync();
return pending;
}
/** The plan of a way that was planned; a plan of the other kind fails the case. */
function routed({ plan: hike }: PlannedHike): RoutedHikePlan {
if (hike.kind !== 'routed') throw new Error(`expected a routed plan, got ${hike.kind} (${hike.why})`);
return hike;
}
/** The plan of a way that was not planned, with why; a routed plan fails the case. */
function unrouted({ plan: hike }: PlannedHike): UnroutedHikePlan {
if (hike.kind !== 'unrouted') throw new Error('expected no way, got a routed plan');
return hike;
}
beforeEach(() => {
vi.useFakeTimers();
getUserSettings.mockReset().mockResolvedValue({});
grades.mockReset().mockResolvedValue(GRADES);
valhalla();
});
afterEach(() => vi.useRealTimers());
describe('HikeRoutingService: the way, its heights and its grades', () => {
it('HIKE-ROUTE-001: plans the way under the hike limit and preferences, with its heights, time and grades', async () => {
const planned = await plan({
max_hiking_difficulty: 4,
hiking_preferences: { avoid_climbs: true, avoid_steps: false },
});
expect(bodyOf('/route')).toMatchObject({
costing: 'pedestrian',
costing_options: { pedestrian: { max_hiking_difficulty: 4, use_hills: 0.2 } },
});
const hike = routed(planned);
expect(hikePlanSchema.parse(hike)).toEqual(hike);
expect(hike).toMatchObject({
kind: 'routed',
max_hiking_difficulty: 4,
hiking_preferences: { avoid_climbs: true },
distance_m: 222,
ascent_m: 100,
descent_m: 50,
// DIN 33466: 0.42 h for the height against 0.06 h for the distance, the smaller by half.
duration_minutes: 28,
duration_basis: 'din33466',
has_heights: true,
});
// The legs meet on one point: the line has it once.
expect(planned.line).toHaveLength(21);
expect(planned.line?.[10]).toEqual([...at(10), 700]);
expect(hike.line).toEqual(planned.line);
expect(hike.profile[0]).toEqual([0, 600]);
expect(hike.profile[10]).toEqual([111, 700]);
expect(hike.profile.at(-1)).toEqual([222, 650]);
expect(hike.grades).toEqual({
segments: [
{ from_m: 0, to_m: 152, sac: 2 },
{ from_m: 172, to_m: 202, sac: 4 },
],
highest: 4,
unknown_share: 0.182,
});
expect(grades).toHaveBeenCalledWith({ way_ids: [101, 102, 103, 104] });
expect(bodyOf('/trace_attributes').shape).toHaveLength(21);
});
it('HIKE-ROUTE-002: plans a hike given no limit at T2 and with no preferences', async () => {
const hike = routed(await plan({ hiking_preferences: null }));
expect(bodyOf('/route')).toMatchObject({ costing_options: { pedestrian: { max_hiking_difficulty: 2 } } });
expect((bodyOf('/route').costing_options as { pedestrian: object }).pedestrian).toEqual({
max_hiking_difficulty: 2,
});
expect(hike.max_hiking_difficulty).toBe(2);
expect(hike.hiking_preferences).toBeNull();
});
it('HIKE-ROUTE-003: thins a long line and its profile for display while the tour keeps every point', async () => {
valhalla({ route: () => json(valhallaRoute([{ line: stepsOf(0, 1199), km: 13.3, seconds: 10000 }])) });
const planned = await plan({ waypoints: [WAYPOINTS[0], { lat: at(1199)[0], lng: 11 }] });
const hike = routed(planned);
expect(asked('/height')).toHaveLength(3);
expect(planned.line).toHaveLength(1200);
expect(planned.line?.every((point) => point.length === 3)).toBe(true);
expect(hike.line).toHaveLength(HIKE_PLAN_LINE_POINTS);
expect(hike.line[0]).toEqual(planned.line?.[0]);
expect(hike.line.at(-1)).toEqual(planned.line?.at(-1));
expect(hike.profile).toHaveLength(HIKE_PLAN_PROFILE_SAMPLES);
expect(hikePlanSchema.safeParse(hike).success).toBe(true);
});
});
describe('HikeRoutingService: no way planned', () => {
it('HIKE-ROUTE-004: says the router did not answer where Valhalla is down, and asks nothing more', async () => {
valhalla({ route: () => new Response('', { status: 503 }) });
const planned = await plan({ max_hiking_difficulty: 3 });
expect(planned).toEqual({
plan: { kind: 'unrouted', max_hiking_difficulty: 3, why: 'router_unavailable', position: null, snapped_m: null },
line: null,
});
expect(asked('/height')).toHaveLength(0);
expect(asked('/trace_attributes')).toHaveLength(0);
expect(hikePlanSchema.safeParse(planned.plan).success).toBe(true);
});
it('HIKE-ROUTE-005: says there is no Valhalla where an own OSRM has none, and never routes the hike with OSRM', async () => {
getUserSettings.mockResolvedValue({ routing_base_url: 'http://osrm.internal' });
expect(unrouted(await plan()).why).toBe('no_valhalla');
expect(safeFetchAdminConfigured).not.toHaveBeenCalled();
});
it('HIKE-ROUTE-006: says no way within the limit joins the points where Valhalla finds none', async () => {
valhalla({ route: () => json({ error_code: 442, error: 'No path could be found for input' }, 400) });
expect(unrouted(await plan({ max_hiking_difficulty: 1 }))).toMatchObject({
why: 'no_way',
max_hiking_difficulty: 1,
});
});
it('HIKE-ROUTE-007: names the point no way reaches and how far it was moved, and measures nothing', async () => {
valhalla({
route: () =>
json(
valhallaRoute([
{ line: stepsOf(0, 10), km: 0.111, seconds: 150 },
{ line: stepsOf(10, 20, 11.005), km: 0.111, seconds: 180 },
]),
),
});
const hike = unrouted(await plan());
expect(hike).toMatchObject({ kind: 'unrouted', why: 'point_off_the_way', position: 2 });
expect(hike.snapped_m).toBeGreaterThan(370);
expect(hike.snapped_m).toBeLessThan(390);
expect(asked('/height')).toHaveLength(0);
});
});
describe('HikeRoutingService: heights and grades are best effort', () => {
it('HIKE-ROUTE-008: keeps the line flat and times it by the router where the heights cannot be read', async () => {
valhalla({ height: () => new Response('', { status: 502 }) });
const planned = await plan();
const hike = routed(planned);
expect(hike).toMatchObject({
ascent_m: null,
descent_m: null,
has_heights: false,
duration_basis: 'router',
// The router's 150 s and 180 s.
duration_minutes: 6,
profile: [],
});
expect(planned.line?.every((point) => point.length === 2)).toBe(true);
expect(hike.grades?.highest).toBe(4);
expect(hikePlanSchema.safeParse(hike).success).toBe(true);
});
it('HIKE-ROUTE-009: keeps the line flat where only part of it has heights', async () => {
valhalla({
route: () => json(valhallaRoute([{ line: stepsOf(0, 599), km: 6.7, seconds: 5000 }])),
// The second piece of the line is answered for fewer points than it asked about.
height: (shape) =>
json({ range_height: shape.slice(0, shape.length > 200 ? undefined : 1).map((point, index) => [index, 600]) }),
});
const short = await plan({ waypoints: [WAYPOINTS[0], { lat: at(599)[0], lng: 11 }] });
// The second piece is asked once more before the reading stops.
expect(asked('/height')).toHaveLength(3);
expect(routed(short).has_heights).toBe(false);
expect(short.line?.every((point) => point.length === 2)).toBe(true);
// One point the terrain data has no height for.
valhalla({ height: (shape) => json({ range_height: shape.map((_, index) => [index, index === 3 ? null : 600]) }) });
const gap = await plan();
expect(routed(gap)).toMatchObject({ has_heights: false, ascent_m: null });
expect(gap.line?.every((point) => point.length === 2)).toBe(true);
});
it('HIKE-ROUTE-010: plans the way without grades where the ways under it cannot be described', async () => {
valhalla({ trace: () => new Response('', { status: 500 }) });
const hike = routed(await plan());
expect(hike.grades).toBeNull();
expect(hike.has_heights).toBe(true);
expect(grades).not.toHaveBeenCalled();
valhalla({ trace: () => json({ edges: [] }) });
expect(routed(await plan()).grades).toBeNull();
});
it('HIKE-ROUTE-011: says the grades could not be read where no source answered, and unknown where no way is graded', async () => {
grades.mockResolvedValueOnce({ ways: [], unanswered: [101, 102, 103, 104] });
expect(routed(await plan()).grades).toBeNull();
grades.mockRejectedValueOnce(new Error('layer down'));
expect(routed(await plan()).grades).toBeNull();
// Ways the router names none of: nothing to ask, the whole line unknown.
valhalla({ trace: () => json({ edges: [{ length: 0.1 }, { way_id: 0, length: 0.12 }] }) });
grades.mockClear();
expect(routed(await plan()).grades).toEqual({ segments: [], highest: null, unknown_share: 1 });
expect(grades).not.toHaveBeenCalled();
});
it('HIKE-ROUTE-012: keeps the line flat where reading the heights throws, and takes a route without snapped points as it is', async () => {
const router = t.get(RoadtripRouterService);
const real = router.route.bind(router);
const heights = vi.spyOn(router, 'heights').mockRejectedValueOnce('settings unreadable');
const route = vi
.spyOn(router, 'route')
.mockImplementationOnce(async (...args) => ({ ...(await real(...args)), snapped: undefined }));
const hike = routed(await plan());
expect(hike.has_heights).toBe(false);
expect(hike.distance_m).toBe(222);
expect(heights).toHaveBeenCalledTimes(1);
route.mockRestore();
heights.mockRestore();
});
it('HIKE-ROUTE-013: reads no grades where none are asked for, the way and its heights all the same', async () => {
const hike = routed(await plan({}, false));
expect(hike).toMatchObject({ has_heights: true, distance_m: 222, grades: null });
expect(asked('/route')).toHaveLength(1);
expect(asked('/height')).toHaveLength(1);
expect(asked('/trace_attributes')).toHaveLength(0);
expect(grades).not.toHaveBeenCalled();
});
});
describe('HikeRoutingService: what it plans at all', () => {
it('HIKE-ROUTE-014: plans no way longer than a hike is planned for, measuring nothing of it', async () => {
const far = { lat: at(HIKE_MAX_LINE_POINTS)[0], lng: 11 };
valhalla({
route: () => json(valhallaRoute([{ line: stepsOf(0, HIKE_MAX_LINE_POINTS), km: 111.2, seconds: 90_000 }])),
});
expect(unrouted(await plan({ waypoints: [WAYPOINTS[0], far] }))).toMatchObject({
why: 'too_long',
position: null,
snapped_m: null,
});
expect(asked('/height')).toHaveLength(0);
expect(asked('/trace_attributes')).toHaveLength(0);
// A line of exactly that many points is still planned.
valhalla({
route: () => json(valhallaRoute([{ line: stepsOf(1, HIKE_MAX_LINE_POINTS), km: 111.2, seconds: 90_000 }])),
});
expect(routed(await plan({ waypoints: [{ lat: at(1)[0], lng: 11 }, far] }, false)).has_heights).toBe(true);
// Valhalla's own word that the way is longer than it plans.
valhalla({ route: () => json({ error_code: 154, error: 'Path distance exceeds the max distance limit' }, 400) });
expect(unrouted(await plan()).why).toBe('too_long');
});
it('HIKE-ROUTE-015: refuses points the plan does not take, before asking the router anything', async () => {
const many = Array.from({ length: HIKE_PLAN_MAX_WAYPOINTS + 1 }, (_, k) => ({ lat: at(k)[0], lng: 11 }));
const requests: HikePlanRequest[] = [
{ waypoints: many },
{ waypoints: WAYPOINTS.slice(0, 1) },
{ waypoints: WAYPOINTS, max_hiking_difficulty: 9 },
];
const refusals = await Promise.all(
requests.map((request) => hikes.plan(1, 1, request).catch((err: unknown) => err)),
);
expect(refusals.map((err) => err instanceof DomainError && err.getStatus())).toEqual([400, 400, 400]);
expect(safeFetchAdminConfigured).not.toHaveBeenCalled();
});
});
+307 -21
View File
@@ -1,5 +1,6 @@
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
import { RoadtripRouterService, RouteRefusal } from '../../../src/nest/roadtrip/roadtrip-router.service';
import { safeFetchAdminConfigured } from '../../../src/utils/ssrfGuard';
import { valhallaPolyline } from '../../helpers/valhalla';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -54,16 +55,10 @@ afterEach(() => vi.useRealTimers());
describe('server Roadtrip router', () => {
it('reads Valhalla leg times, geometry and unmet road preferences', async () => {
const encode = (value: number) => {
let remaining = value < 0 ? ~(value << 1) : value << 1;
let encoded = '';
while (remaining >= 32) {
encoded += String.fromCharCode((32 | (remaining & 31)) + 63);
remaining >>>= 5;
}
return encoded + String.fromCharCode(remaining + 63);
};
const shape = encode(48000000) + encode(10000000) + encode(1000000) + encode(1000000);
const shape = valhallaPolyline([
[48, 10],
[49, 11],
]);
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(
new Response(
JSON.stringify({
@@ -213,16 +208,10 @@ describe('server Roadtrip router', () => {
});
describe('server Roadtrip router, a walk on foot only (CW-12)', () => {
const encode = (value: number) => {
let remaining = value < 0 ? ~(value << 1) : value << 1;
let encoded = '';
while (remaining >= 32) {
encoded += String.fromCharCode((32 | (remaining & 31)) + 63);
remaining >>>= 5;
}
return encoded + String.fromCharCode(remaining + 63);
};
const shape = encode(48000000) + encode(10000000) + encode(1000000) + encode(1000000);
const shape = valhallaPolyline([
[48, 10],
[49, 11],
]);
const valhallaLegs = (legs: unknown[]) => new Response(JSON.stringify({ trip: { legs } }));
const walked = { shape, summary: { length: 1.4, time: 1000, has_ferry: false } };
// Measured on Venice: the boat leg as FOSSGIS answers it, boarding at type 28 with ferry true.
@@ -302,3 +291,300 @@ describe('server Roadtrip router, a walk on foot only (CW-12)', () => {
expect(safeFetchAdminConfigured).toHaveBeenLastCalledWith(expect.stringContaining('routed-car'), expect.anything());
});
});
describe('server Roadtrip router, a hike: its costing, heights and ways', () => {
const hikeLeg = {
shape: valhallaPolyline([
[48, 10],
[49, 11],
]),
summary: { length: 2.5, time: 1800 },
};
const costingOptions = { max_hiking_difficulty: 3, step_penalty: 600 };
const bodyOf = (call: number) =>
JSON.parse((vi.mocked(safeFetchAdminConfigured).mock.calls[call][1] as { body: string }).body) as Record<
string,
unknown
>;
const line = (count: number) => Array.from({ length: count }, (_, index): [number, number] => [47 + index / 1e4, 11]);
/** Valhalla's /height answer for whatever piece it was asked about: a metre a point of the line, from 500. */
const heightAnswer = (init: RequestInit | undefined) => {
const body = JSON.parse(String(init?.body)) as { shape: Array<{ lat: number }> };
return new Response(
JSON.stringify({
range_height: body.shape.map((point, index) => [index, 500 + Math.round((point.lat - 47) * 1e4)]),
}),
);
};
const heightsByIndex = () =>
vi.mocked(safeFetchAdminConfigured).mockImplementation(async (_url, init) => heightAnswer(init));
/** Every question answered with `answer`, the router asked through the queue until it is done. */
const failing = (answer: () => Response | Promise<Response>) =>
vi.mocked(safeFetchAdminConfigured).mockImplementation(async () => answer());
const settled = async <T>(pending: Promise<T>): Promise<T> => {
await vi.runAllTimersAsync();
return await pending;
};
it('ROUTER-HIKE-001: asks Valhalla on foot with exactly the hike costing, no point bound off the boats, and keeps it apart in the cache', async () => {
vi.mocked(safeFetchAdminConfigured).mockImplementation(
async () => new Response(JSON.stringify({ trip: { legs: [hikeLeg] } })),
);
const { router } = setup();
const route = await router.route(1, 1, 0, points.slice(0, 2), 'walking', [], { costingOptions });
const [url] = vi.mocked(safeFetchAdminConfigured).mock.calls[0];
expect(url).toBe('https://valhalla1.openstreetmap.de/route');
const body = bodyOf(0);
expect(body.costing).toBe('pedestrian');
expect(body.costing_options).toEqual({ pedestrian: costingOptions });
expect((body.locations as Array<Record<string, unknown>>).every((location) => !('search_filter' in location))).toBe(
true,
);
expect(route.parts).toEqual([{ distance: 2500, duration: 1800 }]);
expect(route).not.toHaveProperty('ferryLegs');
// The same hike again comes from the cache; the same points as a city walk or with another limit are other questions.
await router.route(1, 1, 0, points.slice(0, 2), 'walking', [], { costingOptions });
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(1);
const other = router.route(1, 1, 0, points.slice(0, 2), 'walking', [], {
costingOptions: { ...costingOptions, max_hiking_difficulty: 4 },
});
await vi.runAllTimersAsync();
await other;
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(2);
const walk = await settled(router.route(1, 1, 0, points.slice(0, 2), 'walking', [], { footOnly: true }));
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(3);
expect(bodyOf(2).costing_options).toEqual({ pedestrian: { use_ferry: 0 } });
expect(walk.ferryLegs).toEqual([]);
});
it('ROUTER-HIKE-002: refuses a hike where there is no Valhalla to ask, and never falls back to OSRM', async () => {
const { router } = setup({ routing_base_url: 'http://router.internal' });
const refused = router.route(1, 1, 0, points, 'walking', [], { costingOptions });
await expect(refused).rejects.toBeInstanceOf(RouteRefusal);
await expect(refused).rejects.toMatchObject({ why: 'no_valhalla' });
expect(safeFetchAdminConfigured).not.toHaveBeenCalled();
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(new Response('', { status: 503 }));
await expect(setup().router.route(1, 1, 0, points, 'walking', [], { costingOptions })).rejects.toThrow(
'Routing provider returned 503',
);
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(1);
});
it('ROUTER-HIKE-003: reads Valhalla saying no way joins the points as a refusal, and any other 400 as a failure', async () => {
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(
new Response(JSON.stringify({ error_code: 442, error: 'No path could be found for input' }), { status: 400 }),
);
await expect(setup().router.route(1, 1, 0, points, 'walking', [], { costingOptions })).rejects.toMatchObject({
name: 'RouteRefusal',
why: 'no_way',
});
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(
new Response(JSON.stringify({ error_code: 150, error: 'Exceeded max locations' }), { status: 400 }),
);
const failure: unknown = await setup()
.router.route(1, 1, 0, points, 'walking', [], { costingOptions })
.catch((err: unknown) => err);
expect(failure).not.toBeInstanceOf(RouteRefusal);
expect(failure).toMatchObject({ message: 'Routing provider returned 400' });
// A way past the instance's distance limit is refused for its length, which asking again does not change.
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(
new Response(JSON.stringify({ error_code: 154, error: 'Path distance exceeds the max distance limit' }), {
status: 400,
}),
);
await expect(setup().router.route(1, 1, 0, points, 'walking', [], { costingOptions })).rejects.toMatchObject({
name: 'RouteRefusal',
why: 'too_long',
});
// A 400 that names no code of Valhalla's is a failure of the host as well.
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(new Response('Bad request', { status: 400 }));
const unread: unknown = await setup()
.router.route(1, 1, 0, points, 'walking', [], { costingOptions })
.catch((err: unknown) => err);
expect(unread).not.toBeInstanceOf(RouteRefusal);
expect(unread).toMatchObject({ message: 'Routing provider returned 400' });
});
it('ROUTER-HIKE-004: leaves a city walk falling back to OSRM on a refusal, and a drive untouched by a hike costing', async () => {
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(
new Response(JSON.stringify({ error_code: 171 }), { status: 400 }),
);
const walk = setup().router.route(1, 1, 0, points, 'walking', [], { footOnly: true });
await vi.runAllTimersAsync();
expect((await walk).parts).toHaveLength(2);
expect(safeFetchAdminConfigured).toHaveBeenLastCalledWith(
expect.stringContaining('routed-foot'),
expect.anything(),
);
await setup().router.route(1, 1, 0, points, 'driving', [], { costingOptions });
expect(safeFetchAdminConfigured).toHaveBeenLastCalledWith(expect.stringContaining('routed-car'), expect.anything());
});
it('ROUTER-HIKE-005: reads the heights in pieces of 500 that overlap by one, through the paced queue', async () => {
heightsByIndex();
const pending = setup().router.heights(1, line(1000));
await vi.runAllTimersAsync();
const measured = await pending;
const calls = vi.mocked(safeFetchAdminConfigured).mock.calls;
expect(calls.map(([url]) => url)).toEqual(Array(3).fill('https://valhalla1.openstreetmap.de/height'));
expect([0, 1, 2].map((call) => (bodyOf(call).shape as unknown[]).length)).toEqual([500, 500, 2]);
expect(bodyOf(0).range).toBe(true);
expect((bodyOf(1).shape as Array<{ lat: number; lon: number }>)[0]).toEqual({ lat: 47 + 499 / 1e4, lon: 11 });
expect(measured).toHaveLength(1000);
expect(measured?.[0]).toEqual([47, 11, 500]);
expect(measured?.[999]).toEqual([47 + 999 / 1e4, 11, 1499]);
expect(measured?.every((point) => point.length === 3)).toBe(true);
// A line whose last piece would hold only the point the one before ended at asks no third time.
vi.mocked(safeFetchAdminConfigured).mockClear();
const shorter = setup().router.heights(1, line(999));
await vi.runAllTimersAsync();
expect((await shorter)?.at(-1)).toEqual([47 + 998 / 1e4, 11, 1498]);
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(2);
});
it('ROUTER-HIKE-006: keeps the height the overlapping point already has, and a point without one flat', async () => {
heightsByIndex();
let calls = 0;
vi.mocked(safeFetchAdminConfigured).mockImplementationOnce(async (_url, init) => {
calls += 1;
const body = JSON.parse(String(init?.body)) as { shape: unknown[] };
// The terrain data has no height for the second point.
return new Response(
JSON.stringify({ range_height: body.shape.map((_, index) => [index, index === 1 ? null : 7]) }),
);
});
const pending = setup().router.heights(1, line(501));
await vi.runAllTimersAsync();
const measured = await pending;
expect(calls).toBe(1);
expect(measured?.[1]).toEqual([47 + 1 / 1e4, 11]);
expect(measured?.[499]).toEqual([47 + 499 / 1e4, 11, 7]);
expect(measured?.[500]).toEqual([47 + 500 / 1e4, 11, 1000]);
});
it('ROUTER-HIKE-007: stops reading heights at a piece that fails twice or twice does not name every point', async () => {
const failures = [
() => failing(() => new Response('', { status: 503 })),
() => failing(() => new Response(JSON.stringify({ range_height: [[0, 500]] }))),
() => failing(() => new Response(JSON.stringify({ heights: [1, 2, 3] }))),
() => vi.mocked(safeFetchAdminConfigured).mockRejectedValue(new Error('socket hang up')),
// A transport that rejects with a bare value.
() => vi.mocked(safeFetchAdminConfigured).mockRejectedValue('socket hang up'),
];
// One failure after the other: what each read gave, and how often the host was asked for it.
const reads = await failures.reduce<Promise<unknown[]>>(async (before, fail) => {
const done = await before;
vi.mocked(safeFetchAdminConfigured).mockReset();
fail();
const read = await settled(setup().router.heights(1, line(3)));
return [...done, [read, vi.mocked(safeFetchAdminConfigured).mock.calls.length]];
}, Promise.resolve([]));
expect(reads).toEqual(failures.map(() => [line(3), 2]));
});
it('ROUTER-HIKE-011: asks a failed piece once more, and stops at one that fails again, the points after it flat', async () => {
let asked = 0;
vi.mocked(safeFetchAdminConfigured).mockImplementation(async (_url, init) => {
asked += 1;
return asked === 1 ? heightAnswer(init) : new Response('', { status: 503 });
});
const measured = await settled(setup().router.heights(1, line(1000)));
// The first piece, the second, and the second again; the third is never asked.
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(3);
expect(measured).toHaveLength(1000);
expect(measured?.slice(0, 500).every((point) => point.length === 3)).toBe(true);
expect(measured?.slice(500).every((point) => point.length === 2)).toBe(true);
expect(measured?.[499]).toEqual([47 + 499 / 1e4, 11, 999]);
// A piece that fails once and answers when asked again is read.
vi.mocked(safeFetchAdminConfigured).mockClear();
heightsByIndex().mockResolvedValueOnce(new Response('', { status: 429 }));
const again = await settled(setup().router.heights(1, line(3)));
expect(again?.every((point) => point.length === 3)).toBe(true);
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(2);
});
it('ROUTER-HIKE-012: keeps a line read whole for five minutes per user, and asks again for one read in part', async () => {
heightsByIndex();
const { router } = setup();
const first = await settled(router.heights(1, line(3)));
expect(first?.every((point) => point.length === 3)).toBe(true);
expect(await router.heights(1, line(3))).toEqual(first);
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(1);
// Another user, another line, and the same line five minutes on are asked again.
await settled(router.heights(2, line(3)));
await settled(router.heights(1, line(4)));
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(3);
vi.advanceTimersByTime(300_001);
await settled(router.heights(1, line(3)));
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(4);
// A line read in part is not kept: the next question asks the host again.
failing(() => new Response('', { status: 502 })).mockClear();
expect(await settled(router.heights(1, line(5)))).toEqual(line(5));
heightsByIndex().mockClear();
expect((await settled(router.heights(1, line(5))))?.every((point) => point.length === 3)).toBe(true);
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(1);
// Only the twenty lines read last are kept; the first of them is asked again.
vi.mocked(safeFetchAdminConfigured).mockClear();
// The router's one queue answers them in the order asked.
const counts = Array.from({ length: 21 }, (_, index) => 10 + index);
await settled(Promise.all(counts.map((count) => router.heights(3, line(count)))));
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(21);
await settled(router.heights(3, line(30)));
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(21);
await settled(router.heights(3, line(10)));
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(22);
});
it('ROUTER-HIKE-008: asks no heights without a Valhalla or a line to measure', async () => {
expect(await setup({ routing_base_url: 'http://router.internal' }).router.heights(1, line(3))).toBeNull();
expect(await setup().router.heights(1, line(1))).toBeNull();
expect(safeFetchAdminConfigured).not.toHaveBeenCalled();
});
it('ROUTER-HIKE-009: describes the ways under a line, matched on foot with every grade open, thinned to 4000 points', async () => {
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(
new Response(
JSON.stringify({
edges: [{ way_id: 11, length: 0.25 }, { way_id: 0, length: 0.1 }, { length: 0.05 }, { way_id: 12 }],
}),
),
);
const { router } = setup({ valhalla_base_url: 'https://valhalla.internal/' });
const ways = await router.wayIds(1, line(9001));
expect(vi.mocked(safeFetchAdminConfigured).mock.calls[0][0]).toBe('https://valhalla.internal/trace_attributes');
const body = bodyOf(0);
expect(body).toMatchObject({
costing: 'pedestrian',
costing_options: { pedestrian: { max_hiking_difficulty: 6 } },
shape_match: 'map_snap',
filters: { attributes: ['edge.way_id', 'edge.length'], action: 'include' },
});
const shape = body.shape as Array<{ lat: number; lon: number }>;
expect(shape.length).toBeLessThanOrEqual(4000);
expect(shape[0]).toEqual({ lat: 47, lon: 11 });
expect(shape.at(-1)).toEqual({ lat: 47 + 9000 / 1e4, lon: 11 });
expect(ways).toEqual([
{ wayId: 11, metres: 250 },
{ wayId: null, metres: 100 },
{ wayId: null, metres: 50 },
]);
});
it('ROUTER-HIKE-010: refuses to describe a line without a Valhalla, and an answer of another kind', async () => {
await expect(setup({ routing_base_url: 'http://router.internal' }).router.wayIds(1, line(3))).rejects.toMatchObject(
{
why: 'no_valhalla',
},
);
expect(safeFetchAdminConfigured).not.toHaveBeenCalled();
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(new Response(JSON.stringify({ edges: 'none' })));
await expect(setup().router.wayIds(1, line(3))).rejects.toThrow();
});
});
+6
View File
@@ -67,6 +67,10 @@ export const ASSISTANT_CHANGE_VERB_KEYS = [
'addReservation',
'importBooking',
'rememberNote',
// A tour: a hike or a city walk created, a hike changed, tours taken in from a GPX file.
'addTour',
'updateTour',
'importTour',
// Any other tool: by what its annotations say it does.
'genericCreate',
'genericUpdate',
@@ -113,6 +117,8 @@ export const ASSISTANT_CHANGE_LABEL_KEYS = [
'stay',
'members',
'route',
// The hardest SAC grade a hike's way may take, T1 to T6.
'difficulty',
// A new order of a day's places or of the days, as the names in that order.
'order',
// An argument of a tool without a card of its own; the row names it in `field`.
+6
View File
@@ -113,6 +113,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'التحقق من التكاليف',
'assistant.step.help': 'البحث في المساعدة',
'assistant.step.readingBooking': 'قراءة الحجز',
'assistant.step.planningHike': 'تخطيط رحلة المشي',
'assistant.step.planningHikeTo': 'تخطيط رحلة المشي إلى {place}',
'assistant.step.working': 'جارٍ العمل على ذلك',
'assistant.step.collapsed': '{count} خطوة',
'assistant.step.collapsed.zero': 'لا خطوات',
@@ -471,7 +473,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'إضافة',
'assistant.change.verb.genericUpdate': 'تغيير',
'assistant.change.verb.genericRemove': 'إزالة',
'assistant.change.verb.addTour': 'إضافة جولة',
'assistant.change.verb.updateTour': 'تعديل الجولة',
'assistant.change.verb.importTour': 'إضافة جولة من ملف',
'assistant.change.label.field': 'القيمة',
'assistant.change.label.difficulty': 'أقصى مستوى لصعوبة الدرب',
'assistant.domain.tours': 'الجولات',
'assistant.domain.roadtrip': 'رحلة برية',
'assistant.domain.journey': 'يوميات السفر',
+6
View File
@@ -113,6 +113,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Xərclər yoxlanılır',
'assistant.step.help': 'Kömək bölməsində axtarılır',
'assistant.step.readingBooking': 'Bron oxunur',
'assistant.step.planningHike': 'Yürüş planlaşdırılır',
'assistant.step.planningHikeTo': '{place} istiqamətində yürüş planlaşdırılır',
'assistant.step.working': 'Üzərində işləyirəm',
'assistant.step.collapsed': '{count} addım',
'assistant.step.collapsed.one': '{count} addım',
@@ -391,7 +393,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Əlavə et',
'assistant.change.verb.genericUpdate': 'Dəyiş',
'assistant.change.verb.genericRemove': 'Sil',
'assistant.change.verb.addTour': 'Tur əlavə et',
'assistant.change.verb.updateTour': 'Turu dəyiş',
'assistant.change.verb.importTour': 'Fayldan tur əlavə et',
'assistant.change.label.field': 'Dəyər',
'assistant.change.label.difficulty': 'Maksimum cığır çətinliyi',
'assistant.domain.tours': 'Turlar',
'assistant.domain.roadtrip': 'Avtosəyahət',
'assistant.domain.journey': 'Səyahət gündəliyi',
+6
View File
@@ -114,6 +114,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Verificando os custos',
'assistant.step.help': 'Consultando a ajuda',
'assistant.step.readingBooking': 'Lendo a reserva',
'assistant.step.planningHike': 'Planejando a trilha',
'assistant.step.planningHikeTo': 'Planejando a trilha até {place}',
'assistant.step.working': 'Trabalhando nisso',
'assistant.step.collapsed': '{count} etapas',
'assistant.step.collapsed.one': '{count} etapa',
@@ -392,7 +394,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Adicionar',
'assistant.change.verb.genericUpdate': 'Alterar',
'assistant.change.verb.genericRemove': 'Remover',
'assistant.change.verb.addTour': 'Adicionar passeio',
'assistant.change.verb.updateTour': 'Alterar passeio',
'assistant.change.verb.importTour': 'Adicionar passeio a partir de arquivo',
'assistant.change.label.field': 'Valor',
'assistant.change.label.difficulty': 'Dificuldade máxima da trilha',
'assistant.domain.tours': 'Passeios',
'assistant.domain.roadtrip': 'Viagem de carro',
'assistant.domain.journey': 'Jornada',
+6
View File
@@ -114,6 +114,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Revisant les despeses',
'assistant.step.help': 'Cercant a l’ajuda',
'assistant.step.readingBooking': 'Llegint la reserva',
'assistant.step.planningHike': 'Planificant l’excursió',
'assistant.step.planningHikeTo': 'Planificant l’excursió fins a {place}',
'assistant.step.working': 'Hi estic treballant',
'assistant.step.collapsed': '{count} passos',
'assistant.step.collapsed.one': '{count} pas',
@@ -394,7 +396,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Afegeix',
'assistant.change.verb.genericUpdate': 'Canvia',
'assistant.change.verb.genericRemove': 'Elimina',
'assistant.change.verb.addTour': 'Afegeix una excursió',
'assistant.change.verb.updateTour': 'Canvia l’excursió',
'assistant.change.verb.importTour': 'Afegeix una excursió des d’un fitxer',
'assistant.change.label.field': 'Valor',
'assistant.change.label.difficulty': 'Dificultat màxima del sender',
'assistant.domain.tours': 'Rutes',
'assistant.domain.roadtrip': 'Viatge per carretera',
'assistant.domain.journey': 'Diari de viatge',
+6
View File
@@ -113,6 +113,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Kontroluji náklady',
'assistant.step.help': 'Hledám v nápovědě',
'assistant.step.readingBooking': 'Čtu rezervaci',
'assistant.step.planningHike': 'Plánuji túru',
'assistant.step.planningHikeTo': 'Plánuji túru do místa {place}',
'assistant.step.working': 'Pracuji na tom',
'assistant.step.collapsed': '{count} kroků',
'assistant.step.collapsed.one': '{count} krok',
@@ -410,7 +412,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Přidat',
'assistant.change.verb.genericUpdate': 'Změnit',
'assistant.change.verb.genericRemove': 'Odebrat',
'assistant.change.verb.addTour': 'Přidat výlet',
'assistant.change.verb.updateTour': 'Změnit výlet',
'assistant.change.verb.importTour': 'Přidat výlet ze souboru',
'assistant.change.label.field': 'Hodnota',
'assistant.change.label.difficulty': 'Maximální obtížnost stezky',
'assistant.domain.tours': 'Túry',
'assistant.domain.roadtrip': 'Cesta autem',
'assistant.domain.journey': 'Cestovní deník',
+6
View File
@@ -113,6 +113,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Prüft die Kosten',
'assistant.step.help': 'Schaut in der Hilfe nach',
'assistant.step.readingBooking': 'Liest die Buchung',
'assistant.step.planningHike': 'Plant die Wanderung',
'assistant.step.planningHikeTo': 'Plant die Wanderung bis {place}',
'assistant.step.working': 'Arbeitet daran',
'assistant.step.collapsed': '{count} Schritte',
'assistant.step.collapsed.one': '{count} Schritt',
@@ -391,7 +393,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Hinzufügen',
'assistant.change.verb.genericUpdate': 'Ändern',
'assistant.change.verb.genericRemove': 'Entfernen',
'assistant.change.verb.addTour': 'Tour hinzufügen',
'assistant.change.verb.updateTour': 'Tour ändern',
'assistant.change.verb.importTour': 'Tour aus Datei hinzufügen',
'assistant.change.label.field': 'Wert',
'assistant.change.label.difficulty': 'Maximale Wegschwierigkeit',
'assistant.domain.tours': 'Touren',
'assistant.domain.roadtrip': 'Roadtrip',
'assistant.domain.journey': 'Reisetagebuch',
+6
View File
@@ -110,6 +110,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Checking the costs',
'assistant.step.help': 'Looking it up in the help',
'assistant.step.readingBooking': 'Reading the booking',
'assistant.step.planningHike': 'Planning the hike',
'assistant.step.planningHikeTo': 'Planning the hike to {place}',
'assistant.step.working': 'Working on it',
'assistant.step.collapsed': '{count} steps',
'assistant.step.collapsed.one': '{count} step',
@@ -386,7 +388,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Add',
'assistant.change.verb.genericUpdate': 'Change',
'assistant.change.verb.genericRemove': 'Remove',
'assistant.change.verb.addTour': 'Add tour',
'assistant.change.verb.updateTour': 'Change tour',
'assistant.change.verb.importTour': 'Add tour from file',
'assistant.change.label.field': 'Value',
'assistant.change.label.difficulty': 'Maximum trail difficulty',
'assistant.domain.tours': 'Tours',
'assistant.domain.roadtrip': 'Road trip',
'assistant.domain.journey': 'Journey',
+6
View File
@@ -112,6 +112,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Revisando los gastos',
'assistant.step.help': 'Buscando en la ayuda',
'assistant.step.readingBooking': 'Leyendo la reserva',
'assistant.step.planningHike': 'Planificando la excursión',
'assistant.step.planningHikeTo': 'Planificando la excursión hasta {place}',
'assistant.step.working': 'Trabajando en ello',
'assistant.step.collapsed': '{count} pasos',
'assistant.step.collapsed.one': '{count} paso',
@@ -389,7 +391,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Añadir',
'assistant.change.verb.genericUpdate': 'Cambiar',
'assistant.change.verb.genericRemove': 'Quitar',
'assistant.change.verb.addTour': 'Añadir excursión',
'assistant.change.verb.updateTour': 'Cambiar excursión',
'assistant.change.verb.importTour': 'Añadir excursión desde archivo',
'assistant.change.label.field': 'Valor',
'assistant.change.label.difficulty': 'Dificultad máxima del sendero',
'assistant.domain.tours': 'Rutas',
'assistant.domain.roadtrip': 'Viaje por carretera',
'assistant.domain.journey': 'Diario de viaje',
+6
View File
@@ -113,6 +113,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Kontrollin kulusid',
'assistant.step.help': 'Otsin abist',
'assistant.step.readingBooking': 'Loen broneeringut',
'assistant.step.planningHike': 'Planeerin matka',
'assistant.step.planningHikeTo': 'Planeerin matka sihtkohta {place}',
'assistant.step.working': 'Töötan selle kallal',
'assistant.step.collapsed': '{count} sammu',
'assistant.step.collapsed.one': '{count} samm',
@@ -390,7 +392,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Lisa',
'assistant.change.verb.genericUpdate': 'Muuda',
'assistant.change.verb.genericRemove': 'Eemalda',
'assistant.change.verb.addTour': 'Lisa retk',
'assistant.change.verb.updateTour': 'Muuda retke',
'assistant.change.verb.importTour': 'Lisa retk failist',
'assistant.change.label.field': 'Väärtus',
'assistant.change.label.difficulty': 'Raja suurim raskusaste',
'assistant.domain.tours': 'Matkad',
'assistant.domain.roadtrip': 'Autoreis',
'assistant.domain.journey': 'Reisipäevik',
+6
View File
@@ -114,6 +114,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Vérification des dépenses',
'assistant.step.help': 'Recherche dans l’aide',
'assistant.step.readingBooking': 'Lecture de la réservation',
'assistant.step.planningHike': 'Planification de la randonnée',
'assistant.step.planningHikeTo': 'Planification de la randonnée jusqu’à {place}',
'assistant.step.working': 'Travail en cours',
'assistant.step.collapsed': '{count} étapes',
'assistant.step.collapsed.one': '{count} étape',
@@ -396,7 +398,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Ajouter',
'assistant.change.verb.genericUpdate': 'Modifier',
'assistant.change.verb.genericRemove': 'Supprimer',
'assistant.change.verb.addTour': 'Ajouter une sortie',
'assistant.change.verb.updateTour': 'Modifier la sortie',
'assistant.change.verb.importTour': 'Ajouter une sortie depuis un fichier',
'assistant.change.label.field': 'Valeur',
'assistant.change.label.difficulty': 'Difficulté maximale du sentier',
'assistant.domain.tours': 'Randonnées',
'assistant.domain.roadtrip': 'Voyage en voiture',
'assistant.domain.journey': 'Carnet de voyage',
+6
View File
@@ -116,6 +116,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Έλεγχος εξόδων',
'assistant.step.help': 'Αναζήτηση στη βοήθεια',
'assistant.step.readingBooking': 'Διαβάζω την κράτηση',
'assistant.step.planningHike': 'Σχεδιάζω την πεζοπορία',
'assistant.step.planningHikeTo': 'Σχεδιάζω την πεζοπορία προς {place}',
'assistant.step.working': 'Δουλεύω πάνω σε αυτό',
'assistant.step.collapsed': '{count} βήματα',
'assistant.step.collapsed.one': '{count} βήμα',
@@ -395,7 +397,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Προσθήκη',
'assistant.change.verb.genericUpdate': 'Αλλαγή',
'assistant.change.verb.genericRemove': 'Αφαίρεση',
'assistant.change.verb.addTour': 'Προσθήκη εκδρομής',
'assistant.change.verb.updateTour': 'Αλλαγή εκδρομής',
'assistant.change.verb.importTour': 'Προσθήκη εκδρομής από αρχείο',
'assistant.change.label.field': 'Τιμή',
'assistant.change.label.difficulty': 'Μέγιστη δυσκολία μονοπατιού',
'assistant.domain.tours': 'Διαδρομές',
'assistant.domain.roadtrip': 'Οδικό ταξίδι',
'assistant.domain.journey': 'Ημερολόγιο ταξιδιού',
+6
View File
@@ -115,6 +115,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'A költségek ellenőrzése',
'assistant.step.help': 'Utánanézés a súgóban',
'assistant.step.readingBooking': 'A foglalás olvasása',
'assistant.step.planningHike': 'A túra tervezése',
'assistant.step.planningHikeTo': 'Túra tervezése ide: {place}',
'assistant.step.working': 'Dolgozom rajta',
'assistant.step.collapsed': '{count} lépés',
'assistant.step.collapsed.one': '{count} lépés',
@@ -396,7 +398,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Hozzáadás',
'assistant.change.verb.genericUpdate': 'Módosítás',
'assistant.change.verb.genericRemove': 'Eltávolítás',
'assistant.change.verb.addTour': 'Túra hozzáadása',
'assistant.change.verb.updateTour': 'Túra módosítása',
'assistant.change.verb.importTour': 'Túra hozzáadása fájlból',
'assistant.change.label.field': 'Érték',
'assistant.change.label.difficulty': 'Ösvény legnagyobb nehézsége',
'assistant.domain.tours': 'Túrák',
'assistant.domain.roadtrip': 'Autós körút',
'assistant.domain.journey': 'Útinapló',
+6
View File
@@ -111,6 +111,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Memeriksa biaya',
'assistant.step.help': 'Mencari di bantuan',
'assistant.step.readingBooking': 'Membaca pemesanan',
'assistant.step.planningHike': 'Merencanakan pendakian',
'assistant.step.planningHikeTo': 'Merencanakan pendakian ke {place}',
'assistant.step.working': 'Sedang dikerjakan',
'assistant.step.collapsed': '{count} langkah',
'assistant.change.title': 'Perubahan yang disarankan',
@@ -371,7 +373,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Tambah',
'assistant.change.verb.genericUpdate': 'Ubah',
'assistant.change.verb.genericRemove': 'Hapus',
'assistant.change.verb.addTour': 'Tambah tur',
'assistant.change.verb.updateTour': 'Ubah tur',
'assistant.change.verb.importTour': 'Tambah tur dari file',
'assistant.change.label.field': 'Nilai',
'assistant.change.label.difficulty': 'Tingkat kesulitan jalur maksimum',
'assistant.domain.tours': 'Tur',
'assistant.domain.roadtrip': 'Perjalanan darat',
'assistant.domain.journey': 'Jurnal perjalanan',
+6
View File
@@ -115,6 +115,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Controllo i costi',
'assistant.step.help': 'Cerco nella guida',
'assistant.step.readingBooking': 'Leggo la prenotazione',
'assistant.step.planningHike': 'Pianifico l’escursione',
'assistant.step.planningHikeTo': 'Pianifico l’escursione fino a {place}',
'assistant.step.working': 'Ci sto lavorando',
'assistant.step.collapsed': '{count} passaggi',
'assistant.step.collapsed.one': '{count} passaggio',
@@ -397,7 +399,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Aggiungi',
'assistant.change.verb.genericUpdate': 'Modifica',
'assistant.change.verb.genericRemove': 'Rimuovi',
'assistant.change.verb.addTour': 'Aggiungi escursione',
'assistant.change.verb.updateTour': 'Modifica escursione',
'assistant.change.verb.importTour': 'Aggiungi escursione da file',
'assistant.change.label.field': 'Valore',
'assistant.change.label.difficulty': 'Difficoltà massima del sentiero',
'assistant.domain.tours': 'Percorsi',
'assistant.domain.roadtrip': 'Viaggio su strada',
'assistant.domain.journey': 'Diario di viaggio',
+6
View File
@@ -111,6 +111,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': '費用を確認中',
'assistant.step.help': 'ヘルプを検索中',
'assistant.step.readingBooking': '予約を読み込み中',
'assistant.step.planningHike': 'ハイキングを計画中',
'assistant.step.planningHikeTo': '{place}までのハイキングを計画中',
'assistant.step.working': '処理中',
'assistant.step.collapsed': '{count} ステップ',
'assistant.change.title': '提案された変更',
@@ -366,7 +368,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': '追加',
'assistant.change.verb.genericUpdate': '変更',
'assistant.change.verb.genericRemove': '削除',
'assistant.change.verb.addTour': 'ツアーを追加',
'assistant.change.verb.updateTour': 'ツアーを変更',
'assistant.change.verb.importTour': 'ファイルからツアーを追加',
'assistant.change.label.field': '値',
'assistant.change.label.difficulty': '登山道の難易度上限',
'assistant.domain.tours': 'ツアー',
'assistant.domain.roadtrip': 'ロードトリップ',
'assistant.domain.journey': '旅行記',
+6
View File
@@ -110,6 +110,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': '비용을 확인하는 중',
'assistant.step.help': '도움말을 찾는 중',
'assistant.step.readingBooking': '예약을 읽는 중',
'assistant.step.planningHike': '하이킹 계획 중',
'assistant.step.planningHikeTo': '{place}까지 하이킹 계획 중',
'assistant.step.working': '작업 중',
'assistant.step.collapsed': '{count}단계',
'assistant.change.title': '제안된 변경 사항',
@@ -363,7 +365,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': '추가',
'assistant.change.verb.genericUpdate': '변경',
'assistant.change.verb.genericRemove': '삭제',
'assistant.change.verb.addTour': '투어 추가',
'assistant.change.verb.updateTour': '투어 변경',
'assistant.change.verb.importTour': '파일에서 투어 추가',
'assistant.change.label.field': '값',
'assistant.change.label.difficulty': '탐방로 최대 난이도',
'assistant.domain.tours': '투어',
'assistant.domain.roadtrip': '로드 트립',
'assistant.domain.journey': '여행 일지',
+6
View File
@@ -113,6 +113,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'De kosten controleren',
'assistant.step.help': 'In de help opzoeken',
'assistant.step.readingBooking': 'Boeking lezen',
'assistant.step.planningHike': 'Wandeling plannen',
'assistant.step.planningHikeTo': 'Plant de wandeling naar {place}',
'assistant.step.working': 'Bezig',
'assistant.step.collapsed': '{count} stappen',
'assistant.step.collapsed.one': '{count} stap',
@@ -392,7 +394,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Toevoegen',
'assistant.change.verb.genericUpdate': 'Wijzigen',
'assistant.change.verb.genericRemove': 'Verwijderen',
'assistant.change.verb.addTour': 'Tocht toevoegen',
'assistant.change.verb.updateTour': 'Tocht wijzigen',
'assistant.change.verb.importTour': 'Tocht uit bestand toevoegen',
'assistant.change.label.field': 'Waarde',
'assistant.change.label.difficulty': 'Maximale moeilijkheid van het pad',
'assistant.domain.tours': 'Routes',
'assistant.domain.roadtrip': 'Roadtrip',
'assistant.domain.journey': 'Reisdagboek',
+6
View File
@@ -114,6 +114,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Sprawdzam koszty',
'assistant.step.help': 'Szukam w pomocy',
'assistant.step.readingBooking': 'Czytam rezerwację',
'assistant.step.planningHike': 'Planuję wędrówkę',
'assistant.step.planningHikeTo': 'Planuje wędrówkę do celu: {place}',
'assistant.step.working': 'Pracuję nad tym',
'assistant.step.collapsed': '{count} kroku',
'assistant.step.collapsed.one': '{count} krok',
@@ -434,7 +436,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Dodaj',
'assistant.change.verb.genericUpdate': 'Zmień',
'assistant.change.verb.genericRemove': 'Usuń',
'assistant.change.verb.addTour': 'Dodaj wycieczkę',
'assistant.change.verb.updateTour': 'Zmień wycieczkę',
'assistant.change.verb.importTour': 'Dodaj wycieczkę z pliku',
'assistant.change.label.field': 'Wartość',
'assistant.change.label.difficulty': 'Maksymalna trudność szlaku',
'assistant.domain.tours': 'Trasy',
'assistant.domain.roadtrip': 'Podróż samochodem',
'assistant.domain.journey': 'Dziennik podróży',
+6
View File
@@ -114,6 +114,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Проверяю расходы',
'assistant.step.help': 'Ищу в справке',
'assistant.step.readingBooking': 'Читаю бронирование',
'assistant.step.planningHike': 'Планирую поход',
'assistant.step.planningHikeTo': 'Планирую поход, цель: {place}',
'assistant.step.working': 'Работаю над этим',
'assistant.step.collapsed': '{count} шага',
'assistant.step.collapsed.one': '{count} шаг',
@@ -434,7 +436,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Добавить',
'assistant.change.verb.genericUpdate': 'Изменить',
'assistant.change.verb.genericRemove': 'Удалить',
'assistant.change.verb.addTour': 'Добавить тур',
'assistant.change.verb.updateTour': 'Изменить тур',
'assistant.change.verb.importTour': 'Добавить тур из файла',
'assistant.change.label.field': 'Значение',
'assistant.change.label.difficulty': 'Максимальная сложность тропы',
'assistant.domain.tours': 'Маршруты',
'assistant.domain.roadtrip': 'Автопутешествие',
'assistant.domain.journey': 'Дневник путешествий',
+6
View File
@@ -113,6 +113,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Kontrolujem náklady',
'assistant.step.help': 'Hľadám v pomocníkovi',
'assistant.step.readingBooking': 'Čítam rezerváciu',
'assistant.step.planningHike': 'Plánujem túru',
'assistant.step.planningHikeTo': 'Plánujem túru do cieľa {place}',
'assistant.step.working': 'Pracujem na tom',
'assistant.step.collapsed': '{count} krokov',
'assistant.step.collapsed.one': '{count} krok',
@@ -410,7 +412,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Pridať',
'assistant.change.verb.genericUpdate': 'Zmeniť',
'assistant.change.verb.genericRemove': 'Odstrániť',
'assistant.change.verb.addTour': 'Pridať túru',
'assistant.change.verb.updateTour': 'Zmeniť túru',
'assistant.change.verb.importTour': 'Pridať túru zo súboru',
'assistant.change.label.field': 'Hodnota',
'assistant.change.label.difficulty': 'Maximálna náročnosť chodníka',
'assistant.domain.tours': 'Túry',
'assistant.domain.roadtrip': 'Cesta autom',
'assistant.domain.journey': 'Cestovný denník',
+6
View File
@@ -114,6 +114,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Kontrollerar kostnaderna',
'assistant.step.help': 'Letar i hjälpen',
'assistant.step.readingBooking': 'Läser bokningen',
'assistant.step.planningHike': 'Planerar vandringen',
'assistant.step.planningHikeTo': 'Planerar vandringen till {place}',
'assistant.step.working': 'Arbetar på det',
'assistant.step.collapsed': '{count} steg',
'assistant.step.collapsed.one': '{count} steg',
@@ -390,7 +392,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Lägg till',
'assistant.change.verb.genericUpdate': 'Ändra',
'assistant.change.verb.genericRemove': 'Ta bort',
'assistant.change.verb.addTour': 'Lägg till tur',
'assistant.change.verb.updateTour': 'Ändra tur',
'assistant.change.verb.importTour': 'Lägg till tur från fil',
'assistant.change.label.field': 'Värde',
'assistant.change.label.difficulty': 'Högsta svårighetsgrad för leden',
'assistant.domain.tours': 'Turer',
'assistant.domain.roadtrip': 'Bilresa',
'assistant.domain.journey': 'Resedagbok',
+6
View File
@@ -109,6 +109,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'กำลังตรวจสอบค่าใช้จ่าย',
'assistant.step.help': 'กำลังค้นในความช่วยเหลือ',
'assistant.step.readingBooking': 'กำลังอ่านการจอง',
'assistant.step.planningHike': 'กำลังวางแผนเดินป่า',
'assistant.step.planningHikeTo': 'กำลังวางแผนเดินป่าไปยัง {place}',
'assistant.step.working': 'กำลังดำเนินการ',
'assistant.step.collapsed': '{count} ขั้นตอน',
'assistant.change.title': 'การเปลี่ยนแปลงที่แนะนำ',
@@ -363,7 +365,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'เพิ่ม',
'assistant.change.verb.genericUpdate': 'เปลี่ยน',
'assistant.change.verb.genericRemove': 'ลบ',
'assistant.change.verb.addTour': 'เพิ่มทัวร์',
'assistant.change.verb.updateTour': 'แก้ไขทัวร์',
'assistant.change.verb.importTour': 'เพิ่มทัวร์จากไฟล์',
'assistant.change.label.field': 'ค่า',
'assistant.change.label.difficulty': 'ระดับความยากสูงสุดของทางเดิน',
'assistant.domain.tours': 'ทัวร์',
'assistant.domain.roadtrip': 'โรดทริป',
'assistant.domain.journey': 'บันทึกการเดินทาง',
+6
View File
@@ -112,6 +112,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Maliyetler kontrol ediliyor',
'assistant.step.help': 'Yardımda aranıyor',
'assistant.step.readingBooking': 'Rezervasyon okunuyor',
'assistant.step.planningHike': 'Yürüyüş planlanıyor',
'assistant.step.planningHikeTo': '{place} için yürüyüş planlanıyor',
'assistant.step.working': 'Üzerinde çalışılıyor',
'assistant.step.collapsed': '{count} adım',
'assistant.step.collapsed.one': '{count} adım',
@@ -390,7 +392,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Ekle',
'assistant.change.verb.genericUpdate': 'Değiştir',
'assistant.change.verb.genericRemove': 'Kaldır',
'assistant.change.verb.addTour': 'Tur ekle',
'assistant.change.verb.updateTour': 'Turu değiştir',
'assistant.change.verb.importTour': 'Dosyadan tur ekle',
'assistant.change.label.field': 'Değer',
'assistant.change.label.difficulty': 'En yüksek patika zorluğu',
'assistant.domain.tours': 'Turlar',
'assistant.domain.roadtrip': 'Yolculuk',
'assistant.domain.journey': 'Seyahat günlüğü',
+6
View File
@@ -114,6 +114,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Перевіряю витрати',
'assistant.step.help': 'Шукаю в довідці',
'assistant.step.readingBooking': 'Читаю бронювання',
'assistant.step.planningHike': 'Планую похід',
'assistant.step.planningHikeTo': 'Планую похід, ціль: {place}',
'assistant.step.working': 'Працюю над цим',
'assistant.step.collapsed': '{count} кроку',
'assistant.step.collapsed.one': '{count} крок',
@@ -432,7 +434,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Додати',
'assistant.change.verb.genericUpdate': 'Змінити',
'assistant.change.verb.genericRemove': 'Видалити',
'assistant.change.verb.addTour': 'Додати тур',
'assistant.change.verb.updateTour': 'Змінити тур',
'assistant.change.verb.importTour': 'Додати тур із файлу',
'assistant.change.label.field': 'Значення',
'assistant.change.label.difficulty': 'Максимальна складність стежки',
'assistant.domain.tours': 'Маршрути',
'assistant.domain.roadtrip': 'Автоподорож',
'assistant.domain.journey': 'Щоденник подорожей',
+6
View File
@@ -111,6 +111,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Đang kiểm tra chi phí',
'assistant.step.help': 'Đang tra trong trợ giúp',
'assistant.step.readingBooking': 'Đang đọc đặt chỗ',
'assistant.step.planningHike': 'Đang lên kế hoạch đi bộ',
'assistant.step.planningHikeTo': 'Đang lên kế hoạch đi bộ đến {place}',
'assistant.step.working': 'Đang xử lý',
'assistant.step.collapsed': '{count} bước',
'assistant.change.title': 'Thay đổi được đề xuất',
@@ -366,7 +368,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Thêm',
'assistant.change.verb.genericUpdate': 'Thay đổi',
'assistant.change.verb.genericRemove': 'Xóa',
'assistant.change.verb.addTour': 'Thêm chuyến đi',
'assistant.change.verb.updateTour': 'Sửa chuyến đi',
'assistant.change.verb.importTour': 'Thêm chuyến đi từ tệp',
'assistant.change.label.field': 'Giá trị',
'assistant.change.label.difficulty': 'Độ khó tối đa của đường mòn',
'assistant.domain.tours': 'Tuyến đi',
'assistant.domain.roadtrip': 'Đi đường bộ',
'assistant.domain.journey': 'Nhật ký du lịch',
+6
View File
@@ -108,6 +108,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': '正在檢查費用',
'assistant.step.help': '正在說明中查找',
'assistant.step.readingBooking': '正在讀取預訂',
'assistant.step.planningHike': '正在規劃健行路線',
'assistant.step.planningHikeTo': '正在規劃前往{place}的健行路線',
'assistant.step.working': '處理中',
'assistant.step.collapsed': '{count} 個步驟',
'assistant.change.title': '建議的變更',
@@ -360,7 +362,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': '新增',
'assistant.change.verb.genericUpdate': '變更',
'assistant.change.verb.genericRemove': '刪除',
'assistant.change.verb.addTour': '新增路線',
'assistant.change.verb.updateTour': '修改路線',
'assistant.change.verb.importTour': '從檔案新增路線',
'assistant.change.label.field': '值',
'assistant.change.label.difficulty': '步道最高難度',
'assistant.domain.tours': '路線',
'assistant.domain.roadtrip': '公路旅行',
'assistant.domain.journey': '旅行日誌',
+6
View File
@@ -108,6 +108,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': '正在检查费用',
'assistant.step.help': '正在帮助中查找',
'assistant.step.readingBooking': '正在读取预订',
'assistant.step.planningHike': '正在规划徒步路线',
'assistant.step.planningHikeTo': '正在规划前往{place}的徒步路线',
'assistant.step.working': '正在处理',
'assistant.step.collapsed': '{count} 个步骤',
'assistant.change.title': '建议的更改',
@@ -360,7 +362,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': '添加',
'assistant.change.verb.genericUpdate': '更改',
'assistant.change.verb.genericRemove': '删除',
'assistant.change.verb.addTour': '添加路线',
'assistant.change.verb.updateTour': '修改路线',
'assistant.change.verb.importTour': '从文件添加路线',
'assistant.change.label.field': '值',
'assistant.change.label.difficulty': '步道最高难度',
'assistant.domain.tours': '路线',
'assistant.domain.roadtrip': '自驾游',
'assistant.domain.journey': '旅行日记',
+3
View File
@@ -80,6 +80,9 @@ export * from './tours/walk-hours';
export * from './tours/walk-remaining';
export * from './tours/walk-ferry';
export * from './tours/tours.metrics';
export * from './tours/hiking-costing';
export * from './tours/hike-plan.schema';
export * from './tours/tour-hike.schema';
export * from './tours/tour-gpx-import.schema';
export * from './tours/paddle-plan.schema';
export * from './tours/paddle-plan';
+114
View File
@@ -0,0 +1,114 @@
import {
HIKE_MAX_LINE_POINTS,
HIKE_PLAN_LINE_POINTS,
HIKE_PLAN_MAX_WAYPOINTS,
HIKE_PLAN_PROFILE_SAMPLES,
hikeLinePointSchema,
hikePlanRequestSchema,
hikePlanSchema,
hikeProfileSampleSchema,
} from './hike-plan.schema';
import { describe, expect, it } from 'vitest';
const point = (index: number) => ({ lat: 47.4 + index / 100, lng: 11.1 });
const routed = {
kind: 'routed',
max_hiking_difficulty: 3,
hiking_preferences: null,
distance_m: 8200,
ascent_m: 640,
descent_m: 640,
duration_minutes: 200,
duration_basis: 'din33466',
has_heights: true,
line: [
[47.4, 11.1, 720],
[47.41, 11.1, 1360],
],
profile: [
[0, 720],
[8200, 1360],
],
grades: { segments: [{ from_m: 0, to_m: 1200, sac: 2 }], highest: 2, unknown_share: 0.4 },
};
describe('a hike the server plans through its points', () => {
it('HIKE-PLAN-SCHEMA-001: takes two points and nothing else, and the limit, the preferences and the names it can be given', () => {
expect(hikePlanRequestSchema.safeParse({ waypoints: [point(0), point(1)] }).success).toBe(true);
const full = {
waypoints: [{ ...point(0), name: 'Parking' }, point(1), { ...point(2), name: 'Summit' }],
max_hiking_difficulty: 4,
hiking_preferences: { avoid_steps: true },
};
expect(hikePlanRequestSchema.parse(full)).toEqual(full);
expect(hikePlanRequestSchema.safeParse({ ...full, hiking_preferences: null }).success).toBe(true);
});
it('HIKE-PLAN-SCHEMA-002: refuses a single point, too many, a limit off the scale, an unknown preference and an unknown key', () => {
const two = [point(0), point(1)];
expect(hikePlanRequestSchema.safeParse({ waypoints: [point(0)] }).success).toBe(false);
const many = Array.from({ length: HIKE_PLAN_MAX_WAYPOINTS + 1 }, (_, index) => point(index));
expect(hikePlanRequestSchema.safeParse({ waypoints: many }).success).toBe(false);
expect(hikePlanRequestSchema.safeParse({ waypoints: two, max_hiking_difficulty: 7 }).success).toBe(false);
expect(hikePlanRequestSchema.safeParse({ waypoints: two, max_hiking_difficulty: 0 }).success).toBe(false);
expect(hikePlanRequestSchema.safeParse({ waypoints: two, hiking_preferences: { avoid_snow: true } }).success).toBe(
false,
);
expect(hikePlanRequestSchema.safeParse({ waypoints: two, tripId: 1 }).success).toBe(false);
expect(hikePlanRequestSchema.safeParse({ waypoints: [{ lat: 91, lng: 0 }, point(1)] }).success).toBe(false);
});
it('HIKE-PLAN-SCHEMA-003: reads a routed plan with or without heights and grades', () => {
expect(hikePlanSchema.parse(routed)).toEqual(routed);
const flat = {
...routed,
ascent_m: null,
descent_m: null,
duration_basis: 'router',
has_heights: false,
line: [
[47.4, 11.1],
[47.41, 11.1],
],
profile: [],
grades: null,
};
expect(hikePlanSchema.safeParse(flat).success).toBe(true);
});
it('HIKE-PLAN-SCHEMA-004: keeps a routed plan within what it shows: a line, a profile and grades within their bounds', () => {
const long = Array.from({ length: HIKE_PLAN_LINE_POINTS + 1 }, () => [47.4, 11.1]);
expect(hikePlanSchema.safeParse({ ...routed, line: long }).success).toBe(false);
const samples = Array.from({ length: HIKE_PLAN_PROFILE_SAMPLES + 1 }, (_, index) => [index, 700]);
expect(hikePlanSchema.safeParse({ ...routed, profile: samples }).success).toBe(false);
const hard = { ...routed.grades, segments: [{ from_m: 0, to_m: 10, sac: 7 }] };
expect(hikePlanSchema.safeParse({ ...routed, grades: hard }).success).toBe(false);
expect(hikePlanSchema.safeParse({ ...routed, grades: { ...routed.grades, unknown_share: 1.2 } }).success).toBe(
false,
);
expect(hikePlanSchema.safeParse({ ...routed, duration_basis: 'guess' }).success).toBe(false);
});
it('HIKE-PLAN-SCHEMA-005: says why no way was planned, with the point no way reaches where that is the reason', () => {
const off = { kind: 'unrouted', max_hiking_difficulty: 2, why: 'point_off_the_way', position: 3, snapped_m: 520 };
expect(hikePlanSchema.parse(off)).toEqual(off);
const none = { kind: 'unrouted', max_hiking_difficulty: 2, why: 'no_valhalla', position: null, snapped_m: null };
expect(hikePlanSchema.safeParse(none).success).toBe(true);
expect(hikePlanSchema.safeParse({ ...none, why: 'tired' }).success).toBe(false);
expect(hikePlanSchema.safeParse({ ...off, position: 0 }).success).toBe(false);
expect(hikePlanSchema.safeParse({ ...none, why: 'too_long' }).success).toBe(true);
});
it('HIKE-PLAN-SCHEMA-006: reads a point of the line with or without its height, inside the globe only', () => {
expect(hikeLinePointSchema.safeParse([47.4, 11.1]).success).toBe(true);
expect(hikeLinePointSchema.safeParse([47.4, 11.1, 812.5]).success).toBe(true);
expect(hikeLinePointSchema.safeParse([91, 11.1]).success).toBe(false);
expect(hikeLinePointSchema.safeParse([47.4, 181, 800]).success).toBe(false);
expect(hikeLinePointSchema.safeParse([47.4, 11.1, Number.NaN]).success).toBe(false);
expect(hikeProfileSampleSchema.safeParse([-1, 800]).success).toBe(false);
// A line that long takes some twenty height questions, and stays far below a tour write.
expect(HIKE_MAX_LINE_POINTS).toBe(10_000);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { hikingPreferencesSchema, tourMaxHikingDifficultySchema, tourWaypointSchema } from './tours.schema';
import { z } from 'zod';
/**
* A hike the server plans through the points it is given: the way on foot
* under the hike's hiking limit and preferences, its heights, its length,
* climb, descent and walking time, and the SAC grades OpenStreetMap records
* for the paths it takes. The plan reads only; a hike created from it keeps
* the whole line, the plan shows a thinned one.
*/
/** The most points one hike is planned through, its start and end included. */
export const HIKE_PLAN_MAX_WAYPOINTS = 30;
/** The most points of the line a plan shows; the tour created from it keeps every point. */
export const HIKE_PLAN_LINE_POINTS = 300;
/** The most samples of the height profile a plan shows. */
export const HIKE_PLAN_PROFILE_SAMPLES = 200;
/**
* The most points a hike's routed line may hold before it is planned no
* further. Its heights are read 499 points a question through the router's
* one paced queue, about a second each, which every road trip and walk of the
* instance waits behind: this many points take some twenty questions. A line
* on paths carries a point every few metres to every few tens of metres, so
* this is a hike of fifty kilometres and more, and at some fifty bytes a point
* with its height it stays far below what one tour write carries
* (TOUR_WRITE_MAX_BYTES).
*/
export const HIKE_MAX_LINE_POINTS = 10_000;
const { lat, lng, name } = tourWaypointSchema.shape;
export const hikePlanPointSchema = z
.object({
lat,
lng,
name: name.describe('What the point is, such as a car park, a hut or a summit, where it has a name'),
})
.strict();
export type HikePlanPoint = z.infer<typeof hikePlanPointSchema>;
export const hikePlanRequestSchema = z
.object({
waypoints: z
.array(hikePlanPointSchema)
.min(2)
.max(HIKE_PLAN_MAX_WAYPOINTS)
.describe('The points the hike goes through in order: its start, the points on the way and its end'),
max_hiking_difficulty: tourMaxHikingDifficultySchema
.optional()
.describe('The hardest SAC grade the way may take, 1 (T1, hiking) to 6 (T6); 2 when left out'),
hiking_preferences: hikingPreferencesSchema
.nullable()
.optional()
.describe('Steep climbs, steps or ferries the way avoids where there is a way round'),
})
.strict();
export type HikePlanRequest = z.infer<typeof hikePlanRequestSchema>;
/** A step of the SAC hiking scale, T1 to T6 as 1 to 6. */
const sacStepSchema = z.number().int().min(1).max(6);
/** A stretch of the line whose paths OpenStreetMap grades with one SAC step, in metres along the line. */
export const hikeSacSegmentSchema = z.object({
from_m: z.number().nonnegative(),
to_m: z.number().nonnegative(),
sac: sacStepSchema,
});
export type HikeSacSegment = z.infer<typeof hikeSacSegmentSchema>;
/**
* The SAC grades along the line: the graded stretches in order, the hardest
* step among them, and how much of the line has no grade at all. A path
* without a grade is unknown, never easy.
*/
export const hikeTrailGradesSchema = z.object({
segments: z.array(hikeSacSegmentSchema),
highest: sacStepSchema.nullable(),
/** Of the whole line, 0 to 1. */
unknown_share: z.number().min(0).max(1),
});
export type HikeTrailGrades = z.infer<typeof hikeTrailGradesSchema>;
/** A point of a hike's line, `[lat, lng]`, or `[lat, lng, ele]` where its height is known. */
export const hikeLinePointSchema = z.union([z.tuple([lat, lng]), z.tuple([lat, lng, z.number().finite()])]);
/** A sample of a height profile, `[metres along the line, height in metres]`. */
export const hikeProfileSampleSchema = z.tuple([z.number().nonnegative(), z.number().finite()]);
/** Where the walking time comes from: the signposts' rule over the heights, or the router's own time without them. */
export const HIKE_DURATION_BASES = ['din33466', 'router'] as const;
export const routedHikePlanSchema = z.object({
kind: z.literal('routed'),
max_hiking_difficulty: tourMaxHikingDifficultySchema,
hiking_preferences: hikingPreferencesSchema.nullable(),
distance_m: z.number().int().nonnegative(),
/** Null where the heights are unknown. */
ascent_m: z.number().int().nonnegative().nullable(),
descent_m: z.number().int().nonnegative().nullable(),
duration_minutes: z.number().int().nonnegative(),
duration_basis: z.enum(HIKE_DURATION_BASES),
/** Whether every point of the line has its height; without them the line is flat and the profile empty. */
has_heights: z.boolean(),
/** The line thinned for display, `[lat, lng, ele]` where the heights are known. */
line: z.array(hikeLinePointSchema).min(2).max(HIKE_PLAN_LINE_POINTS),
/** The height profile thinned for display, `[metres along the line, height in metres]`. */
profile: z.array(hikeProfileSampleSchema).max(HIKE_PLAN_PROFILE_SAMPLES),
/** Null where the grades could not be read this time, or were not asked for. */
grades: hikeTrailGradesSchema.nullable(),
});
export type RoutedHikePlan = z.infer<typeof routedHikePlanSchema>;
/**
* Why no way was planned: no Valhalla to ask (an own OSRM without one, which
* knows no hiking limit), a router that did not answer, the router's word
* that no way within the limit joins the points, a point further from any
* way than a tour allows, or a way longer than one hike is planned for (the
* router's own distance limit, or more than HIKE_MAX_LINE_POINTS points).
*/
export const HIKE_UNROUTED_REASONS = [
'no_valhalla',
'router_unavailable',
'no_way',
'point_off_the_way',
'too_long',
] as const;
export const unroutedHikePlanSchema = z.object({
kind: z.literal('unrouted'),
max_hiking_difficulty: tourMaxHikingDifficultySchema,
why: z.enum(HIKE_UNROUTED_REASONS),
/** The point no way reaches, counted from 1; null for every other reason. */
position: z.number().int().positive().nullable(),
/** How far the router had to move that point to reach a way, in metres; null for every other reason. */
snapped_m: z.number().int().nonnegative().nullable(),
});
export type UnroutedHikePlan = z.infer<typeof unroutedHikePlanSchema>;
export const hikePlanSchema = z.discriminatedUnion('kind', [routedHikePlanSchema, unroutedHikePlanSchema]);
export type HikePlan = z.infer<typeof hikePlanSchema>;
+32
View File
@@ -0,0 +1,32 @@
import { hikingCosting, hikingCostingOptions, TOURS_MAX_HIKING_DIFFICULTY } from './hiking-costing';
import { describe, expect, it } from 'vitest';
describe('hikingCostingOptions (MF-08, RTE-01)', () => {
it('HIKE-COST-001: asks the router only for what was switched on, in the options it was checked with', () => {
expect(hikingCostingOptions(null)).toEqual({});
expect(hikingCostingOptions(undefined)).toEqual({});
expect(hikingCostingOptions({ avoid_steps: false })).toEqual({});
expect(hikingCostingOptions({ avoid_climbs: true, avoid_steps: true, avoid_ferries: true })).toEqual({
use_hills: 0.2,
step_penalty: 600,
use_ferry: 0,
});
});
});
describe('hikingCosting (RTE-02)', () => {
it('HIKE-COST-002: weighs the whole tour and one of its sections alike: the limit with the preferences', () => {
expect(hikingCosting()).toEqual({ max_hiking_difficulty: 2 });
expect(hikingCosting(4, { avoid_steps: true })).toEqual({ max_hiking_difficulty: 4, step_penalty: 600 });
});
it('HIKE-COST-003: falls back to T2 only where no limit is given, and names the limit first', () => {
expect(TOURS_MAX_HIKING_DIFFICULTY).toBe(2);
expect(Object.keys(hikingCosting(6, { avoid_ferries: true, avoid_climbs: true }))).toEqual([
'max_hiking_difficulty',
'use_hills',
'use_ferry',
]);
});
});
@@ -1,5 +1,4 @@
import type { HikingPreferences } from '@trek/shared';
import type { CostingOptions } from '../../Map/valhallaRoute';
import type { HikingPreferences } from './tours.schema';
/**
* Defensive fallback only. Normal planner calls pass the persisted/draft value.
@@ -7,6 +6,14 @@ import type { CostingOptions } from '../../Map/valhallaRoute';
*/
export const TOURS_MAX_HIKING_DIFFICULTY = 2;
/**
* What a hike asks Valhalla's pedestrian costing for: its hiking limit, and
* the weightings its preferences switch on. The planner in the browser and
* the server's hike routing both build it here, so a hike is weighed alike
* wherever it is planned.
*/
export type HikingCosting = Record<string, number> & { max_hiking_difficulty: number };
/**
* MF-08, RTE-01: a hike's preferences as Valhalla's pedestrian costing
* options. Each one was checked against the public instance before it was
@@ -39,7 +46,7 @@ export function hikingCostingOptions(preferences: HikingPreferences | null | und
*/
export function hikingCosting(
maxHikingDifficulty: number = TOURS_MAX_HIKING_DIFFICULTY,
preferences: HikingPreferences | null = null
): CostingOptions {
preferences: HikingPreferences | null = null,
): HikingCosting {
return { max_hiking_difficulty: maxHikingDifficulty, ...hikingCostingOptions(preferences) };
}
+182
View File
@@ -0,0 +1,182 @@
import { HIKE_PLAN_LINE_POINTS } from './hike-plan.schema';
import {
HIKE_MAX_WAYPOINTS,
HIKE_PROPOSAL_SAC_SEGMENTS,
hikeCreateRequestSchema,
hikeProposalSchema,
hikeProposeRequestSchema,
hikeRouteOutcomeSchema,
TOUR_GPX_TEXT_MAX,
tourGpxImportRequestSchema,
tourUpdateRequestSchema,
} from './tour-hike.schema';
import { describe, expect, it } from 'vitest';
const point = (index: number) => ({ lat: 47.4 + index / 100, lng: 11.1 });
const two = [point(0), point(1)];
const proposal = {
name: 'Herzogstand',
variant_label: 'short and steep',
route: { planned: true },
unreachable: null,
distance_m: 8200,
ascent_m: 640,
descent_m: 640,
duration_minutes: 200,
duration_basis: 'din33466',
heights_known: true,
min_ele_m: 720,
max_ele_m: 1360,
line: [
[47.4, 11.1, 720],
[47.41, 11.1, 1360],
],
profile: [
[0, 720],
[8200, 1360],
],
sac: { highest: 2, segments: [{ from_m: 0, to_m: 1200, sac: 2 }], unknown_share: 0.4 },
waypoints: [
{ lat: 47.4, lng: 11.1, name: 'Parking', at_m: 0 },
{ lat: 47.41, lng: 11.1, name: null, at_m: 8200 },
],
max_hiking_difficulty: 3,
hiking_preferences: null,
round_trip: false,
date: '2027-07-10',
start_time: '08:30',
};
describe('a hike proposed over MCP', () => {
it('TOUR-HIKE-SCHEMA-001: takes two points and everything a proposal can be asked with', () => {
expect(hikeProposeRequestSchema.safeParse({ waypoints: two }).success).toBe(true);
const full = {
name: 'Herzogstand',
waypoints: [{ ...point(0), name: 'Parking' }, point(1), { ...point(2), name: 'Summit' }],
max_hiking_difficulty: 4,
hiking_preferences: { avoid_steps: true },
round_trip: true,
variant_label: 'by the lake',
date: '2027-07-10',
start_time: '08:30',
};
expect(hikeProposeRequestSchema.parse(full)).toEqual(full);
expect(hikeProposeRequestSchema.safeParse({ ...full, hiking_preferences: null }).success).toBe(true);
});
it('TOUR-HIKE-SCHEMA-002: refuses one point, too many, a long label, a date or a time it cannot read, and a key it does not know', () => {
const many = Array.from({ length: HIKE_MAX_WAYPOINTS + 1 }, (_, index) => point(index));
for (const request of [
{ waypoints: [point(0)] },
{ waypoints: many },
{ waypoints: two, variant_label: 'x'.repeat(41) },
{ waypoints: two, variant_label: ' ' },
{ waypoints: two, date: '10.07.2027' },
{ waypoints: two, start_time: '8:30' },
{ waypoints: two, start_time: '24:00' },
{ waypoints: two, max_hiking_difficulty: 7 },
{ waypoints: two, tripId: 1 },
]) {
expect(hikeProposeRequestSchema.safeParse(request).success, JSON.stringify(request).slice(0, 80)).toBe(false);
}
});
it('TOUR-HIKE-SCHEMA-003: reads a proposal with its way, and one without a way', () => {
expect(hikeProposalSchema.parse(proposal)).toEqual(proposal);
const unrouted = {
...proposal,
route: { planned: false, why: 'point_off_the_way', position: 2 },
unreachable: { position: 2, snapped_m: 420 },
distance_m: null,
ascent_m: null,
descent_m: null,
duration_minutes: null,
duration_basis: null,
heights_known: false,
min_ele_m: null,
max_ele_m: null,
line: [],
profile: [],
sac: null,
waypoints: proposal.waypoints.map((waypoint) => ({ ...waypoint, at_m: null })),
};
expect(hikeProposalSchema.safeParse(unrouted).success).toBe(true);
});
it('TOUR-HIKE-SCHEMA-004: keeps a proposal within what a card draws', () => {
const long = Array.from({ length: HIKE_PLAN_LINE_POINTS + 1 }, () => [47.4, 11.1]);
expect(hikeProposalSchema.safeParse({ ...proposal, line: long }).success).toBe(false);
const segments = Array.from({ length: HIKE_PROPOSAL_SAC_SEGMENTS + 1 }, (_, index) => ({
from_m: index,
to_m: index + 1,
sac: 2,
}));
expect(hikeProposalSchema.safeParse({ ...proposal, sac: { ...proposal.sac, segments } }).success).toBe(false);
expect(hikeProposalSchema.safeParse({ ...proposal, sac: { ...proposal.sac, highest: 7 } }).success).toBe(false);
expect(hikeProposalSchema.safeParse({ ...proposal, sac: { ...proposal.sac, unknown_share: 1.5 } }).success).toBe(
false,
);
expect(hikeProposalSchema.safeParse({ ...proposal, duration_basis: 'guess' }).success).toBe(false);
expect(hikeProposalSchema.safeParse({ ...proposal, line: [[47.4, 191]] }).success).toBe(false);
expect(
hikeProposalSchema.safeParse({ ...proposal, waypoints: [{ ...proposal.waypoints[0], lat: -91 }] }).success,
).toBe(false);
});
it('TOUR-HIKE-SCHEMA-005: says why a way was not planned only with a reason the plan knows', () => {
expect(hikeRouteOutcomeSchema.safeParse({ planned: true }).success).toBe(true);
expect(hikeRouteOutcomeSchema.safeParse({ planned: false, why: 'no_way', position: null }).success).toBe(true);
expect(hikeRouteOutcomeSchema.safeParse({ planned: false, why: 'too_long', position: null }).success).toBe(true);
expect(hikeRouteOutcomeSchema.safeParse({ planned: false, why: 'ferry_only', position: null }).success).toBe(false);
expect(hikeRouteOutcomeSchema.safeParse({ planned: false, why: 'no_way' }).success).toBe(false);
});
});
describe('a hike created, changed or taken in from a file over MCP', () => {
it('TOUR-HIKE-SCHEMA-006: creates a hike with a name and its points', () => {
expect(hikeCreateRequestSchema.safeParse({ name: 'Herzogstand', waypoints: two }).success).toBe(true);
expect(
hikeCreateRequestSchema.safeParse({
name: 'Herzogstand',
description: null,
waypoints: two,
max_hiking_difficulty: 3,
hiking_preferences: { avoid_climbs: true },
round_trip: true,
planned_duration_minutes: 360,
}).success,
).toBe(true);
expect(hikeCreateRequestSchema.safeParse({ waypoints: two }).success).toBe(false);
expect(hikeCreateRequestSchema.safeParse({ name: ' ', waypoints: two }).success).toBe(false);
expect(
hikeCreateRequestSchema.safeParse({ name: 'X', waypoints: two, planned_duration_minutes: 1441 }).success,
).toBe(false);
expect(hikeCreateRequestSchema.safeParse({ name: 'X', waypoints: two, variant_label: 'a' }).success).toBe(false);
});
it('TOUR-HIKE-SCHEMA-007: changes a hike only against the revision it was read at', () => {
expect(tourUpdateRequestSchema.safeParse({ placeId: 4, expected_revision: 2, name: 'New' }).success).toBe(true);
expect(tourUpdateRequestSchema.safeParse({ placeId: 4, expected_revision: 2, waypoints: two }).success).toBe(true);
expect(tourUpdateRequestSchema.safeParse({ placeId: 4, name: 'New' }).success).toBe(false);
expect(tourUpdateRequestSchema.safeParse({ placeId: 4, expected_revision: 0 }).success).toBe(false);
expect(tourUpdateRequestSchema.safeParse({ placeId: 4, expected_revision: 2, round_trip: true }).success).toBe(
false,
);
});
it('TOUR-HIKE-SCHEMA-008: takes a GPX text as a hike by default, and a type with what that type needs', () => {
const parsed = tourGpxImportRequestSchema.parse({ gpx: '<gpx/>' });
expect(parsed).toEqual({ gpx: '<gpx/>', tour_type: 'hike' });
expect(tourGpxImportRequestSchema.safeParse({ gpx: '<gpx/>', tour_type: 'bike' }).success).toBe(false);
expect(
tourGpxImportRequestSchema.safeParse({ gpx: '<gpx/>', tour_type: 'bike', bike_profile: 'road' }).success,
).toBe(true);
expect(tourGpxImportRequestSchema.safeParse({ gpx: '<gpx/>', bike_profile: 'road' }).success).toBe(false);
expect(tourGpxImportRequestSchema.safeParse({ gpx: '' }).success).toBe(false);
expect(tourGpxImportRequestSchema.safeParse({ gpx: 'x'.repeat(TOUR_GPX_TEXT_MAX + 1) }).success).toBe(false);
expect(tourGpxImportRequestSchema.safeParse({ gpx: '<gpx/>', file_name: 'x'.repeat(201) }).success).toBe(false);
expect(Object.keys(tourGpxImportRequestSchema.shape)).toContain('tour_type');
});
});
+222
View File
@@ -0,0 +1,222 @@
import { idSchema } from '../common/primitives.schema';
import { clockTimeSchema } from '../place/place-hours';
import {
HIKE_DURATION_BASES,
HIKE_PLAN_LINE_POINTS,
HIKE_PLAN_PROFILE_SAMPLES,
HIKE_UNROUTED_REASONS,
hikeLinePointSchema,
hikePlanPointSchema,
hikeProfileSampleSchema,
hikeSacSegmentSchema,
hikeTrailGradesSchema,
} from './hike-plan.schema';
import { tourGpxImportFieldsSchema } from './tour-gpx-import.schema';
import {
hikingPreferencesSchema,
plannedTourDurationMinutesSchema,
tourDescriptionSchema,
tourMaxHikingDifficultySchema,
tourNameSchema,
tourRevisionSchema,
} from './tours.schema';
import { z } from 'zod';
/**
* A hike an assistant plans with the traveller over MCP: proposed first, read
* only, as often as they want to compare ways, then created once they said
* yes, changed later, or taken in from a GPX file. The server plans the way
* on foot through the points (HikeRoutingService), with its heights, walking
* time and SAC grades; nothing here carries a line from the caller.
*/
/** The most points a hike is asked through, its start and end included; a round trip adds its way back. */
export const HIKE_MAX_WAYPOINTS = 25;
/** The longest label a variant is given, short enough for a chip that compares two ways. */
export const HIKE_VARIANT_LABEL_MAX = 40;
/** The most graded stretches a proposal shows; more are joined, the harder grade kept. */
export const HIKE_PROPOSAL_SAC_SEGMENTS = 200;
/** The largest GPX text a tool takes in, as the road trip's GPX import does. */
export const TOUR_GPX_TEXT_MAX = 1_000_000;
const hikeWaypointsSchema = z
.array(hikePlanPointSchema)
.min(2)
.max(HIKE_MAX_WAYPOINTS)
.describe(
'The points the hike goes through in order: its start, the points on the way (a hut, a summit, a lake) and its end. For a round trip leave the end out and set round_trip',
);
const hikeLimitSchema = tourMaxHikingDifficultySchema.describe(
'The hardest SAC grade the way may take, 1 (T1, hiking) to 6 (T6, difficult alpine hiking); 2 (T2, mountain hiking) when left out',
);
const hikePreferencesSchema = hikingPreferencesSchema
.nullable()
.describe('Steep climbs, steps or ferries the way avoids where there is a way round');
const roundTripSchema = z.boolean().describe('The hike ends back at its start: the way back is planned too');
const plannedMinutesSchema = plannedTourDurationMinutesSchema.describe(
'How long the travellers plan to be out, in minutes, breaks included; left out the walking time stands alone',
);
export const hikeProposeRequestSchema = z
.object({
name: tourNameSchema
.optional()
.describe('What the hike is called, such as "Over the Herzogstand to the Heimgarten"'),
waypoints: hikeWaypointsSchema,
max_hiking_difficulty: hikeLimitSchema.optional(),
hiking_preferences: hikePreferencesSchema.optional(),
round_trip: roundTripSchema.optional(),
variant_label: z
.string()
.trim()
.min(1)
.max(HIKE_VARIANT_LABEL_MAX)
.optional()
.describe(
'A short name for this way when several are compared, such as "short and steep" or "by the lake"; given back as it is',
),
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional()
.describe('The day the hike is planned for, YYYY-MM-DD; given back as it is'),
start_time: clockTimeSchema.optional().describe('When the hike starts, HH:MM; given back as it is'),
})
.strict();
export type HikeProposeRequest = z.infer<typeof hikeProposeRequestSchema>;
/** Whether the way was planned, and why not: the plan's reasons, with the point no way reaches. */
export const hikeRouteOutcomeSchema = z.union([
z.object({ planned: z.literal(true) }),
z.object({
planned: z.literal(false),
why: z.enum(HIKE_UNROUTED_REASONS),
/** The point no way reaches, counted from 1; null for every other reason. */
position: z.number().int().positive().nullable(),
}),
]);
export type HikeRouteOutcome = z.infer<typeof hikeRouteOutcomeSchema>;
const { lat, lng } = hikePlanPointSchema.shape;
/**
* The SAC grades along a proposed way, as the plan reads them (the hardest
* grade, the graded stretches in metres along the line, the share that is
* unknown and never easy), with at most HIKE_PROPOSAL_SAC_SEGMENTS stretches.
*/
export const hikeProposalSacSchema = hikeTrailGradesSchema.extend({
segments: z.array(hikeSacSegmentSchema).max(HIKE_PROPOSAL_SAC_SEGMENTS),
});
export type HikeProposalSac = z.infer<typeof hikeProposalSacSchema>;
/**
* What propose_hike answers, and what a chat card for the hike is drawn
* from: the way, its measures, its height profile and grades, the points it
* was asked through with where each lies along the way, and what the request
* named for the schedule, given back as it came.
*/
export const hikeProposalSchema = z.object({
name: z.string().nullable(),
variant_label: z.string().nullable(),
route: hikeRouteOutcomeSchema,
/** Where a point lies further from any way than a tour allows: which one, and how far it was off. */
unreachable: z
.object({ position: z.number().int().positive(), snapped_m: z.number().int().nonnegative() })
.nullable(),
/** The length of the way in metres; null where none was planned. */
distance_m: z.number().int().nonnegative().nullable(),
ascent_m: z.number().int().nonnegative().nullable(),
descent_m: z.number().int().nonnegative().nullable(),
/** The walking time in minutes, breaks left out: by DIN 33466 where the heights are known, else the router's. */
duration_minutes: z.number().int().nonnegative().nullable(),
duration_basis: z.enum(HIKE_DURATION_BASES).nullable(),
heights_known: z.boolean(),
min_ele_m: z.number().int().nullable(),
max_ele_m: z.number().int().nullable(),
/** The way thinned for display, `[lat, lng, ele]` where the heights are known; empty where none was planned. */
line: z.array(hikeLinePointSchema).max(HIKE_PLAN_LINE_POINTS),
/** The height profile thinned for display, `[metres along the way, height in metres]`. */
profile: z.array(hikeProfileSampleSchema).max(HIKE_PLAN_PROFILE_SAMPLES),
sac: hikeProposalSacSchema.nullable(),
/** The points in the order walked, a round trip's way back included; at_m null where no way was planned. */
waypoints: z.array(
z.object({
lat,
lng,
name: z.string().nullable(),
at_m: z.number().int().nonnegative().nullable(),
}),
),
max_hiking_difficulty: tourMaxHikingDifficultySchema,
hiking_preferences: hikingPreferencesSchema.nullable(),
round_trip: z.boolean(),
date: z.string().nullable(),
start_time: z.string().nullable(),
});
export type HikeProposal = z.infer<typeof hikeProposalSchema>;
export const hikeCreateRequestSchema = z
.object({
name: tourNameSchema.describe('What the hike is called'),
description: tourDescriptionSchema.describe('A few words about the hike for the travellers, up to 2000 characters'),
waypoints: hikeWaypointsSchema,
max_hiking_difficulty: hikeLimitSchema.optional(),
hiking_preferences: hikePreferencesSchema.optional(),
round_trip: roundTripSchema.optional(),
planned_duration_minutes: plannedMinutesSchema.optional(),
})
.strict();
export type HikeCreateRequest = z.infer<typeof hikeCreateRequestSchema>;
export const tourUpdateRequestSchema = z
.object({
placeId: idSchema.describe('The place id of the hike, as list_tours gives it'),
expected_revision: tourRevisionSchema.describe(
'The revision of the hike as last read (get_tour_plan or list_tours); a hike changed since is refused, not overwritten',
),
name: tourNameSchema.optional().describe('A new name for the hike'),
description: tourDescriptionSchema.describe('A new description; null clears it'),
waypoints: hikeWaypointsSchema
.optional()
.describe(
'New points for the hike in order, start to end (a round trip ends on its start); the way is planned anew through them. Left out, the points stay',
),
max_hiking_difficulty: hikeLimitSchema.optional(),
hiking_preferences: hikePreferencesSchema.optional(),
planned_duration_minutes: plannedMinutesSchema.optional(),
})
.strict();
export type TourUpdateRequest = z.infer<typeof tourUpdateRequestSchema>;
/**
* A GPX file's text taken in as tours, one for each track or route it holds,
* with the fields the multipart import takes beside the file. The fields keep
* their own rules (a bike import names its profile, a paddle import its boat
* and water), checked here as the import checks them.
*/
export const tourGpxImportRequestSchema = z
.object({
gpx: z.string().min(1).max(TOUR_GPX_TEXT_MAX).describe('The GPX file as text, its XML as it is'),
file_name: z
.string()
.trim()
.min(1)
.max(200)
.optional()
.describe('The file name the travellers know it by, such as "herzogstand.gpx"'),
...tourGpxImportFieldsSchema.shape,
})
.strict()
.superRefine((value, ctx) => {
const { gpx: _gpx, file_name: _fileName, ...fields } = value;
const checked = tourGpxImportFieldsSchema.safeParse(fields);
for (const issue of checked.error?.issues ?? []) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: issue.path, message: issue.message });
}
});
export type TourGpxImportRequest = z.infer<typeof tourGpxImportRequestSchema>;
+5 -1
View File
@@ -241,7 +241,7 @@ Each tool asks for the scope of what it writes, so the scopes differ per tool. T
### Tours _(Tours addon required)_
The saved tours of a trip, as the Tours list and the tour detail show them, how far a city walk got on a day, paddle legs planned on the water, a city walk prepared from a briefing, a change to a saved city walk prepared for review and saved once the traveller confirmed it, a saved change taken back the same way, and the tours the user has done, summed across their trips as the Atlas shows them. `take_over_tour`, `create_city_walk`, `save_tour_change`, `undo_tour_change`, `assign_tour_track`, `unassign_tour_track`, `set_tour_activity_excluded`, `save_tour_to_collection`, `take_over_collection_tour`, `delete_collection_tour`, `link_tour_packing` and `unlink_tour_packing` write; every other tool here only reads. The collection tools need the Collections addon as well, the packing tools the Lists addon. Tours someone is still drawing in the planner are not saved yet and do not show here.
The saved tours of a trip, as the Tours list and the tour detail show them, how far a city walk got on a day, paddle legs planned on the water, a city walk prepared from a briefing, a change to a saved city walk prepared for review and saved once the traveller confirmed it, a saved change taken back the same way, a hike planned on the server with its heights and SAC grades, created, changed or taken in from a GPX file once the traveller confirmed it, and the tours the user has done, summed across their trips as the Atlas shows them. `take_over_tour`, `create_city_walk`, `create_hike`, `update_tour`, `import_tour_gpx`, `save_tour_change`, `undo_tour_change`, `assign_tour_track`, `unassign_tour_track`, `set_tour_activity_excluded`, `save_tour_to_collection`, `take_over_collection_tour`, `delete_collection_tour`, `link_tour_packing` and `unlink_tour_packing` write; every other tool here only reads. The collection tools need the Collections addon as well, the packing tools the Lists addon. Tours someone is still drawing in the planner are not saved yet and do not show here.
| Tool | Description | Scope |
|---|---|---|
@@ -269,6 +269,10 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how
| `preview_google_maps_walk` | Reads the stops of a Google Maps walking route shared as a link (a `maps.app.goo.gl` short link or a `google.com/maps/dir` link), in order, for a city walk: each stop's name and position, or no position when the link gives none (nothing is looked up or guessed), and the long link that was read. Only a route Google Maps marks as on foot is read; a car, bike or transit route, or a link that does not say, is refused. A short link is followed only to Google Maps itself. Nothing is saved, and the line drawn in Google Maps is not taken over: TREK plans the way on foot between the stops again. Needs `place_edit` on the trip | `trips:read` |
| `propose_city_walk` | Proposes a city walk from a briefing without saving anything: start (and end), the time frame on a day, the themes (`culture`, `parks`, `food`, `mixed`), the stations the traveller wished for, the places of the trip it may use and fixed appointments. Places for the themes come from the same search as `search_tour_pois`, up to 8, each with its source, theme and category; nothing is made up. Answers the stations in order with the walking distance and time from the one before, the arrival and leave times, the stay where the traveller gave it (unknown otherwise, which makes later arrivals the earliest possible), and the opening hours checked against the arrival on that day. The order is chosen on estimates; the walk in that order is then planned on foot by the router TREK uses for road trips (the traveller's own routing server where they set one), whose legs give the distances and times. Where it gives no way, because the router cannot be reached or a station lies more than 300 m off every way, the times are estimates over the straight distance with an allowance for streets at 4.5 km/h, and the answer says so and why. Walking and stays are kept apart, a complete total only when every stay is known, with the minutes the frame leaves for the stays not known. Lists free time, the wishes and appointments that cannot be kept as asked, what was left out and why, and `stations_for_create` for `create_city_walk` | `trips:read` |
| `create_city_walk` | Creates a city walk from stations the traveller confirmed (for instance `stations_for_create` of `propose_city_walk`), through the same creation the planner's save uses: a key for every station, its name, stay, importance, protection, opening hours and the place of the trip it stands for. The way on foot is planned by the same router as for the proposal, and the walk is saved with it, its distance and its walking time. Where the router gives no way, the walk is saved with its stations only (`route_pending`), says why, and has no line, distance or walking time until someone opens it in the planner, where TREK plans the way on foot and the walk can be saved with it. Needs `place_edit` on the trip | `trips:write` |
| `propose_hike` | Plans a hike for the traveller to look at, without saving anything: the way on foot through its points in order (start, points on the way, end; `round_trip` comes back to the start), on paths no harder than `max_hiking_difficulty` on the SAC scale (T1 to T6, T2 when left out) and avoiding steep climbs, steps or ferries where there is a way round (`hiking_preferences`). The way is planned by TREK's Valhalla router with the planner's hiking costing (the instance an admin set, else the public FOSSGIS one), through the same paced queue as the road trip. Answers the distance, ascent and descent, the walking time without breaks (by DIN 33466 from the heights where every point has one, else the router's), the lowest and highest point, the SAC grades OpenStreetMap records along the way (the hardest, the graded stretches and how much is unknown, which is never read as easy), where each point lies along the way, and the line (up to 300 points) and height profile (up to 200 samples) for display. Where no way is planned it says why: no Valhalla (a routing server of the traveller's own knows no hiking limit), the router not answering, no way within the limit, a point more than 300 m from any way, or a way too long for one hike (past the router's own distance limit, or more than 10,000 points, whose heights would hold the router's queue up for everyone). A line's heights are kept for five minutes, so a hike created from the proposal does not ask for them again. `variant_label`, `date` and `start_time` are given back, so several ways can be compared | `trips:read` |
| `create_hike` | Creates a hike from points the traveller confirmed (usually those of `propose_hike`), through the same creation the planner's save uses: its points in order as start, points on the way and end, the way planned by the same router and saved whole with the height of every point, its distance, ascent, descent, walking time, hiking limit, preferences and planned time. Where the router does not answer, for the way or for its heights, the hike is saved with its points only (`route_pending`), says so, and TREK plans the way when someone opens it in the planner. Any other reason the proposal names (no Valhalla, no way within the limit, a point off every way, a way too long) refuses the hike with why, and nothing is saved, since the planner could not plan it either. Needs `place_edit` on the trip | `trips:write` |
| `update_tour` | Changes a saved hike against the `revision` it was read at: a new name, description or planned time keeps its way; new points, a new hiking limit or new preferences plan the way anew as `propose_hike` does, and so does a hike still waiting for its way. A hike changed since that revision is refused as a conflict before anything is planned, and a new way that cannot be planned, or not with its heights, is refused with the reason, leaving the hike as it was. A new point standing where a saved one stood (within 25 m) keeps that point's link to a place of the trip and its name. A hike with a section drawn by hand (a via ferrata, a free line, a ferry) or split into stages takes new points only in the planner, the hand-drawn one a new limit or new preferences too. A recorded track without points of its own takes only new points, which plan it over and keep the recording; every other tour type is refused (a city walk changes through `prepare_tour_change` and `save_tour_change`). The hike stays on its days. Needs `place_edit` on the trip | `trips:write` |
| `import_tour_gpx` | Takes in the tracks and routes of a GPX file, passed as its text, as tours of the trip, one each, through the same import as the planner's file import: a hike unless another type is named (a bike import with its profile, a run with its profile, a paddle import with its boat and water, a ski import with its discipline). A track already in the trip under the same name and start is skipped and counted; a file without a track or route is refused. Needs `place_edit` on the trip | `trips:write` |
| `prepare_tour_change` | Prepares a change to a saved city walk for the traveller to review, without saving anything: no tour, station, visit or day plan changes, and turning it down keeps the plan as it was. The changes apply in order and name the saved stations by their keys: `add` (a new station after a station; a spontaneous stop on the way is an add after the station the traveller is at), `remove`, `replace` and `move`. A protected station, or one visited or skipped on any day of the walk, is never removed, replaced or moved, nothing is put before the last station already done, and a leg drawn by hand stays as drawn, the closing leg of a loop included; such a change is refused, and with it the whole proposal, naming every change and why. Another type of tour, or a walk that is a recorded track without stations of its own, is refused as well: so far only a city walk takes prepared changes. The answer names the tour and the `revision` it was prepared on (a save has to name it, and a tour changed since is refused rather than overwritten), the stations after the change with what changed each and where it stood before, what was removed, the way on foot before and after planned by the same router TREK uses for city walks and its change in metres and minutes (the distance and walking time stored with the walk given apart), the stays as far as known before and after, every leg after the change with the new ones marked, `data_gaps` for what could not be worked out (no way from the router, stays not known, no arrival times, shaping points dropped because the leg they steered changed), a warning for a station of importance must removed or replaced, each new station that is not yet a place of the trip as `create_place` takes it to keep it as a place instead (a station linked to a place of the trip is left out), whether the traveller may change the trip's places at all (`can_save`) and the `proposal_id` a save of it names | `trips:read` |
| `save_tour_change` | Saves a change `prepare_tour_change` prepared, once the traveller said yes to it exactly as shown: the same changes, the `revision` it was prepared on, its `proposal_id` and `confirmed` set to true. Anything else is refused and nothing is saved. The trip permission and the tour are checked again before anything is written: a tour changed since the proposal was prepared is refused as a conflict rather than overwritten (prepare the change again), a proposal already saved, sent again because its answer was lost, writes nothing and answers `already_saved` with the step it was saved as, and a change the rules no longer allow, a station visited or skipped meanwhile or a place of the trip gone, is refused with every reason. The change is saved whole or not at all, through the same write the planner's save uses: the stations in their order, the stations kept keep their keys and their visits, and the way on foot is planned by the same router as the proposal; where it gives no way the walk keeps its stations only (`route_pending`) until someone opens it in the planner. Every save is kept as a step of the tour with the revision it was prepared on and the one it raised the tour to, the changes and the tour as it was before them, so it can be followed and taken back with `undo_tour_change`; nothing of the conversation is kept. A new walk has no revision yet and is made with `create_city_walk`. Needs `place_edit` on the trip | `trips:write` |
| `list_tour_change_steps` | Lists the changes saved to a tour with `save_tour_change`, the latest first: each step with when it was saved, the revision it was prepared on and the one it raised the tour to, and the changes as confirmed. `standing` says whether it can be taken back: `latest` (the tour's latest change), `undone` (taken back already, with when and the revision that raised the tour to), `later_change` (something was saved on top of it since, so taking it back would lose that) or `no_earlier_state` (its record of the tour before cannot be read). Edits saved in the planner are no steps, and a draft in the planner is never seen | `trips:read` |