test(web): add 162 tests for animation components and hooks
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<number, FrameRequestCallback> = 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(<AnimatedNumber value={42} />);
|
||||
flushAllFrames();
|
||||
});
|
||||
// The component formats with de-DE locale; 42 → "42"
|
||||
expect(screen.getByText("42")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("wraps the output in a <span>", () => {
|
||||
act(() => {
|
||||
render(<AnimatedNumber value={10} />);
|
||||
flushAllFrames();
|
||||
});
|
||||
const span = screen.getByText("10");
|
||||
expect(span.tagName.toLowerCase()).toBe("span");
|
||||
});
|
||||
|
||||
it("applies the className prop to the span", () => {
|
||||
act(() => {
|
||||
render(<AnimatedNumber value={5} className="text-red-500" />);
|
||||
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(<AnimatedNumber value={100} prefix="€ " />);
|
||||
flushAllFrames();
|
||||
});
|
||||
expect(screen.getByText(/€/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("appends the suffix string to the displayed value", () => {
|
||||
act(() => {
|
||||
render(<AnimatedNumber value={75} suffix="%" />);
|
||||
flushAllFrames();
|
||||
});
|
||||
const span = screen.getByText(/75/);
|
||||
expect(span.textContent).toContain("%");
|
||||
});
|
||||
|
||||
it("renders both prefix and suffix together", () => {
|
||||
act(() => {
|
||||
render(<AnimatedNumber value={50} prefix="~" suffix=" hrs" />);
|
||||
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(<AnimatedNumber value={1234} />);
|
||||
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(<AnimatedNumber value={3} decimals={2} />);
|
||||
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(<AnimatedNumber value={0} />);
|
||||
// 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(<AnimatedNumber value={0} />);
|
||||
// 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(<AnimatedNumber value={999} />);
|
||||
});
|
||||
|
||||
// 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(<AnimatedNumber value={7} />);
|
||||
act(() => {
|
||||
flushAllFrames();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
rerender(<AnimatedNumber value={7} />);
|
||||
flushAllFrames();
|
||||
});
|
||||
|
||||
expect(screen.getByText("7")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles value=0 without error", () => {
|
||||
act(() => {
|
||||
render(<AnimatedNumber value={0} />);
|
||||
flushAllFrames();
|
||||
});
|
||||
expect(screen.getByText("0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles negative values", () => {
|
||||
act(() => {
|
||||
render(<AnimatedNumber value={-42} />);
|
||||
flushAllFrames();
|
||||
});
|
||||
expect(screen.getByText(/-42/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles large numbers via de-DE thousand separators", () => {
|
||||
act(() => {
|
||||
render(<AnimatedNumber value={1000000} />);
|
||||
flushAllFrames();
|
||||
});
|
||||
// de-DE: 1_000_000 → "1.000.000"
|
||||
expect(screen.getByText("1.000.000")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 <div> 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<HTMLDivElement> & {
|
||||
children?: React.ReactNode;
|
||||
initial?: unknown;
|
||||
whileInView?: unknown;
|
||||
viewport?: unknown;
|
||||
variants?: unknown;
|
||||
}) => (
|
||||
<div
|
||||
className={className}
|
||||
data-initial={JSON.stringify(initial)}
|
||||
data-while-in-view={JSON.stringify(whileInView)}
|
||||
data-viewport={JSON.stringify(viewport)}
|
||||
data-variants={JSON.stringify(variants)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getWrapper(container: HTMLElement) {
|
||||
return container.firstChild as HTMLElement;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("FadeIn", () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// Children rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("children rendering", () => {
|
||||
it("renders its children", () => {
|
||||
render(
|
||||
<FadeIn>
|
||||
<p>Hello FadeIn</p>
|
||||
</FadeIn>,
|
||||
);
|
||||
expect(screen.getByText("Hello FadeIn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders multiple children", () => {
|
||||
render(
|
||||
<FadeIn>
|
||||
<span>First</span>
|
||||
<span>Second</span>
|
||||
</FadeIn>,
|
||||
);
|
||||
expect(screen.getByText("First")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders text node children", () => {
|
||||
render(<FadeIn>plain text</FadeIn>);
|
||||
expect(screen.getByText("plain text")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// className prop
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("className prop", () => {
|
||||
it("applies className to the wrapper div", () => {
|
||||
const { container } = render(<FadeIn className="custom-class">content</FadeIn>);
|
||||
expect(getWrapper(container)).toHaveClass("custom-class");
|
||||
});
|
||||
|
||||
it("does not apply a className when the prop is omitted", () => {
|
||||
const { container } = render(<FadeIn>content</FadeIn>);
|
||||
// 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(<FadeIn>content</FadeIn>);
|
||||
const wrapper = getWrapper(container);
|
||||
expect(wrapper.dataset["initial"]).toBe(JSON.stringify("hidden"));
|
||||
});
|
||||
|
||||
it("sets whileInView to 'visible'", () => {
|
||||
const { container } = render(<FadeIn>content</FadeIn>);
|
||||
const wrapper = getWrapper(container);
|
||||
expect(wrapper.dataset["whileInView"]).toBe(JSON.stringify("visible"));
|
||||
});
|
||||
|
||||
it("passes once=true to the viewport by default", () => {
|
||||
const { container } = render(<FadeIn>content</FadeIn>);
|
||||
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(<FadeIn once={false}>content</FadeIn>);
|
||||
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(<FadeIn>content</FadeIn>);
|
||||
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(<FadeIn distance={12}>content</FadeIn>);
|
||||
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(
|
||||
<FadeIn direction="down" distance={12}>
|
||||
content
|
||||
</FadeIn>,
|
||||
);
|
||||
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(
|
||||
<FadeIn direction="left" distance={20}>
|
||||
content
|
||||
</FadeIn>,
|
||||
);
|
||||
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(
|
||||
<FadeIn direction="right" distance={20}>
|
||||
content
|
||||
</FadeIn>,
|
||||
);
|
||||
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(
|
||||
<FadeIn direction="none" distance={20}>
|
||||
content
|
||||
</FadeIn>,
|
||||
);
|
||||
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(
|
||||
<FadeIn direction={direction} distance={16}>
|
||||
content
|
||||
</FadeIn>,
|
||||
);
|
||||
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(<FadeIn>content</FadeIn>);
|
||||
expect(getTransition(container).duration).toBe(0.3);
|
||||
});
|
||||
|
||||
it("uses the provided duration prop", () => {
|
||||
const { container } = render(<FadeIn duration={0.6}>content</FadeIn>);
|
||||
expect(getTransition(container).duration).toBe(0.6);
|
||||
});
|
||||
|
||||
it("uses the default delay of 0 when not specified", () => {
|
||||
const { container } = render(<FadeIn>content</FadeIn>);
|
||||
expect(getTransition(container).delay).toBe(0);
|
||||
});
|
||||
|
||||
it("uses the provided delay prop", () => {
|
||||
const { container } = render(<FadeIn delay={0.2}>content</FadeIn>);
|
||||
expect(getTransition(container).delay).toBe(0.2);
|
||||
});
|
||||
|
||||
it("uses the correct cubic-bezier ease array", () => {
|
||||
const { container } = render(<FadeIn>content</FadeIn>);
|
||||
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(<FadeIn direction="up">content</FadeIn>);
|
||||
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(
|
||||
<FadeIn direction="up" distance={32}>
|
||||
content
|
||||
</FadeIn>,
|
||||
);
|
||||
const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as {
|
||||
hidden: { y: number };
|
||||
};
|
||||
expect(variants.hidden.y).toBe(32);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
let observeMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
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(
|
||||
<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />,
|
||||
);
|
||||
// The outer div is always present.
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT render the spinner when isLoading is false", () => {
|
||||
const { container } = render(
|
||||
<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />,
|
||||
);
|
||||
// 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(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading />);
|
||||
const spinner = container.querySelector(".animate-spin");
|
||||
expect(spinner).not.toBeNull();
|
||||
});
|
||||
|
||||
it("toggles the spinner when isLoading changes", () => {
|
||||
const { rerender, container } = render(
|
||||
<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />,
|
||||
);
|
||||
expect(container.querySelector(".animate-spin")).toBeNull();
|
||||
|
||||
rerender(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading />);
|
||||
expect(container.querySelector(".animate-spin")).not.toBeNull();
|
||||
|
||||
rerender(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
|
||||
expect(container.querySelector(".animate-spin")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IntersectionObserver lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("IntersectionObserver lifecycle", () => {
|
||||
it("creates an IntersectionObserver on mount", () => {
|
||||
render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
|
||||
expect(IntersectionObserver).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("observes the sentinel element", () => {
|
||||
render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
|
||||
expect(observeMock).toHaveBeenCalledOnce();
|
||||
expect(observedElement).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
|
||||
it("creates the observer with threshold: 0.1", () => {
|
||||
render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
|
||||
const ctorCall = vi.mocked(IntersectionObserver).mock.calls[0]!;
|
||||
expect(ctorCall[1]).toEqual({ threshold: 0.1 });
|
||||
});
|
||||
|
||||
it("disconnects the observer on unmount", () => {
|
||||
const { unmount } = render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
|
||||
unmount();
|
||||
expect(disconnectMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("recreates the observer when isLoading changes", () => {
|
||||
const { rerender } = render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
|
||||
expect(IntersectionObserver).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading />);
|
||||
// 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(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
|
||||
rerender(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading />);
|
||||
// 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(<InfiniteScrollSentinel onVisible={onVisible} isLoading={false} />);
|
||||
|
||||
act(() => {
|
||||
simulateIntersection(true);
|
||||
});
|
||||
|
||||
expect(onVisible).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does NOT call onVisible when the sentinel intersects but isLoading is true", () => {
|
||||
const onVisible = vi.fn();
|
||||
render(<InfiniteScrollSentinel onVisible={onVisible} isLoading />);
|
||||
|
||||
act(() => {
|
||||
simulateIntersection(true);
|
||||
});
|
||||
|
||||
expect(onVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onVisible when isIntersecting is false", () => {
|
||||
const onVisible = vi.fn();
|
||||
render(<InfiniteScrollSentinel onVisible={onVisible} isLoading={false} />);
|
||||
|
||||
act(() => {
|
||||
simulateIntersection(false);
|
||||
});
|
||||
|
||||
expect(onVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onVisible each time intersection fires with isLoading=false", () => {
|
||||
const onVisible = vi.fn();
|
||||
render(<InfiniteScrollSentinel onVisible={onVisible} isLoading={false} />);
|
||||
|
||||
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(
|
||||
<InfiniteScrollSentinel onVisible={firstCallback} isLoading={false} />,
|
||||
);
|
||||
|
||||
// Update the callback prop — this triggers effect re-run because onVisible is
|
||||
// listed as a dependency.
|
||||
rerender(<InfiniteScrollSentinel onVisible={secondCallback} isLoading={false} />);
|
||||
|
||||
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(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
observerCallback?.([]);
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not call onVisible when entry is undefined (empty array)", () => {
|
||||
const onVisible = vi.fn();
|
||||
render(<InfiniteScrollSentinel onVisible={onVisible} isLoading={false} />);
|
||||
|
||||
act(() => {
|
||||
observerCallback?.([]);
|
||||
});
|
||||
|
||||
expect(onVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLElement> & {
|
||||
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 (
|
||||
<Element className={className} {...dataProps} {...rest}>
|
||||
{children}
|
||||
</Element>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
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(
|
||||
<StaggerList>
|
||||
<div>Item A</div>
|
||||
<div>Item B</div>
|
||||
</StaggerList>,
|
||||
);
|
||||
expect(screen.getByText("Item A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item B")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with no children without crashing", () => {
|
||||
const { container } = render(<StaggerList>{null}</StaggerList>);
|
||||
expect(getWrapper(container)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// className prop
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("className prop", () => {
|
||||
it("applies className to the container element", () => {
|
||||
const { container } = render(<StaggerList className="grid gap-2">content</StaggerList>);
|
||||
expect(getWrapper(container)).toHaveClass("grid");
|
||||
expect(getWrapper(container)).toHaveClass("gap-2");
|
||||
});
|
||||
|
||||
it("does not crash when className is omitted", () => {
|
||||
const { container } = render(<StaggerList>content</StaggerList>);
|
||||
expect(getWrapper(container)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// as prop — rendered element tag
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("as prop", () => {
|
||||
it("defaults to a <div> element", () => {
|
||||
const { container } = render(<StaggerList>content</StaggerList>);
|
||||
expect(getWrapper(container).tagName.toLowerCase()).toBe("div");
|
||||
});
|
||||
|
||||
it("renders a <ul> when as='ul'", () => {
|
||||
const { container } = render(<StaggerList as="ul">content</StaggerList>);
|
||||
expect(getWrapper(container).tagName.toLowerCase()).toBe("ul");
|
||||
});
|
||||
|
||||
it("renders an <ol> when as='ol'", () => {
|
||||
const { container } = render(<StaggerList as="ol">content</StaggerList>);
|
||||
expect(getWrapper(container).tagName.toLowerCase()).toBe("ol");
|
||||
});
|
||||
|
||||
it("renders a <tbody> when as='tbody'", () => {
|
||||
// tbody must be inside a table for valid HTML, but jsdom accepts it.
|
||||
const { container } = render(
|
||||
<table>
|
||||
<StaggerList as="tbody">
|
||||
<tr>
|
||||
<td>cell</td>
|
||||
</tr>
|
||||
</StaggerList>
|
||||
</table>,
|
||||
);
|
||||
expect(container.querySelector("tbody")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Animation props
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("animation configuration", () => {
|
||||
it("sets initial to 'hidden'", () => {
|
||||
const { container } = render(<StaggerList>content</StaggerList>);
|
||||
expect(getWrapper(container).dataset["initial"]).toBe(JSON.stringify("hidden"));
|
||||
});
|
||||
|
||||
it("sets animate to 'visible'", () => {
|
||||
const { container } = render(<StaggerList>content</StaggerList>);
|
||||
expect(getWrapper(container).dataset["animate"]).toBe(JSON.stringify("visible"));
|
||||
});
|
||||
|
||||
it("variants hidden state is an empty object", () => {
|
||||
const { container } = render(<StaggerList>content</StaggerList>);
|
||||
const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as {
|
||||
hidden: Record<string, unknown>;
|
||||
};
|
||||
expect(variants.hidden).toEqual({});
|
||||
});
|
||||
|
||||
it("variants visible state has a staggerChildren transition", () => {
|
||||
const { container } = render(<StaggerList>content</StaggerList>);
|
||||
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(<StaggerList>content</StaggerList>);
|
||||
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(<StaggerList staggerDelay={0.1}>content</StaggerList>);
|
||||
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(<StaggerList staggerDelay={0}>content</StaggerList>);
|
||||
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(
|
||||
<StaggerItem>
|
||||
<span>List item content</span>
|
||||
</StaggerItem>,
|
||||
);
|
||||
expect(screen.getByText("List item content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders text node children", () => {
|
||||
render(<StaggerItem>raw text</StaggerItem>);
|
||||
expect(screen.getByText("raw text")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// className prop
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("className prop", () => {
|
||||
it("applies className to the wrapper div", () => {
|
||||
const { container } = render(<StaggerItem className="p-4 border">content</StaggerItem>);
|
||||
expect(getWrapper(container)).toHaveClass("p-4");
|
||||
expect(getWrapper(container)).toHaveClass("border");
|
||||
});
|
||||
|
||||
it("renders without a className when the prop is omitted", () => {
|
||||
const { container } = render(<StaggerItem>content</StaggerItem>);
|
||||
// Must not throw and wrapper must exist.
|
||||
expect(getWrapper(container)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Always renders a <div>
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("always renders a <div> element", () => {
|
||||
const { container } = render(<StaggerItem>content</StaggerItem>);
|
||||
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(<StaggerItem>content</StaggerItem>);
|
||||
expect(getVariants(container).hidden.opacity).toBe(0);
|
||||
});
|
||||
|
||||
it("hidden variant has y=8 (slides up slightly)", () => {
|
||||
const { container } = render(<StaggerItem>content</StaggerItem>);
|
||||
expect(getVariants(container).hidden.y).toBe(8);
|
||||
});
|
||||
|
||||
it("visible variant has opacity=1", () => {
|
||||
const { container } = render(<StaggerItem>content</StaggerItem>);
|
||||
expect(getVariants(container).visible.opacity).toBe(1);
|
||||
});
|
||||
|
||||
it("visible variant has y=0", () => {
|
||||
const { container } = render(<StaggerItem>content</StaggerItem>);
|
||||
expect(getVariants(container).visible.y).toBe(0);
|
||||
});
|
||||
|
||||
it("visible transition has duration 0.25", () => {
|
||||
const { container } = render(<StaggerItem>content</StaggerItem>);
|
||||
expect(getVariants(container).visible.transition.duration).toBe(0.25);
|
||||
});
|
||||
|
||||
it("visible transition has the correct cubic-bezier ease array", () => {
|
||||
const { container } = render(<StaggerItem>content</StaggerItem>);
|
||||
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(<StaggerItem>content</StaggerItem>);
|
||||
// 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(
|
||||
<StaggerList>
|
||||
<StaggerItem>Alpha</StaggerItem>
|
||||
<StaggerItem>Beta</StaggerItem>
|
||||
<StaggerItem>Gamma</StaggerItem>
|
||||
</StaggerList>,
|
||||
);
|
||||
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(
|
||||
<StaggerList>
|
||||
<StaggerItem>One</StaggerItem>
|
||||
<StaggerItem>Two</StaggerItem>
|
||||
</StaggerList>,
|
||||
);
|
||||
// The StaggerList wrapper contains two StaggerItem divs.
|
||||
const items = container.querySelectorAll("div > div");
|
||||
expect(items.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("renders StaggerItems inside a <ul> StaggerList", () => {
|
||||
render(
|
||||
<StaggerList as="ul">
|
||||
<StaggerItem>Item 1</StaggerItem>
|
||||
<StaggerItem>Item 2</StaggerItem>
|
||||
</StaggerList>,
|
||||
);
|
||||
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item 2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user