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