Files
ridebus-board/test/board.test.mjs
2026-10-08 22:56:41 +03:00

127 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { ApiError, createBoardClient, normalizeAlerts, normalizeDepartures, scheduledInstant } from '../server/board.mjs';
import { activeAlerts, isBoardData, isFresh, minutesUntil, shouldHoldScene, upcomingDepartures } from '../src/lib/board.mjs';
const now = Date.parse('2026-10-08T12:30:40+03:00');
const routes = [{ id: 37, number: '5', name: 'Полоцк — Новополоцк', type: 'bus' }];
const raw = { routes: [{ id: 37, route_number: '5', name: 'Полоцк — Новополоцк', arrivals: [
{ scheduledTime: '12:35:00', estimatedTime: '12:40:00', delay: 5, minutesUntil: 10, trip: { note: 'Через Экимань' } },
{ scheduledTime: '12:25:00', estimatedTime: '12:40:00', delay: 15, minutesUntil: 10 },
] }] };
const makeSnapshot = () => ({ cityId: 1, stop: { id: 1 }, mode: 'live', serviceDate: '2026-10-08', updatedAt: new Date(now).toISOString(),
...normalizeDepartures(raw, now), alerts: [] });
test('schedule display uses exact Minsk wall-clock time, excluding historical delay', () => {
const result = normalizeDepartures(raw, now);
assert.equal(result.departures.length, 1);
assert.equal(result.departures[0].scheduledAt, '2026-10-08T12:35:00+03:00');
assert.equal(result.departures[0].destination, 'Новополоцк');
assert.equal(result.departures[0].note, 'Через Экимань');
assert.equal(minutesUntil(result.departures[0], now), 5);
});
test('invalid clock values do not create false arrivals', () => {
assert.equal(scheduledInstant('25:01', now), null);
assert.equal(scheduledInstant('12:99', now), null);
assert.equal(scheduledInstant(null, now), null);
assert.equal(scheduledInstant('9:01', now), '2026-10-08T09:01:00+03:00');
});
test('partial route failures preserve working routes and label incomplete data', () => {
const result = normalizeDepartures({ routes: [...raw.routes, { id: 3, route_number: '6', name: 'А — Б', error: 'Failed' }] }, now);
assert.equal(result.partial, true);
assert.equal(result.departures.length, 1);
});
test('malformed ETA response is not reported as an empty timetable', () => {
assert.throws(() => normalizeDepartures({}, now), ApiError);
});
test('partial trips use the actual terminal stop when available', () => {
const result = normalizeDepartures({ routes: [{ ...raw.routes[0], arrivals: [{ scheduledTime: '12:35', trip: { endStopId: 99 } }] }] }, now, new Map([[99, 'Автовокзал']]));
assert.equal(result.departures[0].destination, 'Автовокзал');
});
test('alerts match arrays before legacy scalar fields; unrelated and foreign-city alerts are excluded', () => {
const alerts = [
{ id: 1, route_id: null, route_ids: [37], route_numbers: ['5', '27'], title: 'Изменения', message: 'Полный текст', city_id: 1 },
{ id: 2, route_id: null, route_ids: [88], route_numbers: ['88'], city_id: 1 },
{ id: 3, route_id: 37, city_id: 2 },
{ id: 4, route_id: 37, city_id: 1 },
{ id: 5, route_id: null, route_numbers: [], city_id: 1 },
];
const result = normalizeAlerts(alerts, routes, 1, now);
assert.deepEqual(result.map(alert => alert.id), ['1', '4', '5']);
assert.deepEqual(result[0].routeNumbers, ['5', '27']);
assert.equal(result[0].message, 'Полный текст');
});
test('expired, future and duplicate warnings never rotate onto the display', () => {
const result = normalizeAlerts([
{ id: 1, end_time: '2026-10-08T12:00:00+03:00' },
{ id: 2, start_time: '2026-10-08T13:00:00+03:00' },
{ id: 3, title: 'Актуальное' }, { id: 3, title: 'Актуальное' },
{ id: 4, start_time: 'invalid' },
], routes, 1, now);
assert.deepEqual(result.map(alert => alert.id), ['3']);
assert.equal(activeAlerts([{ ...result[0], endAt: new Date(now + 1000).toISOString() }], now + 1001).length, 0);
});
test('cached arrivals expire instead of counting down forever or rolling into tomorrow', () => {
const snapshot = makeSnapshot();
assert.equal(upcomingDepartures(snapshot, now).length, 1);
assert.equal(upcomingDepartures(snapshot, Date.parse('2026-10-08T12:36:00+03:00')).length, 0);
assert.equal(upcomingDepartures(snapshot, Date.parse('2026-10-09T00:01:00+03:00')).length, 0);
});
test('offline and old snapshots never report fresh data', () => {
const data = makeSnapshot();
assert.equal(isFresh(data, now, false), true);
assert.equal(isFresh(data, now, true), false);
assert.equal(isFresh(data, now + 90001, false), false);
});
test('number-only warnings respect the selected route while citywide warnings remain visible', () => {
const common = { startAt: null, endAt: null, routeIds: [] };
const alerts = [{ ...common, id: '1', routeNumbers: ['5'] }, { ...common, id: '2', routeNumbers: ['88'] }, { ...common, id: '3', routeNumbers: [] }];
assert.deepEqual(activeAlerts(alerts, now, '37', routes).map(alert => alert.id), ['1', '3']);
});
test('city and platform identity are checked before restoring a snapshot', () => {
const data = makeSnapshot();
assert.equal(isBoardData(data, 1, 1), true);
assert.equal(isBoardData(data, 2, 1), false);
assert.equal(isBoardData(data, 1, 2), false);
assert.equal(isBoardData({ ...data, mode: 'demo' }, 1, 1), false);
});
test('route filtering and transport priority use absolute arrival time', () => {
const data = makeSnapshot();
assert.equal(upcomingDepartures(data, now, '88').length, 0);
assert.equal(shouldHoldScene({ holdNear: true }, data.departures[0], now), false);
assert.equal(shouldHoldScene({ holdNear: true }, data.departures[0], now + 180000), true);
assert.equal(shouldHoldScene({ holdNear: false }, data.departures[0], now + 180000), false);
});
function fakeClient(options = {}) {
const calls = [];
const data = { 'api/v1/stops': [{ id: 1, name: 'Швейная фабрика', description: 'основная', city_id: 1 }],
'api/v1/stops/1': { id: 1, name: 'Швейная фабрика', city_id: 1, routes: [{ id: 37, route_number: '5', name: 'Полоцк — Новополоцк' }] },
'api/v1/eta/stop/1': raw, 'api/v1/alerts/active': [] };
const client = createBoardClient({ apiKey: 'server-only-test-key', baseUrl: 'https://api.test/', now: () => now,
fetchData: async (url, init) => {
calls.push({ path: url.pathname, headers: init.headers });
if (options.alertsFail && url.pathname.endsWith('/alerts/active')) return new Response('{}', { status: 500 });
return new Response(JSON.stringify(data[url.pathname.slice(1)]), { headers: { 'Content-Type': 'application/json' } });
} });
return { client, calls };
}
test('concurrent board requests are coalesced; secrets stay server-side and city is passed upstream', async () => {
const { client, calls } = fakeClient();
const [first, second] = await Promise.all([client.board(1), client.board(1)]);
assert.deepEqual(first, second);
assert.equal(calls.filter(call => call.path === '/api/v1/eta/stop/1').length, 1);
assert.ok(calls.every(call => call.headers['X-City-Id'] === '1'));
assert.ok(!JSON.stringify(first).includes('server-only-test-key'));
});
test('alert API failure does not remove arrivals or claim no warnings', async () => {
const { client } = fakeClient({ alertsFail: true });
const data = await client.board(1);
assert.equal(data.departures.length, 1);
assert.equal(data.alertsAvailable, false);
});
test('missing configuration and unknown stops produce explicit errors', async () => {
await assert.rejects(createBoardClient({ apiKey: '' }).stops(), error => error.status === 503 && error.code === 'api_not_configured');
await assert.rejects(fakeClient().client.board(999), error => error.status === 404);
});