feat: add StopsMap and WalkingDirections components for enhanced map functionality

- Implemented StopsMap component to display bus stops on a map using MapLibre GL.
- Integrated walking directions feature in WalkingDirections component to calculate routes from user location to selected bus stop.
- Added geo utility functions for coordinate validation and distance calculation.
- Created route grouping and stop filtering logic in routeGroups module.
- Developed custom hooks for managing stop routes with React Query.
- Added tests for routing and stop functionalities to ensure reliability.
This commit is contained in:
2026-10-08 00:18:10 +03:00
parent 1492f179eb
commit 6fe039cefa
43 changed files with 1518 additions and 59 deletions

58
test/routing.test.mjs Normal file
View File

@@ -0,0 +1,58 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRideBusClient, normalizeRoutes, normalizeRouteStops, parseRequest } from '../server/ridebus.mjs';
import { normalizeWalking } from '../server/walking.mjs';
import { groupRoutes, stopsForRoutes } from '../src/lib/routeGroups.ts';
const geometry = { type: 'LineString', coordinates: [[28.783, 55.486], [28.7855, 55.4865]] };
const foot = { code: 'Ok', routes: [{ distance: 230, duration: 165.4, geometry, legs: [{ private: 'unneeded' }] }] };
test('route and walking parameters require bounded coordinates and fixed identifiers', () => {
assert.deepEqual(parseRequest(new URLSearchParams('resource=routes&cityId=1')), { resource: 'routes', cityId: 1 });
assert.deepEqual(parseRequest(new URLSearchParams('resource=routeStops&cityId=1&routeId=10')), { resource: 'routeStops', cityId: 1, routeId: 10 });
assert.deepEqual(parseRequest(new URLSearchParams('resource=walk&cityId=1&stopId=28&lat=0&lon=0')), { resource: 'walk', cityId: 1, stopId: 28, lat: 0, lon: 0 });
for (const params of ['resource=routeStops&cityId=1&routeId=../10', 'resource=walk&cityId=1&stopId=28&lat=91&lon=28', 'resource=walk&cityId=1&stopId=28&lat=55&lon=-181', 'resource=walk&cityId=1&lat=55&lon=28', 'resource=routes&cityId=-1']) assert.throws(() => parseRequest(new URLSearchParams(params)), error => error.status === 400);
});
test('route numbers group directions but distinct boarding points retain their identities', () => {
const routes = normalizeRoutes([{ id: 10, route_number: '4', name: 'А → Б' }, { id: 11, route_number: '№4', name: 'Б → А' }, { id: 12, route_number: '10', name: 'В → Г' }, { id: 13, route_number: '2', name: 'А → Г', is_active: false }]);
assert.deepEqual(groupRoutes(routes).map(group => [group.key, group.routes.map(route => route.id)]), [['4', [10, 11]], ['10', [12]]]);
const detail = normalizeRouteStops({ id: 10, route_number: '4', name: 'А → Б', stops: [
{ id: 1, name: 'Вокзал', description: 'В центр', sequence: 2, latitude: 55, longitude: 28 },
{ id: 2, name: 'Вокзал', description: 'Обратно', sequence: 1, latitude: 55.001, longitude: 28 },
] });
assert.deepEqual(detail.stops.map(stop => stop.id), [2, 1]);
const filtered = stopsForRoutes([{ ...detail.stops[0], direction: 'Уточнённое направление' }], [detail, detail]);
assert.deepEqual(filtered.map(stop => stop.id), [2, 1]);
assert.equal(filtered[0].direction, 'Уточнённое направление');
assert.equal(filtered[1].latitude, 55);
});
test('walking geometry and metrics are validated without inventing a path on failure', () => {
assert.deepEqual(normalizeWalking(foot), { geometry, distance: 230, duration: 165.4 });
assert.throws(() => normalizeWalking({ code: 'NoRoute' }), error => error.status === 404 && error.code === 'walking_not_found');
for (const route of [{ ...foot.routes[0], distance: null }, { ...foot.routes[0], duration: -1 }, { ...foot.routes[0], geometry: { type: 'LineString', coordinates: [[28, 55], [999, 55]] } }, { ...foot.routes[0], geometry: { type: 'LineString', coordinates: [[28, 55]] } }]) assert.throws(() => normalizeWalking({ code: 'Ok', routes: [route] }), error => error.status === 502);
});
test('walking goes through the verified foot endpoint and never enters origin caches', async () => {
const calls = [];
const client = createRideBusClient({ apiKey: 'private-key', fetchData: async (url, options) => {
calls.push([url, options]);
return { ok: true, json: async () => url.pathname.startsWith('/routing/') ? foot : { id: 28, latitude: 55.4869, longitude: 28.7856 } };
} });
const input = { resource: 'walk', cityId: 1, stopId: 28, lat: 55.486, lon: 28.783 };
await client.get(input); await client.get(input); await client.get({ ...input, lat: 55.487 });
const paths = calls.filter(([url]) => url.pathname.startsWith('/routing/'));
assert.equal(paths.length, 3);
assert.equal(paths[0][0].pathname, '/routing/foot/route/v1/foot/28.783000,55.486000;28.785600,55.486900');
assert.equal(paths[0][0].searchParams.get('geometries'), 'geojson');
assert.equal(paths[0][1].headers['X-City-Id'], '1');
assert.equal(paths[0][1].headers['X-API-Key'], 'private-key');
assert.equal(calls.filter(([url]) => url.pathname.startsWith('/api/v1/stops/')).length, 1);
});
test('a no-segment HTTP response maps to a safe walking error', async () => {
const client = createRideBusClient({ apiKey: 'key', fetchData: async url => url.pathname.startsWith('/routing/')
? { ok: false, status: 400, json: async () => ({ code: 'NoSegment', message: 'internal details' }) }
: { ok: true, json: async () => ({ latitude: 55, longitude: 28 }) } });
await assert.rejects(client.get({ resource: 'walk', cityId: 1, stopId: 28, lat: 55, lon: 28 }), error => error.code === 'walking_not_found' && !error.message.includes('internal'));
});

