feat: implement night mode functionality with UI updates and journey tracking

- Added NightScreen component for displaying night mode information and alerts.
- Introduced NextStops component to show upcoming stops during a journey.
- Implemented logic for determining night mode activation based on schedule and user settings.
- Created journey utility functions to manage onward journey data and stop information.
- Updated tests to cover new night mode features and journey logic.
- Added fixture for testing night mode scenarios with specific timestamps.
- Removed QR code from night screen for a cleaner UI experience.
- Enhanced admin settings to manage night mode configurations.
This commit is contained in:
2026-10-11 05:04:00 +03:00
parent d04b364047
commit 5c69f6691d
49 changed files with 590 additions and 121 deletions

View File

@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { isManagedState } from '../src/lib/device.mjs';
import { hydrateManagedState, isManagedState } from '../src/lib/device.mjs';
import { createDeviceBridge } from '../server/device.mjs';
import { createApp } from '../server/index.mjs';
const source = await readFile(new URL('../src/lib/storage.ts', import.meta.url), 'utf8');
@@ -14,6 +14,9 @@ test('managed configuration validation rejects damaged, mismatched or unsupporte
for (const patch of [{ rowCount: 8 }, { demo: true }, { theme: 'sepia' }, { stopId: 2 }, { colorScheme: 'sky' }, { routeFilter: '-1' }, { holdNear: null }]) assert.equal(isManagedState({ ...value, device: { ...value.device, settings: { ...defaults, ...patch } } }), false);
assert.equal(isManagedState({ ...value, config: { ...value.config, refreshSeconds: 0 } }), false);
assert.equal(isManagedState(null), false);
const legacy = structuredClone(value); delete legacy.device.settings.nightMode;delete legacy.device.settings.nightWakeMinutes;
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('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 = [];

34
test/fixtures/night-server.mjs vendored Normal file
View File

@@ -0,0 +1,34 @@
// Explicit local fixture using the production App and timetable logic.
import express from 'express';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { createApp } from '../../server/index.mjs';
import { createDeviceBridge } from '../../server/device.mjs';
import { localDate, shiftDate, timetableDepartures } from '../../src/lib/timetable.mjs';
const scenarios = { evening: '2026-10-10T23:20:00+03:00', overnight: '2026-10-11T00:14:00+03:00', after: '2026-10-11T00:20:00+03:00', night: '2026-10-11T02:00:00+03:00', ninety: '2026-10-11T04:10:00+03:00', wake: '2026-10-11T04:40:00+03:00', wake45: '2026-10-11T04:55:00+03:00', day: '2026-10-11T12:00:00+03:00', avtovokzal: '2026-10-11T03:31:00+03:00', avtovokzalWake60: '2026-10-11T04:15:00+03:00', avtovokzalWake45: '2026-10-11T04:30:00+03:00' };
// Optional public timetable snapshot, with explicit fixture clock and fresh TTL.
const snapshot = process.env.NIGHT_TIMETABLE_PATH ? JSON.parse(readFileSync(process.env.NIGHT_TIMETABLE_PATH, 'utf8')) : null;
const defaultStop = snapshot?.stop || {id:1,name:'Швейная фабрика',direction:'Тестовое расписание'};
let scenario = scenarios[process.env.NIGHT_SCENARIO] ? process.env.NIGHT_SCENARIO : 'evening', started = Date.now(), alertsOn = false, expired = false;
const now = () => Date.parse(scenarios[scenario]) + Date.now() - started;
function timetable(stopId=defaultStop.id) {
const updated = now() - (expired ? 8*86400000 : 0);
if (snapshot) return {...snapshot,stop:{...snapshot.stop,id:stopId,direction:`Тестовые часы · ${snapshot.stop.direction}`},updatedAt:new Date(updated).toISOString(),expiresAt:new Date(updated+7*86400000).toISOString()};
const route={id:6,number:'6',name:'Швейная фабрика — Больница (Новка)',type:'bus'};
const trip=(seconds)=>({arrivalSeconds:seconds,destination:'Больница (Новка) (через Аэропорт)',note:'',daysOfWeek:[],exceptDaysOfWeek:[],originStopId:1,originTime:seconds>=86400?'23:45':`${String(Math.floor(seconds/3600)).padStart(2,'0')}:${String(Math.floor(seconds%3600/60)).padStart(2,'0')}`});
return {version:1,cityId:1,cityName:'Полоцк',stop:{id:stopId,name:'Швейная фабрика',direction:'Тестовое расписание'},routes:[{...route,schedules:[
{dayType:'saturday',validFrom:null,validUntil:null,trips:[trip(23*3600),...(scenario==='overnight'||scenario==='after'?[trip(86400+15*60)]:[])]},
{dayType:'sunday',validFrom:null,validUntil:null,trips:[trip(5*3600+40*60),trip(8*3600),trip(19*3600),trip(23*3600)]},
{dayType:'weekday',validFrom:null,validUntil:null,trips:[trip(6*3600),trip(23*3600)]}]}],holidays:[],calendarYears:[2026,2027],updatedAt:new Date(updated).toISOString(),expiresAt:new Date(updated+7*86400000).toISOString()};
}
const client={ config:()=>({cityId:1,cityName:'Полоцк',stopName:defaultStop.name,defaultStopId:defaultStop.id,configured:true,refreshSeconds:5,timeZone:'Europe/Minsk',passengerBaseUrl:'https://pwa.ridebus.by/'}),stops:async()=>[defaultStop],timetable:async id=>timetable(id),coordinates:async()=>({latitude:55,longitude:28}),
board:async id=>{const t=timetable(id);return {cityId:1,cityName:'Полоцк',stop:t.stop,routes:t.routes.map(({schedules,...r})=>r),departures:timetableDepartures(t,localDate(now())).filter(d=>Date.parse(d.scheduledAt)+60000>now()),alerts:alertsOn?[{id:'fixture-cancel',type:'cancellation',routeNumbers:['6'],routeIds:[6],title:'Изменение утренних рейсов',message:'Рейс №6 в 05:40 отменён. Первое отправление — в 08:00. Остальные рейсы выполняются по расписанию.',startAt:'2026-10-11T00:00:00+03:00',endAt:'2026-10-12T00:00:00+03:00',cancellations:[{routeNumber:'6',stopId:1,serviceDate:'2026-10-11',time:'05:40'}]}]:[],alertsAvailable:true,partial:false,updatedAt:new Date(now()).toISOString(),serviceDate:localDate(now()),mode:'live',serverTime:new Date(now()).toISOString()};}};
// createApp supplies real wall time by default. This fixture explicitly replaces
// serverTime so the production client exercises schedule boundaries deterministically.
const app=express();app.use('/api/board',async(req,res,next)=>{if(req.method==='GET')res.setHeader('Cache-Control','no-store');if(req.method==='GET'&&req.query.stopId===String(defaultStop.id))return res.json(await client.board(defaultStop.id));next();});
app.use(createApp(client,{weather:async(cityId,stopId)=>({cityId,stopId,updatedAt:new Date(now()).toISOString(),observedAt:new Date(now()).toISOString(),temperature:7,apparentTemperature:5,code:3,isDay:false,windSpeed:2,hourly:[]})},createDeviceBridge({enabled:false})));
app.use('/__fixture',express.urlencoded({extended:false}));
app.post('/__fixture',(req,res)=>{if(scenarios[req.body.scenario]){scenario=req.body.scenario;started=Date.now();}if(req.body.alerts!==undefined)alertsOn=req.body.alerts==='true';if(req.body.expired!==undefined)expired=req.body.expired==='true';res.redirect(303,'/__fixture');});
app.get('/__fixture',(_req,res)=>res.type('html').send(`<html lang="ru"><body style="font:20px system-ui;padding:32px"><h1>Тестовые сценарии ночного режима</h1><p>Текущий: ${scenario}; уведомление: ${alertsOn}; истёкший кеш: ${expired}</p><form method="post">${Object.keys(scenarios).map(s=>`<button style="padding:14px;margin:6px" name="scenario" value="${s}">${s}</button>`).join('')}<button name="alerts" value="true">Показать отмену</button><button name="alerts" value="false">Убрать отмену</button><button name="expired" value="true">Истёкший кеш</button><button name="expired" value="false">Свежий кеш</button></form><p>Часы приложения обновятся при опросе (5 секунд). После смены полного расписания обновите страницу табло.</p></body></html>`));
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(5193,'127.0.0.1',resolve));console.log('Night fixture http://127.0.0.1:5193/ ; controls /__fixture');

View File

@@ -1,10 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { boardScenes, informationBlocks, serviceWindow } from '../src/lib/information.ts';
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 trip = (routeId, at) => ({ routeId, routeNumber: String(routeId), scheduledAt: at });
test('disabled information blocks are omitted without leaving gaps in the cycle', () => {
assert.deepEqual(informationBlocks(defaultSettings).map(block => block.id), ['service', 'weather', 'qr']);
@@ -18,25 +17,3 @@ test('all blocks off keeps departures, retains optional advertising, and still s
assert.deepEqual(boardScenes({ ...off, adsEnabled: true }, true, true), ['departures', 'info']);
assert.deepEqual(boardScenes(off, true, false), ['departures', 'info']);
});
test('service boundaries use local arrival dates, including after-midnight trips', () => {
const now = Date.parse('2026-10-09T23:00:00+03:00');
const last = trip(2, '2026-10-09T23:50:00+03:00');
const first = trip(1, '2026-10-09T21:15:00Z'); // 00:15 tomorrow in Minsk
const departures = [trip(1, '2026-10-10T06:00:00+03:00'), last, first,
trip(1, '2026-10-09T00:10:00+03:00'), trip(1, '2026-10-08T23:59:00+03:00')];
assert.deepEqual(serviceWindow(departures, now, ''), { lastToday: last, firstTomorrow: first });
});
test('route selection scopes both boundaries; empty days do not invent a trip', () => {
const now = Date.parse('2026-10-09T12:00:00+03:00');
const filtered = trip(1, '2026-10-09T20:00:00+03:00');
const departures = [filtered, trip(2, '2026-10-09T23:00:00+03:00'), trip(2, '2026-10-10T05:00:00+03:00')];
assert.deepEqual(serviceWindow(departures, now, '1'), { lastToday: filtered, firstTomorrow: null });
assert.deepEqual(serviceWindow([], now, ''), { lastToday: null, firstTomorrow: null });
});
test('service boundaries roll to the new day and across the year at local midnight', () => {
const last = trip(1, '2026-12-31T23:59:00+03:00');
const first = trip(1, '2027-01-01T05:00:00+03:00');
const next = trip(1, '2027-01-02T06:00:00+03:00');
assert.deepEqual(serviceWindow([last, first, next], Date.parse('2026-12-31T23:59:00+03:00'), ''), { lastToday: last, firstTomorrow: first });
assert.deepEqual(serviceWindow([last, first, next], Date.parse('2027-01-01T00:00:00+03:00'), ''), { lastToday: first, firstTomorrow: next });
});

80
test/journey.test.mjs Normal file
View File

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

79
test/night.test.mjs Normal file
View File

@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { nightWindow } from '../src/lib/night.ts';
import { defaultSettings } from '../src/lib/storage.ts';
const at = value => Date.parse(`${value}+03:00`);
const trip = (id, time, serviceDate = time.slice(0,10), status = 'scheduled', routeId = 1) => ({ id, routeId, routeNumber: String(routeId), routeName: 'Маршрут', destination: 'Автовокзал', type: 'bus', scheduledAt: new Date(at(time)).toISOString(), scheduledTime: time.slice(11,16), serviceDate, source: 'schedule', note: '', status });
const trips = [trip('last', '2026-10-10T23:00:00'), trip('first', '2026-10-11T05:40:00'), trip('later', '2026-10-11T22:00:00')];
const check = (now, departures = trips, settings = defaultSettings, available = true) => nightWindow(settings, departures, at(now), available);
test('night starts only after the final scheduled trip and an overnight break longer than two hours', () => {
assert.equal(check('2026-10-10T22:59:00').active, false);
assert.equal(check('2026-10-10T23:00:30').active, false);
const night = check('2026-10-10T23:02:00'); assert.equal(night.active, true); assert.equal(night.first.id, 'first'); assert.equal(night.completedToday, true);
assert.equal(check('2026-10-11T03:40:00').active, true);
const short = [trips[0], trip('short', '2026-10-11T01:00:00'), trips[2]];
assert.equal(check('2026-10-10T23:02:00', short).active, false);
assert.equal(check('2026-10-10T23:02:00', [trips[0], trip('long', '2026-10-11T01:01:00'), trips[2]]).active, true);
});
test('a daytime gap does not declare movement complete', () => {
const day = [trip('early','2026-10-11T06:00:00'),trip('late','2026-10-11T19:00:00')];
assert.equal(check('2026-10-11T12:00:00', day).active, false);
});
test('cold starts during the same break stay in night mode until the selected 60 or 45 minute boundary', () => {
assert.equal(check('2026-10-11T04:10:00').active,true);
assert.equal(check('2026-10-11T04:39:59').active,true);
assert.equal(check('2026-10-11T04:40:00').active,false);
assert.equal(check('2026-10-11T04:50:00',trips,{...defaultSettings,nightWakeMinutes:45}).active,true);
assert.equal(check('2026-10-11T04:55:00',trips,{...defaultSettings,nightWakeMinutes:45}).active,false);
});
test('midnight keeps night mode and the correct first trip date', () => {
const before = check('2026-10-10T23:40:00'), after = check('2026-10-11T00:05:00');
assert.equal(after.active,true);assert.equal(after.first.id,before.first.id);assert.equal(after.completedToday,false);assert.equal(after.first.scheduledTime,'05:40');
});
test('after-midnight arrivals keep their service day and cannot be hidden while approaching', () => {
const overnight = [trip('late','2026-10-11T00:15:00','2026-10-10'),...trips];
assert.equal(check('2026-10-11T00:14:00',overnight).active,false);
assert.equal(check('2026-10-11T00:15:30',overnight).active,false);
assert.equal(check('2026-10-11T00:17:00',overnight).active,true);
});
test('a calendar-day 00:40 continuation closes the previous night, while continuous overnight service and midday gaps do not', () => {
const closing = [trip('yesterday','2026-10-10T23:55:00'),trip('tail','2026-10-11T00:40:00'),trip('morning','2026-10-11T05:44:00'),trip('end','2026-10-11T23:00:00')];
assert.equal(check('2026-10-11T00:39:00',closing).active,false);
assert.equal(check('2026-10-11T00:40:30',closing).active,false);
const night=check('2026-10-11T02:00:00',closing);assert.equal(night.active,true);assert.equal(night.first.id,'morning');
assert.equal(check('2026-10-11T04:00:00',closing).active,true);
assert.equal(check('2026-10-11T04:44:00',closing).active,false);
assert.equal(check('2026-10-11T12:00:00',closing).active,false);
const continuous=[...closing,trip('one','2026-10-11T01:30:00'),trip('two','2026-10-11T02:20:00'),trip('three','2026-10-11T03:10:00')];
assert.equal(check('2026-10-11T04:00:00',continuous).active,false);
});
test('Avtovokzal midnight trip at 00:31 does not prevent night mode on startup at 03:31 before the 05:15 trip', () => {
const departures = [trip('last', '2026-10-10T23:55:00'), trip('tail', '2026-10-11T00:31:00'), trip('morning', '2026-10-11T05:15:00'), trip('later', '2026-10-11T23:55:00')];
assert.equal(check('2026-10-11T00:30:00', departures).active, false);
assert.equal(check('2026-10-11T00:31:59', departures).active, false);
const night = check('2026-10-11T03:31:00', departures);
assert.equal(night.active, true); assert.equal(night.first.scheduledTime, '05:15'); assert.equal(night.completedToday, false);
assert.equal(check('2026-10-11T04:14:59', departures).active, true);
assert.equal(check('2026-10-11T04:15:00', departures).active, false);
const wake45 = {...defaultSettings, nightWakeMinutes:45};
assert.equal(check('2026-10-11T04:15:00', departures, wake45).active, true);
assert.equal(check('2026-10-11T04:30:00', departures, wake45).active, false);
});
test('cancelled first trip is skipped; all cancelled, missing, stale, demo and disabled data cannot start night', () => {
const cancelled = [trips[0],{...trips[1],status:'cancelled'},trip('next','2026-10-11T06:00:00')];
assert.equal(check('2026-10-11T00:05:00',cancelled).first.id,'next');
for(const data of [[],[trips[0]],trips.map(d=>({...d,status:'cancelled'}))])assert.equal(check('2026-10-11T00:05:00',data).active,false);
assert.equal(check('2026-10-11T00:05:00',trips,defaultSettings,false).active,false);
assert.equal(check('2026-10-11T00:05:00',trips,{...defaultSettings,demo:true}).active,false);
assert.equal(check('2026-10-11T00:05:00',trips,{...defaultSettings,nightMode:'off'}).active,false);
});
test('route selection recalculates the break; unordered data and year boundaries are safe', () => {
const mixed = [trip('other','2026-10-11T00:20:00','2026-10-10','scheduled',2),trip('other-first','2026-10-11T04:00:00','2026-10-11','scheduled',2),...trips];
assert.equal(check('2026-10-11T00:10:00',mixed).active,false);
const filteredSettings = {...defaultSettings,routeFilter:'1'};
assert.equal(check('2026-10-11T00:10:00',mixed,filteredSettings).active,true);
assert.equal(check('2026-10-11T03:31:00',mixed).active,false);
assert.equal(check('2026-10-11T03:31:00',mixed,filteredSettings).active,true);
const year = [trip('year-last','2026-12-31T23:00:00'),trip('year-first','2027-01-01T06:00:00')].reverse();
assert.equal(check('2027-01-01T00:10:00',year).first.id,'year-first');
});

View File

@@ -2,6 +2,13 @@ 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('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);
saveSettings({...old,nightMode:'off',nightWakeMinutes:45});assert.equal(readSettings().nightMode,'off');assert.equal(readSettings().nightWakeMinutes,45);
values.set('ridebus-board-settings-v1',JSON.stringify({nightMode:'always',nightWakeMinutes:5}));assert.equal(readSettings().nightMode,'auto');assert.equal(readSettings().nightWakeMinutes,60);
});
const data = { cityId: 1, stop: { id: 1, name: 'Швейная фабрика' }, mode: 'live', serviceDate: '2026-10-08', updatedAt: '2026-10-08T12:00:00+03:00', routes: [], alerts: [],
departures: [{ id: '1:12:30', scheduledAt: '2026-10-08T12:30:00+03:00' }] };