Enforce 1h minimum gap on time-range slider per TZ 4.1.9 Tables 12/14

This commit is contained in:
2026-04-21 21:36:07 +03:00
parent 83951d4292
commit 66518a6f0c
4 changed files with 151 additions and 13 deletions
@@ -6,7 +6,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { OnlineBoardFilter } from "./OnlineBoardFilter.js";
// ---------------------------------------------------------------------------
@@ -51,9 +51,14 @@ vi.mock("primereact/calendar", () => ({
},
}));
// PrimeReact Slider stub
// PrimeReact Slider stub — captures onChange so tests can fire range events
let _sliderOnChange: ((e: { value: number[] }) => void) | null = null;
vi.mock("primereact/slider", () => ({
Slider: () => <div data-testid="slider-stub" />,
Slider: (props: Record<string, unknown>) => {
_sliderOnChange = props["onChange"] as (e: { value: number[] }) => void;
return <div data-testid="slider-stub" />;
},
}));
// DayQuickPick — not needed for these tests
@@ -197,3 +202,40 @@ describe("OnlineBoardFilter clear-button (X) per TZ §4.1.9 Tables 11/12", (
expect(screen.queryByTestId("route-date-clear")).toBeNull();
});
});
describe("OnlineBoardFilter time slider 1h minimum gap per TZ §4.1.9 Tables 12", () => {
beforeEach(() => {
vi.clearAllMocks();
_sliderOnChange = null;
});
it("4.1.9-R: time slider enforces 1h minimum gap (zero gap → to clamped to from + 60)", () => {
render(<OnlineBoardFilter initialTab="route" initialTimeFrom="0600" initialTimeTo="0660" />);
// Fire slider onChange with zero-gap [600, 600]
act(() => {
_sliderOnChange?.({ value: [600, 600] });
});
// The displayed time value should show from=10:00 and to=11:00
const value = screen.getByTestId("time-selector").querySelector(".time-selector__value");
expect(value?.textContent).toBe("10:00 — 11:00");
});
it("4.1.9-R: time slider cannot set timeTo before timeFrom (inverted range is normalised)", () => {
render(<OnlineBoardFilter initialTab="route" />);
act(() => {
_sliderOnChange?.({ value: [800, 600] });
});
// After normalisation from = min(800,600)=600, to = max(800,600)=800; gap >= 60 → kept
const value = screen.getByTestId("time-selector").querySelector(".time-selector__value");
expect(value?.textContent).toBe("10:00 — 13:20");
});
it("4.1.9-R: time slider clamps from at right edge (24:00 zero gap → from = 23:00, to = 24:00)", () => {
render(<OnlineBoardFilter initialTab="route" />);
act(() => {
_sliderOnChange?.({ value: [1440, 1440] });
});
const value = screen.getByTestId("time-selector").querySelector(".time-selector__value");
expect(value?.textContent).toBe("23:00 — 24:00");
});
});
@@ -587,7 +587,21 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
<div className="time-selector">
<Slider
value={timeRange}
onChange={(e: SliderChangeEvent) => setTimeRange(e.value as [number, number])}
onChange={(e: SliderChangeEvent) => {
const raw = e.value as [number, number];
const MIN_GAP = 60;
let from = Math.min(raw[0], raw[1]);
let to = Math.max(raw[0], raw[1]);
if (to - from < MIN_GAP) {
if (from + MIN_GAP <= 1440) {
to = from + MIN_GAP;
} else {
to = 1440;
from = to - MIN_GAP;
}
}
setTimeRange([from, to]);
}}
range
min={0}
max={1440}
@@ -6,7 +6,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { ScheduleFilter } from "./ScheduleFilter.js";
// ---------------------------------------------------------------------------
@@ -47,9 +47,20 @@ vi.mock("primereact/calendar", () => ({
},
}));
// PrimeReact Slider stub
// PrimeReact Slider stub — captures onChange per testId so tests can fire events.
// The last-mounted Slider's onChange is tracked; for the return-time slider tests
// we render with returnFlights=true so the second Slider mounts last.
let _sliderOnChange: ((e: { value: number[] }) => void) | null = null;
// All mounted sliders — keyed by render order; index 0 = outbound, 1 = return
const _sliderOnChanges: Array<((e: { value: number[] }) => void) | null> = [];
vi.mock("primereact/slider", () => ({
Slider: () => <div data-testid="slider-stub" />,
Slider: (props: Record<string, unknown>) => {
const cb = props["onChange"] as (e: { value: number[] }) => void;
_sliderOnChange = cb;
_sliderOnChanges.push(cb);
return <div data-testid="slider-stub" />;
},
}));
vi.mock("@/ui/city-autocomplete/index.js", () => ({
@@ -76,6 +87,8 @@ vi.mock("@/shared/dateWindow.js", () => ({
describe("ScheduleFilter clear-button (X) per TZ §4.1.9 Tables 13/14", () => {
beforeEach(() => {
vi.clearAllMocks();
_sliderOnChange = null;
_sliderOnChanges.length = 0;
});
// -------------------------------------------------------------------------
@@ -170,3 +183,48 @@ describe("ScheduleFilter clear-button (X) per TZ §4.1.9 Tables 13/14", () =
expect(screen.queryByTestId("time-slider-clear")).toBeNull();
});
});
describe("ScheduleFilter time slider 1h minimum gap per TZ §4.1.9 Table 14", () => {
beforeEach(() => {
vi.clearAllMocks();
_sliderOnChange = null;
_sliderOnChanges.length = 0;
});
it("4.1.9-R: outbound time slider enforces 1h minimum gap (zero gap → to clamped to from + 60)", () => {
render(<ScheduleFilter initialTimeFrom="0600" initialTimeTo="0700" />);
act(() => {
_sliderOnChanges[0]?.({ value: [600, 600] });
});
const value = screen.getByTestId("time-selector").querySelector(".time-selector__value");
expect(value?.textContent).toBe("10:00 — 11:00");
});
it("4.1.9-R: outbound time slider cannot set timeTo before timeFrom (inverted range normalised)", () => {
render(<ScheduleFilter />);
act(() => {
_sliderOnChanges[0]?.({ value: [800, 600] });
});
const value = screen.getByTestId("time-selector").querySelector(".time-selector__value");
expect(value?.textContent).toBe("10:00 — 13:20");
});
it("4.1.9-R: outbound time slider clamps from at right edge (24:00 zero gap → 23:00 24:00)", () => {
render(<ScheduleFilter />);
act(() => {
_sliderOnChanges[0]?.({ value: [1440, 1440] });
});
const value = screen.getByTestId("time-selector").querySelector(".time-selector__value");
expect(value?.textContent).toBe("23:00 — 24:00");
});
it("4.1.9-R: return time slider enforces 1h minimum gap", () => {
render(<ScheduleFilter initialReturnFlights={true} />);
// _sliderOnChanges[0] = outbound, _sliderOnChanges[1] = return
act(() => {
_sliderOnChanges[1]?.({ value: [720, 720] });
});
const value = screen.getByTestId("return-time-selector").querySelector(".time-selector__value");
expect(value?.textContent).toBe("12:00 — 13:00");
});
});
@@ -346,9 +346,21 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
<div className="time-selector">
<Slider
value={timeRange}
onChange={(e: SliderChangeEvent) =>
setTimeRange(e.value as [number, number])
}
onChange={(e: SliderChangeEvent) => {
const raw = e.value as [number, number];
const MIN_GAP = 60;
let from = Math.min(raw[0], raw[1]);
let to = Math.max(raw[0], raw[1]);
if (to - from < MIN_GAP) {
if (from + MIN_GAP <= 1440) {
to = from + MIN_GAP;
} else {
to = 1440;
from = to - MIN_GAP;
}
}
setTimeRange([from, to]);
}}
range
min={0}
max={1440}
@@ -429,9 +441,21 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
<div className="time-selector">
<Slider
value={returnTimeRange}
onChange={(e: SliderChangeEvent) =>
setReturnTimeRange(e.value as [number, number])
}
onChange={(e: SliderChangeEvent) => {
const raw = e.value as [number, number];
const MIN_GAP = 60;
let from = Math.min(raw[0], raw[1]);
let to = Math.max(raw[0], raw[1]);
if (to - from < MIN_GAP) {
if (from + MIN_GAP <= 1440) {
to = from + MIN_GAP;
} else {
to = 1440;
from = to - MIN_GAP;
}
}
setReturnTimeRange([from, to]);
}}
range
min={0}
max={1440}