- Introduced `PassengerInfo.tsx` for displaying trip details, including upcoming trips and fare information. - Added `PassengerSettings.tsx` for managing user-defined important places and fare settings. - Implemented validation functions for passenger settings in `passenger-settings.mjs`. - Created utility functions for transliterating stop names in `transliteration.ts`. - Developed weather hint functionality in `weather-hint.ts` to provide precipitation probabilities. - Established a mock server for testing passenger-related features in `passenger-server.mjs`. - Added comprehensive tests for passenger guide logic and transliteration functionality.
104 lines
9.0 KiB
JavaScript
104 lines
9.0 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { passengerGuide } from '../src/lib/passenger-guide.ts';
|
|
import { precipitationHint } from '../src/lib/weather-hint.ts';
|
|
import { defaultSettings } from '../src/lib/storage.ts';
|
|
import { validPlaces, validFareRules, validFareByType } from '../src/lib/passenger-settings.mjs';
|
|
|
|
const instant = text => Date.parse(`2026-10-11T${text}:00+03:00`);
|
|
const stops = [1, 2, 3, 4, 5].map((id, i) => ({ id, name: `Остановка ${id}`, sequence: i, offsetMinutes: [0, 5, 20, 35, 45][i] }));
|
|
const trip = (time, endStopId = 5, originStopId = 1) => ({ arrivalSeconds: time, originStopId, endStopId, originTime: '12:00', destination: `Конечная ${endStopId}`, note: '', daysOfWeek: [], exceptDaysOfWeek: [] });
|
|
function bundle(now, schedules = { sunday: [trip(12 * 3600 + 5 * 60, 3), trip(12 * 3600 + 10 * 60)] }) {
|
|
return { version: 1, cityId: 1, cityName: 'Полоцк', stop: { id: 2, name: 'Текущая', direction: '' }, routes: [{ id: 7, number: '7', name: 'Маршрут', type: 'bus', stops,
|
|
schedules: Object.entries(schedules).map(([dayType, trips]) => ({ dayType, trips, validFrom: null, validUntil: null })) }], holidays: [], calendarYears: [2026, 2027],
|
|
updatedAt: new Date(now - 60000).toISOString(), expiresAt: new Date(now + 7 * 86400000).toISOString() };
|
|
}
|
|
const prefs = { ...defaultSettings, stopId: 2, places: [{ stopId: 3, name: 'Больница' }, { stopId: 4, name: 'Вокзал' }] };
|
|
const data = (now, alerts = []) => ({ cityId: 1, stop: { id: 2, name: 'Текущая' }, routes: [{ id: 7, number: '7' }], mode: 'live', serviceDate: '2026-10-11', updatedAt: new Date(now).toISOString(), departures: [], alerts, alertsAvailable: true });
|
|
const cancellation = (date, time) => ({ id: 'cancel', type: 'cancellation', routeIds: [7], routeNumbers: ['7'], title: 'Отмена', message: '', startAt: null, endAt: null,
|
|
cancellations: [{ routeNumber: '7', stopId: 2, serviceDate: date, time }] });
|
|
|
|
test('important places choose the next direct trip that actually reaches the target, including shortened trips', () => {
|
|
const now = instant('12:00'), guide = passengerGuide(prefs, bundle(now), data(now), now, 1, true);
|
|
assert.equal(guide.places[0].departure.scheduledTime, '12:05');
|
|
assert.equal(guide.places[0].minutes, 15); assert.equal(guide.places[0].arrivalAt, '2026-10-11T09:20:00.000Z');
|
|
assert.equal(guide.places[1].departure.scheduledTime, '12:10'); assert.equal(guide.places[1].minutes, 30);
|
|
assert.equal(guide.places[1].arrivalAt, '2026-10-11T09:40:00.000Z');
|
|
});
|
|
test('confirmed cancellations are excluded and route filters, reverse paths and missing offsets remain honest', () => {
|
|
const now = instant('12:00'), b = bundle(now);
|
|
assert.equal(passengerGuide(prefs, b, data(now, [cancellation('2026-10-11', '12:05')]), now, 1, true).places[0].departure.scheduledTime, '12:10');
|
|
assert.equal(passengerGuide({ ...prefs, routeFilter: '999' }, b, data(now), now, 1, true).places[0].departure, null);
|
|
const reverse = bundle(now, { sunday: [trip(12 * 3600 + 15 * 60, 1, 5)] });
|
|
const reversed = passengerGuide({ ...prefs, places: [{ stopId: 1, name: 'Начало' }, { stopId: 3, name: 'Позади' }] }, reverse, data(now), now, 1, true);
|
|
assert.equal(reversed.places[0].minutes, 5); assert.equal(reversed.places[1].departure, null);
|
|
const missing = structuredClone(b); missing.routes[0].stops[2].offsetMinutes = null;
|
|
const unknown = passengerGuide(prefs, missing, data(now), now, 1, true).places[0];
|
|
assert.equal(unknown.departure.scheduledTime, '12:05'); assert.equal(unknown.minutes, null); assert.equal(unknown.arrivalAt, null);
|
|
});
|
|
test('old or foreign caches do not produce place guesses, and type fares never cross city boundaries', () => {
|
|
const now = instant('12:00'), b = bundle(now);
|
|
const stale = { ...b, expiresAt: new Date(now - 1).toISOString() };
|
|
assert.deepEqual(passengerGuide(prefs, stale, data(now), now, 1, true).places, []);
|
|
assert.deepEqual(passengerGuide(prefs, { ...b, cityId: 2 }, data(now), now, 1, true).places, []);
|
|
const legacy = structuredClone(b); delete legacy.routes[0].stops;
|
|
assert.deepEqual(passengerGuide(prefs, legacy, data(now), now, 1, true).places, []);
|
|
assert.equal(passengerGuide(prefs, b, data(now), now, 2, true).fare, null);
|
|
assert.equal(passengerGuide({ ...prefs, infoPlacesEnabled: false }, b, data(now), now, 1, true).places.length, 0);
|
|
});
|
|
test('arrival at an important place keeps its actual date when the trip crosses midnight', () => {
|
|
const now = instant('23:40'), b = bundle(now, { sunday: [trip(23 * 3600 + 55 * 60)] });
|
|
const place = passengerGuide(prefs, b, data(now), now, 1, true).places[0];
|
|
assert.equal(place.departure.scheduledTime, '23:55'); assert.equal(place.arrivalAt, '2026-10-11T21:10:00.000Z');
|
|
assert.equal(localDay(place.arrivalAt), '2026-10-12');
|
|
});
|
|
function localDay(iso) { return new Date(Date.parse(iso) + 10800000).toISOString().slice(0, 10); }
|
|
test('fares follow the nearest available transport type and a dated route override takes priority', () => {
|
|
const now = instant('12:00'), b = bundle(now);
|
|
assert.equal(passengerGuide(prefs, b, data(now), now, 1, true).fare.price, '1,05 BYN');
|
|
const mini = structuredClone(b); mini.routes[0].type = 'minibus';
|
|
assert.equal(passengerGuide(prefs, mini, data(now), now, 1, true).fare.price, '2,10 BYN');
|
|
const override = { routeId: 7, price: 'Особый тариф', payment: 'Подтверждённый способ', checkedAt: '2026-10-11' };
|
|
assert.equal(passengerGuide({ ...prefs, fareRules: [override] }, mini, data(now), now, 1, true).fare.price, 'Особый тариф');
|
|
assert.equal(passengerGuide({ ...prefs, fareRules: [{ ...override, checkedAt: '2026-10-12' }] }, b, data(now), now, 1, true).fare.price, '1,05 BYN');
|
|
assert.equal(passengerGuide({ ...prefs, infoFareEnabled: false }, b, data(now), now, 1, true).fare, null);
|
|
assert.equal(passengerGuide({ ...prefs, fareByType: [], fareRules: [] }, b, data(now), now, 1, true).fare, null);
|
|
});
|
|
test('last-trip notice recognizes an overnight closing gap but ignores a daytime gap and the opposite direction', () => {
|
|
const now = Date.parse('2026-10-10T23:10:00+03:00');
|
|
const b = bundle(now, { saturday: [trip(23 * 3600 + 20 * 60), trip(23 * 3600 + 30 * 60, 1, 5)], sunday: [trip(5 * 3600 + 15 * 60)] });
|
|
const guide = passengerGuide(prefs, b, null, now, 1, false);
|
|
assert.ok(guide.lastTripId); assert.ok(guide.lastTripId.includes('2026-10-10'));
|
|
assert.equal(passengerGuide({ ...prefs, lastTripNoticeEnabled: false }, b, null, now, 1, false).lastTripId, null);
|
|
assert.equal(passengerGuide(prefs, b, null, now - 21 * 60000, 1, false).lastTripId, null);
|
|
const day = instant('12:00');
|
|
assert.equal(passengerGuide(prefs, bundle(day, { sunday: [trip(12 * 3600 + 5 * 60), trip(15 * 3600 + 30 * 60)] }), null, day, 1, false).lastTripId, null);
|
|
});
|
|
test('closing notice handles both service-day and calendar-day after-midnight tails, without closing continuous overnight service', () => {
|
|
const now = instant('00:00');
|
|
for (const afterMidnight of [true, false]) {
|
|
const b = bundle(now, { saturday: [trip(23 * 3600 + 20 * 60), ...(afterMidnight ? [trip(86400 + 15 * 60)] : [])],
|
|
sunday: [...(!afterMidnight ? [trip(15 * 60)] : []), trip(5 * 3600 + 15 * 60)] });
|
|
assert.ok(passengerGuide(prefs, b, null, now, 1, false).lastTripId, String(afterMidnight));
|
|
}
|
|
const continuous = bundle(now, { saturday: [trip(23 * 3600 + 20 * 60)], sunday: [trip(15 * 60), trip(2 * 3600), trip(4 * 3600), trip(5 * 3600 + 15 * 60)] });
|
|
assert.equal(passengerGuide(prefs, continuous, null, now, 1, false).lastTripId, null);
|
|
});
|
|
test('weather hint preserves zero probability and suppresses expired or missing near-term forecast', () => {
|
|
const now = instant('12:10'), weather = { cityId: 1, stopId: 2, updatedAt: new Date(now).toISOString(), observedAt: new Date(now).toISOString(), temperature: 11, apparentTemperature: 10,
|
|
code: 3, isDay: true, windSpeed: 3, hourly: [{ at: '2026-10-11T10:00:00.000Z', temperature: 11, code: 3, precipitationProbability: 0 }] };
|
|
assert.equal(precipitationHint(weather, now), 0);
|
|
assert.equal(precipitationHint({ ...weather, hourly: [{ ...weather.hourly[0], precipitationProbability: 80 }] }, now), 80);
|
|
assert.equal(precipitationHint({ ...weather, updatedAt: new Date(now - 4 * 3600000).toISOString() }, now), null);
|
|
assert.equal(precipitationHint({ ...weather, hourly: [] }, now), null);
|
|
assert.equal(precipitationHint(weather, now - 2 * 3600000), null);
|
|
});
|
|
test('passenger settings reject duplicate targets, malformed dates and unbounded or unknown tariff fields', () => {
|
|
assert.equal(validPlaces(prefs.places), true); assert.equal(validFareByType(prefs.fareByType), true);
|
|
assert.equal(validPlaces([prefs.places[0], prefs.places[0]]), false);
|
|
assert.equal(validPlaces([{ stopId: 3, name: ' ' }]), false);
|
|
for (const patch of [{ checkedAt: '2026-02-30' }, { payment: '' }, { price: 'x'.repeat(41) }, { secret: 'bad' }]) {
|
|
assert.equal(validFareRules([{ routeId: 7, price: '1 BYN', payment: 'Наличные', checkedAt: '2026-10-11', ...patch }]), false);
|
|
}
|
|
});
|