import test from 'node:test'; import assert from 'node:assert/strict'; import { loadTimetable } from '../server/timetable.mjs'; import { isTimetable, timetableDepartures } from '../src/lib/timetable.mjs'; import { onwardJourney } from '../src/lib/journey.ts'; import { displayDepartures, firstAvailable } from '../src/lib/departures.ts'; import { readTimetable, writeTimetable } from '../src/lib/storage.ts'; const now = Date.parse('2026-10-08T11:00:00+03:00'); const route = { id: 37, route_number: '5', name: 'А — З', type: 'bus' }; const stops = ['А', 'Б', 'В', 'Г', 'Д', 'Е', 'Ж', 'З'].map((name, i) => ({ stop_id: i + 1, name, sequence: i + 1, time_offset_minutes: i * 3 })); async function bundle(trips = [{ departureTime: '12:00' }], rawStops = stops) { return loadTimetable({ cityId: 1, cityName: 'Полоцк', stop: { id: 2, name: 'Б', routes: [route] }, now, upstream: async path => path.includes('holidays') ? [] : path.endsWith('/stops') ? rawStops : [{ route_id: 37, day_type: 'weekday', trips }] }); } const first = data => timetableDepartures(data, '2026-10-08')[0]; const journey = (data, departure = first(data), clock = now, cityId = 1, stopId = 2) => onwardJourney(data, departure, clock, cityId, stopId); test('full timetable caches ordered route stops once and shows four downstream stops with time since boarding', async () => { const data = await bundle(undefined, [...stops].reverse()); assert.equal(isTimetable(data, 1, 2), true); assert.deepEqual(data.routes[0].stops.map(stop => stop.id), [1,2,3,4,5,6,7,8]); assert.equal(first(data).endStopId, 8); assert.deepEqual(journey(data), { stops: [{id:3,name:'В',minutes:3},{id:4,name:'Г',minutes:6},{id:5,name:'Д',minutes:9},{id:6,name:'Е',minutes:12}], more:2 }); }); test('partial journeys end at their actual terminal, and a terminal stop does not imply a return service', async () => { const partial = await bundle([{departureTime:'12:00',startStopId:1,endStopId:3}]); assert.deepEqual(journey(partial), {stops:[{id:3,name:'В',minutes:3}],more:0}); const terminal = await bundle([{departureTime:'12:00',startStopId:1,endStopId:2}]); assert.deepEqual(journey(terminal), {stops:[],more:0}); }); test('reverse and shortened reverse trips show decreasing stop sequence with positive duration', async () => { const data = await bundle([{departureTime:'12:00',startStopId:8,endStopId:1}]); assert.equal(first(data).destination, 'А'); assert.deepEqual(journey(data), {stops:[{id:1,name:'А',minutes:3}],more:0}); }); test('after-midnight trips retain service-day and terminal identity for the diagram', async () => { const data = await bundle([{departureTime:'23:59'}]); const departure = timetableDepartures(data, '2026-10-09')[0]; assert.equal(departure.scheduledTime, '00:02');assert.equal(departure.serviceDate,'2026-10-08'); assert.equal(journey(data,departure,Date.parse('2026-10-09T00:00:00+03:00')).stops[0].name,'В'); }); test('missing and nonmonotonic offsets preserve names without inventing zero or negative travel times', async () => { for (const changed of [null, -1, 'not-a-number', 1]) { const data = await bundle(undefined, stops.map(stop => stop.stop_id === 4 ? {...stop,time_offset_minutes:changed} : stop)); assert.equal(isTimetable(data,1,2),true); assert.deepEqual(journey(data).stops.map(stop => stop.minutes), [null,null,null,null]); } }); test('legacy caches remain valid; unknown endpoints, ambiguous stop identity, stale and foreign bundles hide the diagram', async () => { const data = await bundle(); const legacy = structuredClone(data);delete legacy.routes[0].stops;delete legacy.routes[0].schedules[0].trips[0].endStopId; assert.equal(isTimetable(legacy,1,2),true);assert.equal(journey(legacy),null); assert.equal(journey(data,{...first(data),endStopId:999}),null); assert.equal(journey(data,{...first(data),originStopId:6,endStopId:8}),null); assert.equal(journey(data,first(data),now,2,2),null); assert.equal(journey(data,first(data),now,1,3),null); assert.equal(journey(data,first(data),now+7*86400000),null); const ambiguous = await bundle(undefined, [...stops, {...stops[0],sequence:9}]); assert.deepEqual(ambiguous.routes[0].stops, []);assert.equal(journey(ambiguous),null); assert.equal(isTimetable({...data,routes:[{...data.routes[0],stops:[...data.routes[0].stops,data.routes[0].stops[0]]}]},1,2),false); }); test('the diagram follows the next available trip after cancellation, including its different terminal', async () => { const data = await bundle([{departureTime:'12:00',endStopId:8},{departureTime:'12:10',endStopId:3}]); const departures = timetableDepartures(data,'2026-10-08'); const alerts = [{id:'cancel',type:'cancellation',routeIds:[37],routeNumbers:['5'],cancellations:[{routeNumber:'5',stopId:1,serviceDate:'2026-10-08',time:'12:00'}]}]; const displayed = displayDepartures(departures, alerts, 2, now, true); const departure = firstAvailable(displayed); assert.equal(departure.scheduledTime,'12:13'); assert.deepEqual(journey(data,departure),{stops:[{id:3,name:'В',minutes:3}],more:0}); }); test('offline round-trip preserves route topology and per-trip terminal metadata', async () => { const values = new Map(); globalThis.localStorage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}; globalThis.indexedDB = {open:()=>{throw new Error('Unavailable');}}; const data = await bundle();await writeTimetable(data); const saved = await readTimetable(1,2); assert.deepEqual(journey(saved),journey(data)); assert.equal(await readTimetable(2,2),null);assert.equal(await readTimetable(1,3),null); });