diff --git a/src/shared/state/crossSectionNavigation.test.ts b/src/shared/state/crossSectionNavigation.test.ts new file mode 100644 index 00000000..49e1b4be --- /dev/null +++ b/src/shared/state/crossSectionNavigation.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it, beforeEach, vi, afterEach } from "vitest"; +import { + setBoardFilter, getBoardFilter, + setScheduleFilter, getScheduleFilter, + setMapFilter, getMapFilter, + projectBoardToSchedule, projectScheduleToBoard, + resetCrossSectionStore, + type BoardFilterSnapshot, type ScheduleFilterSnapshot, type MapFilterSnapshot, +} from "./crossSectionNavigation.js"; + +beforeEach(() => { + resetCrossSectionStore(); + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 4, 15, 12, 0, 0)); // Fri 2026-05-15 +}); +afterEach(() => vi.useRealTimers()); + +describe("4.1.8: store + retrieval", () => { + it("stores and retrieves board filter", () => { + const snap: BoardFilterSnapshot = { + mode: "route", departure: "MOW", arrival: "LED", + date: "20260515", timeFrom: "0000", timeTo: "2400", + searchExecuted: true, + }; + setBoardFilter(snap); + expect(getBoardFilter()).toEqual(snap); + }); + + it("stores and retrieves schedule filter", () => { + const snap: ScheduleFilterSnapshot = { + mode: "route", departure: "MOW", arrival: "LED", + dateFrom: "20260511", dateTo: "20260517", + timeFrom: "0600", timeTo: "2200", + onlyDirect: false, showReturn: false, + searchExecuted: true, + }; + setScheduleFilter(snap); + expect(getScheduleFilter()).toEqual(snap); + }); + + it("stores and retrieves map filter", () => { + const snap: MapFilterSnapshot = { + departure: "MOW", arrival: "LED", date: "20260515", + showInternal: true, showInternational: true, showTransfers: false, + }; + setMapFilter(snap); + expect(getMapFilter()).toEqual(snap); + }); + + it("getBoardFilter returns null before any set", () => { + expect(getBoardFilter()).toBeNull(); + }); + + it("getScheduleFilter returns null before any set", () => { + expect(getScheduleFilter()).toBeNull(); + }); + + it("getMapFilter returns null before any set", () => { + expect(getMapFilter()).toBeNull(); + }); + + it("resetCrossSectionStore clears all three", () => { + setBoardFilter({ + mode: "route", departure: "MOW", arrival: "LED", + date: "20260515", timeFrom: "0000", timeTo: "2400", + searchExecuted: true, + }); + setMapFilter({ + departure: "MOW", arrival: null, date: "20260515", + showInternal: true, showInternational: true, showTransfers: false, + }); + setScheduleFilter({ + mode: "route", departure: "MOW", arrival: "LED", + dateFrom: "20260511", dateTo: "20260517", + timeFrom: "0000", timeTo: "2400", + onlyDirect: false, showReturn: false, + searchExecuted: false, + }); + resetCrossSectionStore(); + expect(getBoardFilter()).toBeNull(); + expect(getScheduleFilter()).toBeNull(); + expect(getMapFilter()).toBeNull(); + }); + + it("overwrites previous board filter with new value", () => { + const snap1: BoardFilterSnapshot = { + mode: "route", departure: "MOW", arrival: "LED", + date: "20260515", timeFrom: "0000", timeTo: "2400", + searchExecuted: false, + }; + const snap2: BoardFilterSnapshot = { + mode: "flight-number", flightNumber: "1234", + date: "20260516", timeFrom: "0600", timeTo: "2200", + searchExecuted: true, + }; + setBoardFilter(snap1); + setBoardFilter(snap2); + expect(getBoardFilter()).toEqual(snap2); + }); +}); + +describe("4.1.8-R1/R2: projectBoardToSchedule", () => { + it("carries departure + arrival", () => { + const board: BoardFilterSnapshot = { + mode: "route", departure: "MOW", arrival: "LED", + date: "20260515", timeFrom: "0600", timeTo: "2200", + searchExecuted: true, + }; + const p = projectBoardToSchedule(board); + expect(p.departure).toBe("MOW"); + expect(p.arrival).toBe("LED"); + }); + + it("sets dateFrom/dateTo to current week (Mon-Sun of board.date's week)", () => { + const board: BoardFilterSnapshot = { + mode: "route", departure: "MOW", arrival: "LED", + date: "20260515", // Friday + timeFrom: "0600", timeTo: "2200", searchExecuted: true, + }; + const p = projectBoardToSchedule(board); + // Week containing 2026-05-15 (Fri) = 2026-05-11 (Mon) to 2026-05-17 (Sun) + expect(p.dateFrom).toBe("20260511"); + expect(p.dateTo).toBe("20260517"); + }); + + it("clears time/toggles to Schedule defaults", () => { + const p = projectBoardToSchedule({ + mode: "route", departure: "MOW", arrival: "LED", + date: "20260515", timeFrom: "0600", timeTo: "2200", + searchExecuted: true, + }); + expect(p.timeFrom).toBe("0000"); + expect(p.timeTo).toBe("2400"); + expect(p.onlyDirect).toBe(false); + expect(p.showReturn).toBe(false); + expect(p.searchExecuted).toBe(false); + }); + + it("uses current date for week bounds when board.date is malformed", () => { + const p = projectBoardToSchedule({ + mode: "route", departure: "MOW", arrival: "LED", + date: "", timeFrom: "0000", timeTo: "2400", searchExecuted: false, + }); + // Clock frozen at 2026-05-15 (Fri), so week = 2026-05-11 to 2026-05-17 + expect(p.dateFrom).toBe("20260511"); + expect(p.dateTo).toBe("20260517"); + }); + + it("does not carry optional cities when board has only departure", () => { + const p = projectBoardToSchedule({ + mode: "departure", departure: "MOW", + date: "20260515", timeFrom: "0000", timeTo: "2400", searchExecuted: false, + }); + expect(p.departure).toBe("MOW"); + expect(p.arrival).toBeUndefined(); + }); + + it("does not carry optional cities when board has only arrival", () => { + const p = projectBoardToSchedule({ + mode: "arrival", arrival: "LED", + date: "20260515", timeFrom: "0000", timeTo: "2400", searchExecuted: false, + }); + expect(p.departure).toBeUndefined(); + expect(p.arrival).toBe("LED"); + }); + + it("week bounds for a Monday give Mon=Mon+6=Sun", () => { + // 2026-05-11 is a Monday + const p = projectBoardToSchedule({ + mode: "route", departure: "MOW", arrival: "LED", + date: "20260511", timeFrom: "0000", timeTo: "2400", searchExecuted: false, + }); + expect(p.dateFrom).toBe("20260511"); + expect(p.dateTo).toBe("20260517"); + }); + + it("week bounds for a Sunday give Mon-6..Sun", () => { + // 2026-05-17 is a Sunday + const p = projectBoardToSchedule({ + mode: "route", departure: "MOW", arrival: "LED", + date: "20260517", timeFrom: "0000", timeTo: "2400", searchExecuted: false, + }); + expect(p.dateFrom).toBe("20260511"); + expect(p.dateTo).toBe("20260517"); + }); +}); + +describe("4.1.8-R1/R2: projectScheduleToBoard", () => { + it("carries departure + arrival, sets date=today, clears time", () => { + const sched: ScheduleFilterSnapshot = { + mode: "route", departure: "MOW", arrival: "LED", + dateFrom: "20260511", dateTo: "20260517", + timeFrom: "0600", timeTo: "2200", + onlyDirect: true, showReturn: false, + searchExecuted: true, + }; + const p = projectScheduleToBoard(sched); + expect(p.departure).toBe("MOW"); + expect(p.arrival).toBe("LED"); + expect(p.date).toBe("20260515"); // today (frozen clock) + expect(p.timeFrom).toBe("0000"); + expect(p.timeTo).toBe("2400"); + expect(p.searchExecuted).toBe(false); + }); + + it("does not carry optional departure when schedule has only arrival", () => { + const p = projectScheduleToBoard({ + mode: "route", arrival: "LED", + dateFrom: "20260511", dateTo: "20260517", + timeFrom: "0000", timeTo: "2400", + onlyDirect: false, showReturn: false, + searchExecuted: false, + }); + expect(p.departure).toBeUndefined(); + expect(p.arrival).toBe("LED"); + }); + + it("sets mode to route regardless of schedule mode", () => { + const p = projectScheduleToBoard({ + mode: "route", departure: "MOW", arrival: "LED", + dateFrom: "20260511", dateTo: "20260517", + timeFrom: "0000", timeTo: "2400", + onlyDirect: false, showReturn: false, + searchExecuted: false, + }); + expect(p.mode).toBe("route"); + }); +}); + +describe("4.1.1-R26 / 4.1.8-R3: map filter is independent of board/schedule", () => { + it("setBoardFilter does not affect map", () => { + const mapSnap: MapFilterSnapshot = { + departure: "MOW", arrival: null, date: "20260515", + showInternal: true, showInternational: true, showTransfers: false, + }; + setMapFilter(mapSnap); + setBoardFilter({ + mode: "route", departure: "LED", arrival: "KGD", + date: "20260601", timeFrom: "0000", timeTo: "2400", + searchExecuted: false, + }); + expect(getMapFilter()).toEqual(mapSnap); + }); + + it("setScheduleFilter does not affect map", () => { + const mapSnap: MapFilterSnapshot = { + departure: "SVO", arrival: "LED", date: "20260601", + showInternal: false, showInternational: true, showTransfers: true, + }; + setMapFilter(mapSnap); + setScheduleFilter({ + mode: "route", departure: "KZN", arrival: "AER", + dateFrom: "20260601", dateTo: "20260607", + timeFrom: "0000", timeTo: "2400", + onlyDirect: false, showReturn: false, + searchExecuted: true, + }); + expect(getMapFilter()).toEqual(mapSnap); + }); + + it("setMapFilter does not affect board", () => { + const boardSnap: BoardFilterSnapshot = { + mode: "route", departure: "MOW", arrival: "LED", + date: "20260515", timeFrom: "0000", timeTo: "2400", + searchExecuted: false, + }; + setBoardFilter(boardSnap); + setMapFilter({ + departure: "SVO", arrival: null, date: null, + showInternal: false, showInternational: false, showTransfers: false, + }); + expect(getBoardFilter()).toEqual(boardSnap); + }); + + it("setMapFilter does not affect schedule", () => { + const schedSnap: ScheduleFilterSnapshot = { + mode: "route", departure: "MOW", arrival: "LED", + dateFrom: "20260511", dateTo: "20260517", + timeFrom: "0000", timeTo: "2400", + onlyDirect: true, showReturn: false, + searchExecuted: false, + }; + setScheduleFilter(schedSnap); + setMapFilter({ + departure: "SVO", arrival: null, date: null, + showInternal: false, showInternational: false, showTransfers: false, + }); + expect(getScheduleFilter()).toEqual(schedSnap); + }); +}); diff --git a/src/shared/state/crossSectionNavigation.ts b/src/shared/state/crossSectionNavigation.ts new file mode 100644 index 00000000..e1a83400 --- /dev/null +++ b/src/shared/state/crossSectionNavigation.ts @@ -0,0 +1,111 @@ +/** + * Session-scoped store for cross-section filter state (TZ §4.1.8 Table 10). + * In-memory only — cleared on page reload. Projection functions implement + * the exact field mappings from Table 10 when a user navigates between + * Online-Board ↔ Schedule. Map filter is stored separately and is NEVER + * projected from / to Board or Schedule (per TZ §4.1.1 ¶12). + */ + +export interface BoardFilterSnapshot { + mode: "route" | "flight-number" | "departure" | "arrival"; + departure?: string; + arrival?: string; + flightNumber?: string; + date: string; // yyyyMMdd + timeFrom: string; // HHmm + timeTo: string; // HHmm + searchExecuted: boolean; +} + +export interface ScheduleFilterSnapshot { + mode: "route"; + departure?: string; + arrival?: string; + dateFrom: string; // yyyyMMdd + dateTo: string; + timeFrom: string; + timeTo: string; + onlyDirect: boolean; + showReturn: boolean; + returnDateFrom?: string; + returnDateTo?: string; + returnTimeFrom?: string; + returnTimeTo?: string; + searchExecuted: boolean; +} + +export interface MapFilterSnapshot { + departure: string | null; + arrival: string | null; + date: string | null; + showInternal: boolean; + showInternational: boolean; + showTransfers: boolean; +} + +// Module-level in-memory state +let board: BoardFilterSnapshot | null = null; +let schedule: ScheduleFilterSnapshot | null = null; +let map: MapFilterSnapshot | null = null; + +export function setBoardFilter(s: BoardFilterSnapshot): void { board = s; } +export function getBoardFilter(): BoardFilterSnapshot | null { return board; } +export function setScheduleFilter(s: ScheduleFilterSnapshot): void { schedule = s; } +export function getScheduleFilter(): ScheduleFilterSnapshot | null { return schedule; } +export function setMapFilter(s: MapFilterSnapshot): void { map = s; } +export function getMapFilter(): MapFilterSnapshot | null { return map; } + +export function resetCrossSectionStore(): void { + board = null; schedule = null; map = null; +} + +function currentWeekBounds(base: Date): { start: string; end: string } { + const d = new Date(base); + d.setHours(0, 0, 0, 0); + const dow = (d.getDay() + 6) % 7; // 0 = Mon .. 6 = Sun + d.setDate(d.getDate() - dow); + const start = new Date(d); + const end = new Date(d); + end.setDate(end.getDate() + 6); + const y = (x: Date) => + `${x.getFullYear()}${String(x.getMonth() + 1).padStart(2, "0")}${String(x.getDate()).padStart(2, "0")}`; + return { start: y(start), end: y(end) }; +} + +function todayYyyymmdd(): string { + const d = new Date(); + return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`; +} + +/** TZ Table 10: Board → Schedule projection. Preserves cities + dateFrom=current-week. */ +export function projectBoardToSchedule(b: BoardFilterSnapshot): ScheduleFilterSnapshot { + const base = b.date && /^\d{8}$/.test(b.date) + ? new Date(Number(b.date.slice(0, 4)), Number(b.date.slice(4, 6)) - 1, Number(b.date.slice(6, 8))) + : new Date(); + const week = currentWeekBounds(base); + return { + mode: "route", + ...(b.departure ? { departure: b.departure } : {}), + ...(b.arrival ? { arrival: b.arrival } : {}), + dateFrom: week.start, + dateTo: week.end, + timeFrom: "0000", + timeTo: "2400", + onlyDirect: false, + showReturn: false, + searchExecuted: false, + }; +} + +/** TZ Table 10: Schedule → Board projection. Preserves cities + date=today. */ +export function projectScheduleToBoard(s: ScheduleFilterSnapshot): BoardFilterSnapshot { + return { + mode: "route", + ...(s.departure ? { departure: s.departure } : {}), + ...(s.arrival ? { arrival: s.arrival } : {}), + date: todayYyyymmdd(), + timeFrom: "0000", + timeTo: "2400", + searchExecuted: false, + }; +}