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:
2026-04-10 22:45:44 +02:00
parent d3f721ce58
commit 9bd7172018
7 changed files with 2019 additions and 0 deletions
@@ -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();
});
});
});
+301
View File
@@ -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);
});
});
});