trails/apps/planner/app/lib/route-merge.test.ts
Ullrich Schäfer c0c3ce98d0 Cache BRouter segments client-side, fetch only the diff
Moving one waypoint previously re-fetched all N-1 segments of the route
through /api/route every time, because the server-side computeRoute
recomputes the whole route from scratch. Under rapid edits this piles up
on BRouter's thread pool and wastes BRouter+Planner cycles on segments
the caller already has.

This change keeps a host-local LRU segment cache in the Planner tab,
keyed by (from, to, profile, noGoHash). On each computeRoute:

  - build the pair list from the current waypoints
  - split into cached vs. missing by looking up each pair key
  - if any missing, POST { pairs: missing, … } to the new
    /api/route-segments endpoint and populate the cache with its ordered
    raw BRouter responses
  - merge cache-ordered segments into an EnrichedRoute client-side via
    mergeGeoJsonSegments (moved to a pure, isomorphic route-merge.ts)
  - write to yjs.routeData exactly as before

Typical drag of one waypoint in a 10-waypoint route drops from 9 BRouter
fetches to 2. A session re-entering a cached arrangement (undo/redo)
short-circuits the server entirely. Profile/no-go changes invalidate all
keys and trigger a full refetch, matching prior behavior.

Yjs stays out of the cache: segments are bulky and CRDT sync of large
blobs hurts more than it helps. The elected host owns the cache; on host
handover the new host pays one full recompute.

Backwards-compat: /api/route is unchanged for external callers (e2e
integration tests, the Journal demo-bot's format=gpx path). Server-side
it now delegates to the shared fetchSegments helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:57:54 +02:00

60 lines
2.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { hashNoGoAreas, pairKey } from "./route-merge";
describe("pairKey", () => {
it("is stable for the same inputs", () => {
const a = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, "trekking", "abc");
const b = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, "trekking", "abc");
expect(a).toBe(b);
});
it("changes when the profile changes", () => {
const a = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, "trekking", "");
const b = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, "fastbike", "");
expect(a).not.toBe(b);
});
it("changes when either endpoint moves", () => {
const base = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, "p", "");
const moveFrom = pairKey({ lat: 1.0001, lon: 2 }, { lat: 3, lon: 4 }, "p", "");
const moveTo = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4.0001 }, "p", "");
expect(base).not.toBe(moveFrom);
expect(base).not.toBe(moveTo);
});
it("is direction-sensitive (A→B and B→A are distinct)", () => {
const forward = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, "p", "");
const reverse = pairKey({ lat: 3, lon: 4 }, { lat: 1, lon: 2 }, "p", "");
expect(forward).not.toBe(reverse);
});
it("changes when the no-go hash changes", () => {
const a = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, "p", "h1");
const b = pairKey({ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, "p", "h2");
expect(a).not.toBe(b);
});
});
describe("hashNoGoAreas", () => {
it("returns empty string for no areas", () => {
expect(hashNoGoAreas()).toBe("");
expect(hashNoGoAreas([])).toBe("");
});
it("is stable for equal inputs", () => {
const areas = [
{ points: [{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, { lat: 5, lon: 6 }] },
];
expect(hashNoGoAreas(areas)).toBe(hashNoGoAreas(areas));
});
it("differs when a polygon changes", () => {
const a = hashNoGoAreas([
{ points: [{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, { lat: 5, lon: 6 }] },
]);
const b = hashNoGoAreas([
{ points: [{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, { lat: 5, lon: 7 }] },
]);
expect(a).not.toBe(b);
});
});