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:
58
test/routing.test.mjs
Normal file
58
test/routing.test.mjs
Normal 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
55
test/stops.test.mjs
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user