import test from 'node:test'; import assert from 'node:assert/strict'; import { normalizeAlerts, normalizeDepartures } from '../server/board.mjs'; import { parseCancellationNotice } from '../src/lib/cancellations.mjs'; import { displayDepartures, firstAvailable, nextSameRoute, longWait, readability } from '../src/lib/departures.ts'; import { defaultSettings } from '../src/lib/storage.ts'; const now = Date.parse('2026-10-09T12:00:00+03:00'); const stops = [{ id: 1, name: 'Швейная фабрика' }, { id: 2, name: 'Мариненко' }]; const routes = [{ id: 6, number: '6' }, { id: 1, number: '1' }]; const departure = (id, routeId, time, extra = {}) => ({ id, routeId, routeNumber: String(routeId), destination: 'Автовокзал', scheduledAt: `2026-10-09T${time}:00+03:00`, scheduledTime: time, ...extra }); const raw = { id: 1, city_id: 1, route_id: 6, alert_type: 'cancellation', message: '09.10.2026: не выполняются рейсы:\nШвейная фабрика — 12:04', start_time: '2026-10-09T00:00:00+03:00', end_time: '2026-10-10T00:00:00+03:00' }; const alerts = normalizeAlerts([raw], routes, 1, now, stops); test('reviewed cancellation format binds date, route, exact stop and clock; generic prose never cancels every trip', () => { assert.deepEqual(alerts[0].cancellations, [{ routeNumber: '6', stopId: 1, serviceDate: '2026-10-09', time: '12:04' }]); const result = displayDepartures([departure('a', 6, '12:04'), departure('b', 1, '12:08'), departure('c', 6, '12:12')], alerts, 1, now, true); assert.deepEqual(result.map(item => item.status), ['cancelled', 'scheduled', 'notice']); assert.equal(firstAvailable(result).id, 'b'); const generic = normalizeAlerts([{ ...raw, message: 'Один из рейсов отменён, уточните информацию.' }], routes, 1, now, stops); assert.equal(displayDepartures([departure('a', 6, '12:04')], generic, 1, now, true)[0].status, 'notice'); }); test('multi-route notices keep each route and stop separate; ambiguous platform names and malformed notices do not match', () => { const message = '09.10.2026: не выполняются рейсы:\nМаршрут №6:\nШвейная фабрика — 12:04, 13:05\n\nМаршрут №1:\nМариненко — 12:10'; const targets = parseCancellationNotice(message, ['6', '1'], stops); assert.equal(targets.length, 3); assert.equal(targets[2].routeNumber, '1'); assert.equal(targets[2].stopId, 2); assert.deepEqual(parseCancellationNotice(message, ['6'], stops), []); assert.deepEqual(parseCancellationNotice(raw.message, ['6'], [...stops, { id: 3, name: 'Швейная фабрика' }]), []); assert.deepEqual(parseCancellationNotice(raw.message.replace('12:04', '25:04'), ['6'], stops), []); assert.deepEqual(parseCancellationNotice(raw.message.replace('09.10.2026', '31.02.2026'), ['6'], stops), []); }); test('matching an origin cancellation reaches downstream stops and retains the previous service date after midnight', () => { const trip = departure('a', 6, '12:14', { originStopId: 1, originTime: '12:04', serviceDate: '2026-10-09' }); assert.equal(displayDepartures([trip], alerts, 2, now, true)[0].status, 'cancelled'); const overnight = departure('b', 6, '00:14', { scheduledAt: '2026-10-10T00:14:00+03:00', originStopId: 1, originTime: '23:54', serviceDate: '2026-10-09' }); const overnightAlerts = normalizeAlerts([{ ...raw, message: raw.message.replace('12:04', '23:54') }], routes, 1, now, stops); assert.equal(displayDepartures([overnight], overnightAlerts, 2, now, true)[0].status, 'cancelled'); assert.equal(displayDepartures([{ ...overnight, serviceDate: '2026-10-10' }], overnightAlerts, 2, now, true)[0].status, 'notice'); }); test('expired, not-yet-active or stale cancellation data cannot remove a departure; ambiguous simultaneous trips remain available', () => { const trips = [departure('a', 6, '12:04')]; assert.equal(displayDepartures(trips, alerts, 1, now, false)[0].status, 'scheduled'); assert.equal(displayDepartures(trips, [{ ...alerts[0], endAt: new Date(now).toISOString() }], 1, now, true)[0].status, 'scheduled'); assert.equal(displayDepartures(trips, [{ ...alerts[0], startAt: new Date(now + 1000).toISOString() }], 1, now, true)[0].status, 'scheduled'); const ambiguous = displayDepartures([...trips, departure('other-direction', 66, '12:04', { routeNumber: '6', destination: 'Калинина' })], [{ ...alerts[0], routeNumbers: ['6'] }], 1, now, true); assert.deepEqual(ambiguous.map(item => item.status), ['notice', 'notice']); assert.equal(firstAvailable([{ ...trips[0], status: 'cancelled' }]), undefined); }); test('next departure uses the same route and destination, excludes duplicates and cancellations, and crosses midnight', () => { const first = { ...departure('a', 6, '23:54'), status: 'scheduled' }; const next = { ...departure('d', 6, '00:40', { scheduledAt: '2026-10-10T00:40:00+03:00' }), status: 'scheduled' }; const list = [first, { ...first, id: 'same-clock' }, { ...first, id: 'cancelled', scheduledAt: '2026-10-10T00:10:00+03:00', status: 'cancelled' }, { ...first, id: 'opposite', scheduledAt: '2026-10-10T00:20:00+03:00', destination: 'Калинина' }, next]; assert.equal(nextSameRoute(list, first), next); assert.equal(nextSameRoute([first], first), undefined); assert.equal(nextSameRoute(list, undefined), undefined); }); test('long wait starts at 90 minutes, can be disabled, and turns off as an arrival approaches', () => { const trip = departure('a', 6, '13:30'); assert.equal(longWait(defaultSettings, trip, now), true); assert.equal(longWait(defaultSettings, trip, now + 1), false); assert.equal(longWait(defaultSettings, trip, now + 60000), false); assert.equal(longWait({ ...defaultSettings, longWaitEnabled: false }, trip, now), false); assert.equal(longWait(defaultSettings, undefined, now), false); }); test('viewing distance increases text and minimum row height, while street animation is calmer', () => { const near = readability(defaultSettings), far = readability({ ...defaultSettings, viewingDistance: 'far' }); const street = readability({ ...defaultSettings, displayProfile: 'street', viewingDistance: 'far' }); assert.equal(near.scale, 1); assert.equal(near.rowHeight, 62); assert.ok(far.scale > near.scale && far.rowHeight > near.rowHeight); assert.ok(street.scale > far.scale && street.rowHeight > far.rowHeight); assert.equal(street.calm, true); }); test('short live response preserves origin identity and normalizes one-digit hours for exact matching', () => { const result = normalizeDepartures({ routes: [{ id: 6, route_number: '6', name: 'А — Б', arrivals: [{ scheduledTime: '9:04', trip: { departureTime: '8:54', startStopId: 1 } }] }] }, Date.parse('2026-10-09T09:00:00+03:00')); assert.equal(result.departures[0].scheduledTime, '09:04'); assert.equal(result.departures[0].originTime, '08:54'); assert.equal(result.departures[0].originStopId, 1); assert.equal(result.departures[0].serviceDate, '2026-10-09'); });