From a3d75973ee751a323974370c58e2839df71ad6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 10 Apr 2026 17:14:11 +0200 Subject: [PATCH] test(web): add 291 tests for parsers, hooks, and UI components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/ui/AnimatedModal.test.tsx | 148 ++++++++ apps/web/src/components/ui/DateInput.test.tsx | 169 +++++++++ .../src/components/ui/InfoTooltip.test.tsx | 151 ++++++++ .../src/components/ui/ProgressRing.test.tsx | 197 ++++++++++ apps/web/src/hooks/useFilters.test.ts | 316 ++++++++++++++++ apps/web/src/hooks/usePermissions.test.ts | 243 +++++++++++++ apps/web/src/hooks/useRowOrder.test.ts | 241 ++++++++++++ apps/web/src/hooks/useViewPrefs.test.ts | 318 ++++++++++++++++ apps/web/src/lib/planningEntryIds.test.ts | 106 ++++++ apps/web/src/lib/scopeImportParser.test.ts | 275 ++++++++++++++ apps/web/src/lib/status-styles.test.ts | 344 ++++++++++++++++++ apps/web/src/lib/uuid.test.ts | 118 ++++++ 12 files changed, 2626 insertions(+) create mode 100644 apps/web/src/components/ui/AnimatedModal.test.tsx create mode 100644 apps/web/src/components/ui/DateInput.test.tsx create mode 100644 apps/web/src/components/ui/InfoTooltip.test.tsx create mode 100644 apps/web/src/components/ui/ProgressRing.test.tsx create mode 100644 apps/web/src/hooks/useFilters.test.ts create mode 100644 apps/web/src/hooks/usePermissions.test.ts create mode 100644 apps/web/src/hooks/useRowOrder.test.ts create mode 100644 apps/web/src/hooks/useViewPrefs.test.ts create mode 100644 apps/web/src/lib/planningEntryIds.test.ts create mode 100644 apps/web/src/lib/scopeImportParser.test.ts create mode 100644 apps/web/src/lib/status-styles.test.ts create mode 100644 apps/web/src/lib/uuid.test.ts diff --git a/apps/web/src/components/ui/AnimatedModal.test.tsx b/apps/web/src/components/ui/AnimatedModal.test.tsx new file mode 100644 index 0000000..af24e24 --- /dev/null +++ b/apps/web/src/components/ui/AnimatedModal.test.tsx @@ -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 & { children?: React.ReactNode }) => ( +
{children}
+ ), + }, + 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:

Modal content

