Enforce 1h minimum gap on time-range slider per TZ 4.1.9 Tables 12/14
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
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";
|
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", () => ({
|
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
|
// 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();
|
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">
|
<div className="time-selector">
|
||||||
<Slider
|
<Slider
|
||||||
value={timeRange}
|
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
|
range
|
||||||
min={0}
|
min={0}
|
||||||
max={1440}
|
max={1440}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
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";
|
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", () => ({
|
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", () => ({
|
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", () => {
|
describe("ScheduleFilter – clear-button (X) per TZ §4.1.9 Tables 13/14", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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();
|
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">
|
<div className="time-selector">
|
||||||
<Slider
|
<Slider
|
||||||
value={timeRange}
|
value={timeRange}
|
||||||
onChange={(e: SliderChangeEvent) =>
|
onChange={(e: SliderChangeEvent) => {
|
||||||
setTimeRange(e.value as [number, number])
|
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
|
range
|
||||||
min={0}
|
min={0}
|
||||||
max={1440}
|
max={1440}
|
||||||
@@ -429,9 +441,21 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
|||||||
<div className="time-selector">
|
<div className="time-selector">
|
||||||
<Slider
|
<Slider
|
||||||
value={returnTimeRange}
|
value={returnTimeRange}
|
||||||
onChange={(e: SliderChangeEvent) =>
|
onChange={(e: SliderChangeEvent) => {
|
||||||
setReturnTimeRange(e.value as [number, number])
|
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
|
range
|
||||||
min={0}
|
min={0}
|
||||||
max={1440}
|
max={1440}
|
||||||
|
|||||||
Reference in New Issue
Block a user