From f128346a54c61ab3cd3ac412fa3645e91f9ae0cd Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 24 Oct 2026 01:53:58 +0000 Subject: [PATCH] fix(tours): the other ways of a leg follow the points of the call that asks for them The search behind propose_hike_leg_way is kept for a while under the spots of the hike, but it kept the finished points of the first call. A later call over the same spots in another form, such as a round trip given with its way back as its last point, or with other names or stage ends, got the first call's points back, and its create_with no longer came back to the start. The search now keeps only the pins that hold each way, and each call sets them on its own points. --- server/src/nest/tours/hike-ways.service.ts | 33 ++++++++++++-------- server/tests/unit/nest/hike-ways.mcp.test.ts | 22 ++++++++++++- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/server/src/nest/tours/hike-ways.service.ts b/server/src/nest/tours/hike-ways.service.ts index 9c10e7cc3..4437eacaf 100644 --- a/server/src/nest/tours/hike-ways.service.ts +++ b/server/src/nest/tours/hike-ways.service.ts @@ -31,7 +31,6 @@ import { type HikeLegWayRequest, type HikeRoundProposal, type HikeRoundProposeRequest, - type HikeStop, type HikeWayMiss, type HikeWayMissing, type HikeWayPoints, @@ -55,9 +54,13 @@ interface RoundSearch { why: HikeWayMiss | null; } -/** What the ways of one leg came to: the hike's points with each way pinned on, or why there are none. */ +/** + * What the ways of one leg came to: the pins that hold each way the router + * keeps to, or why there are none. Only what the router was asked is kept; + * each call sets the pins on its own points. + */ interface LegWays { - ways: HikeStop[][]; + pins: WayPin[][]; why: HikeWayMiss | null; } @@ -153,7 +156,12 @@ export class HikeWaysService { } const key = legWaysKey(userId, tripId, request); const search = await this.recall(key, () => this.searchLegWays(userId, tripId, request)); - const { ways, why } = search as LegWays; + const { pins, why } = search as LegWays; + // Spots that read as one key may come with other names, stage ends or the way back given as a point. + const ways = pins.flatMap((held) => { + const pinned = pinnedPointsOf(request.waypoints, request.leg, held); + return pinned ? [pinned] : []; + }); const index = wayIndexOf(request.way); const waypoints = ways[index]; if (!waypoints) return wayMissing(request.way, ways.length, why); @@ -254,9 +262,9 @@ export class HikeWaysService { /** * The other ways of one leg: the router's ways between its two ends under * the hike's costing, those that are no way already known, each held by - * the fewest pins the router keeps to (as the planner holds a way chosen), - * and the hike's points with those pins set on the leg. A way the router - * does not keep to, or that would take the hike past its points, is left out. + * the fewest pins the router keeps to (as the planner holds a way chosen). + * A way the router does not keep to is left out; one whose pins would take + * the hike past its points is left out where they are set on them. */ private async searchLegWays(userId: number, tripId: number, request: HikeLegWayRequest): Promise { const ends = legEndsOf(request.waypoints, request.round_trip, request.leg); @@ -268,10 +276,10 @@ export class HikeWaysService { offers = await this.router.alternatesOnFoot(userId, ends.from, ends.to, costingOptions, HIKE_WAY_KEYS.length); } catch (err: unknown) { if (!(err instanceof RouteRefusal)) logWarn(`[Hike] no other ways for trip ${tripId}: ${reasonOf(err)}`); - return { ways: [], why: missOf(err) }; + return { pins: [], why: missOf(err) }; } const [own] = offers; - if (!own) return { ways: [], why: 'no_way' }; + if (!own) return { pins: [], why: 'no_way' }; const current = roadOf(own); const route = async (pins: WayPin[]): Promise => { try { @@ -290,12 +298,11 @@ export class HikeWaysService { HIKE_WAY_KEYS.length, ); // One way after another: each check asks the router's one paced queue. - const ways = await others.reduce>(async (before, offer) => { + const kept = await others.reduce>(async (before, offer) => { const held = await before; const proof = await pinWay({ offer: roadOf(offer.coordinates), current, route }); - const pinned = proof?.held ? pinnedPointsOf(request.waypoints, request.leg, proof.pins) : null; - return pinned ? [...held, pinned] : held; + return proof?.held ? [...held, proof.pins] : held; }, Promise.resolve([])); - return { ways, why: null }; + return { pins: kept, why: null }; } } diff --git a/server/tests/unit/nest/hike-ways.mcp.test.ts b/server/tests/unit/nest/hike-ways.mcp.test.ts index ae8c63b93..b09726d83 100644 --- a/server/tests/unit/nest/hike-ways.mcp.test.ts +++ b/server/tests/unit/nest/hike-ways.mcp.test.ts @@ -23,7 +23,7 @@ import type { HikeProposeRequest } from '@trek/shared'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -// HIKE-WAYS-MCP-001 to HIKE-WAYS-MCP-008 +// HIKE-WAYS-MCP-001 to HIKE-WAYS-MCP-009 type Spot = { lat: number; lng: number }; @@ -230,4 +230,24 @@ describe('propose_hike_leg_way', () => { ); expect(answer.leg_way.tour_id).toBe(place.id); }); + + it('HIKE-WAYS-MCP-009: a later call over the same spots asks the router nothing new, and its way follows its own points', async () => { + const { userId, tripId } = await tripOf(); + // The way back of a round trip, B to A. + alternatesOnFoot.mockResolvedValue([[...OWN].reverse(), [...OTHER].reverse()]); + const pin = { lat: 47.51, lng: 11.12 }; + const round = read( + await mcp.proposeHikeLegWay({ tripId, name: 'H', waypoints: [A, B], round_trip: true, leg: 2 }, ctx(userId)), + ); + expect(round.create_with).toEqual({ waypoints: [A, B, pin], round_trip: true }); + const asked = route.mock.calls.length; + // The same hike as get_tour_plan reads it: the way back given as its last point, and a night at the hut. + const hut = { ...B, stage_end: true }; + const saved = read(await mcp.proposeHikeLegWay({ tripId, name: 'H', waypoints: [A, hut, A], leg: 2 }, ctx(userId))); + expect(alternatesOnFoot).toHaveBeenCalledTimes(1); + expect(route.mock.calls.length).toBe(asked); + expect(saved.leg_way).toMatchObject({ key: 'a', leg: 2, ways_found: 1 }); + expect(saved.create_with).toEqual({ waypoints: [A, hut, pin, A], round_trip: false }); + expect(propose.mock.calls.at(-1)?.[2]).toMatchObject({ waypoints: [A, hut, pin, A], round_trip: false }); + }); });