Add cross-section navigation store with Board↔Schedule projection per TZ 4.1.8 Table 10
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user