From ae1c9b1252e9e9031a46a130d64d221bbe8c92c8 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:34:47 +0900 Subject: [PATCH] Deep links and saved lists: a malformed value no longer locks the app A link with date=2026-10-1 or a stored favorite of null threw inside initApp, and the boot card blamed the data and retried the same URL. URL dates must be real YYYY-MM-DD days and times HH:MM; anything else falls back like an out-of-window date, and the address bar is corrected in place to match the screen. Stored favorites, watched routes and trips are checked per entry and a malformed one is dropped. Station names in a link resolve regardless of case, accents or St spelling, preferring the station that has trains (LILLE resolves to LILLE (intramuros)). A name matching nothing says Unknown station instead of No MAX seat. The boot card now says the page could not open when the data loaded but the app failed, and its Retry opens the app without the link. --- docs/user-flows.md | 5 + src/app.ts | 57 +++++++++-- src/data/stations.ts | 13 +++ src/i18n/ar.ts | 2 + src/i18n/de.ts | 2 + src/i18n/en.ts | 2 + src/i18n/es.ts | 2 + src/i18n/fr.ts | 2 + src/i18n/it.ts | 2 + src/i18n/ja.ts | 2 + src/i18n/ko.ts | 2 + src/i18n/nl.ts | 2 + src/i18n/pt.ts | 2 + src/i18n/zh.ts | 2 + src/main.ts | 27 ++++-- src/state/store.ts | 74 ++++++++++++--- src/styles.css | 1 + tests/crash-guards.test.ts | 190 +++++++++++++++++++++++++++++++++++++ 18 files changed, 358 insertions(+), 31 deletions(-) create mode 100644 tests/crash-guards.test.ts diff --git a/docs/user-flows.md b/docs/user-flows.md index 36561d8..a737185 100644 --- a/docs/user-flows.md +++ b/docs/user-flows.md @@ -175,6 +175,11 @@ either an all-days overview or one specific day. Tap → open the route. (`replaceState`), never pushing a new entry. So repeated toggling can't pile up duplicate entries (the old bug where Back needed ~10 presses and the form appeared wiped). - **Deep links** — every search is a shareable URL; legacy `?rdate=` / `?rt=` links still work. + A station may be named in any case or accent (`from=paris`, `to=LILLE`) and resolves to + the station that has trains; a name matching no station shows "Unknown station" instead + of an empty result. A malformed or out-of-window departure date falls back to today; a + malformed or out-of-window return or finish-by date, and a malformed time filter, are + dropped. The address bar is then corrected in place (`replaceState`) to match the screen. - **PWA** — installable; a "new version — reload" postcard on updates. ## Known open items (see docs/trip-redesign.md for the audit plan) diff --git a/src/app.ts b/src/app.ts index 7d96bf7..a414d9b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -689,14 +689,17 @@ export function initApp(root: HTMLElement, dataset: Dataset, registry: StationRe } /** - * Parse the query from the URL and snap its date back into the bookable window. The - * date is clamped to [today, today+29], so the search form can never produce - * an out-of-range date — but a stale or shared link can. An out-of-window date would - * otherwise collapse the ±flex browse window (only the chosen, unbookable day is in - * range) and skew the exact-trip return calendar, so fall back to today. + * Parse the query from the URL, resolve its station names, snap its dates back into the + * bookable window, and correct the address bar to match. The date is clamped + * to [today, today+29], so the search form can never produce an out-of-range date — but + * a stale or shared link can. An out-of-window date would otherwise collapse the ±flex + * browse window (only the chosen, unbookable day is in range) and skew the exact-trip + * return calendar, so fall back to today. */ function queryFromUrl(): SearchQuery { - const q = store.queryFromParams(new URLSearchParams(location.search), today); + const params = new URLSearchParams(location.search); + const linked = params.toString(); + const q = store.queryFromParams(params, today); const lastBookable = addDays(today, BOOKING_WINDOW_DAYS - 1); const inWindow = (d: string): boolean => d >= today && d <= lastBookable; if (!inWindow(q.date)) q.date = today; @@ -713,11 +716,34 @@ function queryFromUrl(): SearchQuery { if (q.returnDate && q.mode === "od" && q.returnDate >= q.date && q.stay !== "flexible") { q.stay = stayFromNights(dayIndex(q.returnDate) - dayIndex(q.date)); } + // A linked station name ("paris", "LILLE") becomes the id the data uses; a name + // matching no station is kept, and renderSearch reports it. + const station = (name: string): string => deps.registry.resolve(name) ?? name; + if (q.origin) q.origin = station(q.origin); + if (q.destination) q.destination = station(q.destination); + if (q.via) q.via = station(q.via); + q.cities = q.cities?.map(station); // Multi-city legs are clamped too: a leg outside the bookable window can never // have a free MAX seat, so pull it back to today rather than showing an empty leg. - if (q.legs) q.legs = q.legs.map((l) => (inWindow(l.date) ? l : { ...l, date: today })); + q.legs = q.legs?.map((l) => ({ + from: station(l.from), + to: station(l.to), + date: inWindow(l.date) ? l.date : today, + })); // The tour "finish by" date only constrains the plan when it's inside the window. if (q.tourEndDate && !inWindow(q.tourEndDate)) q.tourEndDate = undefined; + // Write every value dropped, clamped or resolved above back into the address bar, in + // place, so a reload or a shared link carries what is on screen. + const parsed = store.queryToParams(q); + for (const key of ["from", "to", "via", "cities", "legs", "date", "rdate", "by", "after", "before", "arrbefore"]) { + if (!params.has(key)) continue; + const value = parsed.get(key); + if (value) params.set(key, value); + else params.delete(key); + } + if (params.toString() !== linked) { + history.replaceState(history.state, "", `${location.pathname}?${params.toString()}`); + } return q; } @@ -1815,6 +1841,23 @@ function renderSearch(): void { ); } + // Only a link can name a station the registry lacks (the form resolves to known ids): + // say so, rather than "No MAX seat" for a place that does not exist. + const unknown = [ + query.origin, + query.destination, + query.via, + ...(query.cities ?? []), + ...(query.legs ?? []).flatMap((l) => [l.from, l.to]), + ].find((s) => s && !deps.registry.get(s)); + if (unknown) { + refs.title.textContent = ""; + refs.results.append(render.emptyEl(t("err_station", { station: unknown }))); + showBaseMap(); + updateSearchBar(); + return; + } + // MAX SENIOR free tickets are weekday-only — flag a weekend outbound, and (round trip) // a weekend RETURN on a later day, since either leg must be booked free. if (query.card === "senior") { diff --git a/src/data/stations.ts b/src/data/stations.ts index b2ac459..e0a0378 100644 --- a/src/data/stations.ts +++ b/src/data/stations.ts @@ -154,6 +154,19 @@ export class StationRegistry { return this.byId.get(id); } + /** The id a linked name means: a bookable id as is, else the id, label or alias equal to + * it ignoring case, accents and "St"/hyphen spelling, preferring one with trains. */ + resolve(name: string): string | undefined { + if (this.present.has(name)) return name; + const n = matchNorm(name); + let best: Station | undefined; + for (const { station } of this.index) { + const names = [station.id, station.label, ...(station.aliases ?? [])]; + if (names.some((s) => matchNorm(s) === n) && (!best || this.better(station, best))) best = station; + } + return best?.id; + } + /** Every registered station (may contain label duplicates). */ all(): Station[] { return [...this.byId.values()]; diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index f13aa60..f4d984a 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -16,6 +16,8 @@ export const ar: Dict = { datepick_window: "المنقّط = الأيام ضمن هامش ±", err_load: "تعذّر تحميل البيانات.", act_retry: "إعادة المحاولة", + err_app: "تعذّر فتح هذه الصفحة.", + err_station: "محطة غير معروفة: «{station}». اختر محطة من القائمة.", tagline: "مقاعد MAX JEUNE / SENIOR المتاحة للحجز.", mode_best: "أفكار", best_title_all: "كل الوجهات من {station}", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 6077c82..ed5ef37 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -16,6 +16,8 @@ export const de: Dict = { datepick_window: "gepunktet = Tage innerhalb der ±-Spanne", err_load: "Daten konnten nicht geladen werden.", act_retry: "Erneut versuchen", + err_app: "Diese Seite konnte nicht geöffnet werden.", + err_station: "Unbekannter Bahnhof: „{station}“. Wähle einen aus der Liste.", tagline: "Reservierbare MAX JEUNE / SENIOR Plätze.", mode_best: "Ideen", best_title_all: "Alle Ziele ab {station}", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 3b92000..0f52150 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -16,6 +16,8 @@ export const en: Dict = { datepick_window: "dotted = days within the ± margin", err_load: "Couldn't load the data.", act_retry: "Retry", + err_app: "This page couldn't be opened.", + err_station: "Unknown station: “{station}”. Pick one from the list.", tagline: "MAX JEUNE / SENIOR seats open for reservation.", mode_best: "Ideas", best_title_all: "All destinations from {station}", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 938e7ed..cf36eb3 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -16,6 +16,8 @@ export const es: Dict = { datepick_window: "punteado = días dentro del margen ±", err_load: "No se pudieron cargar los datos.", act_retry: "Reintentar", + err_app: "No se pudo abrir esta página.", + err_station: "Estación desconocida: «{station}». Elige una de la lista.", tagline: "Plazas MAX JEUNE / SENIOR abiertas a reserva.", mode_best: "Ideas", best_title_all: "Todos los destinos desde {station}", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 4c788a3..5fb6061 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -14,6 +14,8 @@ export const fr = { datepick_window: "pointillés = jours compris dans la marge ±", err_load: "Impossible de charger les données.", act_retry: "Réessayer", + err_app: "Cette page n'a pas pu s'ouvrir.", + err_station: "Gare inconnue : «\u00a0{station}\u00a0». Choisissez-en une dans la liste.", tagline: "Places MAX JEUNE / SENIOR ouvertes à la réservation.", mode_best: "Idées", best_title_all: "Toutes les destinations depuis {station}", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index c67cf2c..2c45630 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -16,6 +16,8 @@ export const it: Dict = { datepick_window: "punteggiato = giorni entro il margine ±", err_load: "Impossibile caricare i dati.", act_retry: "Riprova", + err_app: "Impossibile aprire questa pagina.", + err_station: "Stazione sconosciuta: «{station}». Scegline una dall'elenco.", tagline: "Posti MAX JEUNE / SENIOR aperti alla prenotazione.", mode_best: "Idee", best_title_all: "Tutte le destinazioni da {station}", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 377cafd..04a7754 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -16,6 +16,8 @@ export const ja: Dict = { datepick_window: "点線 = ±範囲内の日", err_load: "データを読み込めませんでした。", act_retry: "再試行", + err_app: "このページを開けませんでした。", + err_station: "不明な駅:「{station}」。リストから選んでください。", tagline: "予約できる MAX JEUNE / SENIOR の座席。", mode_best: "おすすめ", best_title_all: "{station} から行ける全ての行き先", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 39fc01b..1519624 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -16,6 +16,8 @@ export const ko: Dict = { datepick_window: "점선 = ± 범위 내의 날짜", err_load: "데이터를 불러올 수 없습니다.", act_retry: "다시 시도", + err_app: "이 페이지를 열 수 없습니다.", + err_station: "알 수 없는 역: “{station}”. 목록에서 선택하세요.", tagline: "예약 가능한 MAX JEUNE / SENIOR 좌석.", mode_best: "추천", best_title_all: "{station}에서 갈 수 있는 모든 목적지", diff --git a/src/i18n/nl.ts b/src/i18n/nl.ts index e11ec2a..12a5193 100644 --- a/src/i18n/nl.ts +++ b/src/i18n/nl.ts @@ -16,6 +16,8 @@ export const nl: Dict = { datepick_window: "stippellijn = dagen binnen de ±-marge", err_load: "Kan de gegevens niet laden.", act_retry: "Opnieuw proberen", + err_app: "Deze pagina kan niet worden geopend.", + err_station: "Onbekend station: “{station}”. Kies er een uit de lijst.", tagline: "MAX JEUNE / SENIOR-plaatsen die te reserveren zijn.", mode_best: "Ideeën", best_title_all: "Alle bestemmingen vanaf {station}", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index fd411b4..7475064 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -16,6 +16,8 @@ export const pt: Dict = { datepick_window: "pontilhado = dias dentro da margem ±", err_load: "Não foi possível carregar os dados.", act_retry: "Tentar novamente", + err_app: "Não foi possível abrir esta página.", + err_station: "Estação desconhecida: «{station}». Escolha uma da lista.", tagline: "Lugares MAX JEUNE / SENIOR abertos para reserva.", mode_best: "Ideias", best_title_all: "Todos os destinos a partir de {station}", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 8b34164..5c633ac 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -16,6 +16,8 @@ export const zh: Dict = { datepick_window: "虚线 = ± 范围内的日期", err_load: "无法加载数据。", act_retry: "重试", + err_app: "无法打开此页面。", + err_station: "未知车站:“{station}”。请从列表中选择。", tagline: "可预订的 MAX JEUNE / SENIOR 座位。", mode_best: "推荐", best_title_all: "从{station}出发的所有目的地", diff --git a/src/main.ts b/src/main.ts index a1d44ab..9d4aec9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,20 +27,20 @@ function loadingStateEl(): HTMLElement { ]); } -/** Clean error card with a retry action if the dataset fails to load. */ -function errorStateEl(): HTMLElement { +/** Clean error card with a retry action: the data failed to load, or the page failed to open. */ +function errorStateEl(message: string, retry: () => void): HTMLElement { return el("div", { class: "error-state", attrs: { role: "alert" } }, [ el("span", { class: "error-icon", attrs: { "aria-hidden": "true" }, html: ``, }), - el("p", { class: "error-title", text: t("err_load") }), + el("p", { class: "error-title", text: message }), el("button", { class: "btn btn-primary", type: "button", text: t("act_retry"), - on: { click: () => location.reload() }, + on: { click: retry }, }), ]); } @@ -61,12 +61,21 @@ if (root) { root.replaceChildren(loadingStateEl()); } const registry = new StationRegistry(stationData as Station[]); - loadDataset() - .then((dataset) => initApp(root, dataset, registry)) - .catch((err: unknown) => { + loadDataset().then( + (dataset) => { + try { + initApp(root, dataset, registry); + } catch (err) { + // The data loaded but this link or saved state broke the page: retry without the link. + console.error(err); + root.replaceChildren(errorStateEl(t("err_app"), () => location.assign(location.pathname))); + } + }, + (err: unknown) => { console.error(err); - root.replaceChildren(errorStateEl()); - }); + root.replaceChildren(errorStateEl(t("err_load"), () => location.reload())); + }, + ); } // When a new build is deployed, greet the user with a dismissible "reload to update" diff --git a/src/state/store.ts b/src/state/store.ts index 16aa07d..159d906 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -1,7 +1,7 @@ import type { SearchQuery, SearchMode, CardType, Journey, SortKey, TripLeg, StayChoice } from "../types"; import type { Tour } from "../core/tour"; import { stayFromNights } from "../core/roundtrip"; -import { dayIndex } from "../util/time"; +import { dayIndex, parseTimeToMinutes } from "../util/time"; import { isLang, detectLang, type Lang } from "../i18n"; /** URL token for a stay choice (compact + stable): stay=day||flex, where is the @@ -124,8 +124,20 @@ function sameRoute(a: RoutePair, b: RoutePair): boolean { return a.origin === b.origin && a.destination === b.destination; } +// Stored lists are checked per element: one malformed entry is dropped instead of +// crashing every render that reads the list. +type Stored = Record; + +function isStored(v: unknown): v is Stored { + return typeof v === "object" && v !== null; +} + +function isRoutePair(v: unknown): v is RoutePair & Stored { + return isStored(v) && typeof v.origin === "string" && typeof v.destination === "string"; +} + export function loadFavorites(): RoutePair[] { - return readLS(KEY.favorites, [], Array.isArray); + return readLS(KEY.favorites, [], Array.isArray).filter(isRoutePair); } export function isFavorite(r: RoutePair): boolean { @@ -142,7 +154,7 @@ export function toggleFavorite(r: RoutePair): RoutePair[] { } export function loadWatched(): RoutePair[] { - return readLS(KEY.watched, [], Array.isArray); + return readLS(KEY.watched, [], Array.isArray).filter(isRoutePair); } export function isWatched(r: RoutePair): boolean { @@ -190,8 +202,30 @@ export function tourId(tour: Tour): string { return `tour:${tour.legs.map(journeyKey).join("|")}`; } +/** What a saved-trip row and its dialog read: dated endpoints, dated legs, the hub lists. */ +function isJourney(v: unknown): v is Journey { + const dated = (x: unknown): x is Stored => isRoutePair(x) && isValidIsoDate(x.date); + return ( + dated(v) && + Array.isArray(v.legs) && + v.legs.length > 0 && + v.legs.every(dated) && + Array.isArray(v.hubs) && + Array.isArray(v.layovers) + ); +} + +function isSavedTrip(v: unknown): v is SavedTrip { + return ( + isStored(v) && + isJourney(v.outbound) && + (v.inbound == null || isJourney(v.inbound)) && + (v.tour == null || (isStored(v.tour) && Array.isArray(v.tour.legs) && v.tour.legs.every(isJourney))) + ); +} + export function loadTrips(): SavedTrip[] { - return readLS(KEY.trips, [], Array.isArray); + return readLS(KEY.trips, [], Array.isArray).filter(isSavedTrip); } export function isTripSaved(id: string): boolean { @@ -293,6 +327,16 @@ export function queryFromParams(p: URLSearchParams, fallbackDate: string): Searc const stayh = Number(p.get("stayh")); const clampDay = (n: number, fallback: number): number => Number.isFinite(n) && n >= 1 ? Math.min(14, Math.floor(n)) : fallback; + // A malformed date or time is dropped: kept, a date crashes the render and a time + // filters out every train while the form shows no filter. + const isoDate = (key: string): string | undefined => { + const v = p.get(key); + return v && isValidIsoDate(v) ? v : undefined; + }; + const time = (key: string): string | undefined => { + const v = p.get(key); + return v && /^\d{2}:\d{2}$/.test(v) && Number.isFinite(parseTimeToMinutes(v)) ? v : undefined; + }; return { mode, origin: p.get("from") ?? undefined, @@ -306,13 +350,13 @@ export function queryFromParams(p: URLSearchParams, fallbackDate: string): Searc // Clamp to the stepper's 0..7 range (like setStepper) — an out-of-range link // should mean "the widest window", not silently fall back to no flexibility. flexDays: Number.isFinite(Number(p.get("flex"))) && Math.floor(Number(p.get("flex"))) >= 1 ? Math.min(7, Math.floor(Number(p.get("flex")))) : undefined, - returnDate: p.get("rdate") ?? undefined, + returnDate: isoDate("rdate"), legs: parseLegs(p.get("legs")), - date: p.get("date") ?? fallbackDate, + date: isoDate("date") ?? fallbackDate, card: p.get("card") === "senior" ? "senior" : "jeune", - departAfter: p.get("after") ?? undefined, - departBefore: p.get("before") ?? undefined, - arriveBefore: p.get("arrbefore") ?? undefined, + departAfter: time("after"), + departBefore: time("before"), + arriveBefore: time("arrbefore"), maxDurationMin: Number.isFinite(maxdur) && maxdur > 0 ? maxdur : undefined, trainType: p.get("type") ?? undefined, maxConnections: Number.isFinite(conn) && conn >= 0 && conn <= 6 ? conn : 1, @@ -340,7 +384,7 @@ export function queryFromParams(p: URLSearchParams, fallbackDate: string): Searc flexNights: p.get("fn") === "1" || undefined, stayMinHours: Number.isFinite(stayh) && stayh >= 1 ? Math.min(12, Math.floor(stayh)) : undefined, lateReturn: p.get("late") === "1" || undefined, - tourEndDate: p.get("by") ?? undefined, + tourEndDate: isoDate("by"), sort: parseSort(p.get("sort")), }; } @@ -381,11 +425,11 @@ function parseStay(p: URLSearchParams): StayChoice | undefined { // require a real ISO date so both are impossible from a URL. const MAX_LEGS = 12; -/** Whether a string is a well-formed, real ISO date (YYYY-MM-DD). */ -function isValidIsoDate(d: string): boolean { - if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return false; - const t = new Date(`${d}T00:00:00`).getTime(); - return !Number.isNaN(t); +/** Whether a value is a real calendar date written YYYY-MM-DD (not 2026-10-1 or 2026-02-30). */ +function isValidIsoDate(d: unknown): d is string { + if (typeof d !== "string") return false; + const t = Date.parse(`${d}T00:00:00Z`); + return Number.isFinite(t) && new Date(t).toISOString().slice(0, 10) === d; } /** Parse the "legs" param (from>to@date, ~-joined) into multi-city legs. */ diff --git a/src/styles.css b/src/styles.css index 926bb3f..0e3c4ec 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1507,6 +1507,7 @@ a { padding: 1.6rem; text-align: center; color: var(--muted); + overflow-wrap: anywhere; /* a long unbroken name from a link wraps instead of clipping */ } .empty-hint { margin: 0.55rem 0 0; diff --git a/tests/crash-guards.test.ts b/tests/crash-guards.test.ts new file mode 100644 index 0000000..0c89181 --- /dev/null +++ b/tests/crash-guards.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Leaflet needs a real browser canvas; stub the map module so the rest of the +// UI can be exercised under jsdom. +vi.mock("../src/ui/map", () => ({ + RouteMap: class { + onSelect: ((id: string) => void) | null = null; + show(): void {} + route(): void {} + radius(): void {} + base(): void {} + highlight(): void {} + invalidate(): void {} + focus(): void {} + setInfo(): void {} + }, +})); + +import type { RawRecord, Station, DataMeta } from "../src/types"; +import { normalizeRecords } from "../src/data/dataset"; +import { StationRegistry } from "../src/data/stations"; +import { initApp } from "../src/app"; +import { queryFromParams, loadFavorites, loadWatched, loadTrips } from "../src/state/store"; +import { en } from "../src/i18n/en"; +import sample from "../data/tgvmax.sample.json"; +import stations from "../data/stations.json"; + +const meta: DataMeta = { updatedAt: "", source: "sample", recordCount: 0, isSample: true }; +const P = encodeURIComponent("PARIS (intramuros)"); +const L = encodeURIComponent("LYON (intramuros)"); + +function setup(search: string, rows = sample as RawRecord[]): HTMLElement { + document.body.innerHTML = '
'; + const root = document.getElementById("app") as HTMLElement; + history.replaceState(null, "", `/${search}`); + initApp(root, { trains: normalizeRecords(rows), meta }, new StationRegistry(stations as Station[])); + return root; +} + +const journey = { + date: "2026-06-25", + origin: "PARIS (intramuros)", + destination: "LYON (intramuros)", + legs: [{ date: "2026-06-25", origin: "PARIS (intramuros)", destination: "LYON (intramuros)", trainNo: "6601" }], + departMin: 480, + arriveMin: 600, + totalDurationMin: 120, + layovers: [], + hubs: [], +}; + +beforeEach(() => { + localStorage.clear(); + // Pin "today" to the sample-data epoch so its dates sit inside the bookable window. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-25T12:00:00Z")); + globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => { + cb(0); + return 0; + }) as typeof requestAnimationFrame; + Element.prototype.scrollIntoView = function scrollIntoView(): void {}; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("a malformed date or time in a link (H1)", () => { + it("drops dates that are not real YYYY-MM-DD calendar days, and unparseable times", () => { + for (const bad of ["2026-10-1", "2026-10-00", "2026-02-30", "garbage"]) { + const q = queryFromParams(new URLSearchParams({ date: bad, rdate: bad, by: bad }), "2026-06-25"); + expect(q.date).toBe("2026-06-25"); + expect(q.returnDate).toBeUndefined(); + expect(q.tourEndDate).toBeUndefined(); + } + const q = queryFromParams(new URLSearchParams({ after: "garbage", before: "25:99", arrbefore: "8:00" }), "2026-06-25"); + expect([q.departAfter, q.departBefore, q.arriveBefore]).toEqual([undefined, undefined, undefined]); + expect(queryFromParams(new URLSearchParams({ after: "08:30" }), "2026-06-25").departAfter).toBe("08:30"); + }); + + it("opens the trip on today instead of throwing, and puts today in the address bar", () => { + // Both sort inside the bookable window as strings, so only the date check catches them. + for (const bad of ["2026-06-3", "2026-07-00"]) { + const root = setup(`?mode=od&from=${P}&to=${L}&date=${bad}&rdate=${bad}&lang=en`); + expect(root.querySelector(".mode-tab.active")).not.toBeNull(); + const url = new URLSearchParams(location.search); + expect(url.get("date")).toBe("2026-06-25"); + expect(url.has("rdate")).toBe(false); + expect(url.get("lang")).toBe("en"); + } + }); + + it("rewrites an out-of-window date in place, without a new history entry", () => { + const before = history.length; + setup(`?mode=od&from=${P}&to=${L}&date=2020-01-01`); + expect(new URLSearchParams(location.search).get("date")).toBe("2026-06-25"); + expect(history.length).toBe(before); + }); + + it("ignores an unparseable time filter rather than hiding every train", () => { + const root = setup(`?mode=od&from=${P}&to=${L}&date=2026-06-25&after=garbage&lang=en`); + expect(root.textContent).not.toContain(en.res_none); + expect(new URLSearchParams(location.search).has("after")).toBe(false); + }); +}); + +describe("a malformed entry in a stored list (H2)", () => { + it("drops the bad entries and keeps the good ones", () => { + const fav = { origin: "PARIS (intramuros)", destination: "LYON (intramuros)" }; + localStorage.setItem("mj.favorites", JSON.stringify([null, {}, 1, fav])); + localStorage.setItem("mj.watched", JSON.stringify([null, fav])); + const trip = { id: "x", kind: "one-way", outbound: journey, savedAt: 1 }; + localStorage.setItem( + "mj.trips", + JSON.stringify([ + null, + {}, + { ...trip, outbound: { ...journey, legs: undefined } }, + { ...trip, outbound: { ...journey, date: "2026-10-1" } }, + { ...trip, kind: "tour", tour: {} }, + { ...trip, inbound: { ...journey, hubs: undefined } }, + trip, + ]), + ); + expect(loadFavorites()).toEqual([fav]); + expect(loadWatched()).toEqual([fav]); + expect(loadTrips()).toEqual([trip]); + }); + + it("still opens the app", () => { + localStorage.setItem("mj.favorites", "[null]"); + localStorage.setItem("mj.watched", "[{}]"); + localStorage.setItem("mj.trips", '[{"id":"t","kind":"tour","outbound":{},"tour":{}}]'); + const root = setup(`?mode=from&from=${P}&date=2026-06-25`); + expect(root.querySelectorAll(".group-card").length).toBeGreaterThan(0); + }); +}); + +describe("station names in a link (L3)", () => { + it("resolves case- and accent-insensitive names to the id the data uses", () => { + const registry = new StationRegistry(stations as Station[]); + registry.addMissing(["PARIS (intramuros)", "LILLE (intramuros)", "MARSEILLE ST CHARLES"]); + expect(registry.resolve("paris")).toBe("PARIS (intramuros)"); + expect(registry.resolve("LILLE")).toBe("LILLE (intramuros)"); + expect(registry.resolve("marseille saint-charles")).toBe("MARSEILLE ST CHARLES"); + expect(registry.resolve("FOOBAR")).toBeUndefined(); + }); + + it("shows the trains of a lower-case link and writes the ids back", () => { + const root = setup("?mode=od&from=paris&to=lyon&date=2026-06-25&lang=en"); + expect(root.textContent).not.toContain(en.res_none); + const url = new URLSearchParams(location.search); + expect([url.get("from"), url.get("to")]).toEqual(["PARIS (intramuros)", "LYON (intramuros)"]); + }); + + it("picks the station with trains when the registry also has a bare id", () => { + const rows = (sample as RawRecord[]).map((r) => + r.destination === "LILLE" ? { ...r, destination: "LILLE (intramuros)" } : r, + ); + const root = setup(`?mode=od&from=${P}&to=LILLE&date=2026-06-25&lang=en`, rows); + expect(root.textContent).not.toContain(en.res_none); + expect(new URLSearchParams(location.search).get("to")).toBe("LILLE (intramuros)"); + }); + + it("says a name matching no station is unknown", () => { + const root = setup(`?mode=od&from=${P}&to=FOOBAR&date=2026-06-25&lang=en`); + expect(root.textContent).toContain(en.err_station.replace("{station}", "FOOBAR")); + expect(root.textContent).not.toContain(en.res_none); + }); +}); + +describe("the boot error card", () => { + it("does not blame the data when the page itself fails to open", async () => { + vi.resetModules(); + vi.doMock("../src/data/dataset", () => ({ loadDataset: () => Promise.resolve({ trains: [], meta }) })); + vi.doMock("../src/app", () => ({ + initApp: () => { + throw new Error("boom"); + }, + })); + vi.doMock("../src/pwa/register", () => ({ registerServiceWorker: () => {} })); + vi.doMock("../src/native/capacitor", () => ({ initNative: () => Promise.resolve() })); + vi.spyOn(console, "error").mockImplementation(() => {}); + document.body.innerHTML = '
'; + Object.defineProperty(navigator, "language", { value: "en-US", configurable: true }); + await import("../src/main"); + await vi.waitFor(() => expect(document.querySelector(".error-title")).not.toBeNull()); + expect(document.querySelector(".error-title")?.textContent).toBe(en.err_app); + }); +});