test(web): add 210 tests for lib utils, hooks, and UI components

Lib utilities: format (38), sanitize (12), project-colors (18),
csv-export (14).

Hooks: useDebounce (8), useTableSort (22), useLocalStorage (18),
useColumnConfig (19).

Components: BatchActionBar (17), SortableColumnHeader (14),
FilterChips (14), ErrorBoundary (16).

Web test suite: 63 → 75 files, 343 → 553 tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 17:11:00 +02:00
parent c0ba062460
commit 98dca6126f
12 changed files with 2280 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import { generateCsv } from "./csv-export.js";
// downloadCsv is a browser-only side-effect function (creates DOM elements and
// triggers a download). It is intentionally excluded here — testing it would
// require mocking the entire DOM download flow and adds no value for a pure
// utility test suite.
type Row = { name: string; value: number | null; note: string };
const columns = [
{ header: "Name", accessor: (r: Row) => r.name },
{ header: "Value", accessor: (r: Row) => r.value },
{ header: "Note", accessor: (r: Row) => r.note },
];
describe("generateCsv", () => {
it("produces a header row followed by a data row", () => {
const rows: Row[] = [{ name: "Alice", value: 42, note: "ok" }];
const csv = generateCsv(rows, columns);
const lines = csv.split("\n");
expect(lines[0]).toBe("Name,Value,Note");
expect(lines[1]).toBe("Alice,42,ok");
});
it("returns only the header row when there are no data rows", () => {
const csv = generateCsv([], columns);
// generateCsv always appends '\n' between header and body.
// With an empty body the result is "header\n"
expect(csv).toBe("Name,Value,Note\n");
});
it("handles multiple rows", () => {
const rows: Row[] = [
{ name: "Alice", value: 1, note: "a" },
{ name: "Bob", value: 2, note: "b" },
{ name: "Carol", value: 3, note: "c" },
];
const csv = generateCsv(rows, columns);
const lines = csv.split("\n");
expect(lines).toHaveLength(4); // header + 3 data rows
expect(lines[1]).toBe("Alice,1,a");
expect(lines[2]).toBe("Bob,2,b");
expect(lines[3]).toBe("Carol,3,c");
});
it("wraps cell values that contain a comma in double quotes", () => {
const rows: Row[] = [{ name: "Smith, John", value: 10, note: "ok" }];
const csv = generateCsv(rows, columns);
const dataLine = csv.split("\n")[1];
expect(dataLine).toBe('"Smith, John",10,ok');
});
it("escapes double quotes inside cell values by doubling them", () => {
const rows: Row[] = [{ name: 'Say "hello"', value: 0, note: "x" }];
const csv = generateCsv(rows, columns);
const dataLine = csv.split("\n")[1];
expect(dataLine).toBe('"Say ""hello""",0,x');
});
it("wraps cells that contain a newline in double quotes", () => {
const rows: Row[] = [{ name: "line1\nline2", value: 0, note: "x" }];
const csv = generateCsv(rows, columns);
// The header is the first line; the wrapped cell starts on line 2
const raw = csv.split("\n");
// Header + quoted cell (which contains an embedded newline) + remainder
expect(raw[0]).toBe("Name,Value,Note");
expect(raw[1]).toBe('"line1');
expect(raw[2]).toContain("line2");
});
it("converts null accessor results to empty strings", () => {
const rows: Row[] = [{ name: "Test", value: null, note: "" }];
const csv = generateCsv(rows, columns);
const dataLine = csv.split("\n")[1];
expect(dataLine).toBe("Test,,");
});
it("converts undefined accessor results to empty strings", () => {
type Partial = { name?: string };
const partialCols = [{ header: "Name", accessor: (r: Partial) => r.name }];
const csv = generateCsv([{}] as Partial[], partialCols);
expect(csv.split("\n")[1]).toBe("");
});
it("wraps header cells that contain a comma", () => {
const colsWithComma = [{ header: "Last, First", accessor: (r: Row) => r.name }];
const csv = generateCsv([], colsWithComma);
expect(csv.startsWith('"Last, First"')).toBe(true);
});
it("handles numeric zero values without treating them as empty", () => {
const rows: Row[] = [{ name: "Zero", value: 0, note: "" }];
const csv = generateCsv(rows, columns);
const dataLine = csv.split("\n")[1];
expect(dataLine).toBe("Zero,0,");
});
it("handles boolean accessor return values", () => {
const boolCols = [
{ header: "Active", accessor: (_r: unknown) => true },
{ header: "Deleted", accessor: (_r: unknown) => false },
];
const csv = generateCsv([{}], boolCols);
expect(csv.split("\n")[1]).toBe("true,false");
});
it("handles a value that contains both a comma and a double quote", () => {
const rows: Row[] = [{ name: 'a, "b"', value: 1, note: "" }];
const csv = generateCsv(rows, columns);
const dataLine = csv.split("\n")[1];
expect(dataLine).toBe('"a, ""b""",1,');
});
it("does not wrap plain numeric strings", () => {
const numCols = [{ header: "Num", accessor: (_r: unknown) => "12345" }];
const csv = generateCsv([{}], numCols);
expect(csv.split("\n")[1]).toBe("12345");
});
it("generates correct CSV for a single-column dataset", () => {
const singleCol = [{ header: "ID", accessor: (_r: unknown) => "abc" }];
const csv = generateCsv([{}, {}], singleCol);
const lines = csv.split("\n");
expect(lines[0]).toBe("ID");
expect(lines[1]).toBe("abc");
expect(lines[2]).toBe("abc");
});
});
+241
View File
@@ -0,0 +1,241 @@
import { describe, expect, it } from "vitest";
import {
formatCents,
formatDate,
formatDateLong,
formatDateMedium,
formatDateShort,
formatMonthYear,
formatMoney,
toDateInputValue,
} from "./format.js";
// ---------------------------------------------------------------------------
// toDateInputValue
// ---------------------------------------------------------------------------
describe("toDateInputValue", () => {
it("returns empty string for null", () => {
expect(toDateInputValue(null)).toBe("");
});
it("returns empty string for undefined", () => {
expect(toDateInputValue(undefined)).toBe("");
});
it("formats a Date object to yyyy-mm-dd", () => {
const d = new Date(2026, 2, 4); // local: 4 March 2026
expect(toDateInputValue(d)).toBe("2026-03-04");
});
it("pads single-digit month and day with leading zeros", () => {
const d = new Date(2026, 0, 9); // local: 9 January 2026
expect(toDateInputValue(d)).toBe("2026-01-09");
});
it("accepts an ISO date string", () => {
// Use a date string that is unambiguous when parsed by Date constructor
const result = toDateInputValue("2026-11-30T00:00:00");
expect(result).toBe("2026-11-30");
});
it("handles end-of-year boundary", () => {
const d = new Date(2025, 11, 31); // 31 December 2025
expect(toDateInputValue(d)).toBe("2025-12-31");
});
});
// ---------------------------------------------------------------------------
// formatDate
// ---------------------------------------------------------------------------
describe("formatDate", () => {
it("returns empty string for null", () => {
expect(formatDate(null)).toBe("");
});
it("returns empty string for undefined", () => {
expect(formatDate(undefined)).toBe("");
});
it("formats a Date using en-GB locale (dd/mm/yyyy)", () => {
// Use a date created from a local timestamp so locale formatting is stable
const d = new Date(2026, 2, 4); // 4 March 2026
expect(formatDate(d)).toBe("04/03/2026");
});
it("accepts a string input", () => {
const result = formatDate("2026-07-15T00:00:00");
expect(result).toBe("15/07/2026");
});
});
// ---------------------------------------------------------------------------
// formatDateShort
// ---------------------------------------------------------------------------
describe("formatDateShort", () => {
it("returns DD MMM format", () => {
const d = new Date(2026, 2, 4); // 4 March 2026
expect(formatDateShort(d)).toBe("04 Mar");
});
it("pads day with leading zero", () => {
const d = new Date(2026, 0, 5); // 5 January 2026
expect(formatDateShort(d)).toBe("05 Jan");
});
it("accepts a date string", () => {
const result = formatDateShort("2026-12-01T00:00:00");
expect(result).toBe("01 Dec");
});
});
// ---------------------------------------------------------------------------
// formatMonthYear
// ---------------------------------------------------------------------------
describe("formatMonthYear", () => {
it("returns MMM YY format", () => {
const d = new Date(2026, 2, 1); // March 2026
expect(formatMonthYear(d)).toBe("Mar 26");
});
it("handles year boundary (December 2025)", () => {
const d = new Date(2025, 11, 1);
expect(formatMonthYear(d)).toBe("Dec 25");
});
it("accepts a date string", () => {
const result = formatMonthYear("2026-07-01T00:00:00");
expect(result).toBe("Jul 26");
});
});
// ---------------------------------------------------------------------------
// formatDateLong
// ---------------------------------------------------------------------------
describe("formatDateLong", () => {
it("returns long form including full month name and year", () => {
const d = new Date(2026, 2, 4); // 4 March 2026
expect(formatDateLong(d)).toBe("4 March 2026");
});
it("accepts a date string", () => {
const result = formatDateLong("2026-01-01T00:00:00");
expect(result).toBe("1 January 2026");
});
it("handles single-digit day without zero-padding", () => {
const d = new Date(2026, 5, 7); // 7 June 2026
expect(formatDateLong(d)).toBe("7 June 2026");
});
});
// ---------------------------------------------------------------------------
// formatDateMedium
// ---------------------------------------------------------------------------
describe("formatDateMedium", () => {
it("returns empty string for null", () => {
expect(formatDateMedium(null)).toBe("");
});
it("returns empty string for undefined", () => {
expect(formatDateMedium(undefined)).toBe("");
});
it("returns compact date with short month and year", () => {
const d = new Date(2026, 2, 16); // 16 March 2026
expect(formatDateMedium(d)).toBe("16 Mar 2026");
});
it("accepts a date string", () => {
const result = formatDateMedium("2026-08-05T00:00:00");
expect(result).toBe("5 Aug 2026");
});
});
// ---------------------------------------------------------------------------
// formatMoney
// ---------------------------------------------------------------------------
describe("formatMoney", () => {
it("formats zero cents as 0 EUR", () => {
// The de-DE locale uses non-breaking space before the currency symbol
const result = formatMoney(0);
expect(result).toContain("0");
expect(result).toContain("€");
});
it("formats null as 0 EUR", () => {
const result = formatMoney(null);
expect(result).toContain("0");
expect(result).toContain("€");
});
it("formats undefined as 0 EUR", () => {
const result = formatMoney(undefined);
expect(result).toContain("0");
expect(result).toContain("€");
});
it("converts cents to euros (divides by 100)", () => {
// 123400 cents = 1234 EUR, de-DE: "1.234 €"
const result = formatMoney(123400);
expect(result).toContain("1.234");
expect(result).toContain("€");
});
it("rounds to whole euros by default (fractionDigits = 0)", () => {
const result = formatMoney(9950); // 99.50 EUR → rounds to 100
expect(result).toContain("100");
});
it("respects fractionDigits = 2 for precise display", () => {
const result = formatMoney(9950, "EUR", 2); // 99.50 EUR
expect(result).toContain("99,50");
});
it("supports other currencies", () => {
const result = formatMoney(10000, "USD", 0); // 100 USD
expect(result).toContain("100");
expect(result).toContain("$");
});
it("formats negative values correctly", () => {
const result = formatMoney(-50000); // -500 EUR
expect(result).toContain("500");
// de-DE locale may use a regular hyphen-minus or a minus sign
const hasNegativeSign = result.includes("") || result.includes("-");
expect(hasNegativeSign).toBe(true);
});
});
// ---------------------------------------------------------------------------
// formatCents
// ---------------------------------------------------------------------------
describe("formatCents", () => {
it("returns '-' for null", () => {
expect(formatCents(null)).toBe("-");
});
it("returns '-' for undefined", () => {
expect(formatCents(undefined)).toBe("-");
});
it("formats zero as '0,00'", () => {
expect(formatCents(0)).toBe("0,00");
});
it("formats 100 cents as '1,00'", () => {
expect(formatCents(100)).toBe("1,00");
});
it("formats 123456 cents as '1.234,56' (de-DE locale)", () => {
expect(formatCents(123456)).toBe("1.234,56");
});
it("formats negative cents correctly", () => {
expect(formatCents(-100)).toBe("-1,00");
});
it("always shows exactly 2 decimal places", () => {
expect(formatCents(50)).toBe("0,50");
expect(formatCents(5)).toBe("0,05");
});
});
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { buildProjectColorMap, getProjectColor, getProjectHex } from "./project-colors.js";
// The palette has exactly 16 entries — exported indirectly through the type.
const PALETTE_SIZE = 16;
describe("getProjectColor", () => {
it("returns an object with bg, dark, border, and hex fields", () => {
const color = getProjectColor("proj-1");
expect(color).toHaveProperty("bg");
expect(color).toHaveProperty("dark");
expect(color).toHaveProperty("border");
expect(color).toHaveProperty("hex");
});
it("is deterministic — same ID always yields the same color", () => {
const id = "stable-id-abc";
const a = getProjectColor(id);
const b = getProjectColor(id);
expect(a).toBe(b); // same object reference from the const palette
});
it("returns different colors for different IDs (at least across a sample)", () => {
const colors = new Set(
Array.from({ length: 20 }, (_, i) => getProjectColor(`project-${i}`).hex),
);
// With 20 IDs spread across 16 slots we expect several distinct colors
expect(colors.size).toBeGreaterThan(1);
});
it("handles an empty string ID without throwing", () => {
expect(() => getProjectColor("")).not.toThrow();
});
it("handles a very long ID without throwing", () => {
const longId = "x".repeat(1000);
expect(() => getProjectColor(longId)).not.toThrow();
});
it("returned hex value starts with '#'", () => {
const { hex } = getProjectColor("test-id");
expect(hex).toMatch(/^#[0-9a-fA-F]{6}$/);
});
it("cycles through all 16 palette slots given enough IDs", () => {
// Generate enough IDs to guarantee every palette slot is hit
const hexSet = new Set<string>();
for (let i = 0; i < 200; i++) {
hexSet.add(getProjectColor(`id-collision-test-${i}`).hex);
}
expect(hexSet.size).toBe(PALETTE_SIZE);
});
it("bg class contains a valid Tailwind color segment", () => {
const { bg } = getProjectColor("tailwind-check");
expect(bg).toMatch(/^bg-[a-z]+-500\/70$/);
});
it("border class follows expected pattern", () => {
const { border } = getProjectColor("border-check");
expect(border).toMatch(/^border-[a-z]+-600$/);
});
});
describe("getProjectHex", () => {
it("appends the default alpha suffix 'B3'", () => {
const result = getProjectHex("proj-alpha");
expect(result).toMatch(/^#[0-9a-fA-F]{6}B3$/);
});
it("appends a custom alpha suffix", () => {
const result = getProjectHex("proj-alpha", "FF");
expect(result).toMatch(/^#[0-9a-fA-F]{6}FF$/);
});
it("is deterministic for the same ID", () => {
const a = getProjectHex("same-id");
const b = getProjectHex("same-id");
expect(a).toBe(b);
});
it("returns a different hex when a different alpha is supplied", () => {
const withDefault = getProjectHex("id-x");
const withCustom = getProjectHex("id-x", "00");
expect(withDefault).not.toBe(withCustom);
// The base hex part should be the same
expect(withDefault.slice(0, 7)).toBe(withCustom.slice(0, 7));
});
});
describe("buildProjectColorMap", () => {
it("returns a Map with an entry for each ID", () => {
const ids = ["proj-a", "proj-b", "proj-c"];
const map = buildProjectColorMap(ids);
expect(map.size).toBe(3);
for (const id of ids) {
expect(map.has(id)).toBe(true);
}
});
it("returns an empty Map for an empty array", () => {
const map = buildProjectColorMap([]);
expect(map.size).toBe(0);
});
it("each entry equals the direct getProjectColor result", () => {
const ids = ["x", "y", "z"];
const map = buildProjectColorMap(ids);
for (const id of ids) {
expect(map.get(id)).toBe(getProjectColor(id));
}
});
it("handles duplicate IDs — last write wins (same color anyway)", () => {
const map = buildProjectColorMap(["dup", "dup", "dup"]);
expect(map.size).toBe(1);
expect(map.get("dup")).toBe(getProjectColor("dup"));
});
it("does not mutate the input array", () => {
const ids = ["a", "b"];
const copy = [...ids];
buildProjectColorMap(ids);
expect(ids).toEqual(copy);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { sanitizeHtml } from "./sanitize.js";
// The vitest environment is jsdom, so `window` is defined.
// DOMPurify will operate in client mode and strip tags.
describe("sanitizeHtml", () => {
it("returns plain text unchanged", () => {
expect(sanitizeHtml("Hello, World!")).toBe("Hello, World!");
});
it("strips a single HTML tag", () => {
expect(sanitizeHtml("<b>bold</b>")).toBe("bold");
});
it("strips nested HTML tags", () => {
expect(sanitizeHtml("<div><p>text</p></div>")).toBe("text");
});
it("strips attributes", () => {
expect(sanitizeHtml('<a href="https://example.com">link</a>')).toBe("link");
});
it("strips script tags and their content", () => {
// DOMPurify removes script tags entirely (content too)
const result = sanitizeHtml('<script>alert("xss")</script>safe');
expect(result).not.toContain("<script>");
expect(result).not.toContain("alert");
expect(result).toContain("safe");
});
it("strips event handler attributes", () => {
const result = sanitizeHtml('<img src="x" onerror="alert(1)">');
expect(result).not.toContain("onerror");
expect(result).not.toContain("alert");
});
it("returns empty string for empty input", () => {
expect(sanitizeHtml("")).toBe("");
});
it("passes through text that contains HTML entities", () => {
// DOMPurify in jsdom returns the serialised form, keeping '&amp;' as-is
// (it is not the job of sanitiseHtml to decode entities)
const result = sanitizeHtml("&amp;");
expect(result).toBeTruthy();
// Either decoded ('&') or kept encoded ('&amp;') is acceptable; the
// important thing is that no HTML tags survive.
expect(result).not.toContain("<");
expect(result).not.toContain(">");
});
it("strips style tags", () => {
const result = sanitizeHtml("<style>body{color:red}</style>text");
expect(result).not.toContain("<style>");
expect(result).toContain("text");
});
it("handles input with only whitespace", () => {
expect(sanitizeHtml(" ")).toBe(" ");
});
it("handles deeply nested tags leaving only text", () => {
expect(sanitizeHtml("<ul><li><span>item</span></li></ul>")).toBe("item");
});
it("strips iframe tags", () => {
const result = sanitizeHtml('<iframe src="evil.com"></iframe>safe');
expect(result).not.toContain("iframe");
expect(result).toContain("safe");
});
});