test(web): add 23 edge-case tests across UI components and lib utils
Covers: aria-sort/aria-labelledby attributes, non-Error throws in ErrorBoundary, NaN/MAX_SAFE_INTEGER in formatCents, invalid dates, carriage returns in CSV, self-closing HTML tags in sanitize, non-digit input in DateInput, panel-click-not-dismissing in ConfirmDialog, role="search" on FilterBar. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,18 @@ describe("AnimatedModal", () => {
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||
});
|
||||
|
||||
it("sets aria-labelledby when ariaLabelledBy prop is provided", () => {
|
||||
render(<AnimatedModal {...defaultProps} ariaLabelledBy="modal-title" />);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toHaveAttribute("aria-labelledby", "modal-title");
|
||||
});
|
||||
|
||||
it("does not set aria-labelledby when ariaLabelledBy is omitted", () => {
|
||||
render(<AnimatedModal {...defaultProps} />);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).not.toHaveAttribute("aria-labelledby");
|
||||
});
|
||||
});
|
||||
|
||||
describe("backdrop close", () => {
|
||||
|
||||
@@ -62,4 +62,19 @@ describe("ConfirmDialog", () => {
|
||||
await userEvent.click(backdrop);
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does NOT call onCancel when clicking inside the dialog panel", async () => {
|
||||
const onCancel = vi.fn();
|
||||
render(<ConfirmDialog {...defaultProps} onCancel={onCancel} />);
|
||||
// Click on the title text — this is inside the dialog panel, not the backdrop
|
||||
await userEvent.click(screen.getByText("Delete item?"));
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onCancel when clicking the message text inside the dialog panel", async () => {
|
||||
const onCancel = vi.fn();
|
||||
render(<ConfirmDialog {...defaultProps} onCancel={onCancel} />);
|
||||
await userEvent.click(screen.getByText("This action cannot be undone."));
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,4 +166,42 @@ describe("DateInput", () => {
|
||||
expect(nativeDateInput).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("strips non-digit characters typed into the input (autoSlash rejects them)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { textInput } = setup();
|
||||
await user.type(textInput, "ab/cd/efgh");
|
||||
// autoSlash strips all non-digits, so nothing should remain
|
||||
expect(textInput).toHaveValue("");
|
||||
});
|
||||
|
||||
it("returns an ISO string for a leap year Feb 29 (29/02/2024)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { textInput, onChange } = setup();
|
||||
await user.type(textInput, "29022024");
|
||||
// displayToISO only validates numeric ranges (day 1-31, month 1-12, year 1900-2100)
|
||||
// it does NOT check calendar correctness, so this passes and calls onChange
|
||||
expect(textInput).toHaveValue("29/02/2024");
|
||||
expect(onChange).toHaveBeenLastCalledWith("2024-02-29");
|
||||
});
|
||||
|
||||
it("returns an ISO string for Feb 29 on a non-leap year (29/02/2023) — no calendar validation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { textInput, onChange } = setup();
|
||||
await user.type(textInput, "29022023");
|
||||
// displayToISO does not validate calendar correctness — it only checks range bounds
|
||||
expect(textInput).toHaveValue("29/02/2023");
|
||||
expect(onChange).toHaveBeenLastCalledWith("2023-02-29");
|
||||
});
|
||||
|
||||
it("returns an ISO string for day 31 on a 30-day month (31/04/2024) — no calendar validation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { textInput, onChange } = setup();
|
||||
await user.type(textInput, "31042024");
|
||||
// displayToISO only rejects day > 31 or month > 12, not calendar-invalid combos
|
||||
expect(textInput).toHaveValue("31/04/2024");
|
||||
expect(onChange).toHaveBeenLastCalledWith("2024-04-31");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,6 +183,22 @@ describe("ErrorBoundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("catches a thrown string (non-Error object)", () => {
|
||||
function ThrowString(): React.ReactNode {
|
||||
throw "string error";
|
||||
}
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowString />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefaultErrorFallback", () => {
|
||||
it("renders the heading", () => {
|
||||
render(<DefaultErrorFallback error={new Error("Boom")} reset={vi.fn()} />);
|
||||
|
||||
@@ -50,4 +50,22 @@ describe("FilterBar", () => {
|
||||
await userEvent.click(screen.getByText("Clear filters"));
|
||||
expect(onClear).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("has role='search' on the container", () => {
|
||||
render(
|
||||
<FilterBar>
|
||||
<span>Filters</span>
|
||||
</FilterBar>,
|
||||
);
|
||||
expect(screen.getByRole("search")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has aria-label='Filters' on the container", () => {
|
||||
render(
|
||||
<FilterBar>
|
||||
<span>Filters</span>
|
||||
</FilterBar>,
|
||||
);
|
||||
expect(screen.getByRole("search")).toHaveAttribute("aria-label", "Filters");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -263,4 +263,62 @@ describe("SortableColumnHeader", () => {
|
||||
expect(container.querySelector("th")?.className).toContain("w-48");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ARIA semantics", () => {
|
||||
it("sets aria-sort='ascending' on <th> when sorted asc", () => {
|
||||
renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField="name"
|
||||
sortDir="asc"
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const th = document.querySelector("th");
|
||||
expect(th).toHaveAttribute("aria-sort", "ascending");
|
||||
});
|
||||
|
||||
it("sets aria-sort='descending' on <th> when sorted desc", () => {
|
||||
renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField="name"
|
||||
sortDir="desc"
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const th = document.querySelector("th");
|
||||
expect(th).toHaveAttribute("aria-sort", "descending");
|
||||
});
|
||||
|
||||
it("does not set aria-sort when this field is not the active sort", () => {
|
||||
renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField="budget"
|
||||
sortDir="asc"
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const th = document.querySelector("th");
|
||||
expect(th).not.toHaveAttribute("aria-sort");
|
||||
});
|
||||
|
||||
it("does not set aria-sort when sortDir is null", () => {
|
||||
renderInTable(
|
||||
<SortableColumnHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField="name"
|
||||
sortDir={null}
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const th = document.querySelector("th");
|
||||
expect(th).not.toHaveAttribute("aria-sort");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user