feat: add passenger information and settings components
- 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.
This commit is contained in:
@@ -8,6 +8,8 @@ const source = await readFile(new URL('../src/lib/storage.ts', import.meta.url),
|
||||
// Settings used by the actual board; no external project is required for these tests.
|
||||
const body = source.match(/export const defaultSettings: Settings = ([\s\S]*?);\nexport function/)[1];
|
||||
const defaults = Function(`return (${body})`)();
|
||||
// This fixture's device belongs to city 2; Polotsk tariffs must not leak into it.
|
||||
defaults.fareByType = [];
|
||||
const value = { state: 'paired', enabled: true, adminUrl: '', device: { id: 'test-device', name: 'Табло', cityId: 2, revision: 1, settings: defaults }, config: { configured: true, cityId: 2, cityName: 'Новополоцк', stopName: 'Остановка', defaultStopId: 1, refreshSeconds: 20, timeZone: 'Europe/Minsk' } };
|
||||
test('managed configuration validation rejects damaged, mismatched or unsupported settings', () => {
|
||||
assert.equal(isManagedState(value), true);
|
||||
@@ -18,6 +20,27 @@ test('managed configuration validation rejects damaged, mismatched or unsupporte
|
||||
assert.equal(isManagedState(legacy),true);assert.equal(hydrateManagedState(legacy).device.settings.nightMode,'auto');
|
||||
assert.equal(isManagedState({...value,device:{...value.device,settings:{...defaults,nightWakeMinutes:5}}}),false);
|
||||
});
|
||||
test('managed transliteration settings accept legacy devices and reject malformed flags', () => {
|
||||
const legacy = structuredClone(value); delete legacy.device.settings.journeyTranslitEnabled;
|
||||
assert.equal(isManagedState(legacy), true);
|
||||
assert.equal(hydrateManagedState(legacy).device.settings.journeyTranslitEnabled, false);
|
||||
const enabled = { ...value, device: { ...value.device, settings: { ...defaults, journeyTranslitEnabled: true } } };
|
||||
assert.equal(isManagedState(enabled), true);
|
||||
assert.equal(hydrateManagedState(enabled).device.settings.journeyTranslitEnabled, true);
|
||||
for (const flag of ['true', 1, null]) {
|
||||
assert.equal(isManagedState({ ...value, device: { ...value.device, settings: { ...defaults, journeyTranslitEnabled: flag } } }), false);
|
||||
}
|
||||
});
|
||||
test('managed passenger settings migrate without leaking another city tariff or accepting damaged arrays', () => {
|
||||
const legacy = structuredClone(value);
|
||||
for(const key of ['infoPlacesEnabled','infoFareEnabled','lastTripNoticeEnabled','weatherHintEnabled','places','fareRules','fareByType']) delete legacy.device.settings[key];
|
||||
assert.equal(isManagedState(legacy),true);assert.deepEqual(hydrateManagedState(legacy).device.settings.fareByType,[]);
|
||||
const polotsk={...legacy,device:{...legacy.device,cityId:1},config:{...legacy.config,cityId:1}};
|
||||
assert.equal(hydrateManagedState(polotsk).device.settings.fareByType[0].price,'1,05 BYN'); assert.equal(isManagedState(polotsk),true);
|
||||
for(const patch of [{places:null},{fareRules:[{routeId:1}]},{weatherHintEnabled:'true'},{fareByType:[{cityId:1,type:'bus',price:'1 BYN',payment:'Наличные',checkedAt:'2026-10-11'}]}]) {
|
||||
assert.equal(isManagedState({...value,device:{...value.device,settings:{...defaults,...patch}}}),false);
|
||||
}
|
||||
});
|
||||
test('pairing bridge keeps secrets in HttpOnly cookies, binds data to the individual device and rejects cross-site writes', async t => {
|
||||
const tokenA = 'A'.repeat(43), tokenB = 'B'.repeat(43), calls = [];
|
||||
let revoked = false;
|
||||
|
||||
50
test/fixtures/passenger-server.mjs
vendored
Normal file
50
test/fixtures/passenger-server.mjs
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
// Explicit local UI fixture. No production database, telemetry or transit API.
|
||||
import express from 'express';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createApp } from '../../server/index.mjs';
|
||||
import { createDeviceBridge } from '../../server/device.mjs';
|
||||
import { defaultSettings } from '../../src/lib/storage.ts';
|
||||
import { localDate, timetableDepartures } from '../../src/lib/timetable.mjs';
|
||||
|
||||
let firstType = 'bus', started = Date.now();
|
||||
const stop = { id: 1, name: 'Тестовая остановка', direction: 'Фиктивное расписание' };
|
||||
const targets = [{ id: 901, name: 'Больница', sequence: 1, offsetMinutes: 12 }, { id: 902, name: 'Вокзал', sequence: 2, offsetMinutes: 20 }, { id: 903, name: 'Центр', sequence: 3, offsetMinutes: 28 }];
|
||||
function resources() {
|
||||
const now = Date.now(), start = Math.floor(started / 60000) * 60000;
|
||||
const t = { version: 1, cityId: 1, cityName: 'Полоцк', stop, routes: ['bus', 'minibus'].map((type, i) => {
|
||||
const at = new Date(start + (type === firstType ? 20 : 40) * 60000);
|
||||
const civil = new Date(at.getTime() + 10800000);
|
||||
const seconds = civil.getUTCHours() * 3600 + civil.getUTCMinutes() * 60 + (localDate(at) > localDate(start) ? 86400 : 0);
|
||||
return { id: i + 1, number: type === 'bus' ? '1' : '2тк', name: 'Тестовая остановка — Центр', type,
|
||||
stops: [{ id: 1, name: stop.name, sequence: 0, offsetMinutes: 0 }, ...targets],
|
||||
schedules: ['weekday', 'saturday', 'sunday'].map(dayType => ({ dayType, validFrom: null, validUntil: null,
|
||||
trips: [{ arrivalSeconds: seconds, originStopId: 1, endStopId: 903, originTime: `${String(Math.floor(seconds / 3600) % 24).padStart(2, '0')}:${String(Math.floor(seconds % 3600 / 60)).padStart(2, '0')}`,
|
||||
destination: 'Центр', note: 'Тестовые данные', daysOfWeek: [], exceptDaysOfWeek: [] }] })) };
|
||||
}), calendarYears: [2026, 2027], holidays: [], updatedAt: new Date(now).toISOString(), expiresAt: new Date(now + 86400000).toISOString() };
|
||||
const board = { cityId: 1, cityName: 'Полоцк', stop, routes: t.routes.map(({ stops, schedules, ...route }) => route),
|
||||
departures: timetableDepartures(t, localDate(now)).filter(trip => Date.parse(trip.scheduledAt) + 60000 > now), alerts: [], alertsAvailable: true, partial: false, serviceDate: localDate(now), updatedAt: new Date(now).toISOString(), serverTime: new Date(now).toISOString(), mode: 'live' };
|
||||
const nextHour = Math.ceil(now / 3600000) * 3600000;
|
||||
const weather = { cityId: 1, stopId: 1, updatedAt: new Date(now).toISOString(), observedAt: new Date(now).toISOString(), temperature: 11, apparentTemperature: 10, code: 3, isDay: true, windSpeed: 3,
|
||||
hourly: [0, 1, 2].map(i => ({ at: new Date(nextHour + i * 3600000).toISOString(), temperature: 12 - i, code: i ? 61 : 3, precipitationProbability: 80 - i * 10 })) };
|
||||
return { t, board, weather, settings: { ...defaultSettings, stopId: 1, holdNear: false, nightMode: 'off', places: targets.map(s => ({ stopId: s.id, name: s.name })) } };
|
||||
}
|
||||
const client = { config: () => ({ cityId: 1, cityName: 'Полоцк', stopName: stop.name, defaultStopId: 1, configured: true, refreshSeconds: 20, timeZone: 'Europe/Minsk', passengerBaseUrl: 'https://pwa.ridebus.by/' }),
|
||||
stops: async () => [stop, ...targets.map(s => ({ id: s.id, name: s.name, direction: 'тест' }))], board: async () => resources().board, timetable: async () => resources().t, coordinates: async () => null };
|
||||
const app = express();
|
||||
app.get('/__fixture', (req, res) => {
|
||||
if (['bus', 'minibus'].includes(req.query.first)) { firstType = req.query.first; started = Date.now(); }
|
||||
const payload = JSON.stringify(resources()).replaceAll('<', '\\u003c');
|
||||
res.type('html').send(`<html lang="ru"><body style="font:20px system-ui;padding:32px"><h1>Пассажирские блоки · тестовый стенд</h1><p>Фиктивное расписание и погода. Тарифы — подтверждённые пользователем значения для Полоцка. Рабочая БД не используется.</p><form><button name="first" value="bus">Автобус ближайший</button><button name="first" value="minibus">Маршрутка ближайшая</button></form><p>Ближайший: ${firstType === 'bus' ? 'автобус' : 'маршрутка'}. Важные места здесь только для проверки.</p><button id="seed">Открыть тестовый пример</button><script>
|
||||
const seed = ${payload}; document.querySelector('#seed').onclick = async () => {
|
||||
const entries = [['ridebus-board-snapshot:1:1',seed.board],['ridebus-board-timetable:1:1',seed.t],['ridebus-board-weather:1:1',seed.weather]];
|
||||
localStorage.setItem('ridebus-board-settings-v1', JSON.stringify(seed.settings));
|
||||
for(const [key,value] of entries) localStorage.setItem(key,JSON.stringify(value));
|
||||
try { await new Promise((resolve,reject)=>{const req=indexedDB.open('ridebus-board-v1',1);req.onupgradeneeded=()=>req.result.createObjectStore('snapshots');req.onerror=reject;req.onsuccess=()=>{const db=req.result,tx=db.transaction('snapshots','readwrite');for(const [key,value] of entries)tx.objectStore('snapshots').put(value,key);tx.oncomplete=()=>{db.close();resolve();};tx.onerror=reject;};}); } catch {}
|
||||
location.href='/';
|
||||
};</script></body></html>`);
|
||||
});
|
||||
app.use(createApp(client, { weather: async () => resources().weather }, createDeviceBridge({ enabled: false })));
|
||||
const dist = fileURLToPath(new URL('../../dist/', import.meta.url));
|
||||
app.use(express.static(dist));app.get('/{*path}', (_req,res) => res.sendFile(`${dist}/index.html`));
|
||||
await new Promise(resolve => app.listen(5194, '127.0.0.1', resolve));
|
||||
console.log('Passenger UI fixture http://127.0.0.1:5194/__fixture');
|
||||
@@ -3,14 +3,20 @@ import assert from 'node:assert/strict';
|
||||
import { boardScenes, informationBlocks } from '../src/lib/information.ts';
|
||||
import { defaultSettings } from '../src/lib/storage.ts';
|
||||
|
||||
const off = { ...defaultSettings, infoServiceEnabled: false, infoWeatherEnabled: false, infoQrEnabled: false };
|
||||
const off = { ...defaultSettings, infoServiceEnabled: false, infoWeatherEnabled: false, infoQrEnabled: false, infoPlacesEnabled: false, infoFareEnabled: false };
|
||||
|
||||
test('disabled information blocks are omitted without leaving gaps in the cycle', () => {
|
||||
assert.deepEqual(informationBlocks(defaultSettings).map(block => block.id), ['service', 'weather', 'qr']);
|
||||
assert.deepEqual(informationBlocks(defaultSettings).map(block => block.id), ['service', 'weather', 'qr', 'fare']);
|
||||
assert.deepEqual(informationBlocks({ ...off, infoQrEnabled: true }).map(block => block.id), ['qr']);
|
||||
assert.deepEqual(informationBlocks({ ...defaultSettings, infoWeatherEnabled: false }).map(block => block.id), ['service', 'qr']);
|
||||
assert.deepEqual(informationBlocks({ ...defaultSettings, infoWeatherEnabled: false }).map(block => block.id), ['service', 'qr', 'fare']);
|
||||
assert.deepEqual(informationBlocks(off), []);
|
||||
});
|
||||
test('unconfigured or unavailable passenger cards do not leave empty scenes', () => {
|
||||
assert.deepEqual(informationBlocks(defaultSettings, { places: false, fare: false }).map(b => b.id), ['service', 'weather', 'qr']);
|
||||
assert.deepEqual(boardScenes({ ...off, infoFareEnabled: true }, false, false, { places: false, fare: false }), ['departures']);
|
||||
const configured = { ...defaultSettings, places: [{ stopId: 2, name: 'Вокзал' }] };
|
||||
assert.equal(informationBlocks(configured, { places: true, fare: true }).length, 5);
|
||||
});
|
||||
test('all blocks off keeps departures, retains optional advertising, and still surfaces alerts', () => {
|
||||
assert.deepEqual(boardScenes(off, false, false), ['departures']);
|
||||
assert.deepEqual(boardScenes({ ...off, adsEnabled: true }, false, false), ['departures', 'ad']);
|
||||
|
||||
103
test/passenger-guide.test.mjs
Normal file
103
test/passenger-guide.test.mjs
Normal file
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,29 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readSnapshot, writeSnapshot, readTimetable, writeTimetable, readWeather, writeWeather, readSettings, saveSettings } from '../src/lib/storage.ts';
|
||||
|
||||
test('transliteration is opt-in for old settings and persists as a boolean', () => {
|
||||
const values = new Map([['ridebus-board-settings-v1', '{}']]);
|
||||
globalThis.localStorage = { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, value) };
|
||||
const old = readSettings();
|
||||
assert.equal(old.journeyTranslitEnabled, false);
|
||||
saveSettings({ ...old, journeyTranslitEnabled: true });
|
||||
assert.equal(readSettings().journeyTranslitEnabled, true);
|
||||
saveSettings({ ...readSettings(), journeyTranslitEnabled: false });
|
||||
assert.equal(readSettings().journeyTranslitEnabled, false);
|
||||
values.set('ridebus-board-settings-v1', JSON.stringify({ journeyTranslitEnabled: 'true' }));
|
||||
assert.equal(readSettings().journeyTranslitEnabled, false);
|
||||
});
|
||||
test('passenger preferences migrate with confirmed city-bound fares and preserve operator overrides', () => {
|
||||
const values = new Map(); globalThis.localStorage = { getItem:key=>values.get(key)||null, setItem:(key,value)=>values.set(key,value) };
|
||||
const old = readSettings(); assert.deepEqual(old.places, []); assert.deepEqual(old.fareRules, []);
|
||||
assert.equal(old.fareByType[0].price, '1,05 BYN'); assert.equal(old.fareByType[0].cityId, 1);
|
||||
const edited = { ...old, places: [{stopId:28,name:'Вокзал'}], fareRules: [{routeId:7,price:'Особый тариф',payment:'Подтверждённый способ',checkedAt:'2026-10-11'}],
|
||||
infoPlacesEnabled:false, infoFareEnabled:false, lastTripNoticeEnabled:false, weatherHintEnabled:false, fareByType:[] };
|
||||
saveSettings(edited); assert.deepEqual(readSettings(),edited);
|
||||
values.set('ridebus-board-settings-v1',JSON.stringify({places:[{stopId:-1,name:'Неверно'}],fareByType:null}));
|
||||
assert.deepEqual(readSettings().places,[]);assert.deepEqual(readSettings().fareByType,[]);
|
||||
});
|
||||
|
||||
test('night settings migrate and persist, with invalid values falling back to supported defaults', () => {
|
||||
const values = new Map(); globalThis.localStorage = { getItem:key=>values.get(key)||null, setItem:(key,value)=>values.set(key,value) };
|
||||
const old = readSettings();assert.equal(old.nightMode,'auto');assert.equal(old.nightWakeMinutes,60);
|
||||
|
||||
20
test/transliteration.test.mjs
Normal file
20
test/transliteration.test.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { transliterateStopName } from '../src/lib/transliteration.ts';
|
||||
|
||||
test('stop names use readable Latin spelling without translating or losing punctuation', () => {
|
||||
for (const [name, latin] of [
|
||||
['Пушкинская', 'Pushkinskaya'],
|
||||
['Швейная фабрика', 'Shveynaya fabrika'],
|
||||
['Больница (Ксты)', 'Bolnitsa (Ksty)'],
|
||||
['Педагогический колледж', 'Pedagogicheskiy kolledzh'],
|
||||
['Боровуха-3 · Школа №9', 'Borovukha-3 · Shkola №9'],
|
||||
['Ёлочная / Южная / Щорса', 'Yolochnaya / Yuzhnaya / Shchorsa'],
|
||||
['КСТЫ — ТЦ «ЕВРООПТ»', 'KSTY — TTS «EVROOPT»'],
|
||||
['Вуліца Янкі Купалы', 'Vulitsa Yanki Kupaly'],
|
||||
['Уручча · Зялёны Луг', 'Uruchcha · Zyalyony Lug'],
|
||||
['Мастоў · імя Янкі Купалы', 'Mastou · imya Yanki Kupaly'],
|
||||
['RideBus PWA (2026)', 'RideBus PWA (2026)'],
|
||||
['', ''],
|
||||
]) assert.equal(transliterateStopName(name), latin, name);
|
||||
});
|
||||
Reference in New Issue
Block a user