mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
582 lines
26 KiB
TypeScript
582 lines
26 KiB
TypeScript
/**
|
|
* 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;
|
|
* its last case creates a hike through the real HikePlanService.
|
|
*/
|
|
import { Places } from '../../../src/db/entities/Places.entity';
|
|
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 { HikePlanService } from '../../../src/nest/tours/hike-plan.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 { findRow } from '../../helpers/factories/rows';
|
|
import { makeTrip } from '../../helpers/factories/trips';
|
|
import { makeUser } from '../../helpers/factories/users';
|
|
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
|
import { valhallaRoute } from '../../helpers/valhalla';
|
|
import { MikroORM } from '@mikro-orm/core';
|
|
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-021
|
|
|
|
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,
|
|
snapped: null,
|
|
legs: 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 planned = await plan();
|
|
const hike = routed(planned);
|
|
expect(hike.has_heights).toBe(false);
|
|
expect(hike.distance_m).toBe(222);
|
|
expect(planned.snapped).toBeNull();
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe('HikeRoutingService: a planned hike kept for a while', () => {
|
|
/** A plan for one user and trip of its own (or the trip given), the same pair each time it is asked. */
|
|
function planner(id: number, tripId = id) {
|
|
return async (request: Partial<HikePlanRequest> = {}, withGrades = true) => {
|
|
const pending = hikes.plan(id, tripId, { waypoints: WAYPOINTS, ...request }, { grades: withGrades });
|
|
await vi.runAllTimersAsync();
|
|
return pending;
|
|
};
|
|
}
|
|
const requests = () => vi.mocked(safeFetchAdminConfigured).mock.calls.length;
|
|
/** How many hikes the memo holds right now, stale ones included. */
|
|
const keptCount = () => (Reflect.get(hikes, 'memo') as Map<string, unknown>).size;
|
|
|
|
it('HIKE-ROUTE-016: answers the same request again with the very way, asking the router nothing, for a save too', async () => {
|
|
trip += 1;
|
|
const ask = planner(trip);
|
|
const first = await ask();
|
|
expect(first.snapped).toEqual([0, 0, 0]);
|
|
expect(first.legs).toEqual([111, 111]);
|
|
const before = requests();
|
|
// Points a millionth of a degree apart and named otherwise are the same points; a save reads no grades.
|
|
const nudged = WAYPOINTS.map((point) => ({ ...point, lat: point.lat + 0.000001, name: 'Elsewhere' }));
|
|
expect(await ask({ waypoints: nudged }, false)).toBe(first);
|
|
expect(await ask({ max_hiking_difficulty: 2, hiking_preferences: null })).toBe(first);
|
|
expect(requests()).toBe(before);
|
|
|
|
// Another limit, other preferences, another user or trip plan anew.
|
|
expect(await ask({ max_hiking_difficulty: 3 })).not.toBe(first);
|
|
expect(await ask({ hiking_preferences: { avoid_steps: true } })).not.toBe(first);
|
|
const other = planner(trip + 50_000);
|
|
expect(await other()).not.toBe(first);
|
|
});
|
|
|
|
it('HIKE-ROUTE-017: asks again after fifteen minutes, for grades a save did not read, and for a way without heights', async () => {
|
|
trip += 1;
|
|
const ask = planner(trip);
|
|
const saved = await ask({}, false);
|
|
expect(asked('/trace_attributes')).toHaveLength(0);
|
|
const shown = await ask({}, true);
|
|
expect(shown).not.toBe(saved);
|
|
expect(routed(shown).grades).not.toBeNull();
|
|
expect(asked('/trace_attributes')).toHaveLength(1);
|
|
expect(await ask({}, false)).toBe(shown);
|
|
|
|
vi.advanceTimersByTime(15 * 60_000);
|
|
expect(await ask({}, true)).not.toBe(shown);
|
|
|
|
trip += 1;
|
|
const level = planner(trip);
|
|
valhalla({ height: () => new Response('', { status: 502 }) });
|
|
const flatHike = await level();
|
|
expect(routed(flatHike).has_heights).toBe(false);
|
|
const heights = asked('/height').length;
|
|
expect(await level()).not.toBe(flatHike);
|
|
expect(asked('/height').length).toBeGreaterThan(heights);
|
|
|
|
// A way not planned is not kept either: the router is asked again.
|
|
trip += 1;
|
|
const down = planner(trip);
|
|
valhalla({ route: () => new Response('', { status: 503 }) });
|
|
await down();
|
|
await down();
|
|
expect(asked('/route')).toHaveLength(2);
|
|
});
|
|
|
|
it('HIKE-ROUTE-018: keeps at most 64 ways, the one used longest ago going first', async () => {
|
|
const base = 100_000;
|
|
const asks = Array.from({ length: 65 }, (_, index) => planner(base + index));
|
|
// Asked one after the other, so the first is the one used longest ago.
|
|
const kept = await asks
|
|
.slice(0, 64)
|
|
.reduce<Promise<PlannedHike[]>>(
|
|
async (before, ask) => [...(await before), await ask({}, false)],
|
|
Promise.resolve([]),
|
|
);
|
|
// The first is used again, so the second is now the one used longest ago.
|
|
expect(await asks[0]({}, false)).toBe(kept[0]);
|
|
await asks[64]({}, false);
|
|
expect(await asks[0]({}, false)).toBe(kept[0]);
|
|
expect(await asks[2]({}, false)).toBe(kept[2]);
|
|
expect(await asks[1]({}, false)).not.toBe(kept[1]);
|
|
});
|
|
|
|
it('HIKE-ROUTE-019: keeps one user apart from another in the same trip, and one trip apart from another of the same user', async () => {
|
|
trip += 1;
|
|
const first = await planner(trip)({}, false);
|
|
expect(await planner(trip)({}, false)).toBe(first);
|
|
expect(await planner(trip + 60_000, trip)({}, false)).not.toBe(first);
|
|
expect(await planner(trip, trip + 60_000)({}, false)).not.toBe(first);
|
|
expect(await planner(trip)({}, false)).toBe(first);
|
|
});
|
|
|
|
it('HIKE-ROUTE-020: drops stale hikes when it keeps a new one, and keeps no more than 200000 line points together', async () => {
|
|
const before = keptCount();
|
|
await planner(200_000)({}, false);
|
|
expect(keptCount()).toBe(Math.min(before + 1, 64));
|
|
// Well past fifteen minutes, also for a hike an earlier case kept on its own advanced clock.
|
|
vi.advanceTimersByTime(30 * 60_000);
|
|
await planner(200_001)({}, false);
|
|
// Every hike planned before is stale now, and goes as soon as another is kept.
|
|
expect(keptCount()).toBe(1);
|
|
|
|
// Lines of 10000 points: the twentieth pushes the small hike out, the twenty first the oldest long one.
|
|
const long = { waypoints: [WAYPOINTS[0], { lat: at(HIKE_MAX_LINE_POINTS - 1)[0], lng: 11 }] };
|
|
const line = stepsOf(0, HIKE_MAX_LINE_POINTS - 1);
|
|
valhalla({ route: () => json(valhallaRoute([{ line, km: 111.1, seconds: 90_000 }])) });
|
|
const asks = Array.from({ length: 21 }, (_, index) => planner(300_000 + index));
|
|
const kept = await asks
|
|
.slice(0, 20)
|
|
.reduce<Promise<PlannedHike[]>>(
|
|
async (earlier, ask) => [...(await earlier), await ask(long, false)],
|
|
Promise.resolve([]),
|
|
);
|
|
expect(kept[0].line).toHaveLength(HIKE_MAX_LINE_POINTS);
|
|
expect(keptCount()).toBe(20);
|
|
await asks[20](long, false);
|
|
expect(keptCount()).toBe(20);
|
|
expect(await asks[1](long, false)).toBe(kept[1]);
|
|
expect(await asks[0](long, false)).not.toBe(kept[0]);
|
|
});
|
|
});
|
|
|
|
describe('HikeRoutingService: the hike created from a proposal', () => {
|
|
it('HIKE-ROUTE-021: saves the very line the proposal showed while the router would now answer another, and the new one once the memo let it go', async () => {
|
|
const orm = t.get(MikroORM);
|
|
const { user } = await makeUser(orm);
|
|
const tripRow = await makeTrip(orm, user.id);
|
|
const hiking = t.get(HikePlanService);
|
|
const settled = async <T>(pending: Promise<T>): Promise<T> => {
|
|
await vi.runAllTimersAsync();
|
|
return await pending;
|
|
};
|
|
// Points two metres off the other cases' points, so neither the memo nor the router's caches hold them yet.
|
|
const request = {
|
|
name: 'Herzogstand',
|
|
waypoints: WAYPOINTS.map((point) => ({ ...point, lat: point.lat + 0.00002 })),
|
|
};
|
|
const proposal = await settled(hiking.propose(user.id, tripRow.id, { ...request, along: false }));
|
|
expect(proposal.route).toEqual({ planned: true });
|
|
const savedLine = async (placeId: number) =>
|
|
JSON.parse(String((await findRow(orm, Places, { id: placeId }))?.route_geometry)) as number[][];
|
|
|
|
// The router would now plan another way, about 76 m east (within the snap limit, one leg per pair of points as
|
|
// Valhalla answers), and its own caches (five minutes) have let the first go.
|
|
const otherWay = valhallaRoute([
|
|
{ line: stepsOf(0, 10, 11.001), km: 0.111, seconds: 150 },
|
|
{ line: stepsOf(10, 20, 11.001), km: 0.111, seconds: 180 },
|
|
]);
|
|
valhalla({ route: () => json(otherWay) });
|
|
vi.advanceTimersByTime(6 * 60_000);
|
|
const created = await settled(hiking.create(user.id, tripRow.id, request));
|
|
expect(asked('/route')).toHaveLength(0);
|
|
const shown = await savedLine(created.tour.place_id);
|
|
expect(shown).toHaveLength(21);
|
|
expect(shown.every(([, lng]) => lng === 11)).toBe(true);
|
|
expect(shown.map(([lat, , ele]) => ele - heightAt(lat))).toEqual(Array.from({ length: 21 }, () => 0));
|
|
|
|
// Past fifteen minutes the memo is gone, and the hike created is the way the router plans now.
|
|
vi.advanceTimersByTime(10 * 60_000);
|
|
const later = await settled(hiking.create(user.id, tripRow.id, { ...request, name: 'Later' }));
|
|
expect(asked('/route')).toHaveLength(1);
|
|
expect((await savedLine(later.tour.place_id)).every(([, lng]) => lng === 11.001)).toBe(true);
|
|
});
|
|
});
|