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:
2026-10-12 20:30:49 +03:00
parent 823ab14367
commit e239de45dd
34 changed files with 687 additions and 68 deletions

View File

@@ -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
View 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');

View File

@@ -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']);

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

View File

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

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