Files
TREK/server/tests/unit/nest/horse-route-sections.test.ts
T
Maurice 0331d9950f feat(tours): plan rides on horseback from signposted horse routes
A tour can now be a ride (HOR-01). The planner offers "Horse ride" as a
tour type with its own horseshoe, and a Horse routes card beside the
waypoints looks, when asked, for the route=horse relations OpenStreetMap
knows in the map view. A route opens in a dialog that shows it on a map in
the sections it is made of, split at forks, gaps and crossings, so nothing
is ever joined across a gap. The rider picks a section and its direction,
sees what the horse=* tags along it say (open, limited, closed, or unknown
where untagged) and whether it is signposted the other way, and takes it as
one undoable step. A section that does not start where the ride ends is
only joined by a straight manual stretch once the rider switched that on.

Nothing of a ride is routed: every leg follows a section or is drawn by
hand, and the waypoint list and the map say which (a section as a solid
line with its route, a manual stretch dashed and marked unchecked). A point
at a section stays where the route has it, a ride works out no riding time,
and distance, heights, day assignment, GPX import and export, the 3D view
and the reversed ride keep working with each leg's origin saved and opened
again. A failed lookup leaves the ride and the routes already found as they
were.

The server reads the routes through the shared Overpass client
(GET /api/trips/:tripId/tours/horse-routes, Tours addon, trip access),
keeps an answer twelve hours per grid-aligned view, and the tour contract
refuses a routed leg, a via ferrata or a riding time on a ride and a horse
route section on any other type. A migration enables the horse tour type.
2026-10-20 15:11:08 +02:00

114 lines
4.8 KiB
TypeScript

import { routeSections, type RouteEdge } from '../../../src/nest/tours/horse-route-sections';
import { HORSE_ROUTE_SECTION_MAX_POINTS, type HorseAccess } from '@trek/shared';
import { describe, expect, it } from 'vitest';
// HORSE-SECT-001 to HORSE-SECT-007
/** Node n lies n thousandths of a degree north of 47° N, about 111 m apart. */
const coords = (count: number) =>
new Map<number, [number, number]>(Array.from({ length: count }, (_, n) => [n + 1, [47 + n * 0.001, 11]]));
/** The steps of a way along the nodes given, in that order. */
function way(nodes: number[], order: number, extra: { access?: HorseAccess; signed?: 1 | -1 | 0 } = {}): RouteEdge[] {
return nodes.slice(1).map((to, index) => ({
from: nodes[index] as number,
to,
access: extra.access ?? 'unknown',
signed: extra.signed ?? 0,
order,
}));
}
const firstPoints = (sections: ReturnType<typeof routeSections>) =>
sections.map((section) => [section.geometry[0], section.geometry[section.geometry.length - 1]]);
describe('routeSections (HOR-01)', () => {
it('HORSE-SECT-001: ways that meet end to end are one section, with the access of each along it', () => {
const sections = routeSections(
[...way([1, 2, 3], 0, { access: 'designated' }), ...way([3, 4, 5], 1, { access: 'private' })],
coords(5),
);
expect(sections).toHaveLength(1);
expect(sections[0]?.geometry).toEqual([
[47, 11],
[47.001, 11],
[47.002, 11],
[47.003, 11],
[47.004, 11],
]);
expect(sections[0]?.lengthM).toBeCloseTo(445, -1);
expect(sections[0]?.access.map((share) => share.value).sort()).toEqual(['designated', 'private']);
expect(sections[0]?.access.reduce((sum, share) => sum + share.length_m, 0)).toBeCloseTo(445, -1);
expect(sections[0]?.signposted).toBeNull();
});
it('HORSE-SECT-002: a fork ends a section, and each branch is one of its own', () => {
const sections = routeSections([...way([1, 2, 3], 0), ...way([3, 4], 1), ...way([3, 5], 2)], coords(5));
expect(sections).toHaveLength(3);
for (const section of sections) {
const ends = [section.geometry[0], section.geometry[section.geometry.length - 1]];
expect(ends).toContainEqual([47.002, 11]);
}
});
it('HORSE-SECT-003: a gap stays a gap, never joined by a line of its own', () => {
const sections = routeSections([...way([1, 2], 0), ...way([4, 5], 1)], coords(5));
expect(firstPoints(sections)).toEqual([
[
[47, 11],
[47.001, 11],
],
[
[47.003, 11],
[47.004, 11],
],
]);
});
it('HORSE-SECT-004: a ring without a fork is walked once round', () => {
const ring = new Map<number, [number, number]>([
[1, [47, 11]],
[2, [47.001, 11]],
[3, [47.001, 11.001]],
]);
const sections = routeSections(way([1, 2, 3, 1], 0), ring);
expect(sections).toHaveLength(1);
expect(sections[0]?.geometry).toHaveLength(4);
expect(sections[0]?.geometry[0]).toEqual(sections[0]?.geometry[3]);
});
it('HORSE-SECT-005: says the signposted direction only where all of the section is signposted one way', () => {
expect(routeSections(way([1, 2, 3], 0, { signed: 1 }), coords(3))[0]?.signposted).toBe('with');
expect(routeSections(way([1, 2, 3], 0, { signed: -1 }), coords(3))[0]?.signposted).toBe('against');
const mixed = [...way([1, 2], 0, { signed: 1 }), ...way([2, 3], 1)];
expect(routeSections(mixed, coords(3))[0]?.signposted).toBeNull();
// A way listed against the walk still reads the right way round.
const turned = [...way([1, 2], 0, { signed: 1 }), ...way([3, 2], 1, { signed: -1 })];
expect(routeSections(turned, coords(3))[0]?.signposted).toBe('with');
});
it('HORSE-SECT-006: a stretch longer than a fixed leg takes is cut into pieces that meet end to end', () => {
const count = HORSE_ROUTE_SECTION_MAX_POINTS + 10;
const nodes = Array.from({ length: count }, (_, n) => n + 1);
const places = new Map<number, [number, number]>(nodes.map((n) => [n, [47 + n * 0.00001, 11]]));
const sections = routeSections(way(nodes, 0), places);
expect(sections.map((section) => section.geometry.length)).toEqual([HORSE_ROUTE_SECTION_MAX_POINTS, 11]);
expect(sections[1]?.geometry[0]).toEqual(sections[0]?.geometry[HORSE_ROUTE_SECTION_MAX_POINTS - 1]);
});
it('HORSE-SECT-007: sections follow the order the route lists its ways, and one of no length is left out', () => {
const places = new Map<number, [number, number]>([
[1, [47, 11]],
[2, [47.001, 11]],
[3, [47.1, 11]],
[4, [47.101, 11]],
[5, [47.2, 11]],
[6, [47.2, 11]],
]);
const sections = routeSections([...way([3, 4], 0), ...way([1, 2], 1), ...way([5, 6], 2)], places);
expect(sections.map((section) => section.order)).toEqual([0, 1]);
expect(sections[0]?.geometry[0]).toEqual([47.1, 11]);
});
});