test(web): add 291 tests for parsers, hooks, and UI components

Lib utilities: scopeImportParser (31), status-styles (58),
planningEntryIds (10), uuid (11).

Hooks: useFilters (28), useRowOrder (18), usePermissions (30),
useViewPrefs (24).

Components: AnimatedModal (14), DateInput (22), InfoTooltip (13),
ProgressRing (19).

Web test suite: 75 → 87 files, 553 → 844 tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 17:14:11 +02:00
parent 98dca6126f
commit a3d75973ee
12 changed files with 2626 additions and 0 deletions
@@ -0,0 +1,148 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "~/test-utils.js";
import userEvent from "@testing-library/user-event";
import { AnimatedModal } from "./AnimatedModal.js";
// Mock framer-motion so animations resolve immediately in jsdom
vi.mock("framer-motion", () => ({
motion: {
div: ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }) => (
<div {...props}>{children}</div>
),
},
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
// Mock useFocusTrap focus management is tested separately
vi.mock("~/hooks/useFocusTrap.js", () => ({
useFocusTrap: vi.fn(),
}));
const defaultProps = {
open: true,
onClose: vi.fn(),
children: <p>Modal content</p>,
};
describe("AnimatedModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("visibility", () => {
it("renders children when open is true", () => {
render(<AnimatedModal {...defaultProps} />);
expect(screen.getByText("Modal content")).toBeInTheDocument();
});
it("renders nothing when open is false", () => {
render(<AnimatedModal {...defaultProps} open={false} />);
expect(screen.queryByText("Modal content")).not.toBeInTheDocument();
});
});
describe("ARIA semantics", () => {
it("renders a dialog element with aria-modal", () => {
render(<AnimatedModal {...defaultProps} />);
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveAttribute("aria-modal", "true");
});
});
describe("backdrop close", () => {
it("calls onClose when the overlay is clicked (default)", () => {
const onClose = vi.fn();
const { container } = render(<AnimatedModal {...defaultProps} onClose={onClose} />);
// The overlay is the first div inside the outer container (aria-hidden)
const overlay = container.querySelector('[aria-hidden="true"]') as HTMLElement;
expect(overlay).toBeTruthy();
fireEvent.click(overlay);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does NOT call onClose when disableBackdropClose is true", () => {
const onClose = vi.fn();
const { container } = render(
<AnimatedModal {...defaultProps} onClose={onClose} disableBackdropClose />,
);
const overlay = container.querySelector('[aria-hidden="true"]') as HTMLElement;
fireEvent.click(overlay);
expect(onClose).not.toHaveBeenCalled();
});
});
describe("keyboard Escape", () => {
it("calls onClose when Escape is pressed while open", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<AnimatedModal {...defaultProps} onClose={onClose} />);
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does NOT call onClose for other keys", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<AnimatedModal {...defaultProps} onClose={onClose} />);
await user.keyboard("{Enter}");
expect(onClose).not.toHaveBeenCalled();
});
it("does NOT register the keydown listener when closed", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<AnimatedModal {...defaultProps} open={false} onClose={onClose} />);
await user.keyboard("{Escape}");
expect(onClose).not.toHaveBeenCalled();
});
});
describe("className props", () => {
it("applies custom maxWidth class to the panel", () => {
render(<AnimatedModal {...defaultProps} maxWidth="max-w-2xl" />);
const dialog = screen.getByRole("dialog");
expect(dialog.className).toContain("max-w-2xl");
});
it("uses max-w-xl as the default maxWidth", () => {
render(<AnimatedModal {...defaultProps} />);
const dialog = screen.getByRole("dialog");
expect(dialog.className).toContain("max-w-xl");
});
it("applies additional className to the panel", () => {
render(<AnimatedModal {...defaultProps} className="my-custom-class" />);
const dialog = screen.getByRole("dialog");
expect(dialog.className).toContain("my-custom-class");
});
it("applies custom overlayClassName when provided", () => {
const { container } = render(
<AnimatedModal {...defaultProps} overlayClassName="custom-overlay" />,
);
const overlay = container.querySelector('[aria-hidden="true"]') as HTMLElement;
expect(overlay.className).toContain("custom-overlay");
});
it("falls back to default overlay classes when overlayClassName is omitted", () => {
const { container } = render(<AnimatedModal {...defaultProps} />);
const overlay = container.querySelector('[aria-hidden="true"]') as HTMLElement;
expect(overlay.className).toContain("bg-black/40");
});
});
describe("children", () => {
it("renders arbitrary children inside the dialog panel", () => {
render(
<AnimatedModal {...defaultProps}>
<button>Submit</button>
</AnimatedModal>,
);
expect(screen.getByRole("button", { name: /submit/i })).toBeInTheDocument();
});
});
});
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "~/test-utils.js";
import userEvent from "@testing-library/user-event";
import { DateInput } from "./DateInput.js";
function setup(props: Partial<React.ComponentProps<typeof DateInput>> = {}) {
const onChange = props.onChange ?? vi.fn();
const utils = render(<DateInput value={props.value ?? ""} onChange={onChange} {...props} />);
const textInput = screen.getByPlaceholderText("dd/mm/yyyy");
return { ...utils, textInput, onChange };
}
describe("DateInput", () => {
describe("initial display", () => {
it("renders an empty text input when value is empty", () => {
const { textInput } = setup({ value: "" });
expect(textInput).toHaveValue("");
});
it("converts an ISO value to dd/mm/yyyy display format", () => {
const { textInput } = setup({ value: "2024-03-15" });
expect(textInput).toHaveValue("15/03/2024");
});
it("renders the calendar button", () => {
setup();
expect(screen.getByRole("button", { name: /open date picker/i })).toBeInTheDocument();
});
});
describe("text input autoSlash formatting", () => {
it("formats digits as dd/mm/yyyy while typing", async () => {
const user = userEvent.setup();
const { textInput } = setup();
await user.type(textInput, "15032024");
expect(textInput).toHaveValue("15/03/2024");
});
it("inserts first slash after two digits", async () => {
const user = userEvent.setup();
const { textInput } = setup();
await user.type(textInput, "15");
expect(textInput).toHaveValue("15");
await user.type(textInput, "0");
expect(textInput).toHaveValue("15/0");
});
it("inserts second slash after four digits", async () => {
const user = userEvent.setup();
const { textInput } = setup();
await user.type(textInput, "1503");
expect(textInput).toHaveValue("15/03");
await user.type(textInput, "2");
expect(textInput).toHaveValue("15/03/2");
});
it("calls onChange with ISO string once all 8 digits are entered", async () => {
const user = userEvent.setup();
const { textInput, onChange } = setup();
await user.type(textInput, "15032024");
expect(onChange).toHaveBeenLastCalledWith("2024-03-15");
});
it("does NOT call onChange while input is still incomplete", async () => {
const user = userEvent.setup();
const { textInput, onChange } = setup();
await user.type(textInput, "1503");
// The partial value is not valid onChange should not have been called with an ISO string
expect(onChange).not.toHaveBeenCalledWith(expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/));
});
});
describe("validation invalid dates are rejected", () => {
it("does not call onChange for a day > 31", async () => {
const user = userEvent.setup();
const { textInput, onChange } = setup();
// Day 32 is invalid
await user.type(textInput, "32032024");
expect(onChange).not.toHaveBeenCalled();
});
it("does not call onChange for month > 12", async () => {
const user = userEvent.setup();
const { textInput, onChange } = setup();
await user.type(textInput, "01132024");
expect(onChange).not.toHaveBeenCalled();
});
it("does not call onChange for year < 1900", async () => {
const user = userEvent.setup();
const { textInput, onChange } = setup();
await user.type(textInput, "01011899");
expect(onChange).not.toHaveBeenCalled();
});
it("does not call onChange for year > 2100", async () => {
const user = userEvent.setup();
const { textInput, onChange } = setup();
await user.type(textInput, "01012101");
expect(onChange).not.toHaveBeenCalled();
});
});
describe("blur normalisation", () => {
it("re-normalises the display to canonical dd/mm/yyyy on blur", async () => {
const user = userEvent.setup();
const { textInput } = setup();
await user.type(textInput, "15032024");
await user.tab(); // triggers blur
expect(textInput).toHaveValue("15/03/2024");
});
it("keeps the incomplete value on blur so the user can continue editing", async () => {
const user = userEvent.setup();
const { textInput } = setup();
await user.type(textInput, "1503");
await user.tab();
// Incomplete input should remain as-is
expect(textInput).toHaveValue("15/03");
});
it("clears display to empty on blur when the field is empty", async () => {
const user = userEvent.setup();
const { textInput } = setup({ value: "2024-03-15" });
await user.clear(textInput);
await user.tab();
expect(textInput).toHaveValue("");
});
});
describe("external value changes", () => {
it("updates display when the value prop changes", () => {
const { rerender, textInput } = setup({ value: "2024-01-01" });
expect(textInput).toHaveValue("01/01/2024");
rerender(<DateInput value="2025-12-31" onChange={vi.fn()} />);
expect(textInput).toHaveValue("31/12/2025");
});
it("clears display when value prop is reset to empty string", () => {
const { rerender, textInput } = setup({ value: "2024-01-01" });
rerender(<DateInput value="" onChange={vi.fn()} />);
expect(textInput).toHaveValue("");
});
});
describe("accessibility and props", () => {
it("forwards the id prop to the text input", () => {
setup({ id: "my-date" });
expect(screen.getByPlaceholderText("dd/mm/yyyy")).toHaveAttribute("id", "my-date");
});
it("sets the required attribute when required is true", () => {
const { textInput } = setup({ required: true });
expect(textInput).toBeRequired();
});
it("disables both the text input and the calendar button when disabled", () => {
setup({ disabled: true });
expect(screen.getByPlaceholderText("dd/mm/yyyy")).toBeDisabled();
expect(screen.getByRole("button", { name: /open date picker/i })).toBeDisabled();
});
it("hides the native date input from assistive technology", () => {
const { container } = setup();
const nativeDateInput = container.querySelector('input[type="date"]');
expect(nativeDateInput).toHaveAttribute("aria-hidden", "true");
});
});
});
@@ -0,0 +1,151 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "~/test-utils.js";
import userEvent from "@testing-library/user-event";
import { InfoTooltip } from "./InfoTooltip.js";
// InfoTooltip uses createPortal jsdom supports document.body so portals work,
// but getBoundingClientRect returns all-zeros which is fine for logic tests.
describe("InfoTooltip", () => {
describe("trigger button", () => {
it("renders a button with accessible label", () => {
render(<InfoTooltip content="Help text" />);
const btn = screen.getByRole("button", { name: /more information/i });
expect(btn).toBeInTheDocument();
});
it("shows the letter i inside the button", () => {
render(<InfoTooltip content="Help text" />);
expect(screen.getByRole("button", { name: /more information/i })).toHaveTextContent("i");
});
});
describe("hover interaction", () => {
it("shows tooltip content on mouse enter", async () => {
const user = userEvent.setup();
render(<InfoTooltip content="Hover help" />);
const btn = screen.getByRole("button", { name: /more information/i });
await user.hover(btn);
expect(screen.getByText("Hover help")).toBeInTheDocument();
});
it("hides tooltip content on mouse leave", async () => {
const user = userEvent.setup();
render(<InfoTooltip content="Hover help" />);
const btn = screen.getByRole("button", { name: /more information/i });
await user.hover(btn);
expect(screen.getByText("Hover help")).toBeInTheDocument();
await user.unhover(btn);
expect(screen.queryByText("Hover help")).not.toBeInTheDocument();
});
});
describe("focus interaction", () => {
it("shows tooltip on focus", async () => {
const user = userEvent.setup();
render(<InfoTooltip content="Focus help" />);
const btn = screen.getByRole("button", { name: /more information/i });
await user.tab(); // move focus to the button
expect(screen.getByText("Focus help")).toBeInTheDocument();
});
it("hides tooltip on blur", async () => {
const user = userEvent.setup();
render(
<>
<InfoTooltip content="Focus help" />
<button>Other</button>
</>,
);
const btn = screen.getByRole("button", { name: /more information/i });
await user.tab(); // focus the info button
expect(screen.getByText("Focus help")).toBeInTheDocument();
await user.tab(); // move focus away → blur fires
expect(screen.queryByText("Focus help")).not.toBeInTheDocument();
});
});
describe("content", () => {
it("renders plain string content inside the tooltip", async () => {
const user = userEvent.setup();
render(<InfoTooltip content="Plain string" />);
await user.hover(screen.getByRole("button", { name: /more information/i }));
expect(screen.getByText("Plain string")).toBeInTheDocument();
});
it("renders JSX content inside the tooltip", async () => {
const user = userEvent.setup();
render(
<InfoTooltip
content={
<span data-testid="rich-content">
<strong>Bold</strong> text
</span>
}
/>,
);
await user.hover(screen.getByRole("button", { name: /more information/i }));
expect(screen.getByTestId("rich-content")).toBeInTheDocument();
expect(screen.getByText("Bold")).toBeInTheDocument();
});
});
describe("position prop", () => {
it("defaults to top position (no explicit position needed)", async () => {
const user = userEvent.setup();
const { container } = render(<InfoTooltip content="Top tip" />);
await user.hover(screen.getByRole("button", { name: /more information/i }));
// The tooltip div should be in the document (rendered via portal into body)
expect(document.body.textContent).toContain("Top tip");
// Arrow class for top position includes border-t-gray-900
// Look for the arrow span inside the tooltip
const arrows = document.querySelectorAll('[class*="border-t-gray-900"]');
expect(arrows.length).toBeGreaterThan(0);
});
it("renders with bottom position without throwing", async () => {
const user = userEvent.setup();
render(<InfoTooltip content="Bottom tip" position="bottom" />);
await user.hover(screen.getByRole("button", { name: /more information/i }));
expect(document.body.textContent).toContain("Bottom tip");
});
});
describe("width prop", () => {
it("applies default width class w-60 to the tooltip", async () => {
const user = userEvent.setup();
render(<InfoTooltip content="Width test" />);
await user.hover(screen.getByRole("button", { name: /more information/i }));
// Find the tooltip container div rendered in the portal
// It has the width class directly on the tooltip wrapper
const tooltipEl = document.body.querySelector(".w-60");
expect(tooltipEl).toBeTruthy();
});
it("applies custom width class when width prop is provided", async () => {
const user = userEvent.setup();
render(<InfoTooltip content="Wide tip" width="w-72" />);
await user.hover(screen.getByRole("button", { name: /more information/i }));
const tooltipEl = document.body.querySelector(".w-72");
expect(tooltipEl).toBeTruthy();
});
});
describe("portal rendering", () => {
it("renders tooltip into document.body (not inside the component root)", async () => {
const user = userEvent.setup();
const { container } = render(
<div data-testid="component-root">
<InfoTooltip content="Portal check" />
</div>,
);
await user.hover(screen.getByRole("button", { name: /more information/i }));
// Tooltip text should NOT be inside the component root
expect(container.querySelector('[data-testid="component-root"]')?.textContent).not.toContain(
"Portal check",
);
// But it IS present in document.body
expect(document.body.textContent).toContain("Portal check");
});
});
});
@@ -0,0 +1,197 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "~/test-utils.js";
import { ProgressRing } from "./ProgressRing.js";
// Helper: grab the progress circle (second circle = the arc, not the track)
function getProgressCircle(container: HTMLElement): SVGCircleElement {
const circles = container.querySelectorAll("circle");
// First circle is the track, second is the progress arc
return circles[1] as SVGCircleElement;
}
function getTrackCircle(container: HTMLElement): SVGCircleElement {
const circles = container.querySelectorAll("circle");
return circles[0] as SVGCircleElement;
}
describe("ProgressRing", () => {
describe("SVG geometry", () => {
it("renders an SVG element with correct width and height", () => {
const { container } = render(<ProgressRing value={50} size={60} />);
const svg = container.querySelector("svg");
expect(svg).toHaveAttribute("width", "60");
expect(svg).toHaveAttribute("height", "60");
});
it("uses default size of 40 when size is not provided", () => {
const { container } = render(<ProgressRing value={50} />);
const svg = container.querySelector("svg");
expect(svg).toHaveAttribute("width", "40");
expect(svg).toHaveAttribute("height", "40");
});
it("sets the viewBox to match the size", () => {
const { container } = render(<ProgressRing value={25} size={80} />);
const svg = container.querySelector("svg");
expect(svg).toHaveAttribute("viewBox", "0 0 80 80");
});
it("positions both circles at the center of the SVG", () => {
const { container } = render(<ProgressRing value={50} size={40} strokeWidth={4} />);
const track = getTrackCircle(container);
const progress = getProgressCircle(container);
expect(track).toHaveAttribute("cx", "20");
expect(track).toHaveAttribute("cy", "20");
expect(progress).toHaveAttribute("cx", "20");
expect(progress).toHaveAttribute("cy", "20");
});
it("calculates the correct radius from size and strokeWidth", () => {
// radius = (size - strokeWidth) / 2 = (40 - 4) / 2 = 18
const { container } = render(<ProgressRing value={50} size={40} strokeWidth={4} />);
const track = getTrackCircle(container);
expect(track).toHaveAttribute("r", "18");
});
});
describe("progress offset calculation (animated=false)", () => {
// With animated=false, mounted=true from the start so offset is applied immediately
it("sets strokeDashoffset to 0 for value=100 (full circle)", () => {
const { container } = render(
<ProgressRing value={100} size={40} strokeWidth={4} animated={false} />,
);
const progress = getProgressCircle(container);
const circumference = parseFloat(progress.getAttribute("stroke-dasharray") ?? "0");
const offset = parseFloat(progress.getAttribute("stroke-dashoffset") ?? "1");
expect(offset).toBeCloseTo(0, 2);
expect(circumference).toBeGreaterThan(0);
});
it("sets strokeDashoffset equal to circumference for value=0 (empty circle)", () => {
const { container } = render(
<ProgressRing value={0} size={40} strokeWidth={4} animated={false} />,
);
const progress = getProgressCircle(container);
const circumference = parseFloat(progress.getAttribute("stroke-dasharray") ?? "0");
const offset = parseFloat(progress.getAttribute("stroke-dashoffset") ?? "0");
expect(offset).toBeCloseTo(circumference, 2);
});
it("sets strokeDashoffset to half circumference for value=50", () => {
const { container } = render(
<ProgressRing value={50} size={40} strokeWidth={4} animated={false} />,
);
const progress = getProgressCircle(container);
const circumference = parseFloat(progress.getAttribute("stroke-dasharray") ?? "0");
const offset = parseFloat(progress.getAttribute("stroke-dashoffset") ?? "0");
expect(offset).toBeCloseTo(circumference / 2, 1);
});
});
describe("value clamping", () => {
it("clamps values below 0 to 0", () => {
const { container } = render(<ProgressRing value={-10} animated={false} />);
const progress0 = getProgressCircle(container);
const { container: container0 } = render(<ProgressRing value={0} animated={false} />);
const progressAt0 = getProgressCircle(container0);
expect(progress0.getAttribute("stroke-dashoffset")).toBe(
progressAt0.getAttribute("stroke-dashoffset"),
);
});
it("clamps values above 100 to 100", () => {
const { container } = render(<ProgressRing value={150} animated={false} />);
const progressOver = getProgressCircle(container);
const { container: container100 } = render(<ProgressRing value={100} animated={false} />);
const progress100 = getProgressCircle(container100);
expect(progressOver.getAttribute("stroke-dashoffset")).toBe(
progress100.getAttribute("stroke-dashoffset"),
);
});
});
describe("colors", () => {
it("applies the default blue color to the progress arc", () => {
const { container } = render(<ProgressRing value={50} animated={false} />);
const progress = getProgressCircle(container);
expect(progress.getAttribute("stroke")).toContain("blue-500");
});
it("applies a custom color to the progress arc", () => {
const { container } = render(<ProgressRing value={50} color="#ff0000" animated={false} />);
const progress = getProgressCircle(container);
expect(progress).toHaveAttribute("stroke", "#ff0000");
});
it("applies the default gray trackColor to the track circle", () => {
const { container } = render(<ProgressRing value={50} animated={false} />);
const track = getTrackCircle(container);
expect(track.getAttribute("stroke")).toContain("gray-200");
});
it("applies a custom trackColor to the track circle", () => {
const { container } = render(
<ProgressRing value={50} trackColor="#cccccc" animated={false} />,
);
const track = getTrackCircle(container);
expect(track).toHaveAttribute("stroke", "#cccccc");
});
});
describe("children", () => {
it("renders children overlaid in the center when provided", () => {
render(
<ProgressRing value={75} animated={false}>
<span data-testid="label">75%</span>
</ProgressRing>,
);
expect(screen.getByTestId("label")).toBeInTheDocument();
expect(screen.getByText("75%")).toBeInTheDocument();
});
it("does not render the children overlay when children is not provided", () => {
const { container } = render(<ProgressRing value={75} animated={false} />);
// The absolute overlay div for children should be absent
const overlay = container.querySelector(".absolute.inset-0.flex");
expect(overlay).not.toBeInTheDocument();
});
});
describe("animation", () => {
it("sets strokeDashoffset to circumference initially when animated=true (before RAF fires)", () => {
// In jsdom, requestAnimationFrame is synchronous by default in vitest/jsdom,
// so mounted transitions to true immediately. We verify the component doesn't throw
// and renders a valid offset.
const { container } = render(<ProgressRing value={50} animated={true} />);
const progress = getProgressCircle(container);
const offset = progress.getAttribute("stroke-dashoffset");
expect(offset).not.toBeNull();
expect(isNaN(parseFloat(offset!))).toBe(false);
});
it("disables the CSS transition when animated=false", () => {
const { container } = render(<ProgressRing value={50} animated={false} />);
const progress = getProgressCircle(container);
expect(progress).toHaveStyle({ transition: "none" });
});
});
describe("className prop", () => {
it("applies additional className to the wrapper div", () => {
const { container } = render(
<ProgressRing value={50} animated={false} className="my-ring" />,
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("my-ring");
});
});
describe("wrapper dimensions", () => {
it("sets inline style width and height on the wrapper to match size", () => {
const { container } = render(<ProgressRing value={50} size={64} animated={false} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveStyle({ width: "64px", height: "64px" });
});
});
});
+316
View File
@@ -0,0 +1,316 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
// ---------------------------------------------------------------------------
// Mock next/navigation — must be registered before the module under test is
// imported so that the mock factory replaces the real module at resolve time.
// ---------------------------------------------------------------------------
const mockReplace = vi.fn();
let mockSearchParamsEntries: [string, string][] = [];
let mockPathname = "/resources";
vi.mock("next/navigation", () => {
return {
useRouter: () => ({ replace: mockReplace }),
usePathname: () => mockPathname,
useSearchParams: () => {
const params = new URLSearchParams(
mockSearchParamsEntries.map(([k, v]) => `${k}=${v}`).join("&"),
);
return params;
},
};
});
const { useFilters } = await import("./useFilters.js");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function setSearchParams(entries: [string, string][]) {
mockSearchParamsEntries = entries;
}
function lastReplaceUrl(): string {
const calls = mockReplace.mock.calls;
return calls[calls.length - 1][0] as string;
}
function lastReplaceOpts(): unknown {
const calls = mockReplace.mock.calls;
return calls[calls.length - 1][1];
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("useFilters", () => {
beforeEach(() => {
mockReplace.mockReset();
mockSearchParamsEntries = [];
mockPathname = "/resources";
});
afterEach(() => {
vi.restoreAllMocks();
});
// -------------------------------------------------------------------------
// Initial state derivation
// -------------------------------------------------------------------------
describe("initial state — no params", () => {
it("returns empty string for search when not set", () => {
const { result } = renderHook(() => useFilters());
expect(result.current.search).toBe("");
});
it("returns empty string for chapter when not set", () => {
const { result } = renderHook(() => useFilters());
expect(result.current.chapter).toBe("");
});
it("returns empty string for status when not set", () => {
const { result } = renderHook(() => useFilters());
expect(result.current.status).toBe("");
});
it("returns empty array for customFieldFilters when not set", () => {
const { result } = renderHook(() => useFilters());
expect(result.current.customFieldFilters).toEqual([]);
});
it("hasActiveFilters is false when nothing is set", () => {
const { result } = renderHook(() => useFilters());
expect(result.current.hasActiveFilters).toBe(false);
});
});
describe("initial state — params already in URL", () => {
it("reads search from the search param", () => {
setSearchParams([["search", "alice"]]);
const { result } = renderHook(() => useFilters());
expect(result.current.search).toBe("alice");
});
it("reads chapter from the chapter param", () => {
setSearchParams([["chapter", "vfx"]]);
const { result } = renderHook(() => useFilters());
expect(result.current.chapter).toBe("vfx");
});
it("reads status from the status param", () => {
setSearchParams([["status", "ACTIVE"]]);
const { result } = renderHook(() => useFilters());
expect(result.current.status).toBe("ACTIVE");
});
it("hasActiveFilters is true when search is non-empty", () => {
setSearchParams([["search", "bob"]]);
const { result } = renderHook(() => useFilters());
expect(result.current.hasActiveFilters).toBe(true);
});
it("hasActiveFilters is true when chapter is non-empty", () => {
setSearchParams([["chapter", "rigging"]]);
const { result } = renderHook(() => useFilters());
expect(result.current.hasActiveFilters).toBe(true);
});
it("hasActiveFilters is true when status is non-empty", () => {
setSearchParams([["status", "DRAFT"]]);
const { result } = renderHook(() => useFilters());
expect(result.current.hasActiveFilters).toBe(true);
});
});
// -------------------------------------------------------------------------
// customFieldFilters parsing
// -------------------------------------------------------------------------
describe("customFieldFilters parsing", () => {
it("parses a single custom field filter from cf_/cft_ params", () => {
setSearchParams([
["cf_rating", "5"],
["cft_rating", "NUMBER"],
]);
const { result } = renderHook(() => useFilters());
expect(result.current.customFieldFilters).toEqual([
{ key: "rating", value: "5", type: "NUMBER" },
]);
});
it("defaults type to TEXT when cft_ param is absent", () => {
setSearchParams([["cf_tag", "hero"]]);
const { result } = renderHook(() => useFilters());
expect(result.current.customFieldFilters).toEqual([
{ key: "tag", value: "hero", type: "TEXT" },
]);
});
it("parses multiple custom field filters", () => {
setSearchParams([
["cf_dept", "vfx"],
["cft_dept", "SELECT"],
["cf_level", "senior"],
["cft_level", "TEXT"],
]);
const { result } = renderHook(() => useFilters());
expect(result.current.customFieldFilters).toHaveLength(2);
const keys = result.current.customFieldFilters.map((f) => f.key);
expect(keys).toContain("dept");
expect(keys).toContain("level");
});
it("excludes cft_ entries from customFieldFilters (only cf_ entries are rows)", () => {
setSearchParams([
["cf_x", "1"],
["cft_x", "NUMBER"],
]);
const { result } = renderHook(() => useFilters());
expect(result.current.customFieldFilters).toHaveLength(1);
});
it("skips custom field filter when the value is empty string", () => {
setSearchParams([
["cf_empty", ""],
["cft_empty", "TEXT"],
]);
const { result } = renderHook(() => useFilters());
expect(result.current.customFieldFilters).toHaveLength(0);
});
it("hasActiveFilters is true when customFieldFilters is non-empty", () => {
setSearchParams([["cf_color", "red"]]);
const { result } = renderHook(() => useFilters());
expect(result.current.hasActiveFilters).toBe(true);
});
});
// -------------------------------------------------------------------------
// setFilter
// -------------------------------------------------------------------------
describe("setFilter", () => {
it("calls router.replace with the updated param", () => {
const { result } = renderHook(() => useFilters());
act(() => {
result.current.setFilter("search", "robot");
});
expect(mockReplace).toHaveBeenCalledOnce();
const url = lastReplaceUrl();
expect(url).toContain("search=robot");
});
it("removes the param when value is empty string", () => {
setSearchParams([["search", "robot"]]);
const { result } = renderHook(() => useFilters());
act(() => {
result.current.setFilter("search", "");
});
const url = lastReplaceUrl();
expect(url).not.toContain("search=");
});
it("preserves existing unrelated params when setting a filter", () => {
setSearchParams([["chapter", "vfx"]]);
const { result } = renderHook(() => useFilters());
act(() => {
result.current.setFilter("search", "dragon");
});
const url = lastReplaceUrl();
expect(url).toContain("chapter=vfx");
expect(url).toContain("search=dragon");
});
it("calls router.replace with scroll: false", () => {
const { result } = renderHook(() => useFilters());
act(() => {
result.current.setFilter("status", "ACTIVE");
});
expect(lastReplaceOpts()).toEqual({ scroll: false });
});
it("uses the current pathname in the replace URL", () => {
mockPathname = "/projects";
const { result } = renderHook(() => useFilters());
act(() => {
result.current.setFilter("search", "x");
});
expect(lastReplaceUrl()).toMatch(/^\/projects\?/);
});
});
// -------------------------------------------------------------------------
// setCustomFieldFilter
// -------------------------------------------------------------------------
describe("setCustomFieldFilter", () => {
it("sets cf_ and cft_ params for a new custom field filter", () => {
const { result } = renderHook(() => useFilters());
act(() => {
result.current.setCustomFieldFilter("score", "10", "NUMBER");
});
const url = lastReplaceUrl();
expect(url).toContain("cf_score=10");
expect(url).toContain("cft_score=NUMBER");
});
it("removes cf_ and cft_ params when value is empty", () => {
setSearchParams([
["cf_score", "10"],
["cft_score", "NUMBER"],
]);
const { result } = renderHook(() => useFilters());
act(() => {
result.current.setCustomFieldFilter("score", "", "NUMBER");
});
const url = lastReplaceUrl();
expect(url).not.toContain("cf_score");
expect(url).not.toContain("cft_score");
});
it("preserves other params when clearing a custom field filter", () => {
setSearchParams([
["search", "alice"],
["cf_score", "10"],
["cft_score", "NUMBER"],
]);
const { result } = renderHook(() => useFilters());
act(() => {
result.current.setCustomFieldFilter("score", "", "NUMBER");
});
const url = lastReplaceUrl();
expect(url).toContain("search=alice");
});
it("calls router.replace with scroll: false", () => {
const { result } = renderHook(() => useFilters());
act(() => {
result.current.setCustomFieldFilter("tag", "hero", "TEXT");
});
expect(lastReplaceOpts()).toEqual({ scroll: false });
});
});
// -------------------------------------------------------------------------
// clearFilters
// -------------------------------------------------------------------------
describe("clearFilters", () => {
it("calls router.replace with just the pathname (no query string)", () => {
setSearchParams([
["search", "alice"],
["chapter", "vfx"],
]);
const { result } = renderHook(() => useFilters());
act(() => {
result.current.clearFilters();
});
expect(lastReplaceUrl()).toBe(mockPathname);
});
it("calls router.replace with scroll: false when clearing", () => {
const { result } = renderHook(() => useFilters());
act(() => {
result.current.clearFilters();
});
expect(lastReplaceOpts()).toEqual({ scroll: false });
});
});
});
+243
View File
@@ -0,0 +1,243 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
// ---------------------------------------------------------------------------
// Mock next-auth/react so the hook can run without a real auth provider.
// ---------------------------------------------------------------------------
const mockUseSession = vi.fn();
vi.mock("next-auth/react", () => ({
useSession: () => mockUseSession(),
}));
const { usePermissions } = await import("./usePermissions.js");
// ---------------------------------------------------------------------------
// Helper: build a mock session object with the given role.
// ---------------------------------------------------------------------------
function sessionWith(role: string) {
return { data: { user: { role } } };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("usePermissions", () => {
beforeEach(() => {
mockUseSession.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
// -------------------------------------------------------------------------
// No session / unauthenticated
// -------------------------------------------------------------------------
describe("when there is no session", () => {
it("falls back to role USER when data is null", () => {
mockUseSession.mockReturnValue({ data: null });
const { result } = renderHook(() => usePermissions());
expect(result.current.role).toBe("USER");
});
it("falls back to role USER when data is undefined", () => {
mockUseSession.mockReturnValue({ data: undefined });
const { result } = renderHook(() => usePermissions());
expect(result.current.role).toBe("USER");
});
it("falls back to role USER when user has no role property", () => {
mockUseSession.mockReturnValue({ data: { user: {} } });
const { result } = renderHook(() => usePermissions());
expect(result.current.role).toBe("USER");
});
it("denies all privileged permissions for anonymous user", () => {
mockUseSession.mockReturnValue({ data: null });
const { result } = renderHook(() => usePermissions());
expect(result.current.canViewCosts).toBe(false);
expect(result.current.canEdit).toBe(false);
expect(result.current.canManageUsers).toBe(false);
expect(result.current.canManageBlueprints).toBe(false);
expect(result.current.canViewScores).toBe(false);
});
});
// -------------------------------------------------------------------------
// ADMIN role
// -------------------------------------------------------------------------
describe("ADMIN role", () => {
beforeEach(() => {
mockUseSession.mockReturnValue(sessionWith("ADMIN"));
});
it("exposes role as ADMIN", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.role).toBe("ADMIN");
});
it("canViewCosts is true", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canViewCosts).toBe(true);
});
it("canEdit is true", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canEdit).toBe(true);
});
it("canManageUsers is true", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canManageUsers).toBe(true);
});
it("canManageBlueprints is true", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canManageBlueprints).toBe(true);
});
it("canViewScores is true", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canViewScores).toBe(true);
});
});
// -------------------------------------------------------------------------
// MANAGER role
// -------------------------------------------------------------------------
describe("MANAGER role", () => {
beforeEach(() => {
mockUseSession.mockReturnValue(sessionWith("MANAGER"));
});
it("exposes role as MANAGER", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.role).toBe("MANAGER");
});
it("canViewCosts is true", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canViewCosts).toBe(true);
});
it("canEdit is true", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canEdit).toBe(true);
});
it("canManageUsers is false", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canManageUsers).toBe(false);
});
it("canManageBlueprints is false", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canManageBlueprints).toBe(false);
});
it("canViewScores is true", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canViewScores).toBe(true);
});
});
// -------------------------------------------------------------------------
// CONTROLLER role
// -------------------------------------------------------------------------
describe("CONTROLLER role", () => {
beforeEach(() => {
mockUseSession.mockReturnValue(sessionWith("CONTROLLER"));
});
it("exposes role as CONTROLLER", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.role).toBe("CONTROLLER");
});
it("canViewCosts is true", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canViewCosts).toBe(true);
});
it("canEdit is false (CONTROLLER is not in EDIT_ROLES)", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canEdit).toBe(false);
});
it("canManageUsers is false", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canManageUsers).toBe(false);
});
it("canManageBlueprints is false", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canManageBlueprints).toBe(false);
});
it("canViewScores is false (CONTROLLER is not in SCORE_ROLES)", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canViewScores).toBe(false);
});
});
// -------------------------------------------------------------------------
// USER role (default / authenticated but unprivileged)
// -------------------------------------------------------------------------
describe("USER role", () => {
beforeEach(() => {
mockUseSession.mockReturnValue(sessionWith("USER"));
});
it("exposes role as USER", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.role).toBe("USER");
});
it("canViewCosts is false", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canViewCosts).toBe(false);
});
it("canEdit is false", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canEdit).toBe(false);
});
it("canManageUsers is false", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canManageUsers).toBe(false);
});
it("canManageBlueprints is false", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canManageBlueprints).toBe(false);
});
it("canViewScores is false", () => {
const { result } = renderHook(() => usePermissions());
expect(result.current.canViewScores).toBe(false);
});
});
// -------------------------------------------------------------------------
// Unknown / arbitrary role
// -------------------------------------------------------------------------
describe("unknown role", () => {
it("denies all privileged permissions for an unknown role string", () => {
mockUseSession.mockReturnValue(sessionWith("SUPER_DUPER_ADMIN"));
const { result } = renderHook(() => usePermissions());
expect(result.current.canViewCosts).toBe(false);
expect(result.current.canEdit).toBe(false);
expect(result.current.canManageUsers).toBe(false);
expect(result.current.canManageBlueprints).toBe(false);
expect(result.current.canViewScores).toBe(false);
});
it("still surfaces the raw role string for arbitrary roles", () => {
mockUseSession.mockReturnValue(sessionWith("VIEWER"));
const { result } = renderHook(() => usePermissions());
expect(result.current.role).toBe("VIEWER");
});
});
});
+241
View File
@@ -0,0 +1,241 @@
import { describe, expect, it, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useRowOrder } from "./useRowOrder.js";
import type { ViewPrefsHandle } from "./useViewPrefs.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build a minimal ViewPrefsHandle stub for testing. */
function makePrefs(
rowOrder: string[] = [],
setRowOrder = vi.fn(),
): Pick<ViewPrefsHandle, "rowOrder" | "setRowOrder"> {
return { rowOrder, setRowOrder };
}
type Row = { id: string; label: string };
function makeRows(ids: string[]): Row[] {
return ids.map((id) => ({ id, label: `Label-${id}` }));
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("useRowOrder", () => {
// -------------------------------------------------------------------------
// orderedRows — sorting logic
// -------------------------------------------------------------------------
describe("orderedRows", () => {
it("returns rows unchanged when activeSortField is set", () => {
const rows = makeRows(["b", "a", "c"]);
const prefs = makePrefs(["a", "b", "c"]);
const { result } = renderHook(() => useRowOrder(rows, prefs, "name", vi.fn()));
// Even though rowOrder says a→b→c, the active sort must suppress it
expect(result.current.orderedRows.map((r) => r.id)).toEqual(["b", "a", "c"]);
});
it("returns rows unchanged when rowOrder is empty", () => {
const rows = makeRows(["b", "a", "c"]);
const prefs = makePrefs([]);
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
expect(result.current.orderedRows.map((r) => r.id)).toEqual(["b", "a", "c"]);
});
it("sorts rows according to rowOrder when no sort is active", () => {
const rows = makeRows(["b", "a", "c"]);
const prefs = makePrefs(["a", "b", "c"]);
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
expect(result.current.orderedRows.map((r) => r.id)).toEqual(["a", "b", "c"]);
});
it("places rows missing from rowOrder at the end", () => {
const rows = makeRows(["x", "a", "b", "y"]);
// Only a and b have explicit positions; x and y go to the end
const prefs = makePrefs(["a", "b"]);
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
const ids = result.current.orderedRows.map((r) => r.id);
expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("x"));
expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("x"));
});
it("handles a rowOrder that is a partial subset of rows", () => {
const rows = makeRows(["c", "a", "b"]);
const prefs = makePrefs(["b", "c"]);
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
const ids = result.current.orderedRows.map((r) => r.id);
expect(ids[0]).toBe("b");
expect(ids[1]).toBe("c");
// "a" has no saved position → appended after
expect(ids[2]).toBe("a");
});
it("does not mutate the original rows array", () => {
const rows = makeRows(["c", "a", "b"]);
const originalRef = rows;
const prefs = makePrefs(["a", "b", "c"]);
renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
expect(rows).toBe(originalRef);
});
});
// -------------------------------------------------------------------------
// isCustomOrder
// -------------------------------------------------------------------------
describe("isCustomOrder", () => {
it("is false when rowOrder is empty", () => {
const { result } = renderHook(() =>
useRowOrder(makeRows(["a"]), makePrefs([]), null, vi.fn()),
);
expect(result.current.isCustomOrder).toBe(false);
});
it("is false when activeSortField is set (even if rowOrder is non-empty)", () => {
const { result } = renderHook(() =>
useRowOrder(makeRows(["a", "b"]), makePrefs(["b", "a"]), "name", vi.fn()),
);
expect(result.current.isCustomOrder).toBe(false);
});
it("is true when rowOrder is non-empty and no sort is active", () => {
const { result } = renderHook(() =>
useRowOrder(makeRows(["a", "b"]), makePrefs(["b", "a"]), null, vi.fn()),
);
expect(result.current.isCustomOrder).toBe(true);
});
});
// -------------------------------------------------------------------------
// reorder
// -------------------------------------------------------------------------
describe("reorder", () => {
it("calls setRowOrder with the reordered ID list", () => {
const setRowOrder = vi.fn();
const rows = makeRows(["a", "b", "c"]);
const prefs = makePrefs([], setRowOrder);
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
act(() => {
result.current.reorder("c", "a");
});
// "c" dragged to position of "a": result should be [c, a, b]
expect(setRowOrder).toHaveBeenCalledWith(["c", "a", "b"]);
});
it("calls resetSort to clear any active column sort", () => {
const resetSort = vi.fn();
const rows = makeRows(["a", "b", "c"]);
const prefs = makePrefs([], vi.fn());
const { result } = renderHook(() => useRowOrder(rows, prefs, null, resetSort));
act(() => {
result.current.reorder("b", "a");
});
expect(resetSort).toHaveBeenCalledOnce();
});
it("is a no-op when draggedId equals targetId", () => {
const setRowOrder = vi.fn();
const resetSort = vi.fn();
const rows = makeRows(["a", "b"]);
const prefs = makePrefs([], setRowOrder);
const { result } = renderHook(() => useRowOrder(rows, prefs, null, resetSort));
act(() => {
result.current.reorder("a", "a");
});
expect(setRowOrder).not.toHaveBeenCalled();
expect(resetSort).not.toHaveBeenCalled();
});
it("is a no-op when draggedId is not found in current rows", () => {
const setRowOrder = vi.fn();
const rows = makeRows(["a", "b"]);
const prefs = makePrefs([], setRowOrder);
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
act(() => {
result.current.reorder("unknown", "a");
});
expect(setRowOrder).not.toHaveBeenCalled();
});
it("is a no-op when targetId is not found in current rows", () => {
const setRowOrder = vi.fn();
const rows = makeRows(["a", "b"]);
const prefs = makePrefs([], setRowOrder);
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
act(() => {
result.current.reorder("a", "unknown");
});
expect(setRowOrder).not.toHaveBeenCalled();
});
it("moves an item from the end to the beginning", () => {
const setRowOrder = vi.fn();
const rows = makeRows(["a", "b", "c"]);
const prefs = makePrefs([], setRowOrder);
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
act(() => {
result.current.reorder("c", "a");
});
expect(setRowOrder).toHaveBeenCalledWith(["c", "a", "b"]);
});
it("moves an item from the beginning to the end", () => {
const setRowOrder = vi.fn();
const rows = makeRows(["a", "b", "c"]);
const prefs = makePrefs([], setRowOrder);
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
act(() => {
result.current.reorder("a", "c");
});
expect(setRowOrder).toHaveBeenCalledWith(["b", "c", "a"]);
});
it("reorders correctly when a custom rowOrder is already applied", () => {
const setRowOrder = vi.fn();
// Existing saved order: b, a, c → orderedRows will be [b, a, c]
const rows = makeRows(["a", "b", "c"]);
const prefs = makePrefs(["b", "a", "c"], setRowOrder);
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
// Drag "c" (last) to the position of "b" (first)
act(() => {
result.current.reorder("c", "b");
});
expect(setRowOrder).toHaveBeenCalledWith(["c", "b", "a"]);
});
});
// -------------------------------------------------------------------------
// resetOrder
// -------------------------------------------------------------------------
describe("resetOrder", () => {
it("calls setRowOrder with an empty array", () => {
const setRowOrder = vi.fn();
const rows = makeRows(["a", "b"]);
const prefs = makePrefs(["b", "a"], setRowOrder);
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
act(() => {
result.current.resetOrder();
});
expect(setRowOrder).toHaveBeenCalledWith([]);
});
});
});
+318
View File
@@ -0,0 +1,318 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
// ---------------------------------------------------------------------------
// Mock the tRPC client — must be registered before the module is imported.
// ---------------------------------------------------------------------------
const mockMutate = vi.fn();
vi.mock("~/lib/trpc/client.js", () => ({
trpc: {
user: {
setColumnPreferences: {
useMutation: () => ({ mutate: mockMutate }),
},
},
},
}));
const { useViewPrefs } = await import("./useViewPrefs.js");
// ---------------------------------------------------------------------------
// localStorage stub — isolate tests from real browser storage
// ---------------------------------------------------------------------------
function createLocalStorageStub() {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
clear: vi.fn(() => {
store = {};
}),
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("useViewPrefs", () => {
let lsStub: ReturnType<typeof createLocalStorageStub>;
beforeEach(() => {
vi.useFakeTimers();
lsStub = createLocalStorageStub();
vi.spyOn(window, "localStorage", "get").mockReturnValue(lsStub as unknown as Storage);
mockMutate.mockReset();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
// -------------------------------------------------------------------------
// Initial state
// -------------------------------------------------------------------------
describe("initial state", () => {
it("savedSort is null when localStorage is empty", () => {
const { result } = renderHook(() => useViewPrefs("resources"));
expect(result.current.savedSort).toBeNull();
});
it("rowOrder is an empty array when localStorage is empty", () => {
const { result } = renderHook(() => useViewPrefs("resources"));
expect(result.current.rowOrder).toEqual([]);
});
it("reads savedSort from localStorage on mount", () => {
lsStub.getItem.mockReturnValue(JSON.stringify({ sort: { field: "name", dir: "asc" } }));
const { result } = renderHook(() => useViewPrefs("resources"));
expect(result.current.savedSort).toEqual({ field: "name", dir: "asc" });
});
it("reads rowOrder from localStorage on mount", () => {
lsStub.getItem.mockReturnValue(JSON.stringify({ rowOrder: ["b", "a", "c"] }));
const { result } = renderHook(() => useViewPrefs("resources"));
expect(result.current.rowOrder).toEqual(["b", "a", "c"]);
});
it("reads from the correct localStorage key for the view", () => {
renderHook(() => useViewPrefs("projects"));
expect(lsStub.getItem).toHaveBeenCalledWith("viewprefs_projects");
});
it("handles malformed JSON in localStorage gracefully", () => {
lsStub.getItem.mockReturnValue("{broken json{{");
const { result } = renderHook(() => useViewPrefs("resources"));
expect(result.current.savedSort).toBeNull();
expect(result.current.rowOrder).toEqual([]);
});
});
// -------------------------------------------------------------------------
// setSavedSort
// -------------------------------------------------------------------------
describe("setSavedSort", () => {
it("updates savedSort in state immediately", () => {
const { result } = renderHook(() => useViewPrefs("resources"));
act(() => {
result.current.setSavedSort({ field: "age", dir: "desc" });
});
expect(result.current.savedSort).toEqual({ field: "age", dir: "desc" });
});
it("persists the sort to localStorage", () => {
const { result } = renderHook(() => useViewPrefs("resources"));
act(() => {
result.current.setSavedSort({ field: "age", dir: "asc" });
});
const written = JSON.parse(lsStub.setItem.mock.calls[0][1] as string) as unknown;
expect(written).toMatchObject({ sort: { field: "age", dir: "asc" } });
});
it("removes the sort key from localStorage when set to null", () => {
lsStub.getItem.mockReturnValue(JSON.stringify({ sort: { field: "name", dir: "asc" } }));
const { result } = renderHook(() => useViewPrefs("resources"));
act(() => {
result.current.setSavedSort(null);
});
const lastCall = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1];
const written = JSON.parse(lastCall[1] as string) as Record<string, unknown>;
expect(written).not.toHaveProperty("sort");
});
it("does not call the server mutation before the debounce delay", () => {
const { result } = renderHook(() => useViewPrefs("resources"));
act(() => {
result.current.setSavedSort({ field: "name", dir: "asc" });
});
expect(mockMutate).not.toHaveBeenCalled();
});
it("calls the server mutation after the 600 ms debounce fires", () => {
const { result } = renderHook(() => useViewPrefs("resources"));
act(() => {
result.current.setSavedSort({ field: "name", dir: "asc" });
});
act(() => {
vi.advanceTimersByTime(600);
});
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({ view: "resources", sort: { field: "name", dir: "asc" } }),
);
});
it("debounces multiple rapid calls — only fires once after the last call", () => {
const { result } = renderHook(() => useViewPrefs("resources"));
act(() => {
result.current.setSavedSort({ field: "name", dir: "asc" });
result.current.setSavedSort({ field: "age", dir: "desc" });
result.current.setSavedSort({ field: "status", dir: "asc" });
});
act(() => {
vi.advanceTimersByTime(600);
});
expect(mockMutate).toHaveBeenCalledOnce();
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({ sort: { field: "status", dir: "asc" } }),
);
});
it("sends null sort to the server mutation when cleared", () => {
const { result } = renderHook(() => useViewPrefs("resources"));
act(() => {
result.current.setSavedSort(null);
});
act(() => {
vi.advanceTimersByTime(600);
});
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({ view: "resources", sort: null }),
);
});
});
// -------------------------------------------------------------------------
// setRowOrder
// -------------------------------------------------------------------------
describe("setRowOrder", () => {
it("updates rowOrder in state immediately", () => {
const { result } = renderHook(() => useViewPrefs("resources"));
act(() => {
result.current.setRowOrder(["c", "a", "b"]);
});
expect(result.current.rowOrder).toEqual(["c", "a", "b"]);
});
it("persists the rowOrder to localStorage", () => {
const { result } = renderHook(() => useViewPrefs("resources"));
act(() => {
result.current.setRowOrder(["c", "a", "b"]);
});
const written = JSON.parse(
lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1][1] as string,
) as Record<string, unknown>;
expect(written).toMatchObject({ rowOrder: ["c", "a", "b"] });
});
it("removes the rowOrder key from localStorage when set to an empty array", () => {
lsStub.getItem.mockReturnValue(JSON.stringify({ rowOrder: ["a", "b"] }));
const { result } = renderHook(() => useViewPrefs("resources"));
act(() => {
result.current.setRowOrder([]);
});
const lastCall = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1];
const written = JSON.parse(lastCall[1] as string) as Record<string, unknown>;
expect(written).not.toHaveProperty("rowOrder");
});
it("does not call the server mutation before the debounce delay", () => {
const { result } = renderHook(() => useViewPrefs("resources"));
act(() => {
result.current.setRowOrder(["a", "b"]);
});
expect(mockMutate).not.toHaveBeenCalled();
});
it("calls the server mutation after the 600 ms debounce fires", () => {
const { result } = renderHook(() => useViewPrefs("resources"));
act(() => {
result.current.setRowOrder(["a", "b"]);
});
act(() => {
vi.advanceTimersByTime(600);
});
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({ view: "resources", rowOrder: ["a", "b"] }),
);
});
it("sends null rowOrder to the server mutation when the array is empty", () => {
const { result } = renderHook(() => useViewPrefs("resources"));
act(() => {
result.current.setRowOrder([]);
});
act(() => {
vi.advanceTimersByTime(600);
});
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({ view: "resources", rowOrder: null }),
);
});
it("debounces multiple rapid drag reorders into a single mutation", () => {
const { result } = renderHook(() => useViewPrefs("resources"));
act(() => {
result.current.setRowOrder(["a", "b", "c"]);
result.current.setRowOrder(["b", "a", "c"]);
result.current.setRowOrder(["c", "b", "a"]);
});
act(() => {
vi.advanceTimersByTime(600);
});
expect(mockMutate).toHaveBeenCalledOnce();
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({ rowOrder: ["c", "b", "a"] }),
);
});
});
// -------------------------------------------------------------------------
// localStorage key isolation per view
// -------------------------------------------------------------------------
describe("localStorage isolation between views", () => {
it("writes to the correct key for 'projects' view", () => {
const { result } = renderHook(() => useViewPrefs("projects"));
act(() => {
result.current.setSavedSort({ field: "name", dir: "asc" });
});
expect(lsStub.setItem).toHaveBeenCalledWith("viewprefs_projects", expect.any(String));
});
it("writes to the correct key for 'users' view", () => {
const { result } = renderHook(() => useViewPrefs("users"));
act(() => {
result.current.setRowOrder(["1", "2"]);
});
expect(lsStub.setItem).toHaveBeenCalledWith("viewprefs_users", expect.any(String));
});
});
// -------------------------------------------------------------------------
// Interaction: sort and rowOrder coexist in localStorage
// -------------------------------------------------------------------------
describe("combined sort + rowOrder state", () => {
it("preserves existing sort when rowOrder is updated", () => {
lsStub.getItem.mockReturnValue(JSON.stringify({ sort: { field: "name", dir: "asc" } }));
const { result } = renderHook(() => useViewPrefs("resources"));
act(() => {
result.current.setRowOrder(["b", "a"]);
});
const lastCall = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1];
const written = JSON.parse(lastCall[1] as string) as Record<string, unknown>;
expect(written).toMatchObject({
sort: { field: "name", dir: "asc" },
rowOrder: ["b", "a"],
});
});
it("preserves existing rowOrder when sort is updated", () => {
lsStub.getItem.mockReturnValue(JSON.stringify({ rowOrder: ["x", "y"] }));
const { result } = renderHook(() => useViewPrefs("resources"));
act(() => {
result.current.setSavedSort({ field: "date", dir: "desc" });
});
const lastCall = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1];
const written = JSON.parse(lastCall[1] as string) as Record<string, unknown>;
expect(written).toMatchObject({
sort: { field: "date", dir: "desc" },
rowOrder: ["x", "y"],
});
});
});
});
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { getPlanningEntryMutationId } from "./planningEntryIds.js";
describe("getPlanningEntryMutationId", () => {
// ---------------------------------------------------------------------------
// Priority: entityId first
// ---------------------------------------------------------------------------
it("returns entityId when all three fields are present", () => {
const result = getPlanningEntryMutationId({
id: "id-1",
entityId: "entity-1",
sourceAllocationId: "alloc-1",
});
expect(result).toBe("entity-1");
});
it("returns entityId when sourceAllocationId is absent", () => {
const result = getPlanningEntryMutationId({
id: "id-1",
entityId: "entity-1",
});
expect(result).toBe("entity-1");
});
it("returns entityId when sourceAllocationId is null", () => {
const result = getPlanningEntryMutationId({
id: "id-1",
entityId: "entity-1",
sourceAllocationId: null,
});
expect(result).toBe("entity-1");
});
// ---------------------------------------------------------------------------
// Fallback: sourceAllocationId when entityId is absent/null/undefined
// ---------------------------------------------------------------------------
it("returns sourceAllocationId when entityId is null", () => {
const result = getPlanningEntryMutationId({
id: "id-1",
entityId: null,
sourceAllocationId: "alloc-1",
});
expect(result).toBe("alloc-1");
});
it("returns sourceAllocationId when entityId is undefined", () => {
const result = getPlanningEntryMutationId({
id: "id-1",
entityId: undefined,
sourceAllocationId: "alloc-1",
});
expect(result).toBe("alloc-1");
});
it("returns sourceAllocationId when entityId field is omitted", () => {
const result = getPlanningEntryMutationId({
id: "id-1",
sourceAllocationId: "alloc-1",
});
expect(result).toBe("alloc-1");
});
// ---------------------------------------------------------------------------
// Final fallback: id
// ---------------------------------------------------------------------------
it("returns id when both entityId and sourceAllocationId are null", () => {
const result = getPlanningEntryMutationId({
id: "id-1",
entityId: null,
sourceAllocationId: null,
});
expect(result).toBe("id-1");
});
it("returns id when both entityId and sourceAllocationId are undefined", () => {
const result = getPlanningEntryMutationId({
id: "id-1",
entityId: undefined,
sourceAllocationId: undefined,
});
expect(result).toBe("id-1");
});
it("returns id when only id is provided", () => {
const result = getPlanningEntryMutationId({ id: "id-only" });
expect(result).toBe("id-only");
});
// ---------------------------------------------------------------------------
// Return-type guarantee
// ---------------------------------------------------------------------------
it("always returns a string", () => {
const cases = [
{ id: "x", entityId: "e", sourceAllocationId: "a" },
{ id: "x", entityId: null, sourceAllocationId: "a" },
{ id: "x", entityId: null, sourceAllocationId: null },
];
cases.forEach((entry) => {
expect(typeof getPlanningEntryMutationId(entry)).toBe("string");
});
});
});
+275
View File
@@ -0,0 +1,275 @@
import { describe, expect, it, vi } from "vitest";
import type { ParsedScopeRow, ScopeImportResult } from "./scopeImportParser.js";
// parseScopeImport depends on parseSpreadsheet from "./excel.js", which does
// real file I/O. We mock that module so the parser logic can be tested in
// isolation with plain in-memory data.
vi.mock("./excel.js", () => ({
parseSpreadsheet: vi.fn(),
}));
import { parseScopeImport } from "./scopeImportParser.js";
import { parseSpreadsheet } from "./excel.js";
const mockSpreadsheet = parseSpreadsheet as ReturnType<typeof vi.fn>;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeFile(): File {
return new File(["dummy"], "test.xlsx");
}
// ---------------------------------------------------------------------------
// Empty / degenerate input
// ---------------------------------------------------------------------------
describe("parseScopeImport empty input", () => {
it("returns a warning and no rows when the spreadsheet is empty", async () => {
mockSpreadsheet.mockResolvedValueOnce([]);
const result: ScopeImportResult = await parseScopeImport(makeFile());
expect(result.rows).toHaveLength(0);
expect(result.warnings).toContain("File contains no data rows.");
expect(result.mapping.name).toBeNull();
});
it("returns a warning when no name column can be identified", async () => {
mockSpreadsheet.mockResolvedValueOnce([{ quantity: "3", budget: "1000" }]);
const result = await parseScopeImport(makeFile());
expect(result.rows).toHaveLength(0);
expect(result.warnings.some((w) => w.includes('"Name" column'))).toBe(true);
});
it("returns a warning when name column is present but all values are empty", async () => {
mockSpreadsheet.mockResolvedValueOnce([{ name: " " }, { name: "" }]);
const result = await parseScopeImport(makeFile());
expect(result.rows).toHaveLength(0);
expect(result.warnings).toContain("No rows with a non-empty name found.");
});
});
// ---------------------------------------------------------------------------
// Column header resolution aliases
// ---------------------------------------------------------------------------
describe("parseScopeImport header alias resolution", () => {
it("maps 'title' to the name field", async () => {
mockSpreadsheet.mockResolvedValueOnce([{ title: "Shot A" }]);
const result = await parseScopeImport(makeFile());
expect(result.mapping.name).toBe("title");
expect(result.rows[0]?.name).toBe("Shot A");
});
it("maps 'seq' to sequenceNo and uses its integer value", async () => {
mockSpreadsheet.mockResolvedValueOnce([{ seq: "5", name: "Shot B" }]);
const result = await parseScopeImport(makeFile());
expect(result.mapping.sequenceNo).toBe("seq");
expect(result.rows[0]?.sequenceNo).toBe(5);
});
it("maps 'type' to scopeType", async () => {
mockSpreadsheet.mockResolvedValueOnce([{ type: "ASSET", name: "Prop01" }]);
const result = await parseScopeImport(makeFile());
expect(result.mapping.scopeType).toBe("type");
expect(result.rows[0]?.scopeType).toBe("ASSET");
});
it("maps 'pkg' to packageCode", async () => {
mockSpreadsheet.mockResolvedValueOnce([{ pkg: "PKG-01", name: "Shot C" }]);
const result = await parseScopeImport(makeFile());
expect(result.mapping.packageCode).toBe("pkg");
expect(result.rows[0]?.packageCode).toBe("PKG-01");
});
it("maps 'desc' to description", async () => {
mockSpreadsheet.mockResolvedValueOnce([{ desc: "Some details", name: "Shot D" }]);
const result = await parseScopeImport(makeFile());
expect(result.mapping.description).toBe("desc");
expect(result.rows[0]?.description).toBe("Some details");
});
it("treats header matching as case-insensitive", async () => {
mockSpreadsheet.mockResolvedValueOnce([{ NAME: "Shot E", TYPE: "VFX" }]);
const result = await parseScopeImport(makeFile());
expect(result.mapping.name).toBe("NAME");
expect(result.mapping.scopeType).toBe("TYPE");
});
it("handles headers with mixed casing and spaces", async () => {
mockSpreadsheet.mockResolvedValueOnce([{ "Sequence No": "10", Name: "Shot F" }]);
const result = await parseScopeImport(makeFile());
expect(result.mapping.sequenceNo).toBe("Sequence No");
expect(result.rows[0]?.sequenceNo).toBe(10);
});
});
// ---------------------------------------------------------------------------
// Sequence number handling
// ---------------------------------------------------------------------------
describe("parseScopeImport sequence number auto-numbering", () => {
it("auto-numbers rows when no sequenceNo column is present", async () => {
mockSpreadsheet.mockResolvedValueOnce([
{ name: "Shot A" },
{ name: "Shot B" },
{ name: "Shot C" },
]);
const result = await parseScopeImport(makeFile());
expect(result.rows[0]?.sequenceNo).toBe(1);
expect(result.rows[1]?.sequenceNo).toBe(2);
expect(result.rows[2]?.sequenceNo).toBe(3);
expect(result.warnings).toContain("No sequence number column detected — auto-numbering.");
});
it("falls back to auto-number when sequenceNo value is not a positive integer", async () => {
mockSpreadsheet.mockResolvedValueOnce([
{ seq: "0", name: "Shot A" },
{ seq: "-5", name: "Shot B" },
{ seq: "abc", name: "Shot C" },
]);
const result = await parseScopeImport(makeFile());
result.rows.forEach((row, i) => {
expect(row.sequenceNo).toBe(i + 1);
});
});
it("respects explicit positive integer sequence numbers", async () => {
mockSpreadsheet.mockResolvedValueOnce([
{ seq: "100", name: "Shot A" },
{ seq: "200", name: "Shot B" },
]);
const result = await parseScopeImport(makeFile());
expect(result.rows[0]?.sequenceNo).toBe(100);
expect(result.rows[1]?.sequenceNo).toBe(200);
});
});
// ---------------------------------------------------------------------------
// scopeType defaulting
// ---------------------------------------------------------------------------
describe("parseScopeImport scopeType defaulting", () => {
it("defaults scopeType to SHOT when no type column is present", async () => {
mockSpreadsheet.mockResolvedValueOnce([{ name: "Shot A" }]);
const result = await parseScopeImport(makeFile());
expect(result.rows[0]?.scopeType).toBe("SHOT");
expect(result.warnings).toContain("No scope type column detected — defaulting to SHOT.");
});
it("defaults scopeType to SHOT when the type cell is empty", async () => {
mockSpreadsheet.mockResolvedValueOnce([{ type: " ", name: "Shot A" }]);
const result = await parseScopeImport(makeFile());
expect(result.rows[0]?.scopeType).toBe("SHOT");
});
it("preserves the provided scopeType when it is non-empty", async () => {
mockSpreadsheet.mockResolvedValueOnce([{ type: "ASSET", name: "Prop01" }]);
const result = await parseScopeImport(makeFile());
expect(result.rows[0]?.scopeType).toBe("ASSET");
});
});
// ---------------------------------------------------------------------------
// Rows with empty names are skipped
// ---------------------------------------------------------------------------
describe("parseScopeImport skipping empty-name rows", () => {
it("skips rows where the name cell is blank", async () => {
mockSpreadsheet.mockResolvedValueOnce([
{ name: "Shot A" },
{ name: "" },
{ name: "Shot C" },
{ name: " " },
]);
const result = await parseScopeImport(makeFile());
expect(result.rows).toHaveLength(2);
expect(result.rows[0]?.name).toBe("Shot A");
expect(result.rows[1]?.name).toBe("Shot C");
});
});
// ---------------------------------------------------------------------------
// Full happy-path scenario
// ---------------------------------------------------------------------------
describe("parseScopeImport full integration scenario", () => {
it("parses a complete row with all columns present", async () => {
mockSpreadsheet.mockResolvedValueOnce([
{
number: "42",
type: "VFX",
code: "PKG-VFX",
title: "Explosion",
notes: "Big boom",
},
]);
const result = await parseScopeImport(makeFile());
expect(result.warnings).toHaveLength(0);
expect(result.rows).toHaveLength(1);
const row: ParsedScopeRow = result.rows[0]!;
expect(row.sequenceNo).toBe(42);
expect(row.scopeType).toBe("VFX");
expect(row.packageCode).toBe("PKG-VFX");
expect(row.name).toBe("Explosion");
expect(row.description).toBe("Big boom");
});
it("trims whitespace from all string fields", async () => {
mockSpreadsheet.mockResolvedValueOnce([
{
name: " Trimmed Name ",
type: " SHOT ",
code: " PKG-01 ",
desc: " Some notes ",
},
]);
const result = await parseScopeImport(makeFile());
const row = result.rows[0]!;
expect(row.name).toBe("Trimmed Name");
expect(row.scopeType).toBe("SHOT");
expect(row.packageCode).toBe("PKG-01");
expect(row.description).toBe("Some notes");
});
it("returns the correct mapping alongside the rows", async () => {
mockSpreadsheet.mockResolvedValueOnce([
{ seq: "1", type: "VFX", package: "P1", name: "Shot A", description: "desc" },
]);
const result = await parseScopeImport(makeFile());
expect(result.mapping).toMatchObject({
sequenceNo: "seq",
scopeType: "type",
packageCode: "package",
name: "name",
description: "description",
});
});
});
+344
View File
@@ -0,0 +1,344 @@
import { describe, expect, it } from "vitest";
import {
ALLOCATION_STATUS_BADGE,
VACATION_STATUS_BADGE,
VACATION_TYPE_LABELS,
VACATION_TYPE_BADGE,
PROJECT_STATUS_BADGE,
ORDER_TYPE_BADGE,
VACATION_TIMELINE_COLORS,
VACATION_TIMELINE_BORDER,
VACATION_TYPE_LABELS_SHORT,
VACATION_CALENDAR_COLORS,
} from "./status-styles.js";
// ---------------------------------------------------------------------------
// ALLOCATION_STATUS_BADGE
// ---------------------------------------------------------------------------
describe("ALLOCATION_STATUS_BADGE", () => {
const expectedKeys = ["ACTIVE", "PROPOSED", "CONFIRMED", "COMPLETED", "CANCELLED"];
it("contains all expected allocation status keys", () => {
expectedKeys.forEach((key) => {
expect(ALLOCATION_STATUS_BADGE).toHaveProperty(key);
});
});
it("has no extra unexpected keys", () => {
expect(Object.keys(ALLOCATION_STATUS_BADGE).sort()).toEqual(expectedKeys.sort());
});
it.each(expectedKeys)("value for %s is a non-empty string", (key) => {
expect(typeof ALLOCATION_STATUS_BADGE[key]).toBe("string");
expect(ALLOCATION_STATUS_BADGE[key]!.length).toBeGreaterThan(0);
});
it("ACTIVE uses green classes", () => {
expect(ALLOCATION_STATUS_BADGE.ACTIVE).toContain("green");
});
it("CANCELLED uses red classes", () => {
expect(ALLOCATION_STATUS_BADGE.CANCELLED).toContain("red");
});
it("PROPOSED uses yellow classes", () => {
expect(ALLOCATION_STATUS_BADGE.PROPOSED).toContain("yellow");
});
it("CONFIRMED uses blue classes", () => {
expect(ALLOCATION_STATUS_BADGE.CONFIRMED).toContain("blue");
});
it("COMPLETED uses gray classes", () => {
expect(ALLOCATION_STATUS_BADGE.COMPLETED).toContain("gray");
});
});
// ---------------------------------------------------------------------------
// VACATION_STATUS_BADGE
// ---------------------------------------------------------------------------
describe("VACATION_STATUS_BADGE", () => {
const expectedKeys = ["PENDING", "APPROVED", "REJECTED", "CANCELLED"];
it("contains all expected vacation status keys", () => {
expectedKeys.forEach((key) => {
expect(VACATION_STATUS_BADGE).toHaveProperty(key);
});
});
it("has no extra unexpected keys", () => {
expect(Object.keys(VACATION_STATUS_BADGE).sort()).toEqual(expectedKeys.sort());
});
it.each(expectedKeys)("value for %s is a non-empty string", (key) => {
expect(typeof VACATION_STATUS_BADGE[key]).toBe("string");
expect(VACATION_STATUS_BADGE[key]!.length).toBeGreaterThan(0);
});
it("APPROVED uses green/emerald classes", () => {
expect(VACATION_STATUS_BADGE.APPROVED).toMatch(/emerald|green/);
});
it("REJECTED uses red classes", () => {
expect(VACATION_STATUS_BADGE.REJECTED).toContain("red");
});
it("PENDING uses amber/yellow classes", () => {
expect(VACATION_STATUS_BADGE.PENDING).toMatch(/amber|yellow/);
});
it("CANCELLED uses gray classes", () => {
expect(VACATION_STATUS_BADGE.CANCELLED).toContain("gray");
});
});
// ---------------------------------------------------------------------------
// VACATION_TYPE_LABELS
// ---------------------------------------------------------------------------
describe("VACATION_TYPE_LABELS", () => {
it("provides human-readable label for ANNUAL", () => {
expect(VACATION_TYPE_LABELS.ANNUAL).toBe("Annual Leave");
});
it("provides human-readable label for SICK", () => {
expect(VACATION_TYPE_LABELS.SICK).toBe("Sick Leave");
});
it("provides human-readable label for PUBLIC_HOLIDAY", () => {
expect(VACATION_TYPE_LABELS.PUBLIC_HOLIDAY).toBe("Public Holiday");
});
it("provides human-readable label for OTHER", () => {
expect(VACATION_TYPE_LABELS.OTHER).toBe("Other");
});
it("covers exactly the four expected types", () => {
expect(Object.keys(VACATION_TYPE_LABELS).sort()).toEqual([
"ANNUAL",
"OTHER",
"PUBLIC_HOLIDAY",
"SICK",
]);
});
});
// ---------------------------------------------------------------------------
// VACATION_TYPE_BADGE
// ---------------------------------------------------------------------------
describe("VACATION_TYPE_BADGE", () => {
const expectedKeys = ["ANNUAL", "SICK", "PUBLIC_HOLIDAY", "OTHER"];
it("contains all expected vacation type keys", () => {
expectedKeys.forEach((key) => {
expect(VACATION_TYPE_BADGE).toHaveProperty(key);
});
});
it("has no extra unexpected keys", () => {
expect(Object.keys(VACATION_TYPE_BADGE).sort()).toEqual(expectedKeys.sort());
});
it.each(expectedKeys)("value for %s is a non-empty string", (key) => {
expect(typeof VACATION_TYPE_BADGE[key]).toBe("string");
expect(VACATION_TYPE_BADGE[key]!.length).toBeGreaterThan(0);
});
it("SICK uses red classes", () => {
expect(VACATION_TYPE_BADGE.SICK).toContain("red");
});
it("OTHER uses purple classes", () => {
expect(VACATION_TYPE_BADGE.OTHER).toContain("purple");
});
});
// ---------------------------------------------------------------------------
// PROJECT_STATUS_BADGE
// ---------------------------------------------------------------------------
describe("PROJECT_STATUS_BADGE", () => {
const expectedKeys = ["DRAFT", "ACTIVE", "ON_HOLD", "COMPLETED", "CANCELLED"];
it("contains all expected project status keys", () => {
expectedKeys.forEach((key) => {
expect(PROJECT_STATUS_BADGE).toHaveProperty(key);
});
});
it("has no extra unexpected keys", () => {
expect(Object.keys(PROJECT_STATUS_BADGE).sort()).toEqual(expectedKeys.sort());
});
it.each(expectedKeys)("value for %s is a non-empty string", (key) => {
expect(typeof PROJECT_STATUS_BADGE[key]).toBe("string");
expect(PROJECT_STATUS_BADGE[key]!.length).toBeGreaterThan(0);
});
it("DRAFT uses gray classes", () => {
expect(PROJECT_STATUS_BADGE.DRAFT).toContain("gray");
});
it("ACTIVE uses green classes", () => {
expect(PROJECT_STATUS_BADGE.ACTIVE).toContain("green");
});
it("ON_HOLD uses yellow classes", () => {
expect(PROJECT_STATUS_BADGE.ON_HOLD).toContain("yellow");
});
it("COMPLETED uses blue classes", () => {
expect(PROJECT_STATUS_BADGE.COMPLETED).toContain("blue");
});
it("CANCELLED uses red classes", () => {
expect(PROJECT_STATUS_BADGE.CANCELLED).toContain("red");
});
});
// ---------------------------------------------------------------------------
// ORDER_TYPE_BADGE
// ---------------------------------------------------------------------------
describe("ORDER_TYPE_BADGE", () => {
const expectedKeys = ["BD", "CHARGEABLE", "INTERNAL", "OVERHEAD"];
it("contains all expected order type keys", () => {
expectedKeys.forEach((key) => {
expect(ORDER_TYPE_BADGE).toHaveProperty(key);
});
});
it("has no extra unexpected keys", () => {
expect(Object.keys(ORDER_TYPE_BADGE).sort()).toEqual(expectedKeys.sort());
});
it.each(expectedKeys)("value for %s is a non-empty string", (key) => {
expect(typeof ORDER_TYPE_BADGE[key]).toBe("string");
expect(ORDER_TYPE_BADGE[key]!.length).toBeGreaterThan(0);
});
it("BD uses purple classes", () => {
expect(ORDER_TYPE_BADGE.BD).toContain("purple");
});
it("CHARGEABLE uses green classes", () => {
expect(ORDER_TYPE_BADGE.CHARGEABLE).toContain("green");
});
it("INTERNAL uses blue classes", () => {
expect(ORDER_TYPE_BADGE.INTERNAL).toContain("blue");
});
it("OVERHEAD uses gray classes", () => {
expect(ORDER_TYPE_BADGE.OVERHEAD).toContain("gray");
});
});
// ---------------------------------------------------------------------------
// VACATION_TIMELINE_COLORS
// ---------------------------------------------------------------------------
describe("VACATION_TIMELINE_COLORS", () => {
const expectedKeys = ["ANNUAL", "SICK", "PUBLIC_HOLIDAY", "OTHER"];
it("covers all vacation types", () => {
expectedKeys.forEach((key) => {
expect(VACATION_TIMELINE_COLORS).toHaveProperty(key);
});
});
it.each(expectedKeys)("value for %s is a non-empty string", (key) => {
expect(typeof VACATION_TIMELINE_COLORS[key]).toBe("string");
expect(VACATION_TIMELINE_COLORS[key]!.length).toBeGreaterThan(0);
});
it("SICK uses red classes", () => {
expect(VACATION_TIMELINE_COLORS.SICK).toContain("red");
});
});
// ---------------------------------------------------------------------------
// VACATION_TIMELINE_BORDER
// ---------------------------------------------------------------------------
describe("VACATION_TIMELINE_BORDER", () => {
const expectedKeys = ["ANNUAL", "SICK", "PUBLIC_HOLIDAY", "OTHER"];
it("covers all vacation types", () => {
expectedKeys.forEach((key) => {
expect(VACATION_TIMELINE_BORDER).toHaveProperty(key);
});
});
it.each(expectedKeys)("value for %s starts with 'border-'", (key) => {
expect(VACATION_TIMELINE_BORDER[key]).toMatch(/^border-/);
});
it("SICK uses red border", () => {
expect(VACATION_TIMELINE_BORDER.SICK).toContain("red");
});
});
// ---------------------------------------------------------------------------
// VACATION_TYPE_LABELS_SHORT
// ---------------------------------------------------------------------------
describe("VACATION_TYPE_LABELS_SHORT", () => {
it("provides short label for ANNUAL", () => {
expect(VACATION_TYPE_LABELS_SHORT.ANNUAL).toBe("Annual");
});
it("provides short label for SICK", () => {
expect(VACATION_TYPE_LABELS_SHORT.SICK).toBe("Sick");
});
it("provides short label for PUBLIC_HOLIDAY", () => {
expect(VACATION_TYPE_LABELS_SHORT.PUBLIC_HOLIDAY).toBe("Holiday");
});
it("provides short label for OTHER", () => {
expect(VACATION_TYPE_LABELS_SHORT.OTHER).toBe("Other");
});
it("short labels are shorter than or equal to full labels", () => {
(Object.keys(VACATION_TYPE_LABELS_SHORT) as string[]).forEach((key) => {
const short = VACATION_TYPE_LABELS_SHORT[key]!;
const full = VACATION_TYPE_LABELS[key] ?? short;
expect(short.length).toBeLessThanOrEqual(full.length);
});
});
});
// ---------------------------------------------------------------------------
// VACATION_CALENDAR_COLORS
// ---------------------------------------------------------------------------
describe("VACATION_CALENDAR_COLORS", () => {
const expectedKeys = ["ANNUAL", "SICK", "PUBLIC_HOLIDAY", "OTHER"];
it("covers all vacation types", () => {
expectedKeys.forEach((key) => {
expect(VACATION_CALENDAR_COLORS).toHaveProperty(key);
});
});
it.each(expectedKeys)("value for %s starts with 'bg-'", (key) => {
expect(VACATION_CALENDAR_COLORS[key]).toMatch(/^bg-/);
});
it("SICK uses red", () => {
expect(VACATION_CALENDAR_COLORS.SICK).toContain("red");
});
it("PUBLIC_HOLIDAY uses emerald", () => {
expect(VACATION_CALENDAR_COLORS.PUBLIC_HOLIDAY).toContain("emerald");
});
it("OTHER uses purple", () => {
expect(VACATION_CALENDAR_COLORS.OTHER).toContain("purple");
});
});
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { uuid } from "./uuid.js";
// RFC 4122 v4 UUID pattern
const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
describe("uuid", () => {
// ---------------------------------------------------------------------------
// Shape and format
// ---------------------------------------------------------------------------
it("returns a string", () => {
expect(typeof uuid()).toBe("string");
});
it("matches the RFC 4122 v4 UUID format", () => {
expect(uuid()).toMatch(UUID_V4_REGEX);
});
it("has the correct length (36 characters)", () => {
expect(uuid()).toHaveLength(36);
});
it("contains hyphens at positions 8, 13, 18, and 23", () => {
const id = uuid();
expect(id[8]).toBe("-");
expect(id[13]).toBe("-");
expect(id[18]).toBe("-");
expect(id[23]).toBe("-");
});
it("has '4' as the version digit at position 14", () => {
expect(uuid()[14]).toBe("4");
});
it("has a valid variant digit at position 19 (8, 9, a, or b)", () => {
const variantChar = uuid()[19]!;
expect(["8", "9", "a", "b"]).toContain(variantChar.toLowerCase());
});
// ---------------------------------------------------------------------------
// Uniqueness
// ---------------------------------------------------------------------------
it("generates unique values on successive calls", () => {
const ids = new Set(Array.from({ length: 1000 }, () => uuid()));
expect(ids.size).toBe(1000);
});
// ---------------------------------------------------------------------------
// Fallback path (crypto.randomUUID not available)
// ---------------------------------------------------------------------------
describe("fallback when crypto.randomUUID is unavailable", () => {
let originalCrypto: Crypto;
beforeEach(() => {
originalCrypto = globalThis.crypto;
});
afterEach(() => {
Object.defineProperty(globalThis, "crypto", {
value: originalCrypto,
configurable: true,
writable: true,
});
});
it("falls back to Math.random-based generation when randomUUID is missing", () => {
// Stub crypto so that randomUUID is absent
Object.defineProperty(globalThis, "crypto", {
value: {},
configurable: true,
writable: true,
});
const id = uuid();
expect(id).toMatch(UUID_V4_REGEX);
});
it("fallback still produces unique values", () => {
Object.defineProperty(globalThis, "crypto", {
value: {},
configurable: true,
writable: true,
});
const ids = new Set(Array.from({ length: 500 }, () => uuid()));
expect(ids.size).toBe(500);
});
it("falls back when crypto is undefined", () => {
Object.defineProperty(globalThis, "crypto", {
value: undefined,
configurable: true,
writable: true,
});
const id = uuid();
expect(id).toMatch(UUID_V4_REGEX);
});
});
// ---------------------------------------------------------------------------
// Native path (crypto.randomUUID explicitly available)
// ---------------------------------------------------------------------------
describe("native crypto.randomUUID path", () => {
it("delegates to crypto.randomUUID when it is available", () => {
const spy = vi.spyOn(globalThis.crypto, "randomUUID");
uuid();
expect(spy).toHaveBeenCalledOnce();
spy.mockRestore();
});
});
});