mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
c8eb510bd4
A new Nest domain plans the legs of a ski and winter tour on the runs, lifts, cross-country trails, ski touring and winter paths the Places host builds from OpenStreetMap, one SGR1 graph per ski area. TREK fetches the index once, downloads an area by its id when a planner first plans in it, checks it against the index's SHA-256 and keeps it in data/ski; no clicked point ever leaves the server. A leg whose area is slower than two seconds answers loading, and an older copy stands in while the host is down. The reader decodes the writer's golden file byte for byte, including the two edge flags it added (area_has_run, type_unknown). The search is A* in a worker thread over a profile per discipline (alpine, snowboard, cross-country by technique, touring, snowshoe, winter hike) with the times and penalties of the cost model, up to five snap candidates per point, lift stations as entry points, legs that go on from each other, pieces of a split network stitched by their boundary nodes, other ways by the penalty method, and a failed leg explained by relaxing one rule at a time (difficulty, lift class, technique, riding down, uphill) without ever handing out the relaxed line. POST /api/trips/:tripId/ski/route, GET .../ski/area-near and GET .../ski/network/:areaId sit behind Tours and the trip, with plan_ski_route as their MCP twin. A daily job looks for a new build and sweeps areas unused for 90 days. The admin switch ski_routing_enabled is on wherever TREK Places is.
236 lines
9.7 KiB
TypeScript
236 lines
9.7 KiB
TypeScript
/**
|
|
* The REST routes and the MCP tool of the piste router over a stub service:
|
|
* the routes' guards, statuses and contracts, the tool's gate, scope and
|
|
* input, and that both hand the same request to the same service. The answers
|
|
* through the real guards are pinned in the e2e suite.
|
|
*/
|
|
import { ADDON_IDS } from '../../../../src/addons';
|
|
import type { McpContext, McpTextResult, ToolOptions } from '../../../../src/nest-mcp';
|
|
import { getEntry, type ClassRef } from '../../../../src/nest-mcp/metadata';
|
|
import { AddonGuard } from '../../../../src/nest/addons/addon.guard';
|
|
import type { AddonsService } from '../../../../src/nest/addons/addons.service';
|
|
import { REQUIRE_ADDON } from '../../../../src/nest/addons/require-addon.decorator';
|
|
import { JwtAuthGuard } from '../../../../src/nest/auth-core/jwt-auth.guard';
|
|
import { RESPONSE_CONTRACT_KEY } from '../../../../src/nest/common/response-contract';
|
|
import { TripAccessGuard } from '../../../../src/nest/permissions/trip-access.guard';
|
|
import { SkiRoutingController } from '../../../../src/nest/ski-routing/ski-routing.controller';
|
|
import { SkiRoutingMcp } from '../../../../src/nest/ski-routing/ski-routing.mcp';
|
|
import type { SkiRoutingService } from '../../../../src/nest/ski-routing/ski-routing.service';
|
|
import type { TripAccessService } from '../../../../src/nest/trip-membership/trip-access.service';
|
|
import { RequestMethod } from '@nestjs/common';
|
|
import { GUARDS_METADATA, HTTP_CODE_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
|
import {
|
|
skiAreaNearResponseSchema,
|
|
skiNetworkResponseSchema,
|
|
skiRouteRequestSchema,
|
|
skiRouteResponseSchema,
|
|
type SkiRouteResponse,
|
|
} from '@trek/shared';
|
|
|
|
import type { Response } from 'express';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { z } from 'zod';
|
|
|
|
const A = { lat: 45.98, lng: 7.75 };
|
|
const B = { lat: 45.97, lng: 7.74 };
|
|
|
|
const ANSWER: SkiRouteResponse = {
|
|
build: '2026-11-02',
|
|
stale: false,
|
|
area_names: ['Testalp'],
|
|
colour_scheme: 'europe',
|
|
attribution: '© OpenStreetMap contributors, ODbL. Terrain: Mapzen Terrarium',
|
|
legs: [
|
|
{
|
|
status: 'ok',
|
|
geometry: [
|
|
[A.lat, A.lng, 2500],
|
|
[B.lat, B.lng, 1600],
|
|
],
|
|
segments: [
|
|
{
|
|
kind: 'run',
|
|
from: 0,
|
|
to: 1,
|
|
name: 'Rote Eins',
|
|
ref: '1',
|
|
difficulty: 'intermediate',
|
|
lift_type: null,
|
|
grooming: [],
|
|
seconds: 300,
|
|
wait_seconds: 0,
|
|
length_m: 2500,
|
|
flags: [],
|
|
osm_way: 12,
|
|
},
|
|
],
|
|
distance_m: 2500,
|
|
climb_m: 0,
|
|
descent_m: 900,
|
|
seconds: { ski: 300, lift: 0, wait: 0, walk: 0 },
|
|
snapped_m: { from: 3, to: 0 },
|
|
area_ids: ['w:1'],
|
|
why: [],
|
|
alternatives: [],
|
|
},
|
|
{ status: 'failed', code: 'no_area', at: B, hint: null, detail: null },
|
|
],
|
|
};
|
|
|
|
function service() {
|
|
return {
|
|
route: vi.fn<SkiRoutingService['route']>().mockResolvedValue(ANSWER),
|
|
areaNear: vi.fn<SkiRoutingService['areaNear']>().mockResolvedValue({ area: null }),
|
|
network: vi.fn<SkiRoutingService['network']>().mockResolvedValue({
|
|
area_id: 'w:1',
|
|
build: '2026-11-02',
|
|
colour_scheme: 'europe',
|
|
simplified_m: 5,
|
|
features: [],
|
|
sha256: 'a'.repeat(64),
|
|
}),
|
|
};
|
|
}
|
|
|
|
describe('SkiRoutingController', () => {
|
|
it('SKICTL-001: the routes sit behind Tours, the session and the trip, each on its contract', () => {
|
|
expect(Reflect.getMetadata(PATH_METADATA, SkiRoutingController)).toBe('api/trips/:tripId/ski');
|
|
expect(Reflect.getMetadata(GUARDS_METADATA, SkiRoutingController)).toEqual([
|
|
AddonGuard,
|
|
JwtAuthGuard,
|
|
TripAccessGuard,
|
|
]);
|
|
expect(Reflect.getMetadata(REQUIRE_ADDON, SkiRoutingController)).toEqual({ addonId: 'tours', label: 'Tours' });
|
|
const { route, areaNear, network } = SkiRoutingController.prototype;
|
|
expect(Reflect.getMetadata(METHOD_METADATA, route)).toBe(RequestMethod.POST);
|
|
expect(Reflect.getMetadata(PATH_METADATA, route)).toBe('route');
|
|
expect(Reflect.getMetadata(HTTP_CODE_METADATA, route)).toBe(200);
|
|
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, route)).toBe(skiRouteResponseSchema);
|
|
expect(Reflect.getMetadata(METHOD_METADATA, areaNear)).toBe(RequestMethod.GET);
|
|
expect(Reflect.getMetadata(PATH_METADATA, areaNear)).toBe('area-near');
|
|
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, areaNear)).toBe(skiAreaNearResponseSchema);
|
|
expect(Reflect.getMetadata(PATH_METADATA, network)).toBe('network/:areaId');
|
|
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, network)).toBe(skiNetworkResponseSchema);
|
|
});
|
|
|
|
it('SKICTL-002: hands the parsed request to the service and answers what it planned', async () => {
|
|
const routing = service();
|
|
const controller = new SkiRoutingController(routing as unknown as SkiRoutingService);
|
|
const body = skiRouteRequestSchema.parse({ legs: [{ from: A, to: B }], discipline: 'snowboard' });
|
|
await expect(controller.route(body)).resolves.toBe(ANSWER);
|
|
expect(routing.route).toHaveBeenCalledWith(body);
|
|
await expect(controller.areaNear({ lat: 46, lng: 7 })).resolves.toEqual({ area: null });
|
|
expect(routing.areaNear).toHaveBeenCalledWith(46, 7);
|
|
});
|
|
|
|
it('SKICTL-003: the lines of an area go out tagged with its checksum, the checksum not in the body', async () => {
|
|
const routing = service();
|
|
const controller = new SkiRoutingController(routing as unknown as SkiRoutingService);
|
|
const setHeader = vi.fn();
|
|
const body = await controller.network('w:1', { setHeader } as unknown as Response);
|
|
expect(body).toEqual({
|
|
area_id: 'w:1',
|
|
build: '2026-11-02',
|
|
colour_scheme: 'europe',
|
|
simplified_m: 5,
|
|
features: [],
|
|
});
|
|
expect(setHeader).toHaveBeenCalledWith('ETag', `"${'a'.repeat(64)}"`);
|
|
expect(setHeader).toHaveBeenCalledWith('Cache-Control', 'private, max-age=3600');
|
|
});
|
|
});
|
|
|
|
const ctx = { userId: 7, scopes: null, isStaticToken: false } as McpContext;
|
|
|
|
function toolOptions(): ToolOptions {
|
|
const entry = getEntry(SkiRoutingMcp as unknown as ClassRef, 'planSkiRoute');
|
|
if (!entry || entry.kind !== 'tool') throw new Error('plan_ski_route is not registered');
|
|
return entry.options;
|
|
}
|
|
|
|
function makeMcp(accessible: boolean) {
|
|
const routing = service();
|
|
const access = { findAccessible: vi.fn().mockResolvedValue(accessible ? { id: 3 } : null) };
|
|
const mcp = new SkiRoutingMcp(
|
|
routing as unknown as SkiRoutingService,
|
|
access as unknown as TripAccessService,
|
|
{} as AddonsService,
|
|
);
|
|
return { mcp, routing, access };
|
|
}
|
|
|
|
describe('SkiRoutingMcp', () => {
|
|
it('SKIMCP-001: plan_ski_route is read only, gated on Tours and scoped to reading trips', () => {
|
|
const options = toolOptions();
|
|
expect(options.name).toBe('plan_ski_route');
|
|
expect(options.annotations?.readOnlyHint).toBe(true);
|
|
expect(options.access).toEqual({ group: 'trips', mode: 'read' });
|
|
const isAddonEnabled = vi.fn((id: string) => id === ADDON_IDS.TOURS);
|
|
const self = { addons: { isAddonEnabled } } as unknown as SkiRoutingMcp;
|
|
expect(options.when?.(ctx, self)).toBe(true);
|
|
expect(isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.TOURS);
|
|
});
|
|
|
|
it('SKIMCP-002: takes the REST contract plus the trip, with the same defaults and limits', () => {
|
|
const input = z.object(toolOptions().inputSchema as z.ZodRawShape);
|
|
expect(input.safeParse({ tripId: 3, legs: [{ from: A, to: B }], discipline: 'alpine' }).success).toBe(true);
|
|
expect(input.safeParse({ tripId: 3, legs: [], discipline: 'alpine' }).success).toBe(false);
|
|
expect(input.safeParse({ tripId: 3, legs: [{ from: A, to: B }] }).success).toBe(false);
|
|
const three = { tripId: 3, legs: [{ from: A, to: B }], discipline: 'alpine', alternatives: 3 };
|
|
expect(input.safeParse(three).success).toBe(false);
|
|
expect(input.safeParse({ legs: [{ from: A, to: B }], discipline: 'alpine' }).success).toBe(false);
|
|
});
|
|
|
|
it('SKIMCP-003: plans for a trip the caller sees, the same request as REST, legs without their points', async () => {
|
|
const { mcp, routing, access } = makeMcp(true);
|
|
const asked = { tripId: 3, legs: [{ from: A, to: B }], discipline: 'alpine' as const };
|
|
const result = (await mcp.planSkiRoute(asked, ctx)) as McpTextResult;
|
|
expect(access.findAccessible).toHaveBeenCalledWith(3, 7);
|
|
const { tripId: _trip, ...asRest } = asked;
|
|
expect(routing.route).toHaveBeenCalledWith(skiRouteRequestSchema.parse(asRest));
|
|
expect(result.isError).toBeUndefined();
|
|
expect(JSON.parse(result.content[0].text)).toEqual({
|
|
build: '2026-11-02',
|
|
stale: false,
|
|
area_names: ['Testalp'],
|
|
colour_scheme: 'europe',
|
|
attribution: '© OpenStreetMap contributors, ODbL. Terrain: Mapzen Terrarium',
|
|
legs: [
|
|
{
|
|
status: 'ok',
|
|
distance_m: 2500,
|
|
climb_m: 0,
|
|
descent_m: 900,
|
|
seconds: { ski: 300, lift: 0, wait: 0, walk: 0 },
|
|
snapped_m: { from: 3, to: 0 },
|
|
points: 2,
|
|
segments: [
|
|
{
|
|
kind: 'run',
|
|
name: 'Rote Eins',
|
|
ref: '1',
|
|
difficulty: 'intermediate',
|
|
lift_type: null,
|
|
seconds: 300,
|
|
wait_seconds: 0,
|
|
length_m: 2500,
|
|
flags: [],
|
|
},
|
|
],
|
|
why: [],
|
|
alternatives: 0,
|
|
},
|
|
{ status: 'failed', code: 'no_area', at: B, hint: null, detail: null },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('SKIMCP-004: a trip the caller cannot see is refused before anything is planned', async () => {
|
|
const { mcp, routing } = makeMcp(false);
|
|
const asked = { tripId: 9, legs: [{ from: A, to: B }], discipline: 'alpine' as const };
|
|
const result = (await mcp.planSkiRoute(asked, ctx)) as McpTextResult;
|
|
expect(result.isError).toBe(true);
|
|
expect(routing.route).not.toHaveBeenCalled();
|
|
});
|
|
});
|