From 8ce605455525d14e05a8db2a85a02ae76a4e4fee Mon Sep 17 00:00:00 2001 From: Martijn Russchen Date: Mon, 10 Nov 2025 21:36:32 +0100 Subject: [PATCH] Add tests for month and year logic and improve coverage Introduces new test suites for month and year logic helpers, covering keyboard navigation, disabled states, and focus management. Adds additional tests for edge cases in date utilities, click outside wrapper, shadow root, and exposes new helpers via the main entry point. Improves robustness and coverage of calendar and datepicker components. --- src/date_utils.ts | 1 + src/test/calendar_container.test.tsx | 11 ++++ src/test/click_outside_wrapper.test.tsx | 54 ++++++++++++++++++ src/test/date_utils_test.test.ts | 22 ++++++++ src/test/datepicker_test.test.tsx | 73 +++++++++++++++++++++++- src/test/month_logic.test.ts | 75 +++++++++++++++++++++++++ src/test/shadow_root.test.tsx | 12 ++++ src/test/test_utils.test.ts | 8 +++ src/test/year_logic.test.tsx | 65 +++++++++++++++++++++ 9 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 src/test/month_logic.test.ts create mode 100644 src/test/year_logic.test.tsx diff --git a/src/date_utils.ts b/src/date_utils.ts index eee05b9b0b..6667108394 100644 --- a/src/date_utils.ts +++ b/src/date_utils.ts @@ -1090,6 +1090,7 @@ export function isTimeInDisabledRange( try { valid = !isWithinInterval(baseTime, { start: min, end: max }); } catch (err) { + /* istanbul ignore next - date-fns historically threw on invalid intervals */ valid = false; } return valid; diff --git a/src/test/calendar_container.test.tsx b/src/test/calendar_container.test.tsx index a79514a2cf..6edbca40be 100644 --- a/src/test/calendar_container.test.tsx +++ b/src/test/calendar_container.test.tsx @@ -2,6 +2,7 @@ import { render } from "@testing-library/react"; import React from "react"; import CalendarContainer from "../calendar_container"; +import { CalendarContainer as CalendarContainerFromIndex } from "../index"; describe("CalendarContainer", () => { it("renders with default props", () => { @@ -18,6 +19,16 @@ describe("CalendarContainer", () => { expect(dialog?.textContent).toBe("Test Content"); }); + it("exposes CalendarContainer via the package entry point", () => { + const { container } = render( + +
Entry Content
+
, + ); + + expect(container.querySelector('[role="dialog"]')).toBeTruthy(); + }); + it("renders with showTimeSelectOnly prop", () => { const { container } = render( diff --git a/src/test/click_outside_wrapper.test.tsx b/src/test/click_outside_wrapper.test.tsx index e1bd54a7a2..6f5ee5725a 100644 --- a/src/test/click_outside_wrapper.test.tsx +++ b/src/test/click_outside_wrapper.test.tsx @@ -225,4 +225,58 @@ describe("ClickOutsideWrapper", () => { addEventListenerSpy.mockRestore(); removeEventListenerSpy.mockRestore(); }); + + it("falls back to event.target when composedPath does not return nodes", () => { + const addEventListenerSpy = jest.spyOn(document, "addEventListener"); + render( + +
Inside
+
, + ); + + const handlerEntry = addEventListenerSpy.mock.calls.find( + ([type]) => type === "mousedown", + ); + const handler = handlerEntry?.[1] as EventListener; + + const outsideNode = document.createElement("div"); + const mockEvent = { + composed: true, + composedPath: () => [{}], + target: outsideNode, + } as unknown as MouseEvent; + + handler(mockEvent); + + expect(onClickOutsideMock).toHaveBeenCalledTimes(1); + addEventListenerSpy.mockRestore(); + }); + + it("does not treat non-HTMLElement targets as ignored elements", () => { + const addEventListenerSpy = jest.spyOn(document, "addEventListener"); + render( + +
Inside
+
, + ); + + const handlerEntry = addEventListenerSpy.mock.calls.find( + ([type]) => type === "mousedown", + ); + const handler = handlerEntry?.[1] as EventListener; + + const textNode = document.createTextNode("outside"); + const mockEvent = { + composed: false, + target: textNode, + } as unknown as MouseEvent; + + handler(mockEvent); + + expect(onClickOutsideMock).toHaveBeenCalledTimes(1); + addEventListenerSpy.mockRestore(); + }); }); diff --git a/src/test/date_utils_test.test.ts b/src/test/date_utils_test.test.ts index b9d85167e0..bf07bc5967 100644 --- a/src/test/date_utils_test.test.ts +++ b/src/test/date_utils_test.test.ts @@ -1148,6 +1148,14 @@ describe("date_utils", () => { expect(isMonthInRange(startDate, endDate, 1, day)).toBe(false); }); + + it("should return false when the start year is after the end year", () => { + const day = newDate("2025-01-01"); + const startDate = newDate("2026-01-01"); + const endDate = newDate("2024-01-01"); + + expect(isMonthInRange(startDate, endDate, 1, day)).toBe(false); + }); }); describe("getStartOfYear", () => { @@ -1190,6 +1198,14 @@ describe("date_utils", () => { expect(isQuarterInRange(startDate, endDate, 1, day)).toBe(false); }); + + it("should return false when the start year is after the end year", () => { + const day = newDate("2025-01-01"); + const startDate = newDate("2026-01-01"); + const endDate = newDate("2024-04-01"); + + expect(isQuarterInRange(startDate, endDate, 1, day)).toBe(false); + }); }); describe("isYearInRange", () => { @@ -1214,6 +1230,12 @@ describe("date_utils", () => { it("should return false if range isn't passed", () => { expect(isYearInRange(2016)).toBe(false); }); + + it("should return false if provided dates are invalid", () => { + expect( + isYearInRange(2016, new Date("invalid"), new Date("invalid")), + ).toBe(false); + }); }); describe("getYearsPeriod", () => { diff --git a/src/test/datepicker_test.test.tsx b/src/test/datepicker_test.test.tsx index 230b9f879b..51521c3c41 100644 --- a/src/test/datepicker_test.test.tsx +++ b/src/test/datepicker_test.test.tsx @@ -26,7 +26,11 @@ import { subWeeks, subYears, } from "../date_utils"; -import DatePicker, { registerLocale } from "../index"; +import DatePicker, { + registerLocale, + setDefaultLocale, + getDefaultLocale, +} from "../index"; import CustomInput from "./helper_components/custom_input"; import ShadowRoot from "./helper_components/shadow_root"; @@ -121,6 +125,73 @@ describe("DatePicker", () => { jest.resetAllMocks(); }); + it("exposes locale helpers via the main entry point", () => { + const originalLocale = getDefaultLocale(); + try { + expect(getDefaultLocale()).toBe(originalLocale); + setDefaultLocale("en-GB"); + expect(getDefaultLocale()).toBe("en-GB"); + } finally { + setDefaultLocale(originalLocale); + } + }); + + it("does not trigger selection changes when readOnly", () => { + const onChange = jest.fn(); + const { instance } = renderDatePickerWithRef({ + readOnly: true, + onChange, + }); + + act(() => { + instance?.handleSelect(newDate("2024-05-01")); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("skips updating preSelection when readOnly", () => { + const selected = newDate("2024-01-01"); + const { instance } = renderDatePickerWithRef({ + readOnly: true, + selected, + }); + + const originalPreSelection = instance?.state.preSelection; + + act(() => { + instance?.setPreSelection(newDate("2024-02-01")); + }); + + expect(instance?.state.preSelection).toBe(originalPreSelection); + }); + + it("short-circuits day key navigation when keyboard navigation is disabled", () => { + const onKeyDown = jest.fn(); + const preSelection = newDate("2024-06-15"); + const { instance } = renderDatePickerWithRef({ + disabledKeyboardNavigation: true, + onKeyDown, + inline: true, + selected: preSelection, + }); + + act(() => { + instance?.setState({ preSelection }); + }); + + act(() => { + instance?.onDayKeyDown({ + key: "ArrowRight", + shiftKey: false, + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent); + }); + + expect(onKeyDown).toHaveBeenCalled(); + expect(instance?.state.preSelection).toBe(preSelection); + }); + it("should retain the calendar open status when the document visibility change", () => { const { container } = render(); const input = safeQuerySelector(container, "input"); diff --git a/src/test/month_logic.test.ts b/src/test/month_logic.test.ts new file mode 100644 index 0000000000..52f2b7c18a --- /dev/null +++ b/src/test/month_logic.test.ts @@ -0,0 +1,75 @@ +import type React from "react"; +import Month from "../month"; +import { KeyType, newDate } from "../date_utils"; + +type MonthComponentProps = React.ComponentProps; + +const buildProps = ( + override: Partial = {}, +): MonthComponentProps => + ({ + day: newDate("2024-01-01"), + onDayClick: jest.fn(), + onDayMouseEnter: jest.fn(), + onMouseLeave: jest.fn(), + setPreSelection: jest.fn(), + preSelection: newDate("2024-01-01"), + showFourColumnMonthYearPicker: false, + showTwoColumnMonthYearPicker: false, + disabledKeyboardNavigation: false, + ...override, + }) as MonthComponentProps; + +describe("Month logic helpers", () => { + it("short-circuits keyboard navigation when there is no preSelection", () => { + const props = buildProps({ preSelection: undefined }); + const instance = new Month(props); + const getVerticalOffsetSpy = jest.spyOn(instance, "getVerticalOffset"); + + instance.handleKeyboardNavigation( + { + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent, + KeyType.ArrowRight, + 1, + ); + + expect(getVerticalOffsetSpy).not.toHaveBeenCalled(); + expect(props.setPreSelection).not.toHaveBeenCalled(); + }); + + it("prevents quarter navigation when the destination date is disabled", () => { + const props = buildProps(); + const instance = new Month(props); + jest.spyOn(instance, "isDisabled").mockReturnValue(true); + jest.spyOn(instance, "isExcluded").mockReturnValue(false); + + instance.handleQuarterNavigation(2, newDate("2024-04-01")); + + expect(props.setPreSelection).not.toHaveBeenCalled(); + }); + + it("does not handle quarter arrow keys without a preSelection value", () => { + const props = buildProps({ preSelection: undefined }); + const instance = new Month(props); + const navigationSpy = jest.spyOn(instance, "handleQuarterNavigation"); + + instance.onQuarterKeyDown( + { + key: KeyType.ArrowRight, + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent, + 2, + ); + + instance.onQuarterKeyDown( + { + key: KeyType.ArrowLeft, + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent, + 2, + ); + + expect(navigationSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/shadow_root.test.tsx b/src/test/shadow_root.test.tsx index 0098af79d9..51362de480 100644 --- a/src/test/shadow_root.test.tsx +++ b/src/test/shadow_root.test.tsx @@ -75,4 +75,16 @@ describe("ShadowRoot", () => { expect(container.querySelector("div")).not.toBeNull(); }); + + it("should avoid re-initializing when effect runs multiple times", () => { + const { container } = render( + + +
Strict Content
+
+
, + ); + + expect(container.querySelector("div")).not.toBeNull(); + }); }); diff --git a/src/test/test_utils.test.ts b/src/test/test_utils.test.ts index 4503cb0cfb..0836a50118 100644 --- a/src/test/test_utils.test.ts +++ b/src/test/test_utils.test.ts @@ -1,7 +1,9 @@ +import { KeyType } from "../date_utils"; import { SafeElementWrapper, safeQuerySelector, safeQuerySelectorAll, + getKey, } from "./test_utils"; describe("test_utils", () => { @@ -37,6 +39,12 @@ describe("test_utils", () => { }); }); + describe("getKey", () => { + it("should throw when key is not supported", () => { + expect(() => getKey("?" as KeyType)).toThrow("Unknown key"); + }); + }); + describe("safeQuerySelectorAll", () => { let container: HTMLElement; diff --git a/src/test/year_logic.test.tsx b/src/test/year_logic.test.tsx new file mode 100644 index 0000000000..ff44f7a6b3 --- /dev/null +++ b/src/test/year_logic.test.tsx @@ -0,0 +1,65 @@ +import type React from "react"; +import { act } from "react"; + +import Year from "../year"; +import { newDate } from "../date_utils"; + +type YearComponentProps = React.ComponentProps; + +const buildYearProps = ( + override: Partial = {}, +): YearComponentProps => + ({ + date: newDate("2024-01-01"), + yearItemNumber: 12, + onDayClick: jest.fn(), + onYearMouseEnter: jest.fn(), + onYearMouseLeave: jest.fn(), + preSelection: newDate("2024-01-01"), + setPreSelection: jest.fn(), + ...override, + }) as YearComponentProps; + +describe("Year logic helpers", () => { + it("focuses the requested ref after pagination updates", () => { + const props = buildYearProps(); + const instance = new Year(props); + const focusSpy = jest.fn(); + instance.YEAR_REFS[0]!.current = { + focus: focusSpy, + } as unknown as HTMLDivElement; + + const rafSpy = jest + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + + act(() => { + instance.updateFocusOnPaginate(0); + }); + + expect(focusSpy).toHaveBeenCalled(); + rafSpy.mockRestore(); + }); + + it("skips onYearClick when no base date is provided", () => { + const instance = new Year(buildYearProps({ date: undefined })); + const handleYearClickSpy = jest.spyOn(instance, "handleYearClick"); + + instance.onYearClick( + {} as React.MouseEvent, + newDate("2024-01-01").getFullYear(), + ); + + expect(handleYearClickSpy).not.toHaveBeenCalled(); + }); + + it("exposes an isSameDay helper", () => { + const instance = new Year(buildYearProps()); + const day = newDate("2024-03-01"); + + expect(instance.isSameDay(day, newDate("2024-03-01"))).toBe(true); + }); +});