, +}; + +describe("AnimatedModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("visibility", () => { + it("renders children when open is true", () => { + render(); + expect(screen.getByText("Modal content")).toBeInTheDocument(); + }); + + it("renders nothing when open is false", () => { + render(); + expect(screen.queryByText("Modal content")).not.toBeInTheDocument(); + }); + }); + + describe("ARIA semantics", () => { + it("renders a dialog element with aria-modal", () => { + render(); + 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(); + // 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( + , + ); + 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(); + 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(); + 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(); + await user.keyboard("{Escape}"); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + describe("className props", () => { + it("applies custom maxWidth class to the panel", () => { + render(); + const dialog = screen.getByRole("dialog"); + expect(dialog.className).toContain("max-w-2xl"); + }); + + it("uses max-w-xl as the default maxWidth", () => { + render(); + const dialog = screen.getByRole("dialog"); + expect(dialog.className).toContain("max-w-xl"); + }); + + it("applies additional className to the panel", () => { + render(); + const dialog = screen.getByRole("dialog"); + expect(dialog.className).toContain("my-custom-class"); + }); + + it("applies custom overlayClassName when provided", () => { + const { container } = render( + , + ); + 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(); + 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( + + + , + ); + expect(screen.getByRole("button", { name: /submit/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/components/ui/DateInput.test.tsx b/apps/web/src/components/ui/DateInput.test.tsx new file mode 100644 index 0000000..23ce1d3 --- /dev/null +++ b/apps/web/src/components/ui/DateInput.test.tsx @@ -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> = {}) { + const onChange = props.onChange ?? vi.fn(); + const utils = render(); + 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(); + 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(); + 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"); + }); + }); +}); diff --git a/apps/web/src/components/ui/InfoTooltip.test.tsx b/apps/web/src/components/ui/InfoTooltip.test.tsx new file mode 100644 index 0000000..7e1fd63 --- /dev/null +++ b/apps/web/src/components/ui/InfoTooltip.test.tsx @@ -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(); + const btn = screen.getByRole("button", { name: /more information/i }); + expect(btn).toBeInTheDocument(); + }); + + it("shows the letter i inside the button", () => { + render(); + 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(); + 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(); + 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(); + 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( + <> + + + , + ); + 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(); + 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( + + Bold text + + } + />, + ); + 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(); + 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(); + 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(); + 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(); + 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( +
+ +
, + ); + 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"); + }); + }); +}); diff --git a/apps/web/src/components/ui/ProgressRing.test.tsx b/apps/web/src/components/ui/ProgressRing.test.tsx new file mode 100644 index 0000000..cba2d48 --- /dev/null +++ b/apps/web/src/components/ui/ProgressRing.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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(); + const progress0 = getProgressCircle(container); + const { container: container0 } = render(); + const progressAt0 = getProgressCircle(container0); + expect(progress0.getAttribute("stroke-dashoffset")).toBe( + progressAt0.getAttribute("stroke-dashoffset"), + ); + }); + + it("clamps values above 100 to 100", () => { + const { container } = render(); + const progressOver = getProgressCircle(container); + const { container: container100 } = render(); + 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(); + const progress = getProgressCircle(container); + expect(progress.getAttribute("stroke")).toContain("blue-500"); + }); + + it("applies a custom color to the progress arc", () => { + const { container } = render(); + const progress = getProgressCircle(container); + expect(progress).toHaveAttribute("stroke", "#ff0000"); + }); + + it("applies the default gray trackColor to the track circle", () => { + const { container } = render(); + const track = getTrackCircle(container); + expect(track.getAttribute("stroke")).toContain("gray-200"); + }); + + it("applies a custom trackColor to the track circle", () => { + const { container } = render( + , + ); + const track = getTrackCircle(container); + expect(track).toHaveAttribute("stroke", "#cccccc"); + }); + }); + + describe("children", () => { + it("renders children overlaid in the center when provided", () => { + render( + + 75% + , + ); + 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(); + // 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(); + 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(); + const progress = getProgressCircle(container); + expect(progress).toHaveStyle({ transition: "none" }); + }); + }); + + describe("className prop", () => { + it("applies additional className to the wrapper div", () => { + const { container } = render( + , + ); + 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(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper).toHaveStyle({ width: "64px", height: "64px" }); + }); + }); +}); diff --git a/apps/web/src/hooks/useFilters.test.ts b/apps/web/src/hooks/useFilters.test.ts new file mode 100644 index 0000000..4e51557 --- /dev/null +++ b/apps/web/src/hooks/useFilters.test.ts @@ -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 }); + }); + }); +}); diff --git a/apps/web/src/hooks/usePermissions.test.ts b/apps/web/src/hooks/usePermissions.test.ts new file mode 100644 index 0000000..203cafe --- /dev/null +++ b/apps/web/src/hooks/usePermissions.test.ts @@ -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"); + }); + }); +}); diff --git a/apps/web/src/hooks/useRowOrder.test.ts b/apps/web/src/hooks/useRowOrder.test.ts new file mode 100644 index 0000000..578a5ab --- /dev/null +++ b/apps/web/src/hooks/useRowOrder.test.ts @@ -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 { + 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([]); + }); + }); +}); diff --git a/apps/web/src/hooks/useViewPrefs.test.ts b/apps/web/src/hooks/useViewPrefs.test.ts new file mode 100644 index 0000000..a47ff22 --- /dev/null +++ b/apps/web/src/hooks/useViewPrefs.test.ts @@ -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 = {}; + 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; + + 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; + 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; + 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; + 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; + 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; + expect(written).toMatchObject({ + sort: { field: "date", dir: "desc" }, + rowOrder: ["x", "y"], + }); + }); + }); +}); diff --git a/apps/web/src/lib/planningEntryIds.test.ts b/apps/web/src/lib/planningEntryIds.test.ts new file mode 100644 index 0000000..3af07f8 --- /dev/null +++ b/apps/web/src/lib/planningEntryIds.test.ts @@ -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"); + }); + }); +}); diff --git a/apps/web/src/lib/scopeImportParser.test.ts b/apps/web/src/lib/scopeImportParser.test.ts new file mode 100644 index 0000000..2260d6e --- /dev/null +++ b/apps/web/src/lib/scopeImportParser.test.ts @@ -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; + +// --------------------------------------------------------------------------- +// 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", + }); + }); +}); diff --git a/apps/web/src/lib/status-styles.test.ts b/apps/web/src/lib/status-styles.test.ts new file mode 100644 index 0000000..dfaddae --- /dev/null +++ b/apps/web/src/lib/status-styles.test.ts @@ -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"); + }); +}); diff --git a/apps/web/src/lib/uuid.test.ts b/apps/web/src/lib/uuid.test.ts new file mode 100644 index 0000000..5e345ee --- /dev/null +++ b/apps/web/src/lib/uuid.test.ts @@ -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(); + }); + }); +});