test(web): add 291 tests for parsers, hooks, and UI components
Lib utilities: scopeImportParser (31), status-styles (58), planningEntryIds (10), uuid (11). Hooks: useFilters (28), useRowOrder (18), usePermissions (30), useViewPrefs (24). Components: AnimatedModal (14), DateInput (22), InfoTooltip (13), ProgressRing (19). Web test suite: 75 → 87 files, 553 → 844 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AnimatedModal } from "./AnimatedModal.js";
|
||||
|
||||
// Mock framer-motion so animations resolve immediately in jsdom
|
||||
vi.mock("framer-motion", () => ({
|
||||
motion: {
|
||||
div: ({
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
},
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
// Mock useFocusTrap – focus management is tested separately
|
||||
vi.mock("~/hooks/useFocusTrap.js", () => ({
|
||||
useFocusTrap: vi.fn(),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
children: <p>Modal content</p>,
|
||||
};
|
||||
|
||||
describe("AnimatedModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("visibility", () => {
|
||||
it("renders children when open is true", () => {
|
||||
render(<AnimatedModal {...defaultProps} />);
|
||||
expect(screen.getByText("Modal content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when open is false", () => {
|
||||
render(<AnimatedModal {...defaultProps} open={false} />);
|
||||
expect(screen.queryByText("Modal content")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ARIA semantics", () => {
|
||||
it("renders a dialog element with aria-modal", () => {
|
||||
render(<AnimatedModal {...defaultProps} />);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("backdrop close", () => {
|
||||
it("calls onClose when the overlay is clicked (default)", () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(<AnimatedModal {...defaultProps} onClose={onClose} />);
|
||||
// The overlay is the first div inside the outer container (aria-hidden)
|
||||
const overlay = container.querySelector('[aria-hidden="true"]') as HTMLElement;
|
||||
expect(overlay).toBeTruthy();
|
||||
fireEvent.click(overlay);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT call onClose when disableBackdropClose is true", () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(
|
||||
<AnimatedModal {...defaultProps} onClose={onClose} disableBackdropClose />,
|
||||
);
|
||||
const overlay = container.querySelector('[aria-hidden="true"]') as HTMLElement;
|
||||
fireEvent.click(overlay);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard – Escape", () => {
|
||||
it("calls onClose when Escape is pressed while open", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(<AnimatedModal {...defaultProps} onClose={onClose} />);
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT call onClose for other keys", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(<AnimatedModal {...defaultProps} onClose={onClose} />);
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT register the keydown listener when closed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(<AnimatedModal {...defaultProps} open={false} onClose={onClose} />);
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("className props", () => {
|
||||
it("applies custom maxWidth class to the panel", () => {
|
||||
render(<AnimatedModal {...defaultProps} maxWidth="max-w-2xl" />);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.className).toContain("max-w-2xl");
|
||||
});
|
||||
|
||||
it("uses max-w-xl as the default maxWidth", () => {
|
||||
render(<AnimatedModal {...defaultProps} />);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.className).toContain("max-w-xl");
|
||||
});
|
||||
|
||||
it("applies additional className to the panel", () => {
|
||||
render(<AnimatedModal {...defaultProps} className="my-custom-class" />);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.className).toContain("my-custom-class");
|
||||
});
|
||||
|
||||
it("applies custom overlayClassName when provided", () => {
|
||||
const { container } = render(
|
||||
<AnimatedModal {...defaultProps} overlayClassName="custom-overlay" />,
|
||||
);
|
||||
const overlay = container.querySelector('[aria-hidden="true"]') as HTMLElement;
|
||||
expect(overlay.className).toContain("custom-overlay");
|
||||
});
|
||||
|
||||
it("falls back to default overlay classes when overlayClassName is omitted", () => {
|
||||
const { container } = render(<AnimatedModal {...defaultProps} />);
|
||||
const overlay = container.querySelector('[aria-hidden="true"]') as HTMLElement;
|
||||
expect(overlay.className).toContain("bg-black/40");
|
||||
});
|
||||
});
|
||||
|
||||
describe("children", () => {
|
||||
it("renders arbitrary children inside the dialog panel", () => {
|
||||
render(
|
||||
<AnimatedModal {...defaultProps}>
|
||||
<button>Submit</button>
|
||||
</AnimatedModal>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /submit/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { DateInput } from "./DateInput.js";
|
||||
|
||||
function setup(props: Partial<React.ComponentProps<typeof DateInput>> = {}) {
|
||||
const onChange = props.onChange ?? vi.fn();
|
||||
const utils = render(<DateInput value={props.value ?? ""} onChange={onChange} {...props} />);
|
||||
const textInput = screen.getByPlaceholderText("dd/mm/yyyy");
|
||||
return { ...utils, textInput, onChange };
|
||||
}
|
||||
|
||||
describe("DateInput", () => {
|
||||
describe("initial display", () => {
|
||||
it("renders an empty text input when value is empty", () => {
|
||||
const { textInput } = setup({ value: "" });
|
||||
expect(textInput).toHaveValue("");
|
||||
});
|
||||
|
||||
it("converts an ISO value to dd/mm/yyyy display format", () => {
|
||||
const { textInput } = setup({ value: "2024-03-15" });
|
||||
expect(textInput).toHaveValue("15/03/2024");
|
||||
});
|
||||
|
||||
it("renders the calendar button", () => {
|
||||
setup();
|
||||
expect(screen.getByRole("button", { name: /open date picker/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("text input – autoSlash formatting", () => {
|
||||
it("formats digits as dd/mm/yyyy while typing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { textInput } = setup();
|
||||
await user.type(textInput, "15032024");
|
||||
expect(textInput).toHaveValue("15/03/2024");
|
||||
});
|
||||
|
||||
it("inserts first slash after two digits", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { textInput } = setup();
|
||||
await user.type(textInput, "15");
|
||||
expect(textInput).toHaveValue("15");
|
||||
await user.type(textInput, "0");
|
||||
expect(textInput).toHaveValue("15/0");
|
||||
});
|
||||
|
||||
it("inserts second slash after four digits", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { textInput } = setup();
|
||||
await user.type(textInput, "1503");
|
||||
expect(textInput).toHaveValue("15/03");
|
||||
await user.type(textInput, "2");
|
||||
expect(textInput).toHaveValue("15/03/2");
|
||||
});
|
||||
|
||||
it("calls onChange with ISO string once all 8 digits are entered", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { textInput, onChange } = setup();
|
||||
await user.type(textInput, "15032024");
|
||||
expect(onChange).toHaveBeenLastCalledWith("2024-03-15");
|
||||
});
|
||||
|
||||
it("does NOT call onChange while input is still incomplete", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { textInput, onChange } = setup();
|
||||
await user.type(textInput, "1503");
|
||||
// The partial value is not valid – onChange should not have been called with an ISO string
|
||||
expect(onChange).not.toHaveBeenCalledWith(expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/));
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation – invalid dates are rejected", () => {
|
||||
it("does not call onChange for a day > 31", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { textInput, onChange } = setup();
|
||||
// Day 32 is invalid
|
||||
await user.type(textInput, "32032024");
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call onChange for month > 12", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { textInput, onChange } = setup();
|
||||
await user.type(textInput, "01132024");
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call onChange for year < 1900", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { textInput, onChange } = setup();
|
||||
await user.type(textInput, "01011899");
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call onChange for year > 2100", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { textInput, onChange } = setup();
|
||||
await user.type(textInput, "01012101");
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("blur normalisation", () => {
|
||||
it("re-normalises the display to canonical dd/mm/yyyy on blur", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { textInput } = setup();
|
||||
await user.type(textInput, "15032024");
|
||||
await user.tab(); // triggers blur
|
||||
expect(textInput).toHaveValue("15/03/2024");
|
||||
});
|
||||
|
||||
it("keeps the incomplete value on blur so the user can continue editing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { textInput } = setup();
|
||||
await user.type(textInput, "1503");
|
||||
await user.tab();
|
||||
// Incomplete input – should remain as-is
|
||||
expect(textInput).toHaveValue("15/03");
|
||||
});
|
||||
|
||||
it("clears display to empty on blur when the field is empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { textInput } = setup({ value: "2024-03-15" });
|
||||
await user.clear(textInput);
|
||||
await user.tab();
|
||||
expect(textInput).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("external value changes", () => {
|
||||
it("updates display when the value prop changes", () => {
|
||||
const { rerender, textInput } = setup({ value: "2024-01-01" });
|
||||
expect(textInput).toHaveValue("01/01/2024");
|
||||
rerender(<DateInput value="2025-12-31" onChange={vi.fn()} />);
|
||||
expect(textInput).toHaveValue("31/12/2025");
|
||||
});
|
||||
|
||||
it("clears display when value prop is reset to empty string", () => {
|
||||
const { rerender, textInput } = setup({ value: "2024-01-01" });
|
||||
rerender(<DateInput value="" onChange={vi.fn()} />);
|
||||
expect(textInput).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("accessibility and props", () => {
|
||||
it("forwards the id prop to the text input", () => {
|
||||
setup({ id: "my-date" });
|
||||
expect(screen.getByPlaceholderText("dd/mm/yyyy")).toHaveAttribute("id", "my-date");
|
||||
});
|
||||
|
||||
it("sets the required attribute when required is true", () => {
|
||||
const { textInput } = setup({ required: true });
|
||||
expect(textInput).toBeRequired();
|
||||
});
|
||||
|
||||
it("disables both the text input and the calendar button when disabled", () => {
|
||||
setup({ disabled: true });
|
||||
expect(screen.getByPlaceholderText("dd/mm/yyyy")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /open date picker/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("hides the native date input from assistive technology", () => {
|
||||
const { container } = setup();
|
||||
const nativeDateInput = container.querySelector('input[type="date"]');
|
||||
expect(nativeDateInput).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { InfoTooltip } from "./InfoTooltip.js";
|
||||
|
||||
// InfoTooltip uses createPortal – jsdom supports document.body so portals work,
|
||||
// but getBoundingClientRect returns all-zeros which is fine for logic tests.
|
||||
|
||||
describe("InfoTooltip", () => {
|
||||
describe("trigger button", () => {
|
||||
it("renders a button with accessible label", () => {
|
||||
render(<InfoTooltip content="Help text" />);
|
||||
const btn = screen.getByRole("button", { name: /more information/i });
|
||||
expect(btn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the letter i inside the button", () => {
|
||||
render(<InfoTooltip content="Help text" />);
|
||||
expect(screen.getByRole("button", { name: /more information/i })).toHaveTextContent("i");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hover interaction", () => {
|
||||
it("shows tooltip content on mouse enter", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InfoTooltip content="Hover help" />);
|
||||
const btn = screen.getByRole("button", { name: /more information/i });
|
||||
await user.hover(btn);
|
||||
expect(screen.getByText("Hover help")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides tooltip content on mouse leave", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InfoTooltip content="Hover help" />);
|
||||
const btn = screen.getByRole("button", { name: /more information/i });
|
||||
await user.hover(btn);
|
||||
expect(screen.getByText("Hover help")).toBeInTheDocument();
|
||||
await user.unhover(btn);
|
||||
expect(screen.queryByText("Hover help")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("focus interaction", () => {
|
||||
it("shows tooltip on focus", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InfoTooltip content="Focus help" />);
|
||||
const btn = screen.getByRole("button", { name: /more information/i });
|
||||
await user.tab(); // move focus to the button
|
||||
expect(screen.getByText("Focus help")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides tooltip on blur", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<>
|
||||
<InfoTooltip content="Focus help" />
|
||||
<button>Other</button>
|
||||
</>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: /more information/i });
|
||||
await user.tab(); // focus the info button
|
||||
expect(screen.getByText("Focus help")).toBeInTheDocument();
|
||||
await user.tab(); // move focus away → blur fires
|
||||
expect(screen.queryByText("Focus help")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("content", () => {
|
||||
it("renders plain string content inside the tooltip", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InfoTooltip content="Plain string" />);
|
||||
await user.hover(screen.getByRole("button", { name: /more information/i }));
|
||||
expect(screen.getByText("Plain string")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders JSX content inside the tooltip", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<InfoTooltip
|
||||
content={
|
||||
<span data-testid="rich-content">
|
||||
<strong>Bold</strong> text
|
||||
</span>
|
||||
}
|
||||
/>,
|
||||
);
|
||||
await user.hover(screen.getByRole("button", { name: /more information/i }));
|
||||
expect(screen.getByTestId("rich-content")).toBeInTheDocument();
|
||||
expect(screen.getByText("Bold")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("position prop", () => {
|
||||
it("defaults to top position (no explicit position needed)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<InfoTooltip content="Top tip" />);
|
||||
await user.hover(screen.getByRole("button", { name: /more information/i }));
|
||||
// The tooltip div should be in the document (rendered via portal into body)
|
||||
expect(document.body.textContent).toContain("Top tip");
|
||||
// Arrow class for top position includes border-t-gray-900
|
||||
// Look for the arrow span inside the tooltip
|
||||
const arrows = document.querySelectorAll('[class*="border-t-gray-900"]');
|
||||
expect(arrows.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders with bottom position without throwing", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InfoTooltip content="Bottom tip" position="bottom" />);
|
||||
await user.hover(screen.getByRole("button", { name: /more information/i }));
|
||||
expect(document.body.textContent).toContain("Bottom tip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("width prop", () => {
|
||||
it("applies default width class w-60 to the tooltip", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InfoTooltip content="Width test" />);
|
||||
await user.hover(screen.getByRole("button", { name: /more information/i }));
|
||||
// Find the tooltip container div rendered in the portal
|
||||
// It has the width class directly on the tooltip wrapper
|
||||
const tooltipEl = document.body.querySelector(".w-60");
|
||||
expect(tooltipEl).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies custom width class when width prop is provided", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InfoTooltip content="Wide tip" width="w-72" />);
|
||||
await user.hover(screen.getByRole("button", { name: /more information/i }));
|
||||
const tooltipEl = document.body.querySelector(".w-72");
|
||||
expect(tooltipEl).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("portal rendering", () => {
|
||||
it("renders tooltip into document.body (not inside the component root)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<div data-testid="component-root">
|
||||
<InfoTooltip content="Portal check" />
|
||||
</div>,
|
||||
);
|
||||
await user.hover(screen.getByRole("button", { name: /more information/i }));
|
||||
// Tooltip text should NOT be inside the component root
|
||||
expect(container.querySelector('[data-testid="component-root"]')?.textContent).not.toContain(
|
||||
"Portal check",
|
||||
);
|
||||
// But it IS present in document.body
|
||||
expect(document.body.textContent).toContain("Portal check");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import { ProgressRing } from "./ProgressRing.js";
|
||||
|
||||
// Helper: grab the progress circle (second circle = the arc, not the track)
|
||||
function getProgressCircle(container: HTMLElement): SVGCircleElement {
|
||||
const circles = container.querySelectorAll("circle");
|
||||
// First circle is the track, second is the progress arc
|
||||
return circles[1] as SVGCircleElement;
|
||||
}
|
||||
|
||||
function getTrackCircle(container: HTMLElement): SVGCircleElement {
|
||||
const circles = container.querySelectorAll("circle");
|
||||
return circles[0] as SVGCircleElement;
|
||||
}
|
||||
|
||||
describe("ProgressRing", () => {
|
||||
describe("SVG geometry", () => {
|
||||
it("renders an SVG element with correct width and height", () => {
|
||||
const { container } = render(<ProgressRing value={50} size={60} />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toHaveAttribute("width", "60");
|
||||
expect(svg).toHaveAttribute("height", "60");
|
||||
});
|
||||
|
||||
it("uses default size of 40 when size is not provided", () => {
|
||||
const { container } = render(<ProgressRing value={50} />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toHaveAttribute("width", "40");
|
||||
expect(svg).toHaveAttribute("height", "40");
|
||||
});
|
||||
|
||||
it("sets the viewBox to match the size", () => {
|
||||
const { container } = render(<ProgressRing value={25} size={80} />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toHaveAttribute("viewBox", "0 0 80 80");
|
||||
});
|
||||
|
||||
it("positions both circles at the center of the SVG", () => {
|
||||
const { container } = render(<ProgressRing value={50} size={40} strokeWidth={4} />);
|
||||
const track = getTrackCircle(container);
|
||||
const progress = getProgressCircle(container);
|
||||
expect(track).toHaveAttribute("cx", "20");
|
||||
expect(track).toHaveAttribute("cy", "20");
|
||||
expect(progress).toHaveAttribute("cx", "20");
|
||||
expect(progress).toHaveAttribute("cy", "20");
|
||||
});
|
||||
|
||||
it("calculates the correct radius from size and strokeWidth", () => {
|
||||
// radius = (size - strokeWidth) / 2 = (40 - 4) / 2 = 18
|
||||
const { container } = render(<ProgressRing value={50} size={40} strokeWidth={4} />);
|
||||
const track = getTrackCircle(container);
|
||||
expect(track).toHaveAttribute("r", "18");
|
||||
});
|
||||
});
|
||||
|
||||
describe("progress offset calculation (animated=false)", () => {
|
||||
// With animated=false, mounted=true from the start so offset is applied immediately
|
||||
|
||||
it("sets strokeDashoffset to 0 for value=100 (full circle)", () => {
|
||||
const { container } = render(
|
||||
<ProgressRing value={100} size={40} strokeWidth={4} animated={false} />,
|
||||
);
|
||||
const progress = getProgressCircle(container);
|
||||
const circumference = parseFloat(progress.getAttribute("stroke-dasharray") ?? "0");
|
||||
const offset = parseFloat(progress.getAttribute("stroke-dashoffset") ?? "1");
|
||||
expect(offset).toBeCloseTo(0, 2);
|
||||
expect(circumference).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("sets strokeDashoffset equal to circumference for value=0 (empty circle)", () => {
|
||||
const { container } = render(
|
||||
<ProgressRing value={0} size={40} strokeWidth={4} animated={false} />,
|
||||
);
|
||||
const progress = getProgressCircle(container);
|
||||
const circumference = parseFloat(progress.getAttribute("stroke-dasharray") ?? "0");
|
||||
const offset = parseFloat(progress.getAttribute("stroke-dashoffset") ?? "0");
|
||||
expect(offset).toBeCloseTo(circumference, 2);
|
||||
});
|
||||
|
||||
it("sets strokeDashoffset to half circumference for value=50", () => {
|
||||
const { container } = render(
|
||||
<ProgressRing value={50} size={40} strokeWidth={4} animated={false} />,
|
||||
);
|
||||
const progress = getProgressCircle(container);
|
||||
const circumference = parseFloat(progress.getAttribute("stroke-dasharray") ?? "0");
|
||||
const offset = parseFloat(progress.getAttribute("stroke-dashoffset") ?? "0");
|
||||
expect(offset).toBeCloseTo(circumference / 2, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("value clamping", () => {
|
||||
it("clamps values below 0 to 0", () => {
|
||||
const { container } = render(<ProgressRing value={-10} animated={false} />);
|
||||
const progress0 = getProgressCircle(container);
|
||||
const { container: container0 } = render(<ProgressRing value={0} animated={false} />);
|
||||
const progressAt0 = getProgressCircle(container0);
|
||||
expect(progress0.getAttribute("stroke-dashoffset")).toBe(
|
||||
progressAt0.getAttribute("stroke-dashoffset"),
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps values above 100 to 100", () => {
|
||||
const { container } = render(<ProgressRing value={150} animated={false} />);
|
||||
const progressOver = getProgressCircle(container);
|
||||
const { container: container100 } = render(<ProgressRing value={100} animated={false} />);
|
||||
const progress100 = getProgressCircle(container100);
|
||||
expect(progressOver.getAttribute("stroke-dashoffset")).toBe(
|
||||
progress100.getAttribute("stroke-dashoffset"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("colors", () => {
|
||||
it("applies the default blue color to the progress arc", () => {
|
||||
const { container } = render(<ProgressRing value={50} animated={false} />);
|
||||
const progress = getProgressCircle(container);
|
||||
expect(progress.getAttribute("stroke")).toContain("blue-500");
|
||||
});
|
||||
|
||||
it("applies a custom color to the progress arc", () => {
|
||||
const { container } = render(<ProgressRing value={50} color="#ff0000" animated={false} />);
|
||||
const progress = getProgressCircle(container);
|
||||
expect(progress).toHaveAttribute("stroke", "#ff0000");
|
||||
});
|
||||
|
||||
it("applies the default gray trackColor to the track circle", () => {
|
||||
const { container } = render(<ProgressRing value={50} animated={false} />);
|
||||
const track = getTrackCircle(container);
|
||||
expect(track.getAttribute("stroke")).toContain("gray-200");
|
||||
});
|
||||
|
||||
it("applies a custom trackColor to the track circle", () => {
|
||||
const { container } = render(
|
||||
<ProgressRing value={50} trackColor="#cccccc" animated={false} />,
|
||||
);
|
||||
const track = getTrackCircle(container);
|
||||
expect(track).toHaveAttribute("stroke", "#cccccc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("children", () => {
|
||||
it("renders children overlaid in the center when provided", () => {
|
||||
render(
|
||||
<ProgressRing value={75} animated={false}>
|
||||
<span data-testid="label">75%</span>
|
||||
</ProgressRing>,
|
||||
);
|
||||
expect(screen.getByTestId("label")).toBeInTheDocument();
|
||||
expect(screen.getByText("75%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the children overlay when children is not provided", () => {
|
||||
const { container } = render(<ProgressRing value={75} animated={false} />);
|
||||
// The absolute overlay div for children should be absent
|
||||
const overlay = container.querySelector(".absolute.inset-0.flex");
|
||||
expect(overlay).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("animation", () => {
|
||||
it("sets strokeDashoffset to circumference initially when animated=true (before RAF fires)", () => {
|
||||
// In jsdom, requestAnimationFrame is synchronous by default in vitest/jsdom,
|
||||
// so mounted transitions to true immediately. We verify the component doesn't throw
|
||||
// and renders a valid offset.
|
||||
const { container } = render(<ProgressRing value={50} animated={true} />);
|
||||
const progress = getProgressCircle(container);
|
||||
const offset = progress.getAttribute("stroke-dashoffset");
|
||||
expect(offset).not.toBeNull();
|
||||
expect(isNaN(parseFloat(offset!))).toBe(false);
|
||||
});
|
||||
|
||||
it("disables the CSS transition when animated=false", () => {
|
||||
const { container } = render(<ProgressRing value={50} animated={false} />);
|
||||
const progress = getProgressCircle(container);
|
||||
expect(progress).toHaveStyle({ transition: "none" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("className prop", () => {
|
||||
it("applies additional className to the wrapper div", () => {
|
||||
const { container } = render(
|
||||
<ProgressRing value={50} animated={false} className="my-ring" />,
|
||||
);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.className).toContain("my-ring");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapper dimensions", () => {
|
||||
it("sets inline style width and height on the wrapper to match size", () => {
|
||||
const { container } = render(<ProgressRing value={50} size={64} animated={false} />);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper).toHaveStyle({ width: "64px", height: "64px" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useRowOrder } from "./useRowOrder.js";
|
||||
import type { ViewPrefsHandle } from "./useViewPrefs.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a minimal ViewPrefsHandle stub for testing. */
|
||||
function makePrefs(
|
||||
rowOrder: string[] = [],
|
||||
setRowOrder = vi.fn(),
|
||||
): Pick<ViewPrefsHandle, "rowOrder" | "setRowOrder"> {
|
||||
return { rowOrder, setRowOrder };
|
||||
}
|
||||
|
||||
type Row = { id: string; label: string };
|
||||
|
||||
function makeRows(ids: string[]): Row[] {
|
||||
return ids.map((id) => ({ id, label: `Label-${id}` }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("useRowOrder", () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// orderedRows — sorting logic
|
||||
// -------------------------------------------------------------------------
|
||||
describe("orderedRows", () => {
|
||||
it("returns rows unchanged when activeSortField is set", () => {
|
||||
const rows = makeRows(["b", "a", "c"]);
|
||||
const prefs = makePrefs(["a", "b", "c"]);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, "name", vi.fn()));
|
||||
// Even though rowOrder says a→b→c, the active sort must suppress it
|
||||
expect(result.current.orderedRows.map((r) => r.id)).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
|
||||
it("returns rows unchanged when rowOrder is empty", () => {
|
||||
const rows = makeRows(["b", "a", "c"]);
|
||||
const prefs = makePrefs([]);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
expect(result.current.orderedRows.map((r) => r.id)).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
|
||||
it("sorts rows according to rowOrder when no sort is active", () => {
|
||||
const rows = makeRows(["b", "a", "c"]);
|
||||
const prefs = makePrefs(["a", "b", "c"]);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
expect(result.current.orderedRows.map((r) => r.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("places rows missing from rowOrder at the end", () => {
|
||||
const rows = makeRows(["x", "a", "b", "y"]);
|
||||
// Only a and b have explicit positions; x and y go to the end
|
||||
const prefs = makePrefs(["a", "b"]);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
const ids = result.current.orderedRows.map((r) => r.id);
|
||||
expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("x"));
|
||||
expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("x"));
|
||||
});
|
||||
|
||||
it("handles a rowOrder that is a partial subset of rows", () => {
|
||||
const rows = makeRows(["c", "a", "b"]);
|
||||
const prefs = makePrefs(["b", "c"]);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
const ids = result.current.orderedRows.map((r) => r.id);
|
||||
expect(ids[0]).toBe("b");
|
||||
expect(ids[1]).toBe("c");
|
||||
// "a" has no saved position → appended after
|
||||
expect(ids[2]).toBe("a");
|
||||
});
|
||||
|
||||
it("does not mutate the original rows array", () => {
|
||||
const rows = makeRows(["c", "a", "b"]);
|
||||
const originalRef = rows;
|
||||
const prefs = makePrefs(["a", "b", "c"]);
|
||||
renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
expect(rows).toBe(originalRef);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// isCustomOrder
|
||||
// -------------------------------------------------------------------------
|
||||
describe("isCustomOrder", () => {
|
||||
it("is false when rowOrder is empty", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useRowOrder(makeRows(["a"]), makePrefs([]), null, vi.fn()),
|
||||
);
|
||||
expect(result.current.isCustomOrder).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when activeSortField is set (even if rowOrder is non-empty)", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useRowOrder(makeRows(["a", "b"]), makePrefs(["b", "a"]), "name", vi.fn()),
|
||||
);
|
||||
expect(result.current.isCustomOrder).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when rowOrder is non-empty and no sort is active", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useRowOrder(makeRows(["a", "b"]), makePrefs(["b", "a"]), null, vi.fn()),
|
||||
);
|
||||
expect(result.current.isCustomOrder).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// reorder
|
||||
// -------------------------------------------------------------------------
|
||||
describe("reorder", () => {
|
||||
it("calls setRowOrder with the reordered ID list", () => {
|
||||
const setRowOrder = vi.fn();
|
||||
const rows = makeRows(["a", "b", "c"]);
|
||||
const prefs = makePrefs([], setRowOrder);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
|
||||
act(() => {
|
||||
result.current.reorder("c", "a");
|
||||
});
|
||||
|
||||
// "c" dragged to position of "a": result should be [c, a, b]
|
||||
expect(setRowOrder).toHaveBeenCalledWith(["c", "a", "b"]);
|
||||
});
|
||||
|
||||
it("calls resetSort to clear any active column sort", () => {
|
||||
const resetSort = vi.fn();
|
||||
const rows = makeRows(["a", "b", "c"]);
|
||||
const prefs = makePrefs([], vi.fn());
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, resetSort));
|
||||
|
||||
act(() => {
|
||||
result.current.reorder("b", "a");
|
||||
});
|
||||
|
||||
expect(resetSort).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("is a no-op when draggedId equals targetId", () => {
|
||||
const setRowOrder = vi.fn();
|
||||
const resetSort = vi.fn();
|
||||
const rows = makeRows(["a", "b"]);
|
||||
const prefs = makePrefs([], setRowOrder);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, resetSort));
|
||||
|
||||
act(() => {
|
||||
result.current.reorder("a", "a");
|
||||
});
|
||||
|
||||
expect(setRowOrder).not.toHaveBeenCalled();
|
||||
expect(resetSort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op when draggedId is not found in current rows", () => {
|
||||
const setRowOrder = vi.fn();
|
||||
const rows = makeRows(["a", "b"]);
|
||||
const prefs = makePrefs([], setRowOrder);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
|
||||
act(() => {
|
||||
result.current.reorder("unknown", "a");
|
||||
});
|
||||
|
||||
expect(setRowOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op when targetId is not found in current rows", () => {
|
||||
const setRowOrder = vi.fn();
|
||||
const rows = makeRows(["a", "b"]);
|
||||
const prefs = makePrefs([], setRowOrder);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
|
||||
act(() => {
|
||||
result.current.reorder("a", "unknown");
|
||||
});
|
||||
|
||||
expect(setRowOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves an item from the end to the beginning", () => {
|
||||
const setRowOrder = vi.fn();
|
||||
const rows = makeRows(["a", "b", "c"]);
|
||||
const prefs = makePrefs([], setRowOrder);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
|
||||
act(() => {
|
||||
result.current.reorder("c", "a");
|
||||
});
|
||||
|
||||
expect(setRowOrder).toHaveBeenCalledWith(["c", "a", "b"]);
|
||||
});
|
||||
|
||||
it("moves an item from the beginning to the end", () => {
|
||||
const setRowOrder = vi.fn();
|
||||
const rows = makeRows(["a", "b", "c"]);
|
||||
const prefs = makePrefs([], setRowOrder);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
|
||||
act(() => {
|
||||
result.current.reorder("a", "c");
|
||||
});
|
||||
|
||||
expect(setRowOrder).toHaveBeenCalledWith(["b", "c", "a"]);
|
||||
});
|
||||
|
||||
it("reorders correctly when a custom rowOrder is already applied", () => {
|
||||
const setRowOrder = vi.fn();
|
||||
// Existing saved order: b, a, c → orderedRows will be [b, a, c]
|
||||
const rows = makeRows(["a", "b", "c"]);
|
||||
const prefs = makePrefs(["b", "a", "c"], setRowOrder);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
|
||||
// Drag "c" (last) to the position of "b" (first)
|
||||
act(() => {
|
||||
result.current.reorder("c", "b");
|
||||
});
|
||||
|
||||
expect(setRowOrder).toHaveBeenCalledWith(["c", "b", "a"]);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// resetOrder
|
||||
// -------------------------------------------------------------------------
|
||||
describe("resetOrder", () => {
|
||||
it("calls setRowOrder with an empty array", () => {
|
||||
const setRowOrder = vi.fn();
|
||||
const rows = makeRows(["a", "b"]);
|
||||
const prefs = makePrefs(["b", "a"], setRowOrder);
|
||||
const { result } = renderHook(() => useRowOrder(rows, prefs, null, vi.fn()));
|
||||
|
||||
act(() => {
|
||||
result.current.resetOrder();
|
||||
});
|
||||
|
||||
expect(setRowOrder).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock the tRPC client — must be registered before the module is imported.
|
||||
// ---------------------------------------------------------------------------
|
||||
const mockMutate = vi.fn();
|
||||
|
||||
vi.mock("~/lib/trpc/client.js", () => ({
|
||||
trpc: {
|
||||
user: {
|
||||
setColumnPreferences: {
|
||||
useMutation: () => ({ mutate: mockMutate }),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { useViewPrefs } = await import("./useViewPrefs.js");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage stub — isolate tests from real browser storage
|
||||
// ---------------------------------------------------------------------------
|
||||
function createLocalStorageStub() {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("useViewPrefs", () => {
|
||||
let lsStub: ReturnType<typeof createLocalStorageStub>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
lsStub = createLocalStorageStub();
|
||||
vi.spyOn(window, "localStorage", "get").mockReturnValue(lsStub as unknown as Storage);
|
||||
mockMutate.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initial state
|
||||
// -------------------------------------------------------------------------
|
||||
describe("initial state", () => {
|
||||
it("savedSort is null when localStorage is empty", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
expect(result.current.savedSort).toBeNull();
|
||||
});
|
||||
|
||||
it("rowOrder is an empty array when localStorage is empty", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
expect(result.current.rowOrder).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads savedSort from localStorage on mount", () => {
|
||||
lsStub.getItem.mockReturnValue(JSON.stringify({ sort: { field: "name", dir: "asc" } }));
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
expect(result.current.savedSort).toEqual({ field: "name", dir: "asc" });
|
||||
});
|
||||
|
||||
it("reads rowOrder from localStorage on mount", () => {
|
||||
lsStub.getItem.mockReturnValue(JSON.stringify({ rowOrder: ["b", "a", "c"] }));
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
expect(result.current.rowOrder).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
|
||||
it("reads from the correct localStorage key for the view", () => {
|
||||
renderHook(() => useViewPrefs("projects"));
|
||||
expect(lsStub.getItem).toHaveBeenCalledWith("viewprefs_projects");
|
||||
});
|
||||
|
||||
it("handles malformed JSON in localStorage gracefully", () => {
|
||||
lsStub.getItem.mockReturnValue("{broken json{{");
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
expect(result.current.savedSort).toBeNull();
|
||||
expect(result.current.rowOrder).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// setSavedSort
|
||||
// -------------------------------------------------------------------------
|
||||
describe("setSavedSort", () => {
|
||||
it("updates savedSort in state immediately", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setSavedSort({ field: "age", dir: "desc" });
|
||||
});
|
||||
expect(result.current.savedSort).toEqual({ field: "age", dir: "desc" });
|
||||
});
|
||||
|
||||
it("persists the sort to localStorage", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setSavedSort({ field: "age", dir: "asc" });
|
||||
});
|
||||
const written = JSON.parse(lsStub.setItem.mock.calls[0][1] as string) as unknown;
|
||||
expect(written).toMatchObject({ sort: { field: "age", dir: "asc" } });
|
||||
});
|
||||
|
||||
it("removes the sort key from localStorage when set to null", () => {
|
||||
lsStub.getItem.mockReturnValue(JSON.stringify({ sort: { field: "name", dir: "asc" } }));
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setSavedSort(null);
|
||||
});
|
||||
const lastCall = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1];
|
||||
const written = JSON.parse(lastCall[1] as string) as Record<string, unknown>;
|
||||
expect(written).not.toHaveProperty("sort");
|
||||
});
|
||||
|
||||
it("does not call the server mutation before the debounce delay", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setSavedSort({ field: "name", dir: "asc" });
|
||||
});
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the server mutation after the 600 ms debounce fires", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setSavedSort({ field: "name", dir: "asc" });
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ view: "resources", sort: { field: "name", dir: "asc" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("debounces multiple rapid calls — only fires once after the last call", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setSavedSort({ field: "name", dir: "asc" });
|
||||
result.current.setSavedSort({ field: "age", dir: "desc" });
|
||||
result.current.setSavedSort({ field: "status", dir: "asc" });
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(mockMutate).toHaveBeenCalledOnce();
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sort: { field: "status", dir: "asc" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends null sort to the server mutation when cleared", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setSavedSort(null);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ view: "resources", sort: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// setRowOrder
|
||||
// -------------------------------------------------------------------------
|
||||
describe("setRowOrder", () => {
|
||||
it("updates rowOrder in state immediately", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setRowOrder(["c", "a", "b"]);
|
||||
});
|
||||
expect(result.current.rowOrder).toEqual(["c", "a", "b"]);
|
||||
});
|
||||
|
||||
it("persists the rowOrder to localStorage", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setRowOrder(["c", "a", "b"]);
|
||||
});
|
||||
const written = JSON.parse(
|
||||
lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1][1] as string,
|
||||
) as Record<string, unknown>;
|
||||
expect(written).toMatchObject({ rowOrder: ["c", "a", "b"] });
|
||||
});
|
||||
|
||||
it("removes the rowOrder key from localStorage when set to an empty array", () => {
|
||||
lsStub.getItem.mockReturnValue(JSON.stringify({ rowOrder: ["a", "b"] }));
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setRowOrder([]);
|
||||
});
|
||||
const lastCall = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1];
|
||||
const written = JSON.parse(lastCall[1] as string) as Record<string, unknown>;
|
||||
expect(written).not.toHaveProperty("rowOrder");
|
||||
});
|
||||
|
||||
it("does not call the server mutation before the debounce delay", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setRowOrder(["a", "b"]);
|
||||
});
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the server mutation after the 600 ms debounce fires", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setRowOrder(["a", "b"]);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ view: "resources", rowOrder: ["a", "b"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends null rowOrder to the server mutation when the array is empty", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setRowOrder([]);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ view: "resources", rowOrder: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("debounces multiple rapid drag reorders into a single mutation", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setRowOrder(["a", "b", "c"]);
|
||||
result.current.setRowOrder(["b", "a", "c"]);
|
||||
result.current.setRowOrder(["c", "b", "a"]);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(mockMutate).toHaveBeenCalledOnce();
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ rowOrder: ["c", "b", "a"] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// localStorage key isolation per view
|
||||
// -------------------------------------------------------------------------
|
||||
describe("localStorage isolation between views", () => {
|
||||
it("writes to the correct key for 'projects' view", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("projects"));
|
||||
act(() => {
|
||||
result.current.setSavedSort({ field: "name", dir: "asc" });
|
||||
});
|
||||
expect(lsStub.setItem).toHaveBeenCalledWith("viewprefs_projects", expect.any(String));
|
||||
});
|
||||
|
||||
it("writes to the correct key for 'users' view", () => {
|
||||
const { result } = renderHook(() => useViewPrefs("users"));
|
||||
act(() => {
|
||||
result.current.setRowOrder(["1", "2"]);
|
||||
});
|
||||
expect(lsStub.setItem).toHaveBeenCalledWith("viewprefs_users", expect.any(String));
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Interaction: sort and rowOrder coexist in localStorage
|
||||
// -------------------------------------------------------------------------
|
||||
describe("combined sort + rowOrder state", () => {
|
||||
it("preserves existing sort when rowOrder is updated", () => {
|
||||
lsStub.getItem.mockReturnValue(JSON.stringify({ sort: { field: "name", dir: "asc" } }));
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setRowOrder(["b", "a"]);
|
||||
});
|
||||
const lastCall = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1];
|
||||
const written = JSON.parse(lastCall[1] as string) as Record<string, unknown>;
|
||||
expect(written).toMatchObject({
|
||||
sort: { field: "name", dir: "asc" },
|
||||
rowOrder: ["b", "a"],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves existing rowOrder when sort is updated", () => {
|
||||
lsStub.getItem.mockReturnValue(JSON.stringify({ rowOrder: ["x", "y"] }));
|
||||
const { result } = renderHook(() => useViewPrefs("resources"));
|
||||
act(() => {
|
||||
result.current.setSavedSort({ field: "date", dir: "desc" });
|
||||
});
|
||||
const lastCall = lsStub.setItem.mock.calls[lsStub.setItem.mock.calls.length - 1];
|
||||
const written = JSON.parse(lastCall[1] as string) as Record<string, unknown>;
|
||||
expect(written).toMatchObject({
|
||||
sort: { field: "date", dir: "desc" },
|
||||
rowOrder: ["x", "y"],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ParsedScopeRow, ScopeImportResult } from "./scopeImportParser.js";
|
||||
|
||||
// parseScopeImport depends on parseSpreadsheet from "./excel.js", which does
|
||||
// real file I/O. We mock that module so the parser logic can be tested in
|
||||
// isolation with plain in-memory data.
|
||||
vi.mock("./excel.js", () => ({
|
||||
parseSpreadsheet: vi.fn(),
|
||||
}));
|
||||
|
||||
import { parseScopeImport } from "./scopeImportParser.js";
|
||||
import { parseSpreadsheet } from "./excel.js";
|
||||
|
||||
const mockSpreadsheet = parseSpreadsheet as ReturnType<typeof vi.fn>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeFile(): File {
|
||||
return new File(["dummy"], "test.xlsx");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty / degenerate input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseScopeImport – empty input", () => {
|
||||
it("returns a warning and no rows when the spreadsheet is empty", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([]);
|
||||
|
||||
const result: ScopeImportResult = await parseScopeImport(makeFile());
|
||||
|
||||
expect(result.rows).toHaveLength(0);
|
||||
expect(result.warnings).toContain("File contains no data rows.");
|
||||
expect(result.mapping.name).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a warning when no name column can be identified", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([{ quantity: "3", budget: "1000" }]);
|
||||
|
||||
const result = await parseScopeImport(makeFile());
|
||||
|
||||
expect(result.rows).toHaveLength(0);
|
||||
expect(result.warnings.some((w) => w.includes('"Name" column'))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a warning when name column is present but all values are empty", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([{ name: " " }, { name: "" }]);
|
||||
|
||||
const result = await parseScopeImport(makeFile());
|
||||
|
||||
expect(result.rows).toHaveLength(0);
|
||||
expect(result.warnings).toContain("No rows with a non-empty name found.");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column header resolution – aliases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseScopeImport – header alias resolution", () => {
|
||||
it("maps 'title' to the name field", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([{ title: "Shot A" }]);
|
||||
const result = await parseScopeImport(makeFile());
|
||||
expect(result.mapping.name).toBe("title");
|
||||
expect(result.rows[0]?.name).toBe("Shot A");
|
||||
});
|
||||
|
||||
it("maps 'seq' to sequenceNo and uses its integer value", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([{ seq: "5", name: "Shot B" }]);
|
||||
const result = await parseScopeImport(makeFile());
|
||||
expect(result.mapping.sequenceNo).toBe("seq");
|
||||
expect(result.rows[0]?.sequenceNo).toBe(5);
|
||||
});
|
||||
|
||||
it("maps 'type' to scopeType", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([{ type: "ASSET", name: "Prop01" }]);
|
||||
const result = await parseScopeImport(makeFile());
|
||||
expect(result.mapping.scopeType).toBe("type");
|
||||
expect(result.rows[0]?.scopeType).toBe("ASSET");
|
||||
});
|
||||
|
||||
it("maps 'pkg' to packageCode", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([{ pkg: "PKG-01", name: "Shot C" }]);
|
||||
const result = await parseScopeImport(makeFile());
|
||||
expect(result.mapping.packageCode).toBe("pkg");
|
||||
expect(result.rows[0]?.packageCode).toBe("PKG-01");
|
||||
});
|
||||
|
||||
it("maps 'desc' to description", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([{ desc: "Some details", name: "Shot D" }]);
|
||||
const result = await parseScopeImport(makeFile());
|
||||
expect(result.mapping.description).toBe("desc");
|
||||
expect(result.rows[0]?.description).toBe("Some details");
|
||||
});
|
||||
|
||||
it("treats header matching as case-insensitive", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([{ NAME: "Shot E", TYPE: "VFX" }]);
|
||||
const result = await parseScopeImport(makeFile());
|
||||
expect(result.mapping.name).toBe("NAME");
|
||||
expect(result.mapping.scopeType).toBe("TYPE");
|
||||
});
|
||||
|
||||
it("handles headers with mixed casing and spaces", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([{ "Sequence No": "10", Name: "Shot F" }]);
|
||||
const result = await parseScopeImport(makeFile());
|
||||
expect(result.mapping.sequenceNo).toBe("Sequence No");
|
||||
expect(result.rows[0]?.sequenceNo).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sequence number handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseScopeImport – sequence number auto-numbering", () => {
|
||||
it("auto-numbers rows when no sequenceNo column is present", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([
|
||||
{ name: "Shot A" },
|
||||
{ name: "Shot B" },
|
||||
{ name: "Shot C" },
|
||||
]);
|
||||
|
||||
const result = await parseScopeImport(makeFile());
|
||||
|
||||
expect(result.rows[0]?.sequenceNo).toBe(1);
|
||||
expect(result.rows[1]?.sequenceNo).toBe(2);
|
||||
expect(result.rows[2]?.sequenceNo).toBe(3);
|
||||
expect(result.warnings).toContain("No sequence number column detected — auto-numbering.");
|
||||
});
|
||||
|
||||
it("falls back to auto-number when sequenceNo value is not a positive integer", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([
|
||||
{ seq: "0", name: "Shot A" },
|
||||
{ seq: "-5", name: "Shot B" },
|
||||
{ seq: "abc", name: "Shot C" },
|
||||
]);
|
||||
|
||||
const result = await parseScopeImport(makeFile());
|
||||
|
||||
result.rows.forEach((row, i) => {
|
||||
expect(row.sequenceNo).toBe(i + 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("respects explicit positive integer sequence numbers", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([
|
||||
{ seq: "100", name: "Shot A" },
|
||||
{ seq: "200", name: "Shot B" },
|
||||
]);
|
||||
|
||||
const result = await parseScopeImport(makeFile());
|
||||
|
||||
expect(result.rows[0]?.sequenceNo).toBe(100);
|
||||
expect(result.rows[1]?.sequenceNo).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scopeType defaulting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseScopeImport – scopeType defaulting", () => {
|
||||
it("defaults scopeType to SHOT when no type column is present", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([{ name: "Shot A" }]);
|
||||
|
||||
const result = await parseScopeImport(makeFile());
|
||||
|
||||
expect(result.rows[0]?.scopeType).toBe("SHOT");
|
||||
expect(result.warnings).toContain("No scope type column detected — defaulting to SHOT.");
|
||||
});
|
||||
|
||||
it("defaults scopeType to SHOT when the type cell is empty", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([{ type: " ", name: "Shot A" }]);
|
||||
|
||||
const result = await parseScopeImport(makeFile());
|
||||
|
||||
expect(result.rows[0]?.scopeType).toBe("SHOT");
|
||||
});
|
||||
|
||||
it("preserves the provided scopeType when it is non-empty", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([{ type: "ASSET", name: "Prop01" }]);
|
||||
|
||||
const result = await parseScopeImport(makeFile());
|
||||
|
||||
expect(result.rows[0]?.scopeType).toBe("ASSET");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rows with empty names are skipped
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseScopeImport – skipping empty-name rows", () => {
|
||||
it("skips rows where the name cell is blank", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([
|
||||
{ name: "Shot A" },
|
||||
{ name: "" },
|
||||
{ name: "Shot C" },
|
||||
{ name: " " },
|
||||
]);
|
||||
|
||||
const result = await parseScopeImport(makeFile());
|
||||
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.rows[0]?.name).toBe("Shot A");
|
||||
expect(result.rows[1]?.name).toBe("Shot C");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full happy-path scenario
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseScopeImport – full integration scenario", () => {
|
||||
it("parses a complete row with all columns present", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([
|
||||
{
|
||||
number: "42",
|
||||
type: "VFX",
|
||||
code: "PKG-VFX",
|
||||
title: "Explosion",
|
||||
notes: "Big boom",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await parseScopeImport(makeFile());
|
||||
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
|
||||
const row: ParsedScopeRow = result.rows[0]!;
|
||||
expect(row.sequenceNo).toBe(42);
|
||||
expect(row.scopeType).toBe("VFX");
|
||||
expect(row.packageCode).toBe("PKG-VFX");
|
||||
expect(row.name).toBe("Explosion");
|
||||
expect(row.description).toBe("Big boom");
|
||||
});
|
||||
|
||||
it("trims whitespace from all string fields", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([
|
||||
{
|
||||
name: " Trimmed Name ",
|
||||
type: " SHOT ",
|
||||
code: " PKG-01 ",
|
||||
desc: " Some notes ",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await parseScopeImport(makeFile());
|
||||
const row = result.rows[0]!;
|
||||
|
||||
expect(row.name).toBe("Trimmed Name");
|
||||
expect(row.scopeType).toBe("SHOT");
|
||||
expect(row.packageCode).toBe("PKG-01");
|
||||
expect(row.description).toBe("Some notes");
|
||||
});
|
||||
|
||||
it("returns the correct mapping alongside the rows", async () => {
|
||||
mockSpreadsheet.mockResolvedValueOnce([
|
||||
{ seq: "1", type: "VFX", package: "P1", name: "Shot A", description: "desc" },
|
||||
]);
|
||||
|
||||
const result = await parseScopeImport(makeFile());
|
||||
|
||||
expect(result.mapping).toMatchObject({
|
||||
sequenceNo: "seq",
|
||||
scopeType: "type",
|
||||
packageCode: "package",
|
||||
name: "name",
|
||||
description: "description",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user