- Introduced ServiceWindow component to display the last departure today and the first tomorrow. - Implemented cancellation notice parsing to handle route cancellations effectively. - Enhanced departure display logic to include cancellation alerts and status updates. - Created information blocks for displaying service information, weather, and QR codes. - Added tests for departure features, information handling, and service window functionality. - Updated fixtures for testing various scenarios including cancellations and long wait times.
135 lines
8.2 KiB
JavaScript
135 lines
8.2 KiB
JavaScript
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('malformed cancellation targets in a saved snapshot are rejected instead of cancelling unrelated trips', () => {
|
||
const target = { routeNumber: '5', stopId: 1, serviceDate: '2026-10-08', time: '12:35' };
|
||
const alert = { id: '1', routeIds: [37], routeNumbers: ['5'], cancellations: [target] };
|
||
const snapshot = { ...makeSnapshot(), alerts: [alert] };
|
||
assert.equal(isBoardData(snapshot, 1, 1), true);
|
||
for (const damaged of [null, { ...target, stopId: -1 }, { ...target, serviceDate: '2026-02-31' }, { ...target, time: '25:00' }])
|
||
assert.equal(isBoardData({ ...snapshot, alerts: [{ ...alert, cancellations: [damaged] }] }, 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);
|
||
});
|