From 9bd717201897913a4e61191d4e972305b13cbb57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 10 Apr 2026 22:45:44 +0200 Subject: [PATCH] test(web): add 162 tests for animation components and hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Components: AnimatedNumber (14), InfiniteScrollSentinel (16), FadeIn (22), StaggerList (26). Hooks: useUrlFilters (32), useWidgetFilterOptions (27), useProjectDragContext (27). Web test suite: 96 → 103 files, 1076 → 1238 tests. Co-Authored-By: Claude Opus 4.6 --- .../src/components/ui/AnimatedNumber.test.tsx | 236 ++++++++++++ apps/web/src/components/ui/FadeIn.test.tsx | 290 +++++++++++++++ .../ui/InfiniteScrollSentinel.test.tsx | 243 +++++++++++++ .../src/components/ui/StaggerList.test.tsx | 344 ++++++++++++++++++ .../src/hooks/useProjectDragContext.test.ts | 278 ++++++++++++++ apps/web/src/hooks/useUrlFilters.test.ts | 301 +++++++++++++++ .../src/hooks/useWidgetFilterOptions.test.ts | 327 +++++++++++++++++ 7 files changed, 2019 insertions(+) create mode 100644 apps/web/src/components/ui/AnimatedNumber.test.tsx create mode 100644 apps/web/src/components/ui/FadeIn.test.tsx create mode 100644 apps/web/src/components/ui/InfiniteScrollSentinel.test.tsx create mode 100644 apps/web/src/components/ui/StaggerList.test.tsx create mode 100644 apps/web/src/hooks/useProjectDragContext.test.ts create mode 100644 apps/web/src/hooks/useUrlFilters.test.ts create mode 100644 apps/web/src/hooks/useWidgetFilterOptions.test.ts diff --git a/apps/web/src/components/ui/AnimatedNumber.test.tsx b/apps/web/src/components/ui/AnimatedNumber.test.tsx new file mode 100644 index 0000000..c7cefbf --- /dev/null +++ b/apps/web/src/components/ui/AnimatedNumber.test.tsx @@ -0,0 +1,236 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, act } from "~/test-utils.js"; +import { AnimatedNumber } from "./AnimatedNumber.js"; + +// --------------------------------------------------------------------------- +// rAF / performance mocking +// --------------------------------------------------------------------------- + +// jsdom does not implement requestAnimationFrame in a way that advances with +// fake timers, so we replace it with a minimal synchronous stub that lets us +// drive frames manually when needed. + +let rafCallbacks: Map = new Map(); +let rafId = 0; +// Tracks the simulated clock so we can advance it past the animation duration. +let fakeNow = 0; + +function flushAllFrames() { + // Advance the fake clock well past the default duration (800 ms) so that + // easeOutExpo reaches progress=1 and setDisplay(to) is called. + fakeNow += 2000; + + let safety = 0; + while (rafCallbacks.size > 0 && safety++ < 1000) { + const cbs = new Map(rafCallbacks); + rafCallbacks.clear(); + for (const cb of cbs.values()) { + cb(fakeNow); + } + } +} + +beforeEach(() => { + rafCallbacks = new Map(); + rafId = 0; + fakeNow = 0; + + // Stub performance.now so the animation component uses our controlled clock. + vi.spyOn(performance, "now").mockImplementation(() => fakeNow); + + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + const id = ++rafId; + rafCallbacks.set(id, cb); + return id; + }); + + vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id) => { + rafCallbacks.delete(id); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("AnimatedNumber", () => { + // ------------------------------------------------------------------------- + // Initial render + // ------------------------------------------------------------------------- + + describe("initial render", () => { + it("renders the initial value without animation on first mount", () => { + act(() => { + render(); + flushAllFrames(); + }); + // The component formats with de-DE locale; 42 → "42" + expect(screen.getByText("42")).toBeInTheDocument(); + }); + + it("wraps the output in a ", () => { + act(() => { + render(); + flushAllFrames(); + }); + const span = screen.getByText("10"); + expect(span.tagName.toLowerCase()).toBe("span"); + }); + + it("applies the className prop to the span", () => { + act(() => { + render(); + flushAllFrames(); + }); + const span = screen.getByText("5"); + expect(span).toHaveClass("text-red-500"); + }); + }); + + // ------------------------------------------------------------------------- + // Prefix and suffix + // ------------------------------------------------------------------------- + + describe("prefix and suffix", () => { + it("prepends the prefix string to the displayed value", () => { + act(() => { + render(); + flushAllFrames(); + }); + expect(screen.getByText(/€/)).toBeInTheDocument(); + }); + + it("appends the suffix string to the displayed value", () => { + act(() => { + render(); + flushAllFrames(); + }); + const span = screen.getByText(/75/); + expect(span.textContent).toContain("%"); + }); + + it("renders both prefix and suffix together", () => { + act(() => { + render(); + flushAllFrames(); + }); + const span = screen.getByText(/50/); + expect(span.textContent).toContain("~"); + expect(span.textContent).toContain(" hrs"); + }); + }); + + // ------------------------------------------------------------------------- + // Decimal formatting + // ------------------------------------------------------------------------- + + describe("decimal formatting", () => { + it("formats with 0 decimals by default (no decimal separator)", () => { + act(() => { + render(); + flushAllFrames(); + }); + // de-DE uses . as thousands separator and , as decimal; 1234 → "1.234" + const span = screen.getByText(/1/); + expect(span.textContent).not.toMatch(/,/); + }); + + it("formats with 2 decimals when decimals={2}", () => { + act(() => { + render(); + flushAllFrames(); + }); + // de-DE: 3.00 → "3,00" + expect(screen.getByText(/3,00/)).toBeInTheDocument(); + }); + }); + + // ------------------------------------------------------------------------- + // Animation on value change + // ------------------------------------------------------------------------- + + describe("animation on value change", () => { + it("cancels any pending rAF when the component unmounts mid-animation", () => { + const { unmount } = render(); + // Trigger an animation by re-rendering with a new value but do NOT flush frames. + act(() => { + // The useEffect fires; a rAF is queued but not consumed. + // We just verify that unmounting does not throw. + }); + expect(() => unmount()).not.toThrow(); + }); + + it("settles to the final value after all rAF frames are flushed", () => { + const { rerender } = render(); + // Flush any frames queued by the initial mount effect. + act(() => { + flushAllFrames(); + }); + + // Rerender in its own act() so the useEffect fires and queues a rAF + // before we call flushAllFrames in the next act(). + act(() => { + rerender(); + }); + + // Now flush: performance.now() returns fakeNow; advancing it ensures + // elapsed > duration so progress clamps to 1 and setDisplay(to) is called. + act(() => { + flushAllFrames(); + }); + + // After full animation the display should equal the target value. + expect(screen.getByText("999")).toBeInTheDocument(); + }); + + it("skips animation and immediately shows the value when from === to", () => { + // Render with value=7, then rerender with the same value=7. + const { rerender } = render(); + act(() => { + flushAllFrames(); + }); + + act(() => { + rerender(); + flushAllFrames(); + }); + + expect(screen.getByText("7")).toBeInTheDocument(); + }); + }); + + // ------------------------------------------------------------------------- + // Edge cases + // ------------------------------------------------------------------------- + + describe("edge cases", () => { + it("handles value=0 without error", () => { + act(() => { + render(); + flushAllFrames(); + }); + expect(screen.getByText("0")).toBeInTheDocument(); + }); + + it("handles negative values", () => { + act(() => { + render(); + flushAllFrames(); + }); + expect(screen.getByText(/-42/)).toBeInTheDocument(); + }); + + it("handles large numbers via de-DE thousand separators", () => { + act(() => { + render(); + flushAllFrames(); + }); + // de-DE: 1_000_000 → "1.000.000" + expect(screen.getByText("1.000.000")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/components/ui/FadeIn.test.tsx b/apps/web/src/components/ui/FadeIn.test.tsx new file mode 100644 index 0000000..f483a71 --- /dev/null +++ b/apps/web/src/components/ui/FadeIn.test.tsx @@ -0,0 +1,290 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "~/test-utils.js"; +import { FadeIn } from "./FadeIn.js"; + +// --------------------------------------------------------------------------- +// framer-motion mock +// --------------------------------------------------------------------------- +// Replace motion.div with a plain
that forwards all non-animation props +// plus exposes animation-variant props as data-* attributes for assertions. + +vi.mock("framer-motion", () => ({ + motion: { + div: ({ + children, + className, + initial, + whileInView, + viewport, + variants, + ...rest + }: React.HTMLAttributes & { + children?: React.ReactNode; + initial?: unknown; + whileInView?: unknown; + viewport?: unknown; + variants?: unknown; + }) => ( +
+ {children} +
+ ), + }, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getWrapper(container: HTMLElement) { + return container.firstChild as HTMLElement; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("FadeIn", () => { + // ------------------------------------------------------------------------- + // Children rendering + // ------------------------------------------------------------------------- + + describe("children rendering", () => { + it("renders its children", () => { + render( + +

Hello FadeIn

+
, + ); + expect(screen.getByText("Hello FadeIn")).toBeInTheDocument(); + }); + + it("renders multiple children", () => { + render( + + First + Second + , + ); + expect(screen.getByText("First")).toBeInTheDocument(); + expect(screen.getByText("Second")).toBeInTheDocument(); + }); + + it("renders text node children", () => { + render(plain text); + expect(screen.getByText("plain text")).toBeInTheDocument(); + }); + }); + + // ------------------------------------------------------------------------- + // className prop + // ------------------------------------------------------------------------- + + describe("className prop", () => { + it("applies className to the wrapper div", () => { + const { container } = render(content); + expect(getWrapper(container)).toHaveClass("custom-class"); + }); + + it("does not apply a className when the prop is omitted", () => { + const { container } = render(content); + // className attribute should be absent or empty + const wrapper = getWrapper(container); + expect(wrapper.className).toBeFalsy(); + }); + }); + + // ------------------------------------------------------------------------- + // Animation props (initial / whileInView) + // ------------------------------------------------------------------------- + + describe("animation configuration", () => { + it("sets initial to 'hidden'", () => { + const { container } = render(content); + const wrapper = getWrapper(container); + expect(wrapper.dataset["initial"]).toBe(JSON.stringify("hidden")); + }); + + it("sets whileInView to 'visible'", () => { + const { container } = render(content); + const wrapper = getWrapper(container); + expect(wrapper.dataset["whileInView"]).toBe(JSON.stringify("visible")); + }); + + it("passes once=true to the viewport by default", () => { + const { container } = render(content); + const viewport = JSON.parse(getWrapper(container).dataset["viewport"]!) as { + once: boolean; + margin: string; + }; + expect(viewport.once).toBe(true); + }); + + it("passes once=false to the viewport when once={false}", () => { + const { container } = render(content); + const viewport = JSON.parse(getWrapper(container).dataset["viewport"]!) as { + once: boolean; + }; + expect(viewport.once).toBe(false); + }); + + it("passes margin='-40px' to the viewport", () => { + const { container } = render(content); + const viewport = JSON.parse(getWrapper(container).dataset["viewport"]!) as { + margin: string; + }; + expect(viewport.margin).toBe("-40px"); + }); + }); + + // ------------------------------------------------------------------------- + // Variants — direction offsets + // ------------------------------------------------------------------------- + + describe("direction offsets in variants", () => { + function getVariants(container: HTMLElement) { + return JSON.parse(getWrapper(container).dataset["variants"]!) as { + hidden: { opacity: number; x: number; y: number }; + visible: { opacity: number; x: number; y: number; transition: object }; + }; + } + + it("direction='up' (default) sets positive y offset in hidden state", () => { + const { container } = render(content); + const { hidden } = getVariants(container); + expect(hidden.y).toBe(12); + expect(hidden.x).toBe(0); + expect(hidden.opacity).toBe(0); + }); + + it("direction='down' sets negative y offset in hidden state", () => { + const { container } = render( + + content + , + ); + const { hidden } = getVariants(container); + expect(hidden.y).toBe(-12); + expect(hidden.x).toBe(0); + }); + + it("direction='left' sets positive x offset in hidden state", () => { + const { container } = render( + + content + , + ); + const { hidden } = getVariants(container); + expect(hidden.x).toBe(20); + expect(hidden.y).toBe(0); + }); + + it("direction='right' sets negative x offset in hidden state", () => { + const { container } = render( + + content + , + ); + const { hidden } = getVariants(container); + expect(hidden.x).toBe(-20); + expect(hidden.y).toBe(0); + }); + + it("direction='none' sets both x and y offsets to 0 in hidden state", () => { + const { container } = render( + + content + , + ); + const { hidden } = getVariants(container); + expect(hidden.x).toBe(0); + expect(hidden.y).toBe(0); + }); + + it("visible state always has opacity=1, x=0, y=0", () => { + const directions = ["up", "down", "left", "right", "none"] as const; + for (const direction of directions) { + const { container } = render( + + content + , + ); + const { visible } = getVariants(container); + expect(visible.opacity).toBe(1); + expect(visible.x).toBe(0); + expect(visible.y).toBe(0); + } + }); + }); + + // ------------------------------------------------------------------------- + // Variants — transition options + // ------------------------------------------------------------------------- + + describe("transition options in variants", () => { + function getTransition(container: HTMLElement) { + const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as { + visible: { transition: { duration: number; delay: number; ease: number[] } }; + }; + return variants.visible.transition; + } + + it("uses the default duration of 0.3 when not specified", () => { + const { container } = render(content); + expect(getTransition(container).duration).toBe(0.3); + }); + + it("uses the provided duration prop", () => { + const { container } = render(content); + expect(getTransition(container).duration).toBe(0.6); + }); + + it("uses the default delay of 0 when not specified", () => { + const { container } = render(content); + expect(getTransition(container).delay).toBe(0); + }); + + it("uses the provided delay prop", () => { + const { container } = render(content); + expect(getTransition(container).delay).toBe(0.2); + }); + + it("uses the correct cubic-bezier ease array", () => { + const { container } = render(content); + expect(getTransition(container).ease).toEqual([0.25, 0.1, 0.25, 1]); + }); + }); + + // ------------------------------------------------------------------------- + // Distance prop + // ------------------------------------------------------------------------- + + describe("distance prop", () => { + it("defaults to a distance of 12", () => { + const { container } = render(content); + const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as { + hidden: { y: number }; + }; + expect(variants.hidden.y).toBe(12); + }); + + it("uses a custom distance value", () => { + const { container } = render( + + content + , + ); + const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as { + hidden: { y: number }; + }; + expect(variants.hidden.y).toBe(32); + }); + }); +}); diff --git a/apps/web/src/components/ui/InfiniteScrollSentinel.test.tsx b/apps/web/src/components/ui/InfiniteScrollSentinel.test.tsx new file mode 100644 index 0000000..db9839b --- /dev/null +++ b/apps/web/src/components/ui/InfiniteScrollSentinel.test.tsx @@ -0,0 +1,243 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, act } from "~/test-utils.js"; +import { InfiniteScrollSentinel } from "./InfiniteScrollSentinel.js"; + +// --------------------------------------------------------------------------- +// IntersectionObserver mock +// --------------------------------------------------------------------------- + +type IntersectionCallback = (entries: IntersectionObserverEntry[]) => void; + +let observerCallback: IntersectionCallback | null = null; +let observedElement: Element | null = null; +let disconnectMock: ReturnType; +let observeMock: ReturnType; + +function simulateIntersection(isIntersecting: boolean) { + observerCallback?.([{ isIntersecting } as IntersectionObserverEntry]); +} + +beforeEach(() => { + observerCallback = null; + observedElement = null; + disconnectMock = vi.fn(); + observeMock = vi.fn((el: Element) => { + observedElement = el; + }); + + vi.stubGlobal( + "IntersectionObserver", + vi.fn((cb: IntersectionCallback, _options?: IntersectionObserverInit) => { + observerCallback = cb; + return { + observe: observeMock, + disconnect: disconnectMock, + unobserve: vi.fn(), + takeRecords: vi.fn(() => []), + root: null, + rootMargin: "", + thresholds: [], + } satisfies IntersectionObserver; + }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("InfiniteScrollSentinel", () => { + // ------------------------------------------------------------------------- + // Rendering + // ------------------------------------------------------------------------- + + describe("rendering", () => { + it("renders a sentinel div in the DOM", () => { + const { container } = render( + , + ); + // The outer div is always present. + expect(container.firstChild).toBeTruthy(); + }); + + it("does NOT render the spinner when isLoading is false", () => { + const { container } = render( + , + ); + // The spinner is a div with border classes applied; it must not exist. + const spinner = container.querySelector(".animate-spin"); + expect(spinner).toBeNull(); + }); + + it("renders the spinner when isLoading is true", () => { + const { container } = render(); + const spinner = container.querySelector(".animate-spin"); + expect(spinner).not.toBeNull(); + }); + + it("toggles the spinner when isLoading changes", () => { + const { rerender, container } = render( + , + ); + expect(container.querySelector(".animate-spin")).toBeNull(); + + rerender(); + expect(container.querySelector(".animate-spin")).not.toBeNull(); + + rerender(); + expect(container.querySelector(".animate-spin")).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // IntersectionObserver lifecycle + // ------------------------------------------------------------------------- + + describe("IntersectionObserver lifecycle", () => { + it("creates an IntersectionObserver on mount", () => { + render(); + expect(IntersectionObserver).toHaveBeenCalledOnce(); + }); + + it("observes the sentinel element", () => { + render(); + expect(observeMock).toHaveBeenCalledOnce(); + expect(observedElement).toBeInstanceOf(HTMLDivElement); + }); + + it("creates the observer with threshold: 0.1", () => { + render(); + const ctorCall = vi.mocked(IntersectionObserver).mock.calls[0]!; + expect(ctorCall[1]).toEqual({ threshold: 0.1 }); + }); + + it("disconnects the observer on unmount", () => { + const { unmount } = render(); + unmount(); + expect(disconnectMock).toHaveBeenCalledOnce(); + }); + + it("recreates the observer when isLoading changes", () => { + const { rerender } = render(); + expect(IntersectionObserver).toHaveBeenCalledTimes(1); + + rerender(); + // Effect cleanup + re-run: observer should be recreated. + expect(IntersectionObserver).toHaveBeenCalledTimes(2); + }); + + it("disconnects the previous observer before creating a new one", () => { + const { rerender } = render(); + rerender(); + // disconnect is called at least once during the effect cleanup. + expect(disconnectMock).toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // onVisible callback + // ------------------------------------------------------------------------- + + describe("onVisible callback", () => { + it("calls onVisible when the sentinel intersects and isLoading is false", () => { + const onVisible = vi.fn(); + render(); + + act(() => { + simulateIntersection(true); + }); + + expect(onVisible).toHaveBeenCalledOnce(); + }); + + it("does NOT call onVisible when the sentinel intersects but isLoading is true", () => { + const onVisible = vi.fn(); + render(); + + act(() => { + simulateIntersection(true); + }); + + expect(onVisible).not.toHaveBeenCalled(); + }); + + it("does NOT call onVisible when isIntersecting is false", () => { + const onVisible = vi.fn(); + render(); + + act(() => { + simulateIntersection(false); + }); + + expect(onVisible).not.toHaveBeenCalled(); + }); + + it("calls onVisible each time intersection fires with isLoading=false", () => { + const onVisible = vi.fn(); + render(); + + act(() => { + simulateIntersection(true); + }); + act(() => { + simulateIntersection(false); + }); + act(() => { + simulateIntersection(true); + }); + + expect(onVisible).toHaveBeenCalledTimes(2); + }); + + it("uses the latest onVisible reference after prop update", () => { + const firstCallback = vi.fn(); + const secondCallback = vi.fn(); + + const { rerender } = render( + , + ); + + // Update the callback prop — this triggers effect re-run because onVisible is + // listed as a dependency. + rerender(); + + act(() => { + simulateIntersection(true); + }); + + expect(secondCallback).toHaveBeenCalledOnce(); + expect(firstCallback).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // Edge cases + // ------------------------------------------------------------------------- + + describe("edge cases", () => { + it("does not throw when the observer callback receives an empty entries array", () => { + render(); + + expect(() => { + act(() => { + observerCallback?.([]); + }); + }).not.toThrow(); + }); + + it("does not call onVisible when entry is undefined (empty array)", () => { + const onVisible = vi.fn(); + render(); + + act(() => { + observerCallback?.([]); + }); + + expect(onVisible).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/components/ui/StaggerList.test.tsx b/apps/web/src/components/ui/StaggerList.test.tsx new file mode 100644 index 0000000..1b4e825 --- /dev/null +++ b/apps/web/src/components/ui/StaggerList.test.tsx @@ -0,0 +1,344 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "~/test-utils.js"; +import { StaggerList, StaggerItem } from "./StaggerList.js"; + +// --------------------------------------------------------------------------- +// framer-motion mock +// --------------------------------------------------------------------------- +// All motion.* components become plain HTML equivalents that forward className, +// children and expose animation props as data-* attributes so we can assert +// on variants / initial / animate values without running actual animations. + +type MotionProps = React.HTMLAttributes & { + children?: React.ReactNode; + initial?: unknown; + animate?: unknown; + variants?: unknown; +}; + +function makeMotionComponent(Tag: "div" | "ul" | "ol" | "tbody") { + return function MockMotion({ + children, + className, + initial, + animate, + variants, + ...rest + }: MotionProps) { + const dataProps = { + "data-initial": JSON.stringify(initial), + "data-animate": JSON.stringify(animate), + "data-variants": JSON.stringify(variants), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Element = Tag as any; + return ( + + {children} + + ); + }; +} + +vi.mock("framer-motion", () => ({ + motion: { + div: makeMotionComponent("div"), + ul: makeMotionComponent("ul"), + ol: makeMotionComponent("ol"), + tbody: makeMotionComponent("tbody"), + }, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getWrapper(container: HTMLElement) { + return container.firstChild as HTMLElement; +} + +// --------------------------------------------------------------------------- +// StaggerList tests +// --------------------------------------------------------------------------- + +describe("StaggerList", () => { + // ------------------------------------------------------------------------- + // Children rendering + // ------------------------------------------------------------------------- + + describe("children rendering", () => { + it("renders its children", () => { + render( + +
Item A
+
Item B
+
, + ); + expect(screen.getByText("Item A")).toBeInTheDocument(); + expect(screen.getByText("Item B")).toBeInTheDocument(); + }); + + it("renders with no children without crashing", () => { + const { container } = render({null}); + expect(getWrapper(container)).toBeTruthy(); + }); + }); + + // ------------------------------------------------------------------------- + // className prop + // ------------------------------------------------------------------------- + + describe("className prop", () => { + it("applies className to the container element", () => { + const { container } = render(content); + expect(getWrapper(container)).toHaveClass("grid"); + expect(getWrapper(container)).toHaveClass("gap-2"); + }); + + it("does not crash when className is omitted", () => { + const { container } = render(content); + expect(getWrapper(container)).toBeTruthy(); + }); + }); + + // ------------------------------------------------------------------------- + // as prop — rendered element tag + // ------------------------------------------------------------------------- + + describe("as prop", () => { + it("defaults to a
element", () => { + const { container } = render(content); + expect(getWrapper(container).tagName.toLowerCase()).toBe("div"); + }); + + it("renders a
    when as='ul'", () => { + const { container } = render(content); + expect(getWrapper(container).tagName.toLowerCase()).toBe("ul"); + }); + + it("renders an
      when as='ol'", () => { + const { container } = render(content); + expect(getWrapper(container).tagName.toLowerCase()).toBe("ol"); + }); + + it("renders a when as='tbody'", () => { + // tbody must be inside a table for valid HTML, but jsdom accepts it. + const { container } = render( + + + + + + +
      cell
      , + ); + expect(container.querySelector("tbody")).not.toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // Animation props + // ------------------------------------------------------------------------- + + describe("animation configuration", () => { + it("sets initial to 'hidden'", () => { + const { container } = render(content); + expect(getWrapper(container).dataset["initial"]).toBe(JSON.stringify("hidden")); + }); + + it("sets animate to 'visible'", () => { + const { container } = render(content); + expect(getWrapper(container).dataset["animate"]).toBe(JSON.stringify("visible")); + }); + + it("variants hidden state is an empty object", () => { + const { container } = render(content); + const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as { + hidden: Record; + }; + expect(variants.hidden).toEqual({}); + }); + + it("variants visible state has a staggerChildren transition", () => { + const { container } = render(content); + const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as { + visible: { transition: { staggerChildren: number } }; + }; + expect(typeof variants.visible.transition.staggerChildren).toBe("number"); + }); + + it("uses the default staggerDelay of 0.03", () => { + const { container } = render(content); + const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as { + visible: { transition: { staggerChildren: number } }; + }; + expect(variants.visible.transition.staggerChildren).toBe(0.03); + }); + + it("uses a custom staggerDelay when provided", () => { + const { container } = render(content); + const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as { + visible: { transition: { staggerChildren: number } }; + }; + expect(variants.visible.transition.staggerChildren).toBe(0.1); + }); + + it("staggerDelay=0 is passed through correctly", () => { + const { container } = render(content); + const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as { + visible: { transition: { staggerChildren: number } }; + }; + expect(variants.visible.transition.staggerChildren).toBe(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// StaggerItem tests +// --------------------------------------------------------------------------- + +describe("StaggerItem", () => { + // ------------------------------------------------------------------------- + // Children rendering + // ------------------------------------------------------------------------- + + describe("children rendering", () => { + it("renders its children", () => { + render( + + List item content + , + ); + expect(screen.getByText("List item content")).toBeInTheDocument(); + }); + + it("renders text node children", () => { + render(raw text); + expect(screen.getByText("raw text")).toBeInTheDocument(); + }); + }); + + // ------------------------------------------------------------------------- + // className prop + // ------------------------------------------------------------------------- + + describe("className prop", () => { + it("applies className to the wrapper div", () => { + const { container } = render(content); + expect(getWrapper(container)).toHaveClass("p-4"); + expect(getWrapper(container)).toHaveClass("border"); + }); + + it("renders without a className when the prop is omitted", () => { + const { container } = render(content); + // Must not throw and wrapper must exist. + expect(getWrapper(container)).toBeTruthy(); + }); + }); + + // ------------------------------------------------------------------------- + // Always renders a
      + // ------------------------------------------------------------------------- + + it("always renders a
      element", () => { + const { container } = render(content); + expect(getWrapper(container).tagName.toLowerCase()).toBe("div"); + }); + + // ------------------------------------------------------------------------- + // Animation props / variants + // ------------------------------------------------------------------------- + + describe("animation configuration", () => { + function getVariants(container: HTMLElement) { + return JSON.parse(getWrapper(container).dataset["variants"]!) as { + hidden: { opacity: number; y: number }; + visible: { opacity: number; y: number; transition: { duration: number; ease: number[] } }; + }; + } + + it("hidden variant has opacity=0", () => { + const { container } = render(content); + expect(getVariants(container).hidden.opacity).toBe(0); + }); + + it("hidden variant has y=8 (slides up slightly)", () => { + const { container } = render(content); + expect(getVariants(container).hidden.y).toBe(8); + }); + + it("visible variant has opacity=1", () => { + const { container } = render(content); + expect(getVariants(container).visible.opacity).toBe(1); + }); + + it("visible variant has y=0", () => { + const { container } = render(content); + expect(getVariants(container).visible.y).toBe(0); + }); + + it("visible transition has duration 0.25", () => { + const { container } = render(content); + expect(getVariants(container).visible.transition.duration).toBe(0.25); + }); + + it("visible transition has the correct cubic-bezier ease array", () => { + const { container } = render(content); + expect(getVariants(container).visible.transition.ease).toEqual([0.25, 0.1, 0.25, 1]); + }); + + it("does not set an explicit initial or animate prop (inherits from parent)", () => { + const { container } = render(content); + // StaggerItem does not pass initial/animate — motion should inherit them. + // The data attributes will be 'undefined' serialised as the string "undefined" + // if not passed, or absent. Both mean not explicitly set. + const wrapper = getWrapper(container); + const initialAttr = wrapper.dataset["initial"]; + const animateAttr = wrapper.dataset["animate"]; + expect(initialAttr === undefined || initialAttr === "undefined").toBe(true); + expect(animateAttr === undefined || animateAttr === "undefined").toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // Composition: StaggerList + StaggerItem + // ------------------------------------------------------------------------- + + describe("StaggerList + StaggerItem composition", () => { + it("renders nested StaggerItems inside a StaggerList", () => { + render( + + Alpha + Beta + Gamma + , + ); + expect(screen.getByText("Alpha")).toBeInTheDocument(); + expect(screen.getByText("Beta")).toBeInTheDocument(); + expect(screen.getByText("Gamma")).toBeInTheDocument(); + }); + + it("each StaggerItem renders its own wrapper div", () => { + const { container } = render( + + One + Two + , + ); + // The StaggerList wrapper contains two StaggerItem divs. + const items = container.querySelectorAll("div > div"); + expect(items.length).toBeGreaterThanOrEqual(2); + }); + + it("renders StaggerItems inside a
        StaggerList", () => { + render( + + Item 1 + Item 2 + , + ); + expect(screen.getByText("Item 1")).toBeInTheDocument(); + expect(screen.getByText("Item 2")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/hooks/useProjectDragContext.test.ts b/apps/web/src/hooks/useProjectDragContext.test.ts new file mode 100644 index 0000000..06df1d5 --- /dev/null +++ b/apps/web/src/hooks/useProjectDragContext.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { renderHook } from "@testing-library/react"; + +// --------------------------------------------------------------------------- +// Mock tRPC client — must be declared before module import so Vitest replaces +// the real module at resolve time. +// --------------------------------------------------------------------------- +const mockUseQuery = vi.fn(); + +vi.mock("~/lib/trpc/client.js", () => ({ + trpc: { + timeline: { + getProjectContext: { + useQuery: (input: unknown, opts: unknown) => mockUseQuery(input, opts), + }, + }, + }, +})); + +const { useProjectDragContext } = await import("./useProjectDragContext.js"); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build the shape returned by trpc.timeline.getProjectContext.useQuery */ +function queryResult(data: unknown) { + return { data }; +} + +/** Full project context data shape as returned by the API */ +function buildContextData( + overrides: { + resourceIds?: string[]; + allResourceAllocations?: unknown[]; + assignments?: unknown[]; + demands?: unknown[]; + project?: unknown; + } = {}, +) { + return { + resourceIds: overrides.resourceIds ?? [], + allResourceAllocations: overrides.allResourceAllocations ?? [], + assignments: overrides.assignments ?? [], + demands: overrides.demands ?? [], + project: overrides.project ?? null, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe("useProjectDragContext", () => { + beforeEach(() => { + mockUseQuery.mockReset(); + // Default: query returns no data (loading / disabled state) + mockUseQuery.mockReturnValue(queryResult(undefined)); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ------------------------------------------------------------------------- + // Return shape + // ------------------------------------------------------------------------- + describe("return shape", () => { + it("returns an object with all five expected keys", () => { + const { result } = renderHook(() => useProjectDragContext(null)); + expect(result.current).toHaveProperty("contextResourceIds"); + expect(result.current).toHaveProperty("contextAllocations"); + expect(result.current).toHaveProperty("projectAssignments"); + expect(result.current).toHaveProperty("projectDemands"); + expect(result.current).toHaveProperty("project"); + }); + + it("contextResourceIds is an array", () => { + const { result } = renderHook(() => useProjectDragContext(null)); + expect(Array.isArray(result.current.contextResourceIds)).toBe(true); + }); + + it("contextAllocations is an array", () => { + const { result } = renderHook(() => useProjectDragContext(null)); + expect(Array.isArray(result.current.contextAllocations)).toBe(true); + }); + + it("projectAssignments is an array", () => { + const { result } = renderHook(() => useProjectDragContext(null)); + expect(Array.isArray(result.current.projectAssignments)).toBe(true); + }); + + it("projectDemands is an array", () => { + const { result } = renderHook(() => useProjectDragContext(null)); + expect(Array.isArray(result.current.projectDemands)).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // Default / empty state when data is undefined + // ------------------------------------------------------------------------- + describe("empty / loading state (data is undefined)", () => { + it("returns empty contextResourceIds when data is undefined", () => { + const { result } = renderHook(() => useProjectDragContext("proj-1")); + expect(result.current.contextResourceIds).toEqual([]); + }); + + it("returns empty contextAllocations when data is undefined", () => { + const { result } = renderHook(() => useProjectDragContext("proj-1")); + expect(result.current.contextAllocations).toEqual([]); + }); + + it("returns empty projectAssignments when data is undefined", () => { + const { result } = renderHook(() => useProjectDragContext("proj-1")); + expect(result.current.projectAssignments).toEqual([]); + }); + + it("returns empty projectDemands when data is undefined", () => { + const { result } = renderHook(() => useProjectDragContext("proj-1")); + expect(result.current.projectDemands).toEqual([]); + }); + + it("returns null project when data is undefined", () => { + const { result } = renderHook(() => useProjectDragContext("proj-1")); + expect(result.current.project).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // Populated data is surfaced correctly + // ------------------------------------------------------------------------- + describe("populated data mapping", () => { + it("surfaces contextResourceIds from data.resourceIds", () => { + mockUseQuery.mockReturnValue( + queryResult(buildContextData({ resourceIds: ["res-1", "res-2"] })), + ); + const { result } = renderHook(() => useProjectDragContext("proj-1")); + expect(result.current.contextResourceIds).toEqual(["res-1", "res-2"]); + }); + + it("surfaces contextAllocations from data.allResourceAllocations", () => { + const alloc = { id: "alloc-1", hours: 8 }; + mockUseQuery.mockReturnValue( + queryResult(buildContextData({ allResourceAllocations: [alloc] })), + ); + const { result } = renderHook(() => useProjectDragContext("proj-1")); + expect(result.current.contextAllocations).toEqual([alloc]); + }); + + it("surfaces projectAssignments from data.assignments", () => { + const assignment = { id: "asgn-1", resourceId: "res-1" }; + mockUseQuery.mockReturnValue(queryResult(buildContextData({ assignments: [assignment] }))); + const { result } = renderHook(() => useProjectDragContext("proj-1")); + expect(result.current.projectAssignments).toEqual([assignment]); + }); + + it("surfaces projectDemands from data.demands", () => { + const demand = { id: "dem-1", roleId: "role-1" }; + mockUseQuery.mockReturnValue(queryResult(buildContextData({ demands: [demand] }))); + const { result } = renderHook(() => useProjectDragContext("proj-1")); + expect(result.current.projectDemands).toEqual([demand]); + }); + + it("surfaces project from data.project", () => { + const project = { id: "proj-1", name: "My Project" }; + mockUseQuery.mockReturnValue(queryResult(buildContextData({ project }))); + const { result } = renderHook(() => useProjectDragContext("proj-1")); + expect(result.current.project).toEqual(project); + }); + + it("project is null when data.project is null", () => { + mockUseQuery.mockReturnValue(queryResult(buildContextData({ project: null }))); + const { result } = renderHook(() => useProjectDragContext("proj-1")); + expect(result.current.project).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // Query enabled flag + // ------------------------------------------------------------------------- + describe("query enabled flag", () => { + it("passes enabled: false when projectId is null", () => { + renderHook(() => useProjectDragContext(null)); + const opts = mockUseQuery.mock.calls[0]![1] as { enabled: boolean }; + expect(opts.enabled).toBe(false); + }); + + it("passes enabled: false when projectId is empty string", () => { + renderHook(() => useProjectDragContext("")); + const opts = mockUseQuery.mock.calls[0]![1] as { enabled: boolean }; + expect(opts.enabled).toBe(false); + }); + + it("passes enabled: true when projectId is a non-empty string and enabled=true", () => { + renderHook(() => useProjectDragContext("proj-42", true)); + const opts = mockUseQuery.mock.calls[0]![1] as { enabled: boolean }; + expect(opts.enabled).toBe(true); + }); + + it("passes enabled: false when projectId is set but enabled=false is passed", () => { + renderHook(() => useProjectDragContext("proj-42", false)); + const opts = mockUseQuery.mock.calls[0]![1] as { enabled: boolean }; + expect(opts.enabled).toBe(false); + }); + + it("defaults enabled to true when the second argument is omitted", () => { + renderHook(() => useProjectDragContext("proj-99")); + const opts = mockUseQuery.mock.calls[0]![1] as { enabled: boolean }; + expect(opts.enabled).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // Query staleTime + // ------------------------------------------------------------------------- + describe("query options", () => { + it("passes staleTime: 10_000 to the query", () => { + renderHook(() => useProjectDragContext("proj-1")); + const opts = mockUseQuery.mock.calls[0]![1] as { staleTime: number }; + expect(opts.staleTime).toBe(10_000); + }); + + it("passes the projectId as input to the query", () => { + renderHook(() => useProjectDragContext("proj-abc")); + const input = mockUseQuery.mock.calls[0]![0] as { projectId: string }; + expect(input.projectId).toBe("proj-abc"); + }); + }); + + // ------------------------------------------------------------------------- + // Multiple resources / large datasets + // ------------------------------------------------------------------------- + describe("multiple resources", () => { + it("handles multiple resource IDs correctly", () => { + const resourceIds = ["r1", "r2", "r3", "r4"]; + mockUseQuery.mockReturnValue(queryResult(buildContextData({ resourceIds }))); + const { result } = renderHook(() => useProjectDragContext("proj-1")); + expect(result.current.contextResourceIds).toHaveLength(4); + expect(result.current.contextResourceIds).toEqual(resourceIds); + }); + + it("handles multiple assignments correctly", () => { + const assignments = [ + { id: "a1", resourceId: "r1" }, + { id: "a2", resourceId: "r2" }, + ]; + mockUseQuery.mockReturnValue(queryResult(buildContextData({ assignments }))); + const { result } = renderHook(() => useProjectDragContext("proj-1")); + expect(result.current.projectAssignments).toHaveLength(2); + }); + + it("handles multiple demands correctly", () => { + const demands = [ + { id: "d1", roleId: "role-1" }, + { id: "d2", roleId: "role-2" }, + { id: "d3", roleId: "role-3" }, + ]; + mockUseQuery.mockReturnValue(queryResult(buildContextData({ demands }))); + const { result } = renderHook(() => useProjectDragContext("proj-1")); + expect(result.current.projectDemands).toHaveLength(3); + }); + }); + + // ------------------------------------------------------------------------- + // null projectId always produces empty results + // ------------------------------------------------------------------------- + describe("null projectId always produces empty results", () => { + it("contextResourceIds is empty for null projectId regardless of mock data", () => { + // Even if the mock returns data, the hook itself uses null projectId + mockUseQuery.mockReturnValue(queryResult(undefined)); + const { result } = renderHook(() => useProjectDragContext(null)); + expect(result.current.contextResourceIds).toEqual([]); + expect(result.current.contextAllocations).toEqual([]); + expect(result.current.projectAssignments).toEqual([]); + expect(result.current.projectDemands).toEqual([]); + expect(result.current.project).toBeNull(); + }); + }); +}); diff --git a/apps/web/src/hooks/useUrlFilters.test.ts b/apps/web/src/hooks/useUrlFilters.test.ts new file mode 100644 index 0000000..506d3fe --- /dev/null +++ b/apps/web/src/hooks/useUrlFilters.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +// --------------------------------------------------------------------------- +// Mock next/navigation — registered before module import so the factory +// replaces the real module at resolve time. +// --------------------------------------------------------------------------- +const mockReplace = vi.fn(); +let mockSearchParamsEntries: [string, string][] = []; +let mockPathname = "/projects"; + +vi.mock("next/navigation", () => { + return { + useRouter: () => ({ replace: mockReplace }), + usePathname: () => mockPathname, + useSearchParams: () => { + const raw = mockSearchParamsEntries + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join("&"); + return new URLSearchParams(raw); + }, + }; +}); + +const { useUrlFilters } = await import("./useUrlFilters.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("useUrlFilters", () => { + beforeEach(() => { + mockReplace.mockReset(); + mockSearchParamsEntries = []; + mockPathname = "/projects"; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ------------------------------------------------------------------------- + // Initial state — all defaults + // ------------------------------------------------------------------------- + describe("initial state — no URL params", () => { + it("returns default values when no params are in the URL", () => { + const defaults = { search: "", status: "ALL" }; + const { result } = renderHook(() => useUrlFilters(defaults)); + const [filters] = result.current; + expect(filters.search).toBe(""); + expect(filters.status).toBe("ALL"); + }); + + it("returns a tuple with filters as first element and setFilters as second", () => { + const { result } = renderHook(() => useUrlFilters({ q: "" })); + expect(Array.isArray(result.current)).toBe(true); + expect(result.current).toHaveLength(2); + expect(typeof result.current[1]).toBe("function"); + }); + + it("returns default for every key when URL is empty", () => { + const defaults = { a: "x", b: "y", c: "z" }; + const { result } = renderHook(() => useUrlFilters(defaults)); + const [filters] = result.current; + expect(filters).toEqual({ a: "x", b: "y", c: "z" }); + }); + }); + + // ------------------------------------------------------------------------- + // Reading values from URL params + // ------------------------------------------------------------------------- + describe("reading values from URL params", () => { + it("reads a single param from the URL", () => { + setSearchParams([["search", "dragon"]]); + const { result } = renderHook(() => useUrlFilters({ search: "" })); + const [filters] = result.current; + expect(filters.search).toBe("dragon"); + }); + + it("reads multiple params from the URL", () => { + setSearchParams([ + ["search", "alice"], + ["status", "ACTIVE"], + ]); + const { result } = renderHook(() => useUrlFilters({ search: "", status: "" })); + const [filters] = result.current; + expect(filters.search).toBe("alice"); + expect(filters.status).toBe("ACTIVE"); + }); + + it("falls back to default when only some params are set", () => { + setSearchParams([["status", "DONE"]]); + const { result } = renderHook(() => useUrlFilters({ search: "fallback", status: "" })); + const [filters] = result.current; + expect(filters.search).toBe("fallback"); + expect(filters.status).toBe("DONE"); + }); + + it("ignores URL params that are not in the defaults object", () => { + setSearchParams([ + ["search", "foo"], + ["extra", "ignored"], + ]); + const { result } = renderHook(() => useUrlFilters({ search: "" })); + const [filters] = result.current; + expect(Object.keys(filters)).not.toContain("extra"); + }); + + it("uses URL value even when it matches the default value", () => { + setSearchParams([["status", "ALL"]]); + const { result } = renderHook(() => useUrlFilters({ status: "ALL" })); + const [filters] = result.current; + // The URL value takes precedence over defaults (same result either way) + expect(filters.status).toBe("ALL"); + }); + }); + + // ------------------------------------------------------------------------- + // setFilters — setting a new value + // ------------------------------------------------------------------------- + describe("setFilters — setting values", () => { + it("calls router.replace after setFilters is called", () => { + const { result } = renderHook(() => useUrlFilters({ search: "" })); + act(() => { + result.current[1]({ search: "robot" }); + }); + expect(mockReplace).toHaveBeenCalledOnce(); + }); + + it("includes the updated param in the URL", () => { + const { result } = renderHook(() => useUrlFilters({ search: "" })); + act(() => { + result.current[1]({ search: "robot" }); + }); + expect(lastReplaceUrl()).toContain("search=robot"); + }); + + it("calls router.replace with scroll: false", () => { + const { result } = renderHook(() => useUrlFilters({ search: "" })); + act(() => { + result.current[1]({ search: "x" }); + }); + expect(lastReplaceOpts()).toEqual({ scroll: false }); + }); + + it("uses the current pathname in the replaced URL", () => { + mockPathname = "/timeline"; + const { result } = renderHook(() => useUrlFilters({ search: "" })); + act(() => { + result.current[1]({ search: "test" }); + }); + expect(lastReplaceUrl()).toMatch(/^\/timeline\?/); + }); + + it("preserves existing unrelated params when setting a new value", () => { + setSearchParams([["status", "ACTIVE"]]); + const { result } = renderHook(() => useUrlFilters({ search: "", status: "ALL" })); + act(() => { + result.current[1]({ search: "hello" }); + }); + const url = lastReplaceUrl(); + expect(url).toContain("search=hello"); + expect(url).toContain("status=ACTIVE"); + }); + + it("can update multiple keys in one call", () => { + const { result } = renderHook(() => useUrlFilters({ search: "", status: "" })); + act(() => { + result.current[1]({ search: "foo", status: "DONE" }); + }); + const url = lastReplaceUrl(); + expect(url).toContain("search=foo"); + expect(url).toContain("status=DONE"); + }); + }); + + // ------------------------------------------------------------------------- + // setFilters — removing default values keeps URL clean + // ------------------------------------------------------------------------- + describe("setFilters — default values are not written to URL", () => { + it("deletes the param when the new value matches the default", () => { + setSearchParams([["status", "ACTIVE"]]); + const { result } = renderHook(() => useUrlFilters({ status: "ALL" })); + act(() => { + result.current[1]({ status: "ALL" }); // setting to default + }); + const url = lastReplaceUrl(); + expect(url).not.toContain("status="); + }); + + it("deletes the param when the new value is undefined", () => { + setSearchParams([["search", "hello"]]); + const { result } = renderHook(() => useUrlFilters({ search: "" })); + act(() => { + result.current[1]({ search: undefined as unknown as string }); + }); + const url = lastReplaceUrl(); + expect(url).not.toContain("search="); + }); + + it("keeps URL clean when all values revert to defaults", () => { + setSearchParams([["status", "DONE"]]); + const { result } = renderHook(() => useUrlFilters({ status: "ALL" })); + act(() => { + result.current[1]({ status: "ALL" }); + }); + // After removing the only param the query string should be empty + expect(lastReplaceUrl()).toMatch(/^\/projects(\?)?$/); + }); + }); + + // ------------------------------------------------------------------------- + // setFilters — non-default values are persisted + // ------------------------------------------------------------------------- + describe("setFilters — non-default values are written to URL", () => { + it("writes a non-default string value to the URL", () => { + const { result } = renderHook(() => useUrlFilters({ status: "ALL" })); + act(() => { + result.current[1]({ status: "ACTIVE" }); + }); + expect(lastReplaceUrl()).toContain("status=ACTIVE"); + }); + + it("replaces an existing URL param with a new non-default value", () => { + setSearchParams([["status", "ACTIVE"]]); + const { result } = renderHook(() => useUrlFilters({ status: "ALL" })); + act(() => { + result.current[1]({ status: "DONE" }); + }); + const url = lastReplaceUrl(); + expect(url).toContain("status=DONE"); + expect(url).not.toContain("status=ACTIVE"); + }); + }); + + // ------------------------------------------------------------------------- + // setFilters stability — the updater function is stable across renders + // ------------------------------------------------------------------------- + describe("setFilters reference stability", () => { + it("returns a function on every render (setFilters is always callable)", () => { + const { result, rerender } = renderHook(() => useUrlFilters({ search: "" })); + expect(typeof result.current[1]).toBe("function"); + rerender(); + expect(typeof result.current[1]).toBe("function"); + }); + + it("setFilters remains a function after multiple rerenders", () => { + const { result, rerender } = renderHook(() => useUrlFilters({ search: "" })); + rerender(); + rerender(); + expect(typeof result.current[1]).toBe("function"); + }); + }); + + // ------------------------------------------------------------------------- + // Edge cases + // ------------------------------------------------------------------------- + describe("edge cases", () => { + it("handles an empty defaults object gracefully", () => { + const { result } = renderHook(() => useUrlFilters({})); + const [filters] = result.current; + expect(filters).toEqual({}); + }); + + it("does not call router.replace on initial render", () => { + renderHook(() => useUrlFilters({ search: "default" })); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("handles a single key with an empty string default", () => { + const { result } = renderHook(() => useUrlFilters({ q: "" })); + const [filters] = result.current; + expect(filters.q).toBe(""); + }); + + it("produces a URL that starts with the pathname", () => { + mockPathname = "/resources"; + const { result } = renderHook(() => useUrlFilters({ search: "" })); + act(() => { + result.current[1]({ search: "test" }); + }); + expect(lastReplaceUrl()).toMatch(/^\/resources/); + }); + }); +}); diff --git a/apps/web/src/hooks/useWidgetFilterOptions.test.ts b/apps/web/src/hooks/useWidgetFilterOptions.test.ts new file mode 100644 index 0000000..6e528e0 --- /dev/null +++ b/apps/web/src/hooks/useWidgetFilterOptions.test.ts @@ -0,0 +1,327 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { renderHook } from "@testing-library/react"; + +// --------------------------------------------------------------------------- +// Mock useReferenceData so we control exactly which rows are returned. +// The mock must be declared before the module under test is imported. +// --------------------------------------------------------------------------- +const mockUseReferenceData = vi.fn(); + +vi.mock("~/hooks/useReferenceData.js", () => ({ + useReferenceData: (selection: unknown) => mockUseReferenceData(selection), +})); + +const { useWidgetFilterOptions } = await import("./useWidgetFilterOptions.js"); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function makeReferenceData(overrides: { + clients?: { id: string; name: string; code: string | null }[]; + countries?: { id: string; name: string; code: string }[]; + roles?: { id: string; name: string }[]; + chapters?: string[]; +}) { + return { + clients: overrides.clients ?? [], + countries: overrides.countries ?? [], + roles: overrides.roles ?? [], + chapters: overrides.chapters ?? [], + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe("useWidgetFilterOptions", () => { + beforeEach(() => { + mockUseReferenceData.mockReset(); + // Default: all empty arrays + mockUseReferenceData.mockReturnValue(makeReferenceData({})); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ------------------------------------------------------------------------- + // Return shape + // ------------------------------------------------------------------------- + describe("return shape", () => { + it("returns an object with clients, countries, roles, and chapters keys", () => { + const { result } = renderHook(() => useWidgetFilterOptions()); + expect(result.current).toHaveProperty("clients"); + expect(result.current).toHaveProperty("countries"); + expect(result.current).toHaveProperty("roles"); + expect(result.current).toHaveProperty("chapters"); + }); + + it("all returned values are arrays", () => { + const { result } = renderHook(() => useWidgetFilterOptions()); + expect(Array.isArray(result.current.clients)).toBe(true); + expect(Array.isArray(result.current.countries)).toBe(true); + expect(Array.isArray(result.current.roles)).toBe(true); + expect(Array.isArray(result.current.chapters)).toBe(true); + }); + + it("returns empty arrays when reference data rows are empty", () => { + const { result } = renderHook(() => useWidgetFilterOptions()); + expect(result.current.clients).toHaveLength(0); + expect(result.current.countries).toHaveLength(0); + expect(result.current.roles).toHaveLength(0); + expect(result.current.chapters).toHaveLength(0); + }); + }); + + // ------------------------------------------------------------------------- + // clients mapping + // ------------------------------------------------------------------------- + describe("clients mapping", () => { + it("maps client rows to { value: id, label: name }", () => { + mockUseReferenceData.mockReturnValue( + makeReferenceData({ + clients: [{ id: "c1", name: "Acme Studios", code: "ACME" }], + }), + ); + const { result } = renderHook(() => useWidgetFilterOptions({ clients: true })); + expect(result.current.clients).toEqual([{ value: "c1", label: "Acme Studios" }]); + }); + + it("maps multiple client rows preserving order", () => { + mockUseReferenceData.mockReturnValue( + makeReferenceData({ + clients: [ + { id: "c1", name: "Alpha", code: null }, + { id: "c2", name: "Beta", code: null }, + { id: "c3", name: "Gamma", code: "G" }, + ], + }), + ); + const { result } = renderHook(() => useWidgetFilterOptions({ clients: true })); + expect(result.current.clients).toEqual([ + { value: "c1", label: "Alpha" }, + { value: "c2", label: "Beta" }, + { value: "c3", label: "Gamma" }, + ]); + }); + + it("uses client.id as value and client.name as label", () => { + mockUseReferenceData.mockReturnValue( + makeReferenceData({ + clients: [{ id: "uuid-123", name: "Test Client", code: null }], + }), + ); + const { result } = renderHook(() => useWidgetFilterOptions({ clients: true })); + const [option] = result.current.clients; + expect(option!.value).toBe("uuid-123"); + expect(option!.label).toBe("Test Client"); + }); + + it("returns empty clients array when no client rows are provided", () => { + const { result } = renderHook(() => useWidgetFilterOptions({ clients: true })); + expect(result.current.clients).toHaveLength(0); + }); + }); + + // ------------------------------------------------------------------------- + // countries mapping + // ------------------------------------------------------------------------- + describe("countries mapping", () => { + it("maps country rows to { value: id, label: name }", () => { + mockUseReferenceData.mockReturnValue( + makeReferenceData({ + countries: [{ id: "de", name: "Germany", code: "DE" }], + }), + ); + const { result } = renderHook(() => useWidgetFilterOptions({ countries: true })); + expect(result.current.countries).toEqual([{ value: "de", label: "Germany" }]); + }); + + it("maps multiple country rows preserving order", () => { + mockUseReferenceData.mockReturnValue( + makeReferenceData({ + countries: [ + { id: "at", name: "Austria", code: "AT" }, + { id: "de", name: "Germany", code: "DE" }, + ], + }), + ); + const { result } = renderHook(() => useWidgetFilterOptions({ countries: true })); + expect(result.current.countries).toHaveLength(2); + expect(result.current.countries[0]!.value).toBe("at"); + expect(result.current.countries[1]!.value).toBe("de"); + }); + + it("uses country.id as value and country.name as label", () => { + mockUseReferenceData.mockReturnValue( + makeReferenceData({ + countries: [{ id: "fr", name: "France", code: "FR" }], + }), + ); + const { result } = renderHook(() => useWidgetFilterOptions({ countries: true })); + const [option] = result.current.countries; + expect(option!.value).toBe("fr"); + expect(option!.label).toBe("France"); + }); + + it("returns empty countries array when no rows are provided", () => { + const { result } = renderHook(() => useWidgetFilterOptions({ countries: true })); + expect(result.current.countries).toHaveLength(0); + }); + }); + + // ------------------------------------------------------------------------- + // roles mapping + // ------------------------------------------------------------------------- + describe("roles mapping", () => { + it("maps role rows to { value: id, label: name }", () => { + mockUseReferenceData.mockReturnValue( + makeReferenceData({ + roles: [{ id: "r1", name: "CG Supervisor" }], + }), + ); + const { result } = renderHook(() => useWidgetFilterOptions({ roles: true })); + expect(result.current.roles).toEqual([{ value: "r1", label: "CG Supervisor" }]); + }); + + it("maps multiple role rows preserving order", () => { + mockUseReferenceData.mockReturnValue( + makeReferenceData({ + roles: [ + { id: "r1", name: "Animator" }, + { id: "r2", name: "Rigger" }, + ], + }), + ); + const { result } = renderHook(() => useWidgetFilterOptions({ roles: true })); + expect(result.current.roles).toHaveLength(2); + expect(result.current.roles[0]!.label).toBe("Animator"); + expect(result.current.roles[1]!.label).toBe("Rigger"); + }); + + it("uses role.id as value and role.name as label", () => { + mockUseReferenceData.mockReturnValue( + makeReferenceData({ + roles: [{ id: "uuid-r42", name: "FX TD" }], + }), + ); + const { result } = renderHook(() => useWidgetFilterOptions({ roles: true })); + const [option] = result.current.roles; + expect(option!.value).toBe("uuid-r42"); + expect(option!.label).toBe("FX TD"); + }); + + it("returns empty roles array when no rows are provided", () => { + const { result } = renderHook(() => useWidgetFilterOptions({ roles: true })); + expect(result.current.roles).toHaveLength(0); + }); + }); + + // ------------------------------------------------------------------------- + // chapters mapping + // ------------------------------------------------------------------------- + describe("chapters mapping", () => { + it("maps chapter strings to { value: chapter, label: chapter }", () => { + mockUseReferenceData.mockReturnValue(makeReferenceData({ chapters: ["VFX"] })); + const { result } = renderHook(() => useWidgetFilterOptions({ chapters: true })); + expect(result.current.chapters).toEqual([{ value: "VFX", label: "VFX" }]); + }); + + it("maps multiple chapter strings preserving order", () => { + mockUseReferenceData.mockReturnValue( + makeReferenceData({ chapters: ["Animation", "Rigging", "VFX"] }), + ); + const { result } = renderHook(() => useWidgetFilterOptions({ chapters: true })); + expect(result.current.chapters).toHaveLength(3); + expect(result.current.chapters[0]!.value).toBe("Animation"); + expect(result.current.chapters[0]!.label).toBe("Animation"); + expect(result.current.chapters[2]!.value).toBe("VFX"); + }); + + it("uses the chapter string as both value and label", () => { + mockUseReferenceData.mockReturnValue(makeReferenceData({ chapters: ["Lighting"] })); + const { result } = renderHook(() => useWidgetFilterOptions({ chapters: true })); + const [option] = result.current.chapters; + expect(option!.value).toBe(option!.label); + expect(option!.value).toBe("Lighting"); + }); + + it("returns empty chapters array when no chapters are provided", () => { + const { result } = renderHook(() => useWidgetFilterOptions({ chapters: true })); + expect(result.current.chapters).toHaveLength(0); + }); + }); + + // ------------------------------------------------------------------------- + // FilterOption interface shape + // ------------------------------------------------------------------------- + describe("FilterOption interface", () => { + it("each option has exactly value and label properties", () => { + mockUseReferenceData.mockReturnValue( + makeReferenceData({ + clients: [{ id: "c1", name: "Studio A", code: null }], + }), + ); + const { result } = renderHook(() => useWidgetFilterOptions({ clients: true })); + const option = result.current.clients[0]!; + expect(Object.keys(option).sort()).toEqual(["label", "value"]); + }); + + it("value and label are both strings", () => { + mockUseReferenceData.mockReturnValue( + makeReferenceData({ + roles: [{ id: "r1", name: "Compositor" }], + }), + ); + const { result } = renderHook(() => useWidgetFilterOptions({ roles: true })); + const option = result.current.roles[0]!; + expect(typeof option.value).toBe("string"); + expect(typeof option.label).toBe("string"); + }); + }); + + // ------------------------------------------------------------------------- + // Forwarding the selection argument to useReferenceData + // ------------------------------------------------------------------------- + describe("selection forwarding", () => { + it("calls useReferenceData with the provided selection object", () => { + const selection = { clients: true, roles: true }; + renderHook(() => useWidgetFilterOptions(selection)); + expect(mockUseReferenceData).toHaveBeenCalledWith(selection); + }); + + it("calls useReferenceData with an empty object when no selection is provided", () => { + renderHook(() => useWidgetFilterOptions()); + expect(mockUseReferenceData).toHaveBeenCalledWith({}); + }); + + it("calls useReferenceData with the full selection when all flags are true", () => { + const selection = { clients: true, countries: true, roles: true, chapters: true }; + renderHook(() => useWidgetFilterOptions(selection)); + expect(mockUseReferenceData).toHaveBeenCalledWith(selection); + }); + }); + + // ------------------------------------------------------------------------- + // Memoization — results are stable when data does not change + // ------------------------------------------------------------------------- + describe("memoization", () => { + it("returns the same clients array reference when reference data has not changed", () => { + const clientRows = [{ id: "c1", name: "Stable", code: null }]; + mockUseReferenceData.mockReturnValue(makeReferenceData({ clients: clientRows })); + const { result, rerender } = renderHook(() => useWidgetFilterOptions({ clients: true })); + const first = result.current.clients; + rerender(); + expect(result.current.clients).toBe(first); + }); + + it("returns the same chapters array reference when chapters have not changed", () => { + const chapters = ["VFX", "Animation"]; + mockUseReferenceData.mockReturnValue(makeReferenceData({ chapters })); + const { result, rerender } = renderHook(() => useWidgetFilterOptions({ chapters: true })); + const first = result.current.chapters; + rerender(); + expect(result.current.chapters).toBe(first); + }); + }); +});