55
test/stops.test.mjs Normal file
View File

@@ -0,0 +1,55 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { coordinates } from '../server/coordinates.mjs';
import { createRideBusClient, normalizeCities, normalizeStop, normalizeStops, parseRequest } from '../server/ridebus.mjs';
import { distanceMeters, hasCoordinates, walkingLink } from '../src/lib/geo.ts';
test('map coordinates accept numeric strings and preserve missing/invalid pairs as missing', () => {
assert.deepEqual(coordinates('55.517553', 28.776448), { latitude: 55.517553, longitude: 28.776448 });
assert.deepEqual(coordinates(0, 0), { latitude: 0, longitude: 0 });
for (const [lat, lon] of [[null, 28], ['', 28], [55, undefined], [91, 28], [55, -181], [true, 28], ['bad', 28], [NaN, 28]]) {
assert.deepEqual(coordinates(lat, lon), {});
assert.equal(hasCoordinates({ latitude: lat, longitude: lon }), false);
}
assert.equal(hasCoordinates({ latitude: 0, longitude: 0 }), true);
assert.deepEqual(normalizeCities([{ id: 1, name: 'Полоцк', center_lat: '55.4869', center_lon: '28.7856' }])[0].latitude, 55.4869);
});
test('stop catalog retains platform coordinates and detail exposes only active distinct routes', () => {
const raw = { id: 95, name: '11 Июля', latitude: 55.517553, longitude: 28.776448, address: ' улица Ленина ', description: ' на Лозовку ', routes_count: '4' };
assert.equal(normalizeStops([raw])[0].address, 'улица Ленина');
assert.equal(normalizeStops([raw])[0].longitude, 28.776448);
const detail = normalizeStop({ ...raw, secret: 'hidden', routes: [
{ id: 2, route_number: '12', name: 'Центр', is_active: true },
{ id: 1, route_number: '4', name: 'Вокзал' },
{ id: 1, route_number: '4', name: 'Вокзал' },
{ id: 3, route_number: '2', name: 'Больница', is_active: false },
] });
assert.deepEqual(detail.routes.map(route => route.number), ['4', '12']);
assert.equal(detail.routesCount, 2);
assert.equal('secret' in detail, false);
assert.throws(() => normalizeStop({ ...raw, routes: null }), error => error.status === 502);
});
test('stop details validate identifiers, coalesce loads and isolate city caches', async () => {
assert.deepEqual(parseRequest(new URLSearchParams('resource=stop&cityId=1&stopId=95')), { resource: 'stop', cityId: 1, stopId: 95 });
for (const input of ['resource=stop&cityId=1', 'resource=stop&cityId=1&stopId=-1', 'resource=stop&cityId=1&stopId=../1']) assert.throws(() => parseRequest(new URLSearchParams(input)), error => error.status === 400);
const calls = [];
const client = createRideBusClient({ apiKey: 'private-key', fetchData: async (url, options) => {
calls.push([url.pathname, options.headers['X-City-Id']]);
return { ok: true, json: async () => ({ id: 95, name: '11 Июля', routes: [] }) };
} });
const input = { resource: 'stop', cityId: 1, stopId: 95 };
await Promise.all([client.get(input), client.get(input)]); await client.get(input); await client.get({ ...input, cityId: 2 });
assert.deepEqual(calls, [['/api/v1/stops/95', '1'], ['/api/v1/stops/95', '2']]);
});
test('nearby distances use geographic coordinates and walking links use the selected platform', () => {
const a = { latitude: 55.4849, longitude: 28.7685 }, b = { latitude: 55.4852, longitude: 28.7691 };
assert.equal(distanceMeters(a, a), 0);
assert.ok(distanceMeters(a, b) > 40 && distanceMeters(a, b) < 60);
assert.equal(distanceMeters(a, b), distanceMeters(b, a));
const link = new URL(walkingLink(b));
assert.equal(link.searchParams.get('destination'), '55.4852,28.7691');
assert.equal(link.searchParams.get('travelmode'), 'walking');
});