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:
2026-04-10 23:14:59 +02:00
parent 591842c5a1
commit c794e82464
9 changed files with 223 additions and 0 deletions
@@ -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");
});
});
});
+17
View File
@@ -126,4 +126,21 @@ describe("generateCsv", () => {
expect(lines[1]).toBe("abc");
expect(lines[2]).toBe("abc");
});
it("does NOT quote a cell value that contains only a carriage return (\\r is not in the escape list)", () => {
// The escapeCsvValue function checks for '\n' but not '\r'.
// A bare \r is therefore left unquoted.
const crCols = [{ header: "Field", accessor: (_r: unknown) => "line1\rline2" }];
const csv = generateCsv([{}], crCols);
const dataLine = csv.split("\n")[1];
// Because \r is not special-cased, the value is NOT wrapped in quotes
expect(dataLine).toBe("line1\rline2");
});
it("empty columns array produces just a newline for no rows", () => {
// header = "" (no columns), body = "" (no rows)
// result = "" + "\n" + "" = "\n"
const csv = generateCsv([], []);
expect(csv).toBe("\n");
});
});
+33
View File
@@ -239,3 +239,36 @@ describe("formatCents", () => {
expect(formatCents(5)).toBe("0,05");
});
});
// ---------------------------------------------------------------------------
// edge cases
// ---------------------------------------------------------------------------
describe("edge cases", () => {
it("formatCents with NaN input — passes through toLocaleString which returns 'NaN'", () => {
// NaN == null is false, so it reaches the toLocaleString branch
// NaN / 100 is NaN; de-DE toLocaleString of NaN returns "NaN"
const result = formatCents(NaN);
expect(result).toBe("NaN");
});
it("formatCents with Number.MAX_SAFE_INTEGER — no precision loss in string output", () => {
// Verify it returns a non-empty, numeric-looking string and not '-'
const result = formatCents(Number.MAX_SAFE_INTEGER);
expect(result).not.toBe("-");
expect(result.length).toBeGreaterThan(0);
// The integer part should contain the expected leading digits (90071992547409)
expect(result).toContain("90.071.992.547.409");
});
it("toDateInputValue with an invalid date string — returns 'NaN-NaN-NaN'", () => {
// new Date("not-a-date") produces an Invalid Date; getFullYear() etc. return NaN
expect(toDateInputValue("not-a-date")).toBe("NaN-NaN-NaN");
});
it("formatMoney with 0 cents — returns the zero euro representation", () => {
const result = formatMoney(0);
// de-DE locale: "0 €" (with non-breaking space before €)
expect(result).toContain("0");
expect(result).toContain("€");
});
});
+16
View File
@@ -69,4 +69,20 @@ describe("sanitizeHtml", () => {
expect(result).not.toContain("iframe");
expect(result).toContain("safe");
});
it("strips self-closing <br /> tag leaving no tag remnants", () => {
const result = sanitizeHtml("line1<br />line2");
expect(result).not.toContain("<br");
expect(result).not.toContain("/>");
expect(result).toContain("line1");
expect(result).toContain("line2");
});
it("strips self-closing <img /> tag with attributes leaving no tag remnants", () => {
const result = sanitizeHtml('before<img src="x" alt="y" />after');
expect(result).not.toContain("<img");
expect(result).not.toContain("src=");
expect(result).toContain("before");
expect(result).toContain("after");
});
});