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); });