feat: add service window component and related functionality

- 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.
This commit is contained in:
2026-10-09 23:55:47 +03:00
parent 046e3c6a2f
commit e3074555a7
44 changed files with 601 additions and 62 deletions

View File

@@ -0,0 +1,77 @@
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');
});