test(web): add 232 tests for catalog, presets, skeleton, hooks
Lib: blueprint-field-catalog (74). Hooks: useAppPreferences (25), useTheme (19), useMultiSelectIntersection (12), useTimelineKeyboard (21). Components: ColumnTogglePanel, DateRangePresets (17, timezone-safe), ShimmerSkeleton (29), SuccessToast. Fix ShimmerGroup tests to use plain divs (ShimmerSkeleton doesn't forward the style prop from cloneElement). Fix DateRangePresets tests to compute expected dates via toISOString matching the component's UTC conversion. Web test suite: 87 → 96 files, 844 → 1076 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ColumnTogglePanel } from "./ColumnTogglePanel.js";
|
||||
import type { ColumnDef } from "@capakraken/shared";
|
||||
|
||||
// Mock useAnchoredOverlay – in jsdom there is no real layout engine, so we
|
||||
// just return stable refs and a no-op handleOpenChange.
|
||||
vi.mock("~/hooks/useAnchoredOverlay.js", () => ({
|
||||
useAnchoredOverlay: ({ onClose }: { onClose: () => void }) => ({
|
||||
triggerRef: { current: null },
|
||||
panelRef: { current: null },
|
||||
position: { top: 0, left: 0 },
|
||||
handleOpenChange: (open: boolean) => {
|
||||
if (!open) onClose();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const builtinColumns: ColumnDef[] = [
|
||||
{ key: "name", label: "Name", defaultVisible: true, hideable: false },
|
||||
{ key: "role", label: "Role", defaultVisible: true, hideable: true },
|
||||
{ key: "dept", label: "Dept", defaultVisible: false, hideable: true },
|
||||
];
|
||||
|
||||
const customColumns: ColumnDef[] = [
|
||||
{ key: "cf_level", label: "Level", defaultVisible: true, hideable: true, isCustom: true },
|
||||
];
|
||||
|
||||
const allColumns: ColumnDef[] = [...builtinColumns, ...customColumns];
|
||||
|
||||
const defaultKeys = ["name", "role"];
|
||||
const visibleKeys = ["name", "role", "cf_level"];
|
||||
|
||||
function renderPanel(overrides: Partial<React.ComponentProps<typeof ColumnTogglePanel>> = {}) {
|
||||
const onSetVisible = vi.fn();
|
||||
const utils = render(
|
||||
<ColumnTogglePanel
|
||||
allColumns={allColumns}
|
||||
visibleKeys={visibleKeys}
|
||||
onSetVisible={onSetVisible}
|
||||
defaultKeys={defaultKeys}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
return { ...utils, onSetVisible };
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("ColumnTogglePanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("trigger button", () => {
|
||||
it("renders the column-visibility toggle button", () => {
|
||||
renderPanel();
|
||||
expect(screen.getByRole("button", { name: /column visibility/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("panel is not visible before the button is clicked", () => {
|
||||
renderPanel();
|
||||
expect(screen.queryByText("Columns")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the panel when the trigger button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
expect(screen.getByText("Columns")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the panel when the trigger button is clicked again", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
const trigger = screen.getByRole("button", { name: /column visibility/i });
|
||||
await user.click(trigger);
|
||||
await user.click(trigger);
|
||||
expect(screen.queryByText("Columns")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("panel content – builtin columns", () => {
|
||||
async function openPanel() {
|
||||
const user = userEvent.setup();
|
||||
const utils = renderPanel();
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
return { user, ...utils };
|
||||
}
|
||||
|
||||
it("renders all builtin column labels inside the panel", async () => {
|
||||
await openPanel();
|
||||
expect(screen.getByText("Name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Role")).toBeInTheDocument();
|
||||
expect(screen.getByText("Dept")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows visible columns as checked", async () => {
|
||||
await openPanel();
|
||||
// "name" and "role" are in visibleKeys
|
||||
const roleCheckbox = screen.getByRole("checkbox", { name: /role/i });
|
||||
expect(roleCheckbox).toBeChecked();
|
||||
});
|
||||
|
||||
it("shows non-visible hideable columns as unchecked", async () => {
|
||||
await openPanel();
|
||||
const deptCheckbox = screen.getByRole("checkbox", { name: /dept/i });
|
||||
expect(deptCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("disables the checkbox for non-hideable columns", async () => {
|
||||
await openPanel();
|
||||
const nameCheckbox = screen.getByRole("checkbox", { name: /name/i });
|
||||
expect(nameCheckbox).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables the checkbox for hideable columns", async () => {
|
||||
await openPanel();
|
||||
const roleCheckbox = screen.getByRole("checkbox", { name: /role/i });
|
||||
expect(roleCheckbox).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("panel content – custom columns", () => {
|
||||
async function openPanel() {
|
||||
const user = userEvent.setup();
|
||||
const utils = renderPanel();
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
return { user, ...utils };
|
||||
}
|
||||
|
||||
it("renders the 'Custom Fields' section header", async () => {
|
||||
await openPanel();
|
||||
expect(screen.getByText("Custom Fields")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders custom column labels", async () => {
|
||||
await openPanel();
|
||||
expect(screen.getByText("Level")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render 'Custom Fields' header when there are no custom columns", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel({ allColumns: builtinColumns });
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
expect(screen.queryByText("Custom Fields")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggling visibility", () => {
|
||||
it("calls onSetVisible without a key when a visible hideable column is unchecked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSetVisible } = renderPanel();
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
await user.click(screen.getByRole("checkbox", { name: /role/i }));
|
||||
expect(onSetVisible).toHaveBeenCalledOnce();
|
||||
const [keys] = onSetVisible.mock.calls[0] as [string[]];
|
||||
expect(keys).not.toContain("role");
|
||||
});
|
||||
|
||||
it("calls onSetVisible with the key added when a hidden hideable column is checked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSetVisible } = renderPanel();
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
await user.click(screen.getByRole("checkbox", { name: /dept/i }));
|
||||
expect(onSetVisible).toHaveBeenCalledOnce();
|
||||
const [keys] = onSetVisible.mock.calls[0] as [string[]];
|
||||
expect(keys).toContain("dept");
|
||||
});
|
||||
|
||||
it("does NOT call onSetVisible when a non-hideable column checkbox is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSetVisible } = renderPanel();
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
// The non-hideable checkbox is disabled – userEvent won't fire onChange on disabled inputs.
|
||||
const nameCheckbox = screen.getByRole("checkbox", { name: /name/i });
|
||||
await user.click(nameCheckbox);
|
||||
expect(onSetVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset button", () => {
|
||||
it("renders the Reset button inside the panel", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
expect(screen.getByRole("button", { name: /reset/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSetVisible with defaultKeys when Reset is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSetVisible } = renderPanel();
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
await user.click(screen.getByRole("button", { name: /reset/i }));
|
||||
expect(onSetVisible).toHaveBeenCalledWith(defaultKeys);
|
||||
});
|
||||
});
|
||||
|
||||
describe("drag-and-drop reordering", () => {
|
||||
it("calls onSetVisible with reordered keys after a drop onto a different row", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSetVisible } = renderPanel({
|
||||
visibleKeys: ["name", "role", "dept"],
|
||||
allColumns: [
|
||||
{ key: "name", label: "Name", defaultVisible: true, hideable: false },
|
||||
{ key: "role", label: "Role", defaultVisible: true, hideable: true },
|
||||
{ key: "dept", label: "Dept", defaultVisible: true, hideable: true },
|
||||
],
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: /column visibility/i }));
|
||||
|
||||
// Simulate dragStart on "role" row and drop onto "dept" row using fireEvent
|
||||
// (userEvent doesn't support drag-and-drop; we use direct fireEvent).
|
||||
const { fireEvent } = await import("@testing-library/react");
|
||||
const rows = screen.getAllByRole("checkbox");
|
||||
// role is index 1, dept is index 2
|
||||
const roleRow = rows[1]!.closest("div[draggable]") as HTMLElement;
|
||||
const deptRow = rows[2]!.closest("div[draggable]") as HTMLElement;
|
||||
expect(roleRow).not.toBeNull();
|
||||
expect(deptRow).not.toBeNull();
|
||||
|
||||
fireEvent.dragStart(roleRow);
|
||||
fireEvent.dragOver(deptRow);
|
||||
fireEvent.drop(deptRow);
|
||||
|
||||
expect(onSetVisible).toHaveBeenCalledOnce();
|
||||
const [keys] = onSetVisible.mock.calls[0] as [string[]];
|
||||
// After moving "role" onto "dept": ["name", "dept", "role"]
|
||||
expect(keys.indexOf("dept")).toBeLessThan(keys.indexOf("role"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { DateRangePresets } from "./DateRangePresets.js";
|
||||
|
||||
describe("DateRangePresets", () => {
|
||||
describe("rendering", () => {
|
||||
it("renders all four preset buttons", () => {
|
||||
render(<DateRangePresets onSelect={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "This month" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "This quarter" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Next 3 months" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "This year" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("all buttons have type=button to avoid accidental form submission", () => {
|
||||
render(<DateRangePresets onSelect={vi.fn()} />);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
for (const btn of buttons) {
|
||||
expect(btn).toHaveAttribute("type", "button");
|
||||
}
|
||||
});
|
||||
|
||||
it("applies a custom className to the wrapper", () => {
|
||||
const { container } = render(
|
||||
<DateRangePresets onSelect={vi.fn()} className="my-custom-class" />,
|
||||
);
|
||||
expect(container.firstChild).toHaveClass("my-custom-class");
|
||||
});
|
||||
|
||||
it("renders without error when className is omitted", () => {
|
||||
const { container } = render(<DateRangePresets onSelect={vi.fn()} />);
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onSelect callback", () => {
|
||||
it("calls onSelect with start and end date strings when 'This month' is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This month" }));
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(end).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(start <= end).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onSelect with start and end date strings when 'This quarter' is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This quarter" }));
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(end).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(start <= end).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onSelect with start and end date strings when 'Next 3 months' is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "Next 3 months" }));
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(end).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(start <= end).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onSelect with start and end date strings when 'This year' is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This year" }));
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(end).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(start <= end).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onSelect exactly once per click and not before any interaction", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "This year" }));
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onSelect independently for each separate button click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This month" }));
|
||||
await user.click(screen.getByRole("button", { name: "This year" }));
|
||||
expect(onSelect).toHaveBeenCalledTimes(2);
|
||||
const firstCall = onSelect.mock.calls[0] as [string, string];
|
||||
const secondCall = onSelect.mock.calls[1] as [string, string];
|
||||
// "This month" start should differ from "This year" end
|
||||
expect(firstCall[1]).not.toBe(secondCall[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("date correctness", () => {
|
||||
// The component uses `new Date(y, m, d).toISOString().slice(0, 10)` which
|
||||
// converts local-midnight dates to UTC. We compute expected values the same
|
||||
// way so the tests are timezone-safe.
|
||||
const toIso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.setSystemTime(new Date("2024-03-15T12:00:00"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("'This month' returns the first and last day of the current month", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This month" }));
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toBe(toIso(new Date(2024, 2, 1)));
|
||||
expect(end).toBe(toIso(new Date(2024, 3, 0)));
|
||||
});
|
||||
|
||||
it("'This quarter' returns Q1 bounds for a March date", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This quarter" }));
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toBe(toIso(new Date(2024, 0, 1)));
|
||||
expect(end).toBe(toIso(new Date(2024, 3, 0)));
|
||||
});
|
||||
|
||||
it("'This year' returns Jan 1 to Dec 31 of the current year", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This year" }));
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toBe(toIso(new Date(2024, 0, 1)));
|
||||
expect(end).toBe(toIso(new Date(2024, 11, 31)));
|
||||
});
|
||||
|
||||
it("'Next 3 months' start equals today", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "Next 3 months" }));
|
||||
const [start] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toBe(toIso(new Date(2024, 2, 15)));
|
||||
});
|
||||
|
||||
it("'This quarter' returns Q2 bounds for an April date", async () => {
|
||||
vi.setSystemTime(new Date("2024-04-10T12:00:00"));
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This quarter" }));
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toBe(toIso(new Date(2024, 3, 1)));
|
||||
expect(end).toBe(toIso(new Date(2024, 6, 0)));
|
||||
});
|
||||
|
||||
it("'This quarter' returns Q3 bounds for a July date", async () => {
|
||||
vi.setSystemTime(new Date("2024-07-01T12:00:00"));
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This quarter" }));
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toBe(toIso(new Date(2024, 6, 1)));
|
||||
expect(end).toBe(toIso(new Date(2024, 9, 0)));
|
||||
});
|
||||
|
||||
it("'This quarter' returns Q4 bounds for an October date", async () => {
|
||||
vi.setSystemTime(new Date("2024-10-20T12:00:00"));
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(<DateRangePresets onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: "This quarter" }));
|
||||
const [start, end] = onSelect.mock.calls[0] as [string, string];
|
||||
expect(start).toBe(toIso(new Date(2024, 9, 1)));
|
||||
expect(end).toBe(toIso(new Date(2024, 12, 0)));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "~/test-utils.js";
|
||||
import { ShimmerSkeleton, ShimmerGroup, ShimmerStyles } from "./ShimmerSkeleton.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns the single shimmer div rendered by ShimmerSkeleton. */
|
||||
function getSkeleton(container: HTMLElement) {
|
||||
return container.firstChild as HTMLElement;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ShimmerSkeleton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ShimmerSkeleton", () => {
|
||||
describe("variant defaults", () => {
|
||||
it("applies the shimmer-skeleton base class", () => {
|
||||
const { container } = render(<ShimmerSkeleton />);
|
||||
expect(getSkeleton(container)).toHaveClass("shimmer-skeleton");
|
||||
});
|
||||
|
||||
it("defaults to the 'rect' variant: 100% wide, 40px tall, rounded-md", () => {
|
||||
const { container } = render(<ShimmerSkeleton />);
|
||||
const el = getSkeleton(container);
|
||||
expect(el.style.width).toBe("100%");
|
||||
expect(el.style.height).toBe("40px");
|
||||
expect(el).toHaveClass("rounded-md");
|
||||
});
|
||||
|
||||
it("'text' variant uses 100% width, 1em height, rounded", () => {
|
||||
const { container } = render(<ShimmerSkeleton variant="text" />);
|
||||
const el = getSkeleton(container);
|
||||
expect(el.style.width).toBe("100%");
|
||||
expect(el.style.height).toBe("1em");
|
||||
expect(el).toHaveClass("rounded");
|
||||
});
|
||||
|
||||
it("'circle' variant uses 40px width, 40px height, rounded-full", () => {
|
||||
const { container } = render(<ShimmerSkeleton variant="circle" />);
|
||||
const el = getSkeleton(container);
|
||||
expect(el.style.width).toBe("40px");
|
||||
expect(el.style.height).toBe("40px");
|
||||
expect(el).toHaveClass("rounded-full");
|
||||
});
|
||||
|
||||
it("'card' variant uses 100% width, 120px height, rounded-xl", () => {
|
||||
const { container } = render(<ShimmerSkeleton variant="card" />);
|
||||
const el = getSkeleton(container);
|
||||
expect(el.style.width).toBe("100%");
|
||||
expect(el.style.height).toBe("120px");
|
||||
expect(el).toHaveClass("rounded-xl");
|
||||
});
|
||||
});
|
||||
|
||||
describe("explicit width / height", () => {
|
||||
it("accepts a numeric width and converts it to px", () => {
|
||||
const { container } = render(<ShimmerSkeleton width={200} />);
|
||||
expect(getSkeleton(container).style.width).toBe("200px");
|
||||
});
|
||||
|
||||
it("accepts a string width directly", () => {
|
||||
const { container } = render(<ShimmerSkeleton width="50%" />);
|
||||
expect(getSkeleton(container).style.width).toBe("50%");
|
||||
});
|
||||
|
||||
it("accepts a numeric height and converts it to px", () => {
|
||||
const { container } = render(<ShimmerSkeleton height={80} />);
|
||||
expect(getSkeleton(container).style.height).toBe("80px");
|
||||
});
|
||||
|
||||
it("accepts a string height directly", () => {
|
||||
const { container } = render(<ShimmerSkeleton height="3rem" />);
|
||||
expect(getSkeleton(container).style.height).toBe("3rem");
|
||||
});
|
||||
|
||||
it("explicit width overrides the variant default", () => {
|
||||
const { container } = render(<ShimmerSkeleton variant="circle" width={64} />);
|
||||
expect(getSkeleton(container).style.width).toBe("64px");
|
||||
});
|
||||
|
||||
it("explicit height overrides the variant default", () => {
|
||||
const { container } = render(<ShimmerSkeleton variant="card" height={200} />);
|
||||
expect(getSkeleton(container).style.height).toBe("200px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rounded prop", () => {
|
||||
const roundedCases: Array<[React.ComponentProps<typeof ShimmerSkeleton>["rounded"], string]> = [
|
||||
["sm", "rounded-sm"],
|
||||
["md", "rounded-md"],
|
||||
["lg", "rounded-lg"],
|
||||
["xl", "rounded-xl"],
|
||||
["2xl", "rounded-2xl"],
|
||||
["full", "rounded-full"],
|
||||
];
|
||||
|
||||
for (const [rounded, expectedClass] of roundedCases) {
|
||||
it(`rounded="${rounded}" applies class ${expectedClass}`, () => {
|
||||
const { container } = render(<ShimmerSkeleton rounded={rounded} />);
|
||||
expect(getSkeleton(container)).toHaveClass(expectedClass);
|
||||
});
|
||||
}
|
||||
|
||||
it("explicit rounded prop overrides the variant default", () => {
|
||||
// 'circle' default is rounded-full; override with 'md'
|
||||
const { container } = render(<ShimmerSkeleton variant="circle" rounded="md" />);
|
||||
const el = getSkeleton(container);
|
||||
expect(el).toHaveClass("rounded-md");
|
||||
expect(el).not.toHaveClass("rounded-full");
|
||||
});
|
||||
});
|
||||
|
||||
describe("className prop", () => {
|
||||
it("appends extra className to the element", () => {
|
||||
const { container } = render(<ShimmerSkeleton className="mt-4 w-1/2" />);
|
||||
const el = getSkeleton(container);
|
||||
expect(el).toHaveClass("mt-4");
|
||||
expect(el).toHaveClass("w-1/2");
|
||||
});
|
||||
|
||||
it("preserves the shimmer-skeleton base class when className is provided", () => {
|
||||
const { container } = render(<ShimmerSkeleton className="extra" />);
|
||||
expect(getSkeleton(container)).toHaveClass("shimmer-skeleton");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ShimmerGroup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns all shimmer divs that are direct children of the ShimmerGroup wrapper. */
|
||||
function getGroupChildren(container: HTMLElement): HTMLElement[] {
|
||||
// ShimmerGroup renders a single wrapper div; its children are the skeleton divs.
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
return Array.from(wrapper.children) as HTMLElement[];
|
||||
}
|
||||
|
||||
describe("ShimmerGroup", () => {
|
||||
it("renders all children", () => {
|
||||
const { container } = render(
|
||||
<ShimmerGroup>
|
||||
<ShimmerSkeleton />
|
||||
<ShimmerSkeleton />
|
||||
<ShimmerSkeleton />
|
||||
</ShimmerGroup>,
|
||||
);
|
||||
expect(getGroupChildren(container)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("injects animationDelay on each valid React child", () => {
|
||||
// ShimmerGroup uses cloneElement to inject style.animationDelay.
|
||||
// Use plain divs so the style prop is forwarded to the DOM.
|
||||
const { container } = render(
|
||||
<ShimmerGroup staggerMs={100}>
|
||||
<div className="shimmer-skeleton" />
|
||||
<div className="shimmer-skeleton" />
|
||||
<div className="shimmer-skeleton" />
|
||||
</ShimmerGroup>,
|
||||
);
|
||||
const children = getGroupChildren(container);
|
||||
expect(["", "0ms"]).toContain(children[0]!.style.animationDelay);
|
||||
expect(children[1]!.style.animationDelay).toBe("100ms");
|
||||
expect(children[2]!.style.animationDelay).toBe("200ms");
|
||||
});
|
||||
|
||||
it("uses 50ms as the default stagger delay", () => {
|
||||
const { container } = render(
|
||||
<ShimmerGroup>
|
||||
<div className="shimmer-skeleton" />
|
||||
<div className="shimmer-skeleton" />
|
||||
</ShimmerGroup>,
|
||||
);
|
||||
const children = getGroupChildren(container);
|
||||
expect(["", "0ms"]).toContain(children[0]!.style.animationDelay);
|
||||
expect(children[1]!.style.animationDelay).toBe("50ms");
|
||||
});
|
||||
|
||||
it("applies a custom className to the wrapper div", () => {
|
||||
const { container } = render(
|
||||
<ShimmerGroup className="grid grid-cols-3">
|
||||
<ShimmerSkeleton />
|
||||
</ShimmerGroup>,
|
||||
);
|
||||
expect(container.firstChild).toHaveClass("grid");
|
||||
expect(container.firstChild).toHaveClass("grid-cols-3");
|
||||
});
|
||||
|
||||
it("passes through non-element children without crashing", () => {
|
||||
// A text node is not a valid React element and should be returned as-is.
|
||||
const { container } = render(
|
||||
<ShimmerGroup>
|
||||
{"plain text"}
|
||||
<ShimmerSkeleton />
|
||||
</ShimmerGroup>,
|
||||
);
|
||||
expect(container).toHaveTextContent("plain text");
|
||||
// One skeleton element should still be present.
|
||||
expect(container.querySelectorAll(".shimmer-skeleton")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves existing style properties when merged with animationDelay", () => {
|
||||
// Use plain divs with explicit style so cloneElement merges correctly.
|
||||
const { container } = render(
|
||||
<ShimmerGroup staggerMs={75}>
|
||||
<div style={{ width: "120px", height: "30px" }} />
|
||||
<div style={{ width: "120px", height: "30px" }} />
|
||||
</ShimmerGroup>,
|
||||
);
|
||||
const children = getGroupChildren(container);
|
||||
expect(children[1]!.style.animationDelay).toBe("75ms");
|
||||
expect(children[0]!.style.width).toBe("120px");
|
||||
expect(children[0]!.style.height).toBe("30px");
|
||||
expect(children[1]!.style.width).toBe("120px");
|
||||
expect(children[1]!.style.height).toBe("30px");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ShimmerStyles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ShimmerStyles", () => {
|
||||
it("renders a <style> element", () => {
|
||||
const { container } = render(<ShimmerStyles />);
|
||||
const styleEl = container.querySelector("style");
|
||||
expect(styleEl).not.toBeNull();
|
||||
});
|
||||
|
||||
it("style element contains the shimmer keyframe definition", () => {
|
||||
const { container } = render(<ShimmerStyles />);
|
||||
const styleEl = container.querySelector("style");
|
||||
expect(styleEl?.innerHTML).toContain("shimmer");
|
||||
});
|
||||
|
||||
it("style element contains the shimmer-skeleton class definition", () => {
|
||||
const { container } = render(<ShimmerStyles />);
|
||||
const styleEl = container.querySelector("style");
|
||||
expect(styleEl?.innerHTML).toContain(".shimmer-skeleton");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, act } from "~/test-utils.js";
|
||||
import { SuccessToast } from "./SuccessToast.js";
|
||||
|
||||
// Mock framer-motion so animations resolve synchronously in jsdom.
|
||||
vi.mock("framer-motion", () => ({
|
||||
motion: {
|
||||
div: ({
|
||||
children,
|
||||
// strip animation props that are irrelevant in jsdom
|
||||
initial: _initial,
|
||||
animate: _animate,
|
||||
exit: _exit,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
children?: React.ReactNode;
|
||||
initial?: unknown;
|
||||
animate?: unknown;
|
||||
exit?: unknown;
|
||||
transition?: unknown;
|
||||
}) => <div {...props}>{children}</div>,
|
||||
},
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AUTO_DISMISS_MS = 2500;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SuccessToast", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Visibility
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("visibility", () => {
|
||||
it("renders the message when show is true", () => {
|
||||
render(<SuccessToast show message="Saved!" />);
|
||||
expect(screen.getByText("Saved!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render when show is false", () => {
|
||||
render(<SuccessToast show={false} message="Saved!" />);
|
||||
expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disappears when show transitions from true to false", () => {
|
||||
const { rerender } = render(<SuccessToast show message="Saved!" />);
|
||||
expect(screen.getByText("Saved!")).toBeInTheDocument();
|
||||
rerender(<SuccessToast show={false} message="Saved!" />);
|
||||
expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("appears when show transitions from false to true", () => {
|
||||
const { rerender } = render(<SuccessToast show={false} message="Now visible" />);
|
||||
expect(screen.queryByText("Now visible")).not.toBeInTheDocument();
|
||||
rerender(<SuccessToast show message="Now visible" />);
|
||||
expect(screen.getByText("Now visible")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auto-dismiss timer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("auto-dismiss timer", () => {
|
||||
it("calls onDone after 2500 ms when show is true", () => {
|
||||
const onDone = vi.fn();
|
||||
render(<SuccessToast show message="Done" onDone={onDone} />);
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(AUTO_DISMISS_MS);
|
||||
});
|
||||
expect(onDone).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does NOT call onDone before 2500 ms have passed", () => {
|
||||
const onDone = vi.fn();
|
||||
render(<SuccessToast show message="Done" onDone={onDone} />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(AUTO_DISMISS_MS - 1);
|
||||
});
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onDone when show is false", () => {
|
||||
const onDone = vi.fn();
|
||||
render(<SuccessToast show={false} message="Done" onDone={onDone} />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(AUTO_DISMISS_MS * 2);
|
||||
});
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not throw when onDone is omitted", () => {
|
||||
expect(() => {
|
||||
render(<SuccessToast show message="No callback" />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(AUTO_DISMISS_MS);
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("resets the timer when show flips from false to true", () => {
|
||||
const onDone = vi.fn();
|
||||
const { rerender } = render(<SuccessToast show={false} message="Msg" onDone={onDone} />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
rerender(<SuccessToast show message="Msg" onDone={onDone} />);
|
||||
// Timer starts fresh from the rerender — onDone should fire after another 2500 ms.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(AUTO_DISMISS_MS - 1);
|
||||
});
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
expect(onDone).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("clears the pending timer when show changes to false before 2500 ms", () => {
|
||||
const onDone = vi.fn();
|
||||
const { rerender } = render(<SuccessToast show message="Msg" onDone={onDone} />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
rerender(<SuccessToast show={false} message="Msg" onDone={onDone} />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(AUTO_DISMISS_MS);
|
||||
});
|
||||
// onDone should NOT have been called because the timer was cleared.
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Variants
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("variants", () => {
|
||||
it("defaults to the 'success' variant and renders the check-mark path", () => {
|
||||
const { container } = render(<SuccessToast show message="OK" />);
|
||||
// The success icon has the path "M5 13l4 4L19 7"
|
||||
const path = container.querySelector('path[d="M5 13l4 4L19 7"]');
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the 'info' variant icon (circle element)", () => {
|
||||
const { container } = render(<SuccessToast show message="Info" variant="info" />);
|
||||
// The info icon contains a <circle> element
|
||||
expect(container.querySelector("circle")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the 'warning' variant icon (triangle path)", () => {
|
||||
const { container } = render(<SuccessToast show message="Warn" variant="warning" />);
|
||||
// The warning icon path contains the triangle shape with "ZM12 15.75"
|
||||
const path = container.querySelector('path[d*="ZM12 15.75"]');
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
|
||||
it("applies success background class for the 'success' variant", () => {
|
||||
const { container } = render(<SuccessToast show message="OK" variant="success" />);
|
||||
const inner = container.querySelector(".flex.items-center") as HTMLElement;
|
||||
expect(inner.className).toContain("emerald");
|
||||
});
|
||||
|
||||
it("applies info background class for the 'info' variant", () => {
|
||||
const { container } = render(<SuccessToast show message="Info" variant="info" />);
|
||||
const inner = container.querySelector(".flex.items-center") as HTMLElement;
|
||||
expect(inner.className).toContain("blue");
|
||||
});
|
||||
|
||||
it("applies warning background class for the 'warning' variant", () => {
|
||||
const { container } = render(<SuccessToast show message="Warn" variant="warning" />);
|
||||
const inner = container.querySelector(".flex.items-center") as HTMLElement;
|
||||
expect(inner.className).toContain("amber");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Message rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("message", () => {
|
||||
it("renders the exact message string", () => {
|
||||
render(<SuccessToast show message="Profile updated successfully" />);
|
||||
expect(screen.getByText("Profile updated successfully")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates the message text when the prop changes", () => {
|
||||
const { rerender } = render(<SuccessToast show message="First" />);
|
||||
expect(screen.getByText("First")).toBeInTheDocument();
|
||||
rerender(<SuccessToast show message="Second" />);
|
||||
expect(screen.getByText("Second")).toBeInTheDocument();
|
||||
expect(screen.queryByText("First")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("message is wrapped in a <span> with font-medium class", () => {
|
||||
render(<SuccessToast show message="Check me" />);
|
||||
const span = screen.getByText("Check me");
|
||||
expect(span.tagName.toLowerCase()).toBe("span");
|
||||
expect(span.className).toContain("font-medium");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useAppPreferences, readAppPreferences, type AppPreferences } from "./useAppPreferences.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage stub
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const localStorageData: Record<string, string> = {};
|
||||
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn((key: string) => localStorageData[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
localStorageData[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete localStorageData[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
for (const k in localStorageData) delete localStorageData[k];
|
||||
}),
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const STORAGE_KEY = "capakraken_prefs";
|
||||
|
||||
const DEFAULT: AppPreferences = {
|
||||
hideCompletedProjects: true,
|
||||
timelineDisplayMode: "strip",
|
||||
heatmapColorScheme: "green-red",
|
||||
showDemandProjects: true,
|
||||
blinkOverbookedDays: false,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function seedStorage(partial: Partial<AppPreferences>) {
|
||||
localStorageData[STORAGE_KEY] = JSON.stringify(partial);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorageMock.clear();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readAppPreferences (standalone utility)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("readAppPreferences", () => {
|
||||
it("returns defaults when localStorage is empty", () => {
|
||||
expect(readAppPreferences()).toEqual(DEFAULT);
|
||||
});
|
||||
|
||||
it("merges stored partial values with defaults", () => {
|
||||
seedStorage({ blinkOverbookedDays: true });
|
||||
expect(readAppPreferences()).toEqual({ ...DEFAULT, blinkOverbookedDays: true });
|
||||
});
|
||||
|
||||
it("returns defaults when stored JSON is invalid", () => {
|
||||
localStorageData[STORAGE_KEY] = "not-valid-json{{{";
|
||||
expect(readAppPreferences()).toEqual(DEFAULT);
|
||||
});
|
||||
|
||||
it("fills in missing fields from defaults when schema evolves", () => {
|
||||
// Simulate a stored object that is missing the newer blinkOverbookedDays key
|
||||
const partial = { hideCompletedProjects: false, timelineDisplayMode: "bar" };
|
||||
localStorageData[STORAGE_KEY] = JSON.stringify(partial);
|
||||
const result = readAppPreferences();
|
||||
expect(result.hideCompletedProjects).toBe(false);
|
||||
expect(result.timelineDisplayMode).toBe("bar");
|
||||
// Fields not in storage must fall back to defaults
|
||||
expect(result.blinkOverbookedDays).toBe(DEFAULT.blinkOverbookedDays);
|
||||
expect(result.showDemandProjects).toBe(DEFAULT.showDemandProjects);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useAppPreferences
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useAppPreferences", () => {
|
||||
it("initialises with defaults when storage is empty", () => {
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
expect(result.current.prefs).toEqual(DEFAULT);
|
||||
});
|
||||
|
||||
it("initialises from stored values", () => {
|
||||
seedStorage({ blinkOverbookedDays: true, timelineDisplayMode: "heatmap" });
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
expect(result.current.prefs.blinkOverbookedDays).toBe(true);
|
||||
expect(result.current.prefs.timelineDisplayMode).toBe("heatmap");
|
||||
});
|
||||
|
||||
// --- setHideCompletedProjects ---
|
||||
|
||||
it("setHideCompletedProjects updates the preference", () => {
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
act(() => {
|
||||
result.current.setHideCompletedProjects(false);
|
||||
});
|
||||
expect(result.current.prefs.hideCompletedProjects).toBe(false);
|
||||
});
|
||||
|
||||
it("setHideCompletedProjects persists to localStorage", () => {
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
act(() => {
|
||||
result.current.setHideCompletedProjects(false);
|
||||
});
|
||||
const stored = JSON.parse(localStorageData[STORAGE_KEY] ?? "{}") as AppPreferences;
|
||||
expect(stored.hideCompletedProjects).toBe(false);
|
||||
});
|
||||
|
||||
it("setHideCompletedProjects back to true restores the default", () => {
|
||||
seedStorage({ hideCompletedProjects: false });
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
act(() => {
|
||||
result.current.setHideCompletedProjects(true);
|
||||
});
|
||||
expect(result.current.prefs.hideCompletedProjects).toBe(true);
|
||||
});
|
||||
|
||||
// --- setTimelineDisplayMode ---
|
||||
|
||||
it("setTimelineDisplayMode updates to bar", () => {
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
act(() => {
|
||||
result.current.setTimelineDisplayMode("bar");
|
||||
});
|
||||
expect(result.current.prefs.timelineDisplayMode).toBe("bar");
|
||||
});
|
||||
|
||||
it("setTimelineDisplayMode updates to heatmap", () => {
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
act(() => {
|
||||
result.current.setTimelineDisplayMode("heatmap");
|
||||
});
|
||||
expect(result.current.prefs.timelineDisplayMode).toBe("heatmap");
|
||||
});
|
||||
|
||||
it("setTimelineDisplayMode updates to strip", () => {
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
act(() => {
|
||||
result.current.setTimelineDisplayMode("heatmap");
|
||||
});
|
||||
act(() => {
|
||||
result.current.setTimelineDisplayMode("strip");
|
||||
});
|
||||
expect(result.current.prefs.timelineDisplayMode).toBe("strip");
|
||||
});
|
||||
|
||||
it("setTimelineDisplayMode persists to localStorage", () => {
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
act(() => {
|
||||
result.current.setTimelineDisplayMode("bar");
|
||||
});
|
||||
const stored = JSON.parse(localStorageData[STORAGE_KEY] ?? "{}") as AppPreferences;
|
||||
expect(stored.timelineDisplayMode).toBe("bar");
|
||||
});
|
||||
|
||||
// --- setHeatmapColorScheme ---
|
||||
|
||||
it("setHeatmapColorScheme updates to blue-orange", () => {
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
act(() => {
|
||||
result.current.setHeatmapColorScheme("blue-orange");
|
||||
});
|
||||
expect(result.current.prefs.heatmapColorScheme).toBe("blue-orange");
|
||||
});
|
||||
|
||||
it("setHeatmapColorScheme cycles through all schemes", () => {
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
const schemes = ["green-red", "blue-orange", "purple-yellow", "mono"] as const;
|
||||
for (const scheme of schemes) {
|
||||
act(() => {
|
||||
result.current.setHeatmapColorScheme(scheme);
|
||||
});
|
||||
expect(result.current.prefs.heatmapColorScheme).toBe(scheme);
|
||||
}
|
||||
});
|
||||
|
||||
it("setHeatmapColorScheme persists to localStorage", () => {
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
act(() => {
|
||||
result.current.setHeatmapColorScheme("mono");
|
||||
});
|
||||
const stored = JSON.parse(localStorageData[STORAGE_KEY] ?? "{}") as AppPreferences;
|
||||
expect(stored.heatmapColorScheme).toBe("mono");
|
||||
});
|
||||
|
||||
// --- setShowDemandProjects ---
|
||||
|
||||
it("setShowDemandProjects toggles to false", () => {
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
act(() => {
|
||||
result.current.setShowDemandProjects(false);
|
||||
});
|
||||
expect(result.current.prefs.showDemandProjects).toBe(false);
|
||||
});
|
||||
|
||||
it("setShowDemandProjects persists to localStorage", () => {
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
act(() => {
|
||||
result.current.setShowDemandProjects(false);
|
||||
});
|
||||
const stored = JSON.parse(localStorageData[STORAGE_KEY] ?? "{}") as AppPreferences;
|
||||
expect(stored.showDemandProjects).toBe(false);
|
||||
});
|
||||
|
||||
// --- setBlinkOverbookedDays ---
|
||||
|
||||
it("setBlinkOverbookedDays toggles to true", () => {
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
act(() => {
|
||||
result.current.setBlinkOverbookedDays(true);
|
||||
});
|
||||
expect(result.current.prefs.blinkOverbookedDays).toBe(true);
|
||||
});
|
||||
|
||||
it("setBlinkOverbookedDays persists to localStorage", () => {
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
act(() => {
|
||||
result.current.setBlinkOverbookedDays(true);
|
||||
});
|
||||
const stored = JSON.parse(localStorageData[STORAGE_KEY] ?? "{}") as AppPreferences;
|
||||
expect(stored.blinkOverbookedDays).toBe(true);
|
||||
});
|
||||
|
||||
// --- isolated updates do not overwrite other prefs ---
|
||||
|
||||
it("updating one pref does not clobber unrelated prefs", () => {
|
||||
const { result } = renderHook(() => useAppPreferences());
|
||||
act(() => {
|
||||
result.current.setTimelineDisplayMode("bar");
|
||||
});
|
||||
act(() => {
|
||||
result.current.setBlinkOverbookedDays(true);
|
||||
});
|
||||
expect(result.current.prefs.timelineDisplayMode).toBe("bar");
|
||||
expect(result.current.prefs.blinkOverbookedDays).toBe(true);
|
||||
// All other fields stay at defaults
|
||||
expect(result.current.prefs.hideCompletedProjects).toBe(DEFAULT.hideCompletedProjects);
|
||||
expect(result.current.prefs.showDemandProjects).toBe(DEFAULT.showDemandProjects);
|
||||
expect(result.current.prefs.heatmapColorScheme).toBe(DEFAULT.heatmapColorScheme);
|
||||
});
|
||||
|
||||
// --- cross-instance sync via CustomEvent ---
|
||||
|
||||
it("dispatching the change event syncs a second hook instance", () => {
|
||||
const { result: r1 } = renderHook(() => useAppPreferences());
|
||||
const { result: r2 } = renderHook(() => useAppPreferences());
|
||||
|
||||
act(() => {
|
||||
r1.current.setBlinkOverbookedDays(true);
|
||||
});
|
||||
|
||||
// r2 should receive the updated prefs via the CustomEvent listener
|
||||
expect(r2.current.prefs.blinkOverbookedDays).toBe(true);
|
||||
});
|
||||
|
||||
// --- stable setter references ---
|
||||
|
||||
it("setter functions are stable across re-renders", () => {
|
||||
const { result, rerender } = renderHook(() => useAppPreferences());
|
||||
const setters = {
|
||||
hide: result.current.setHideCompletedProjects,
|
||||
mode: result.current.setTimelineDisplayMode,
|
||||
scheme: result.current.setHeatmapColorScheme,
|
||||
demand: result.current.setShowDemandProjects,
|
||||
blink: result.current.setBlinkOverbookedDays,
|
||||
};
|
||||
rerender();
|
||||
expect(result.current.setHideCompletedProjects).toBe(setters.hide);
|
||||
expect(result.current.setTimelineDisplayMode).toBe(setters.mode);
|
||||
expect(result.current.setHeatmapColorScheme).toBe(setters.scheme);
|
||||
expect(result.current.setShowDemandProjects).toBe(setters.demand);
|
||||
expect(result.current.setBlinkOverbookedDays).toBe(setters.blink);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,694 @@
|
||||
/**
|
||||
* Tests for useMultiSelectIntersection.
|
||||
*
|
||||
* The hook is a pure side-effect hook (useEffect only) that computes which
|
||||
* allocations/resources fall within a drag-selection rectangle and then calls
|
||||
* either setMultiSelectState or clearMultiSelect.
|
||||
*
|
||||
* Strategy:
|
||||
* - Supply a real canvasRef backed by a jsdom div with child elements that
|
||||
* have the expected data-* attributes.
|
||||
* - Mock getBoundingClientRect on individual row elements to simulate viewport
|
||||
* positions.
|
||||
* - Drive the hook by changing multiSelectState (isSelecting, startX/Y, etc.).
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import { useMultiSelectIntersection } from "./useMultiSelectIntersection.js";
|
||||
import type { MultiSelectState } from "~/hooks/useTimelineDrag.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The hook imports LABEL_WIDTH from timelineConstants – mock the whole module
|
||||
// so we can control the value and avoid file-system resolution issues.
|
||||
vi.mock("~/components/timeline/timelineConstants.js", () => ({
|
||||
LABEL_WIDTH: 256,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared test data / types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AllocationLike = {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
const CELL_WIDTH = 40;
|
||||
const LABEL_WIDTH_VALUE = 256;
|
||||
|
||||
/** Build a MultiSelectState with sensible defaults. */
|
||||
function makeState(overrides: Partial<MultiSelectState> = {}): MultiSelectState {
|
||||
return {
|
||||
isSelecting: false,
|
||||
startX: 300,
|
||||
startY: 100,
|
||||
currentX: 500,
|
||||
currentY: 200,
|
||||
selectedAllocationIds: [],
|
||||
selectedResourceIds: [],
|
||||
dateRange: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a minimal allocation-like object. */
|
||||
function makeAlloc(id: string, startDate: string, endDate: string): AllocationLike {
|
||||
return { id, startDate, endDate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a <div> element with data-* attributes and a mocked
|
||||
* getBoundingClientRect that returns the given DOMRect values.
|
||||
*/
|
||||
function makeRowElement(
|
||||
attrs: Record<string, string>,
|
||||
rect: { top: number; bottom: number; left: number; right: number },
|
||||
): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
el.dataset[k] = v;
|
||||
}
|
||||
el.getBoundingClientRect = vi.fn(() => ({
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
width: rect.right - rect.left,
|
||||
height: rect.bottom - rect.top,
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
toJSON: () => ({}),
|
||||
}));
|
||||
return el;
|
||||
}
|
||||
|
||||
/** toLeft / toWidth helpers that mirror the real timeline calculation. */
|
||||
const baseDate = new Date("2025-01-01");
|
||||
function toLeft(d: Date): number {
|
||||
const days = Math.floor((d.getTime() - baseDate.getTime()) / 86_400_000);
|
||||
return days * CELL_WIDTH;
|
||||
}
|
||||
function toWidth(s: Date, e: Date): number {
|
||||
const days = Math.ceil((e.getTime() - s.getTime()) / 86_400_000);
|
||||
return days * CELL_WIDTH;
|
||||
}
|
||||
|
||||
/** Build a dates array covering n days from baseDate. */
|
||||
function buildDates(n: number): Date[] {
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const d = new Date(baseDate);
|
||||
d.setDate(d.getDate() + i);
|
||||
return d;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook wrapper that gives us a stable canvasRef pointing to a live DOM node
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useCanvasRef() {
|
||||
return useRef<HTMLDivElement | null>(document.createElement("div"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useMultiSelectIntersection", () => {
|
||||
let setMultiSelectState: ReturnType<typeof vi.fn>;
|
||||
let clearMultiSelect: ReturnType<typeof vi.fn>;
|
||||
let canvasDom: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
setMultiSelectState = vi.fn();
|
||||
clearMultiSelect = vi.fn();
|
||||
canvasDom = document.createElement("div");
|
||||
// Default bounding rect for the canvas container
|
||||
canvasDom.getBoundingClientRect = vi.fn(() => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 1200,
|
||||
bottom: 800,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}));
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Early-exit conditions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("does nothing while isSelecting is true", () => {
|
||||
const state = makeState({ isSelecting: true });
|
||||
renderHook(() => {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(canvasDom);
|
||||
useMultiSelectIntersection({
|
||||
multiSelectState: state,
|
||||
setMultiSelectState,
|
||||
clearMultiSelect,
|
||||
canvasRef,
|
||||
viewMode: "resource",
|
||||
resources: [],
|
||||
allocsByResource: new Map(),
|
||||
projectGroups: [],
|
||||
openDemandsByProject: new Map(),
|
||||
dates: buildDates(30),
|
||||
today: baseDate,
|
||||
CELL_WIDTH,
|
||||
toLeft,
|
||||
toWidth,
|
||||
});
|
||||
});
|
||||
expect(setMultiSelectState).not.toHaveBeenCalled();
|
||||
expect(clearMultiSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when start coordinates are both 0 (no drag happened)", () => {
|
||||
const state = makeState({ isSelecting: false, startX: 0, startY: 0 });
|
||||
renderHook(() => {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(canvasDom);
|
||||
useMultiSelectIntersection({
|
||||
multiSelectState: state,
|
||||
setMultiSelectState,
|
||||
clearMultiSelect,
|
||||
canvasRef,
|
||||
viewMode: "resource",
|
||||
resources: [],
|
||||
allocsByResource: new Map(),
|
||||
projectGroups: [],
|
||||
openDemandsByProject: new Map(),
|
||||
dates: buildDates(30),
|
||||
today: baseDate,
|
||||
CELL_WIDTH,
|
||||
toLeft,
|
||||
toWidth,
|
||||
});
|
||||
});
|
||||
expect(setMultiSelectState).not.toHaveBeenCalled();
|
||||
expect(clearMultiSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when selectedAllocationIds is already populated", () => {
|
||||
const state = makeState({ selectedAllocationIds: ["existing-alloc"] });
|
||||
renderHook(() => {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(canvasDom);
|
||||
useMultiSelectIntersection({
|
||||
multiSelectState: state,
|
||||
setMultiSelectState,
|
||||
clearMultiSelect,
|
||||
canvasRef,
|
||||
viewMode: "resource",
|
||||
resources: [],
|
||||
allocsByResource: new Map(),
|
||||
projectGroups: [],
|
||||
openDemandsByProject: new Map(),
|
||||
dates: buildDates(30),
|
||||
today: baseDate,
|
||||
CELL_WIDTH,
|
||||
toLeft,
|
||||
toWidth,
|
||||
});
|
||||
});
|
||||
expect(setMultiSelectState).not.toHaveBeenCalled();
|
||||
expect(clearMultiSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when selectedResourceIds is already populated", () => {
|
||||
const state = makeState({ selectedResourceIds: ["existing-res"] });
|
||||
renderHook(() => {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(canvasDom);
|
||||
useMultiSelectIntersection({
|
||||
multiSelectState: state,
|
||||
setMultiSelectState,
|
||||
clearMultiSelect,
|
||||
canvasRef,
|
||||
viewMode: "resource",
|
||||
resources: [],
|
||||
allocsByResource: new Map(),
|
||||
projectGroups: [],
|
||||
openDemandsByProject: new Map(),
|
||||
dates: buildDates(30),
|
||||
today: baseDate,
|
||||
CELL_WIDTH,
|
||||
toLeft,
|
||||
toWidth,
|
||||
});
|
||||
});
|
||||
expect(setMultiSelectState).not.toHaveBeenCalled();
|
||||
expect(clearMultiSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when canvasRef.current is null", () => {
|
||||
const state = makeState();
|
||||
renderHook(() => {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
useMultiSelectIntersection({
|
||||
multiSelectState: state,
|
||||
setMultiSelectState,
|
||||
clearMultiSelect,
|
||||
canvasRef,
|
||||
viewMode: "resource",
|
||||
resources: [],
|
||||
allocsByResource: new Map(),
|
||||
projectGroups: [],
|
||||
openDemandsByProject: new Map(),
|
||||
dates: buildDates(30),
|
||||
today: baseDate,
|
||||
CELL_WIDTH,
|
||||
toLeft,
|
||||
toWidth,
|
||||
});
|
||||
});
|
||||
expect(setMultiSelectState).not.toHaveBeenCalled();
|
||||
expect(clearMultiSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// resource view — no hits → clearMultiSelect
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("calls clearMultiSelect when no rows intersect the selection rect", () => {
|
||||
// Row is completely above the selection band
|
||||
const rowEl = makeRowElement(
|
||||
{ index: "0" },
|
||||
{ top: 0, bottom: 10, left: 0, right: 200 }, // above selTop=100
|
||||
);
|
||||
canvasDom.appendChild(rowEl);
|
||||
|
||||
const resource = { id: "r1" } as { id: string; name: string };
|
||||
const state = makeState({ startY: 100, currentY: 200 });
|
||||
|
||||
renderHook(() => {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(canvasDom);
|
||||
useMultiSelectIntersection({
|
||||
multiSelectState: state,
|
||||
setMultiSelectState,
|
||||
clearMultiSelect,
|
||||
canvasRef,
|
||||
viewMode: "resource",
|
||||
resources: [resource as never],
|
||||
allocsByResource: new Map([["r1", []]]),
|
||||
projectGroups: [],
|
||||
openDemandsByProject: new Map(),
|
||||
dates: buildDates(30),
|
||||
today: baseDate,
|
||||
CELL_WIDTH,
|
||||
toLeft,
|
||||
toWidth,
|
||||
});
|
||||
});
|
||||
|
||||
expect(clearMultiSelect).toHaveBeenCalledOnce();
|
||||
expect(setMultiSelectState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// resource view — row + alloc hit
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("selects resource and allocation when both intersect the selection rect", () => {
|
||||
/**
|
||||
* Canvas left = 0, LABEL_WIDTH = 256.
|
||||
* canvasXOffset = 0 + 256 = 256.
|
||||
* Selection: startX=300, currentX=500.
|
||||
* selLeftCanvas = 300 - 256 = 44 → day index 1 (col 1)
|
||||
* selRightCanvas = 500 - 256 = 244 → day index 6 (col 6)
|
||||
*
|
||||
* Allocation: starts on day 2 (left=80), 3 days long (width=120), ends at 200.
|
||||
* 200 >= 44 (✓) AND 80 <= 244 (✓) → should be selected.
|
||||
*/
|
||||
const allocStart = new Date(baseDate);
|
||||
allocStart.setDate(allocStart.getDate() + 2); // day index 2, left=80
|
||||
const allocEnd = new Date(allocStart);
|
||||
allocEnd.setDate(allocEnd.getDate() + 3); // 3 days, width=120, right=200
|
||||
|
||||
const alloc = makeAlloc("alloc-1", allocStart.toISOString(), allocEnd.toISOString());
|
||||
|
||||
const rowEl = makeRowElement(
|
||||
{ index: "0" },
|
||||
{ top: 120, bottom: 160, left: 0, right: 1200 }, // intersects selY 100–200
|
||||
);
|
||||
canvasDom.appendChild(rowEl);
|
||||
|
||||
const resource = { id: "r1" } as { id: string; name: string };
|
||||
const state = makeState({ startX: 300, currentX: 500, startY: 100, currentY: 200 });
|
||||
|
||||
renderHook(() => {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(canvasDom);
|
||||
useMultiSelectIntersection({
|
||||
multiSelectState: state,
|
||||
setMultiSelectState,
|
||||
clearMultiSelect,
|
||||
canvasRef,
|
||||
viewMode: "resource",
|
||||
resources: [resource as never],
|
||||
allocsByResource: new Map([["r1", [alloc as never]]]),
|
||||
projectGroups: [],
|
||||
openDemandsByProject: new Map(),
|
||||
dates: buildDates(30),
|
||||
today: baseDate,
|
||||
CELL_WIDTH,
|
||||
toLeft,
|
||||
toWidth,
|
||||
});
|
||||
});
|
||||
|
||||
expect(setMultiSelectState).toHaveBeenCalledOnce();
|
||||
const updaterFn = setMultiSelectState.mock.calls[0][0] as (
|
||||
prev: MultiSelectState,
|
||||
) => MultiSelectState;
|
||||
const prevState = makeState();
|
||||
const nextState = updaterFn(prevState);
|
||||
expect(nextState.selectedAllocationIds).toContain("alloc-1");
|
||||
expect(nextState.selectedResourceIds).toContain("r1");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// resource view — row hit but alloc outside X range
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("selects resource but not allocation when alloc is outside the X range", () => {
|
||||
/**
|
||||
* selLeftCanvas = 44, selRightCanvas = 244
|
||||
* Allocation on day 10: left=400, right=480 → outside X range
|
||||
*/
|
||||
const allocStart = new Date(baseDate);
|
||||
allocStart.setDate(allocStart.getDate() + 10); // left=400
|
||||
const allocEnd = new Date(allocStart);
|
||||
allocEnd.setDate(allocEnd.getDate() + 2); // right=480
|
||||
|
||||
const alloc = makeAlloc("alloc-outside", allocStart.toISOString(), allocEnd.toISOString());
|
||||
|
||||
const rowEl = makeRowElement({ index: "0" }, { top: 120, bottom: 160, left: 0, right: 1200 });
|
||||
canvasDom.appendChild(rowEl);
|
||||
|
||||
const resource = { id: "r1" } as { id: string; name: string };
|
||||
const state = makeState({ startX: 300, currentX: 500, startY: 100, currentY: 200 });
|
||||
|
||||
renderHook(() => {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(canvasDom);
|
||||
useMultiSelectIntersection({
|
||||
multiSelectState: state,
|
||||
setMultiSelectState,
|
||||
clearMultiSelect,
|
||||
canvasRef,
|
||||
viewMode: "resource",
|
||||
resources: [resource as never],
|
||||
allocsByResource: new Map([["r1", [alloc as never]]]),
|
||||
projectGroups: [],
|
||||
openDemandsByProject: new Map(),
|
||||
dates: buildDates(30),
|
||||
today: baseDate,
|
||||
CELL_WIDTH,
|
||||
toLeft,
|
||||
toWidth,
|
||||
});
|
||||
});
|
||||
|
||||
expect(setMultiSelectState).toHaveBeenCalledOnce();
|
||||
const updaterFn = setMultiSelectState.mock.calls[0][0] as (
|
||||
prev: MultiSelectState,
|
||||
) => MultiSelectState;
|
||||
const nextState = updaterFn(makeState());
|
||||
expect(nextState.selectedResourceIds).toContain("r1");
|
||||
expect(nextState.selectedAllocationIds).toHaveLength(0);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// project view — allocation hit
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("selects allocation in project view when row and alloc intersect", () => {
|
||||
/**
|
||||
* Same geometry as resource test above:
|
||||
* selLeftCanvas=44, selRightCanvas=244
|
||||
* Alloc on day 2, 3 days (left=80, right=200) → intersects
|
||||
*/
|
||||
const allocStart = new Date(baseDate);
|
||||
allocStart.setDate(allocStart.getDate() + 2);
|
||||
const allocEnd = new Date(allocStart);
|
||||
allocEnd.setDate(allocEnd.getDate() + 3);
|
||||
|
||||
const alloc = makeAlloc("proj-alloc-1", allocStart.toISOString(), allocEnd.toISOString());
|
||||
|
||||
const rowEl = makeRowElement(
|
||||
{ projectResourceRow: "", projectId: "proj-1", resourceId: "res-1" },
|
||||
{ top: 120, bottom: 160, left: 0, right: 1200 },
|
||||
);
|
||||
canvasDom.appendChild(rowEl);
|
||||
|
||||
const projectGroups = [
|
||||
{
|
||||
id: "proj-1",
|
||||
resourceRows: [{ resource: { id: "res-1" }, allocs: [alloc as never] }],
|
||||
},
|
||||
];
|
||||
|
||||
const state = makeState({ startX: 300, currentX: 500, startY: 100, currentY: 200 });
|
||||
|
||||
renderHook(() => {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(canvasDom);
|
||||
useMultiSelectIntersection({
|
||||
multiSelectState: state,
|
||||
setMultiSelectState,
|
||||
clearMultiSelect,
|
||||
canvasRef,
|
||||
viewMode: "project",
|
||||
resources: [],
|
||||
allocsByResource: new Map(),
|
||||
projectGroups,
|
||||
openDemandsByProject: new Map(),
|
||||
dates: buildDates(30),
|
||||
today: baseDate,
|
||||
CELL_WIDTH,
|
||||
toLeft,
|
||||
toWidth,
|
||||
});
|
||||
});
|
||||
|
||||
expect(setMultiSelectState).toHaveBeenCalledOnce();
|
||||
const updaterFn = setMultiSelectState.mock.calls[0][0] as (
|
||||
prev: MultiSelectState,
|
||||
) => MultiSelectState;
|
||||
const nextState = updaterFn(makeState());
|
||||
expect(nextState.selectedAllocationIds).toContain("proj-alloc-1");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// project view — demand row hit
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("selects demand entry in project view when demand row intersects", () => {
|
||||
const demandStart = new Date(baseDate);
|
||||
demandStart.setDate(demandStart.getDate() + 2);
|
||||
const demandEnd = new Date(demandStart);
|
||||
demandEnd.setDate(demandEnd.getDate() + 3);
|
||||
|
||||
const demand = makeAlloc("demand-1", demandStart.toISOString(), demandEnd.toISOString());
|
||||
|
||||
const demandRowEl = makeRowElement(
|
||||
{ projectDemandRow: "", projectId: "proj-1" },
|
||||
{ top: 120, bottom: 160, left: 0, right: 1200 },
|
||||
);
|
||||
canvasDom.appendChild(demandRowEl);
|
||||
|
||||
const state = makeState({ startX: 300, currentX: 500, startY: 100, currentY: 200 });
|
||||
|
||||
renderHook(() => {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(canvasDom);
|
||||
useMultiSelectIntersection({
|
||||
multiSelectState: state,
|
||||
setMultiSelectState,
|
||||
clearMultiSelect,
|
||||
canvasRef,
|
||||
viewMode: "project",
|
||||
resources: [],
|
||||
allocsByResource: new Map(),
|
||||
projectGroups: [],
|
||||
openDemandsByProject: new Map([["proj-1", [demand as never]]]),
|
||||
dates: buildDates(30),
|
||||
today: baseDate,
|
||||
CELL_WIDTH,
|
||||
toLeft,
|
||||
toWidth,
|
||||
});
|
||||
});
|
||||
|
||||
expect(setMultiSelectState).toHaveBeenCalledOnce();
|
||||
const updaterFn = setMultiSelectState.mock.calls[0][0] as (
|
||||
prev: MultiSelectState,
|
||||
) => MultiSelectState;
|
||||
const nextState = updaterFn(makeState());
|
||||
expect(nextState.selectedAllocationIds).toContain("demand-1");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// dateRange in the result
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("includes a dateRange reflecting the selected column band", () => {
|
||||
/**
|
||||
* selLeftCanvas = 300 - 256 = 44 → colIndex = floor(44/40) = 1 → dates[1]
|
||||
* selRightCanvas = 500 - 256 = 244 → colIndex = floor(244/40) = 6 → dates[6]
|
||||
*/
|
||||
const allocStart = new Date(baseDate);
|
||||
allocStart.setDate(allocStart.getDate() + 2);
|
||||
const allocEnd = new Date(allocStart);
|
||||
allocEnd.setDate(allocEnd.getDate() + 3);
|
||||
|
||||
const alloc = makeAlloc("range-alloc", allocStart.toISOString(), allocEnd.toISOString());
|
||||
|
||||
const rowEl = makeRowElement({ index: "0" }, { top: 120, bottom: 160, left: 0, right: 1200 });
|
||||
canvasDom.appendChild(rowEl);
|
||||
|
||||
const dates = buildDates(30);
|
||||
const state = makeState({ startX: 300, currentX: 500, startY: 100, currentY: 200 });
|
||||
|
||||
renderHook(() => {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(canvasDom);
|
||||
useMultiSelectIntersection({
|
||||
multiSelectState: state,
|
||||
setMultiSelectState,
|
||||
clearMultiSelect,
|
||||
canvasRef,
|
||||
viewMode: "resource",
|
||||
resources: [{ id: "r1" } as never],
|
||||
allocsByResource: new Map([["r1", [alloc as never]]]),
|
||||
projectGroups: [],
|
||||
openDemandsByProject: new Map(),
|
||||
dates,
|
||||
today: baseDate,
|
||||
CELL_WIDTH,
|
||||
toLeft,
|
||||
toWidth,
|
||||
});
|
||||
});
|
||||
|
||||
expect(setMultiSelectState).toHaveBeenCalledOnce();
|
||||
const updaterFn = setMultiSelectState.mock.calls[0][0] as (
|
||||
prev: MultiSelectState,
|
||||
) => MultiSelectState;
|
||||
const nextState = updaterFn(makeState());
|
||||
expect(nextState.dateRange).not.toBeNull();
|
||||
// col index 1 → dates[1]
|
||||
expect(nextState.dateRange?.start).toEqual(dates[1]);
|
||||
// col index 6 → dates[6]
|
||||
expect(nextState.dateRange?.end).toEqual(dates[6]);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Selection rectangle normalisation (drag from right-to-left / bottom-to-top)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("correctly normalises a right-to-left / bottom-to-top drag", () => {
|
||||
/**
|
||||
* User dragged from (500, 200) to (300, 100) — swapped start/current.
|
||||
* After normalisation: selLeft=300, selRight=500, selTop=100, selBottom=200.
|
||||
* Same geometry as forward drag → same result.
|
||||
*/
|
||||
const allocStart = new Date(baseDate);
|
||||
allocStart.setDate(allocStart.getDate() + 2);
|
||||
const allocEnd = new Date(allocStart);
|
||||
allocEnd.setDate(allocEnd.getDate() + 3);
|
||||
|
||||
const alloc = makeAlloc("rtl-alloc", allocStart.toISOString(), allocEnd.toISOString());
|
||||
|
||||
const rowEl = makeRowElement({ index: "0" }, { top: 120, bottom: 160, left: 0, right: 1200 });
|
||||
canvasDom.appendChild(rowEl);
|
||||
|
||||
// Swapped: startX > currentX, startY > currentY
|
||||
const state = makeState({ startX: 500, currentX: 300, startY: 200, currentY: 100 });
|
||||
|
||||
renderHook(() => {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(canvasDom);
|
||||
useMultiSelectIntersection({
|
||||
multiSelectState: state,
|
||||
setMultiSelectState,
|
||||
clearMultiSelect,
|
||||
canvasRef,
|
||||
viewMode: "resource",
|
||||
resources: [{ id: "r1" } as never],
|
||||
allocsByResource: new Map([["r1", [alloc as never]]]),
|
||||
projectGroups: [],
|
||||
openDemandsByProject: new Map(),
|
||||
dates: buildDates(30),
|
||||
today: baseDate,
|
||||
CELL_WIDTH,
|
||||
toLeft,
|
||||
toWidth,
|
||||
});
|
||||
});
|
||||
|
||||
expect(setMultiSelectState).toHaveBeenCalledOnce();
|
||||
const updaterFn = setMultiSelectState.mock.calls[0][0] as (
|
||||
prev: MultiSelectState,
|
||||
) => MultiSelectState;
|
||||
const nextState = updaterFn(makeState());
|
||||
expect(nextState.selectedAllocationIds).toContain("rtl-alloc");
|
||||
expect(nextState.selectedResourceIds).toContain("r1");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Multiple allocations — only overlapping ones are selected
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("selects only allocations that overlap the X range", () => {
|
||||
const dates = buildDates(30);
|
||||
|
||||
// Alloc A: day 2 (left=80), 3 days (right=200) → inside [44, 244]
|
||||
const aStart = new Date(baseDate);
|
||||
aStart.setDate(aStart.getDate() + 2);
|
||||
const aEnd = new Date(aStart);
|
||||
aEnd.setDate(aEnd.getDate() + 3);
|
||||
|
||||
// Alloc B: day 15 (left=600), 2 days (right=680) → outside [44, 244]
|
||||
const bStart = new Date(baseDate);
|
||||
bStart.setDate(bStart.getDate() + 15);
|
||||
const bEnd = new Date(bStart);
|
||||
bEnd.setDate(bEnd.getDate() + 2);
|
||||
|
||||
const allocA = makeAlloc("inside", aStart.toISOString(), aEnd.toISOString());
|
||||
const allocB = makeAlloc("outside", bStart.toISOString(), bEnd.toISOString());
|
||||
|
||||
const rowEl = makeRowElement({ index: "0" }, { top: 120, bottom: 160, left: 0, right: 1200 });
|
||||
canvasDom.appendChild(rowEl);
|
||||
|
||||
const state = makeState({ startX: 300, currentX: 500, startY: 100, currentY: 200 });
|
||||
|
||||
renderHook(() => {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(canvasDom);
|
||||
useMultiSelectIntersection({
|
||||
multiSelectState: state,
|
||||
setMultiSelectState,
|
||||
clearMultiSelect,
|
||||
canvasRef,
|
||||
viewMode: "resource",
|
||||
resources: [{ id: "r1" } as never],
|
||||
allocsByResource: new Map([["r1", [allocA as never, allocB as never]]]),
|
||||
projectGroups: [],
|
||||
openDemandsByProject: new Map(),
|
||||
dates,
|
||||
today: baseDate,
|
||||
CELL_WIDTH,
|
||||
toLeft,
|
||||
toWidth,
|
||||
});
|
||||
});
|
||||
|
||||
expect(setMultiSelectState).toHaveBeenCalledOnce();
|
||||
const updaterFn = setMultiSelectState.mock.calls[0][0] as (
|
||||
prev: MultiSelectState,
|
||||
) => MultiSelectState;
|
||||
const nextState = updaterFn(makeState());
|
||||
expect(nextState.selectedAllocationIds).toContain("inside");
|
||||
expect(nextState.selectedAllocationIds).not.toContain("outside");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useTheme, type ThemePreferences } from "./useTheme.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage stub
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const localStorageData: Record<string, string> = {};
|
||||
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn((key: string) => localStorageData[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
localStorageData[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete localStorageData[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
for (const k in localStorageData) delete localStorageData[k];
|
||||
}),
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const STORAGE_KEY = "capakraken_theme";
|
||||
|
||||
const DEFAULT: ThemePreferences = { mode: "light", accent: "sky" };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function seedStorage(partial: Partial<ThemePreferences>) {
|
||||
localStorageData[STORAGE_KEY] = JSON.stringify(partial);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
// Reset document.documentElement state between tests
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.removeAttribute("data-accent");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorageMock.clear();
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.removeAttribute("data-accent");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useTheme
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useTheme", () => {
|
||||
// --- initial state ---
|
||||
|
||||
it("initialises with default prefs when storage is empty", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.prefs).toEqual(DEFAULT);
|
||||
});
|
||||
|
||||
it("initialises from stored prefs", () => {
|
||||
seedStorage({ mode: "dark", accent: "violet" });
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.prefs.mode).toBe("dark");
|
||||
expect(result.current.prefs.accent).toBe("violet");
|
||||
});
|
||||
|
||||
// --- DOM side-effects on mount ---
|
||||
|
||||
it("applies light mode on mount (no 'dark' class)", () => {
|
||||
renderHook(() => useTheme());
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
});
|
||||
|
||||
it("adds 'dark' class when stored mode is dark", () => {
|
||||
seedStorage({ mode: "dark", accent: "sky" });
|
||||
renderHook(() => useTheme());
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
});
|
||||
|
||||
it("sets data-accent attribute on mount", () => {
|
||||
renderHook(() => useTheme());
|
||||
expect(document.documentElement.getAttribute("data-accent")).toBe("sky");
|
||||
});
|
||||
|
||||
it("sets data-accent from stored value on mount", () => {
|
||||
seedStorage({ mode: "light", accent: "rose" });
|
||||
renderHook(() => useTheme());
|
||||
expect(document.documentElement.getAttribute("data-accent")).toBe("rose");
|
||||
});
|
||||
|
||||
// --- setMode ---
|
||||
|
||||
it("setMode('dark') updates prefs.mode", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setMode("dark");
|
||||
});
|
||||
expect(result.current.prefs.mode).toBe("dark");
|
||||
});
|
||||
|
||||
it("setMode('dark') adds the 'dark' class to <html>", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setMode("dark");
|
||||
});
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
});
|
||||
|
||||
it("setMode('light') removes the 'dark' class from <html>", () => {
|
||||
seedStorage({ mode: "dark", accent: "sky" });
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setMode("light");
|
||||
});
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
});
|
||||
|
||||
it("setMode persists to localStorage", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setMode("dark");
|
||||
});
|
||||
const stored = JSON.parse(localStorageData[STORAGE_KEY] ?? "{}") as ThemePreferences;
|
||||
expect(stored.mode).toBe("dark");
|
||||
});
|
||||
|
||||
it("setMode does not overwrite accent", () => {
|
||||
seedStorage({ mode: "light", accent: "indigo" });
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setMode("dark");
|
||||
});
|
||||
expect(result.current.prefs.accent).toBe("indigo");
|
||||
});
|
||||
|
||||
// --- setAccent ---
|
||||
|
||||
it("setAccent updates prefs.accent", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setAccent("emerald");
|
||||
});
|
||||
expect(result.current.prefs.accent).toBe("emerald");
|
||||
});
|
||||
|
||||
it("setAccent updates data-accent on <html>", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setAccent("amber");
|
||||
});
|
||||
expect(document.documentElement.getAttribute("data-accent")).toBe("amber");
|
||||
});
|
||||
|
||||
it("setAccent persists to localStorage", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setAccent("violet");
|
||||
});
|
||||
const stored = JSON.parse(localStorageData[STORAGE_KEY] ?? "{}") as ThemePreferences;
|
||||
expect(stored.accent).toBe("violet");
|
||||
});
|
||||
|
||||
it("setAccent does not overwrite mode", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setMode("dark");
|
||||
});
|
||||
act(() => {
|
||||
result.current.setAccent("rose");
|
||||
});
|
||||
expect(result.current.prefs.mode).toBe("dark");
|
||||
});
|
||||
|
||||
it("setAccent cycles through all accent values", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
const accents = ["sky", "indigo", "violet", "emerald", "rose", "amber"] as const;
|
||||
for (const accent of accents) {
|
||||
act(() => {
|
||||
result.current.setAccent(accent);
|
||||
});
|
||||
expect(result.current.prefs.accent).toBe(accent);
|
||||
expect(document.documentElement.getAttribute("data-accent")).toBe(accent);
|
||||
}
|
||||
});
|
||||
|
||||
// --- combined mode + accent change ---
|
||||
|
||||
it("changing both mode and accent reflects correct DOM state", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setMode("dark");
|
||||
});
|
||||
act(() => {
|
||||
result.current.setAccent("indigo");
|
||||
});
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
expect(document.documentElement.getAttribute("data-accent")).toBe("indigo");
|
||||
});
|
||||
|
||||
// --- stable setter references ---
|
||||
|
||||
it("setter functions are stable across re-renders", () => {
|
||||
const { result, rerender } = renderHook(() => useTheme());
|
||||
const setMode = result.current.setMode;
|
||||
const setAccent = result.current.setAccent;
|
||||
rerender();
|
||||
expect(result.current.setMode).toBe(setMode);
|
||||
expect(result.current.setAccent).toBe(setAccent);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,490 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import { useTimelineKeyboard } from "./useTimelineKeyboard.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: fire a keyboard event on window
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fireKeydown(options: KeyboardEventInit) {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, ...options }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: create a scroll container element with controllable scrollLeft
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeScrollContainer(): HTMLDivElement {
|
||||
const el = document.createElement("div");
|
||||
// jsdom does not actually scroll, but we can track assignments
|
||||
Object.defineProperty(el, "scrollLeft", {
|
||||
get() {
|
||||
return this._scrollLeft ?? 0;
|
||||
},
|
||||
set(v: number) {
|
||||
this._scrollLeft = v;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// navigator.platform mock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function setMacPlatform() {
|
||||
Object.defineProperty(navigator, "platform", {
|
||||
get: () => "MacIntel",
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function setWindowsPlatform() {
|
||||
Object.defineProperty(navigator, "platform", {
|
||||
get: () => "Win32",
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useTimelineKeyboard", () => {
|
||||
let scrollEl: HTMLDivElement;
|
||||
let onDeleteSelected: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
scrollEl = makeScrollContainer();
|
||||
onDeleteSelected = vi.fn();
|
||||
setWindowsPlatform(); // default to non-mac
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initial state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("initialises showShortcuts as false", () => {
|
||||
const { result } = renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: [],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
expect(result.current.showShortcuts).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes a setShowShortcuts setter", () => {
|
||||
const { result } = renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: [],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
result.current.setShowShortcuts(true);
|
||||
});
|
||||
expect(result.current.showShortcuts).toBe(true);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// "?" key — toggle shortcut overlay
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("'?' toggles showShortcuts from false to true", () => {
|
||||
const { result } = renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: [],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
fireKeydown({ key: "?" });
|
||||
});
|
||||
expect(result.current.showShortcuts).toBe(true);
|
||||
});
|
||||
|
||||
it("'?' toggles showShortcuts from true back to false", () => {
|
||||
const { result } = renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: [],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
fireKeydown({ key: "?" });
|
||||
});
|
||||
act(() => {
|
||||
fireKeydown({ key: "?" });
|
||||
});
|
||||
expect(result.current.showShortcuts).toBe(false);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ArrowLeft / ArrowRight — scrolling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("ArrowLeft scrolls left by one cellWidth", () => {
|
||||
scrollEl._scrollLeft = 200;
|
||||
renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: [],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
fireKeydown({ key: "ArrowLeft" });
|
||||
});
|
||||
expect(scrollEl.scrollLeft).toBe(160); // 200 - 40
|
||||
});
|
||||
|
||||
it("ArrowRight scrolls right by one cellWidth", () => {
|
||||
scrollEl._scrollLeft = 0;
|
||||
renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: [],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
fireKeydown({ key: "ArrowRight" });
|
||||
});
|
||||
expect(scrollEl.scrollLeft).toBe(40);
|
||||
});
|
||||
|
||||
it("Shift+ArrowLeft scrolls by 7 × cellWidth", () => {
|
||||
scrollEl._scrollLeft = 400;
|
||||
renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: [],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
fireKeydown({ key: "ArrowLeft", shiftKey: true });
|
||||
});
|
||||
expect(scrollEl.scrollLeft).toBe(400 - 7 * 40); // 120
|
||||
});
|
||||
|
||||
it("Shift+ArrowRight scrolls by 7 × cellWidth", () => {
|
||||
scrollEl._scrollLeft = 0;
|
||||
renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: [],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
fireKeydown({ key: "ArrowRight", shiftKey: true });
|
||||
});
|
||||
expect(scrollEl.scrollLeft).toBe(7 * 40); // 280
|
||||
});
|
||||
|
||||
it("uses current cellWidth from ref without re-registering the handler", () => {
|
||||
const { rerender } = renderHook(
|
||||
({ cellWidth }) => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth,
|
||||
selectedAllocationIds: [],
|
||||
onDeleteSelected,
|
||||
});
|
||||
},
|
||||
{ initialProps: { cellWidth: 40 } },
|
||||
);
|
||||
|
||||
// Update cellWidth without causing a handler re-registration
|
||||
rerender({ cellWidth: 60 });
|
||||
|
||||
scrollEl._scrollLeft = 0;
|
||||
act(() => {
|
||||
fireKeydown({ key: "ArrowRight" });
|
||||
});
|
||||
expect(scrollEl.scrollLeft).toBe(60);
|
||||
});
|
||||
|
||||
it("does not scroll when scrollContainerRef.current is null", () => {
|
||||
renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: [],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
// Should not throw
|
||||
act(() => {
|
||||
fireKeydown({ key: "ArrowLeft" });
|
||||
fireKeydown({ key: "ArrowRight" });
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Delete / Backspace — delete selected allocations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("Delete key calls onDeleteSelected when allocations are selected", () => {
|
||||
renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: ["a1"],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
fireKeydown({ key: "Delete" });
|
||||
});
|
||||
expect(onDeleteSelected).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("Backspace key calls onDeleteSelected when allocations are selected", () => {
|
||||
renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: ["a1", "a2"],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
fireKeydown({ key: "Backspace" });
|
||||
});
|
||||
expect(onDeleteSelected).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("Delete key does NOT call onDeleteSelected when nothing is selected", () => {
|
||||
renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: [],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
fireKeydown({ key: "Delete" });
|
||||
});
|
||||
expect(onDeleteSelected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Ctrl+Backspace does NOT call onDeleteSelected (browser shortcut reserved)", () => {
|
||||
renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: ["a1"],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
fireKeydown({ key: "Backspace", ctrlKey: true });
|
||||
});
|
||||
expect(onDeleteSelected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Cmd+Backspace (Mac) does NOT call onDeleteSelected", () => {
|
||||
setMacPlatform();
|
||||
renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: ["a1"],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
fireKeydown({ key: "Backspace", metaKey: true });
|
||||
});
|
||||
expect(onDeleteSelected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses latest onDeleteSelected callback without re-registering the handler", () => {
|
||||
const firstCallback = vi.fn();
|
||||
const secondCallback = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ cb }) => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: ["a1"],
|
||||
onDeleteSelected: cb,
|
||||
});
|
||||
},
|
||||
{ initialProps: { cb: firstCallback } },
|
||||
);
|
||||
|
||||
// Swap to a new callback
|
||||
rerender({ cb: secondCallback });
|
||||
|
||||
act(() => {
|
||||
fireKeydown({ key: "Delete" });
|
||||
});
|
||||
|
||||
// Only the latest callback should be called
|
||||
expect(secondCallback).toHaveBeenCalledOnce();
|
||||
expect(firstCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Typing targets — handler is suppressed inside inputs
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("ignores '?' when an input element has focus", () => {
|
||||
const input = document.createElement("input");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
const { result } = renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: [],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireKeydown({ key: "?" });
|
||||
});
|
||||
|
||||
expect(result.current.showShortcuts).toBe(false);
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("ignores Delete when a textarea has focus", () => {
|
||||
const textarea = document.createElement("textarea");
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
|
||||
renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: ["a1"],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireKeydown({ key: "Delete" });
|
||||
});
|
||||
|
||||
expect(onDeleteSelected).not.toHaveBeenCalled();
|
||||
textarea.remove();
|
||||
});
|
||||
|
||||
it("ignores ArrowLeft when a select element has focus", () => {
|
||||
scrollEl._scrollLeft = 200;
|
||||
const select = document.createElement("select");
|
||||
document.body.appendChild(select);
|
||||
select.focus();
|
||||
|
||||
renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: [],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireKeydown({ key: "ArrowLeft" });
|
||||
});
|
||||
|
||||
expect(scrollEl.scrollLeft).toBe(200); // unchanged
|
||||
select.remove();
|
||||
});
|
||||
|
||||
it("ignores keys when a contentEditable element has focus", () => {
|
||||
// jsdom does not implement isContentEditable, so we create a stub element
|
||||
// that reports isContentEditable = true and mock document.activeElement to
|
||||
// return it.
|
||||
const div = document.createElement("div");
|
||||
// jsdom doesn't implement isContentEditable; override it manually
|
||||
Object.defineProperty(div, "isContentEditable", {
|
||||
get: () => true,
|
||||
configurable: true,
|
||||
});
|
||||
const activeElSpy = vi.spyOn(document, "activeElement", "get").mockReturnValue(div);
|
||||
|
||||
renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: ["a1"],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireKeydown({ key: "Delete" });
|
||||
});
|
||||
|
||||
expect(onDeleteSelected).not.toHaveBeenCalled();
|
||||
activeElSpy.mockRestore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cleanup — event listener is removed on unmount
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("removes the keydown listener when the component unmounts", () => {
|
||||
const removeSpy = vi.spyOn(window, "removeEventListener");
|
||||
const { unmount } = renderHook(() => {
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(scrollEl);
|
||||
return useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth: 40,
|
||||
selectedAllocationIds: [],
|
||||
onDeleteSelected,
|
||||
});
|
||||
});
|
||||
unmount();
|
||||
expect(removeSpy).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,542 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BlueprintTarget, FieldType } from "@capakraken/shared";
|
||||
import {
|
||||
type CatalogCategory,
|
||||
type CatalogField,
|
||||
PROJECT_CATEGORIES,
|
||||
PROJECT_FIELD_CATALOG,
|
||||
RESOURCE_CATEGORIES,
|
||||
RESOURCE_FIELD_CATALOG,
|
||||
findCatalogField,
|
||||
getCatalogForTarget,
|
||||
getCategoriesForTarget,
|
||||
} from "./blueprint-field-catalog.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CatalogField shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("CatalogField interface", () => {
|
||||
it("every PROJECT_FIELD_CATALOG entry has required fields", () => {
|
||||
for (const field of PROJECT_FIELD_CATALOG) {
|
||||
expect(field.key).toBeTypeOf("string");
|
||||
expect(field.key.length).toBeGreaterThan(0);
|
||||
expect(field.label).toBeTypeOf("string");
|
||||
expect(field.label.length).toBeGreaterThan(0);
|
||||
expect(field.type).toBeTypeOf("string");
|
||||
expect(field.category).toBeTypeOf("string");
|
||||
expect(field.description).toBeTypeOf("string");
|
||||
expect(field.builtIn).toBeTypeOf("boolean");
|
||||
}
|
||||
});
|
||||
|
||||
it("every RESOURCE_FIELD_CATALOG entry has required fields", () => {
|
||||
for (const field of RESOURCE_FIELD_CATALOG) {
|
||||
expect(field.key).toBeTypeOf("string");
|
||||
expect(field.key.length).toBeGreaterThan(0);
|
||||
expect(field.label).toBeTypeOf("string");
|
||||
expect(field.label.length).toBeGreaterThan(0);
|
||||
expect(field.type).toBeTypeOf("string");
|
||||
expect(field.category).toBeTypeOf("string");
|
||||
expect(field.description).toBeTypeOf("string");
|
||||
expect(field.builtIn).toBeTypeOf("boolean");
|
||||
}
|
||||
});
|
||||
|
||||
it("all fields in both catalogs have builtIn === false", () => {
|
||||
const all = [...PROJECT_FIELD_CATALOG, ...RESOURCE_FIELD_CATALOG];
|
||||
for (const field of all) {
|
||||
expect(field.builtIn).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field type coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("FieldType usage in PROJECT_FIELD_CATALOG", () => {
|
||||
const fieldsByType = (type: FieldType) => PROJECT_FIELD_CATALOG.filter((f) => f.type === type);
|
||||
|
||||
it("contains TEXT fields", () => {
|
||||
expect(fieldsByType(FieldType.TEXT).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains NUMBER fields", () => {
|
||||
expect(fieldsByType(FieldType.NUMBER).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains SELECT fields", () => {
|
||||
expect(fieldsByType(FieldType.SELECT).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains MULTI_SELECT fields", () => {
|
||||
expect(fieldsByType(FieldType.MULTI_SELECT).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains DATE fields", () => {
|
||||
expect(fieldsByType(FieldType.DATE).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains BOOLEAN fields", () => {
|
||||
expect(fieldsByType(FieldType.BOOLEAN).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains URL fields", () => {
|
||||
expect(fieldsByType(FieldType.URL).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FieldType usage in RESOURCE_FIELD_CATALOG", () => {
|
||||
const fieldsByType = (type: FieldType) => RESOURCE_FIELD_CATALOG.filter((f) => f.type === type);
|
||||
|
||||
it("contains TEXT fields", () => {
|
||||
expect(fieldsByType(FieldType.TEXT).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains NUMBER fields", () => {
|
||||
expect(fieldsByType(FieldType.NUMBER).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains SELECT fields", () => {
|
||||
expect(fieldsByType(FieldType.SELECT).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains MULTI_SELECT fields", () => {
|
||||
expect(fieldsByType(FieldType.MULTI_SELECT).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains DATE fields", () => {
|
||||
expect(fieldsByType(FieldType.DATE).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains BOOLEAN fields", () => {
|
||||
expect(fieldsByType(FieldType.BOOLEAN).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains EMAIL fields", () => {
|
||||
expect(fieldsByType(FieldType.EMAIL).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains URL fields", () => {
|
||||
expect(fieldsByType(FieldType.URL).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Options on SELECT / MULTI_SELECT fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SELECT and MULTI_SELECT fields have options", () => {
|
||||
const selectTypes = new Set([FieldType.SELECT, FieldType.MULTI_SELECT]);
|
||||
|
||||
it("every SELECT/MULTI_SELECT in PROJECT_FIELD_CATALOG has at least one option", () => {
|
||||
const selectFields = PROJECT_FIELD_CATALOG.filter((f) => selectTypes.has(f.type));
|
||||
expect(selectFields.length).toBeGreaterThan(0);
|
||||
for (const field of selectFields) {
|
||||
expect(Array.isArray(field.options)).toBe(true);
|
||||
expect(field.options!.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("every SELECT/MULTI_SELECT in RESOURCE_FIELD_CATALOG has at least one option", () => {
|
||||
const selectFields = RESOURCE_FIELD_CATALOG.filter((f) => selectTypes.has(f.type));
|
||||
expect(selectFields.length).toBeGreaterThan(0);
|
||||
for (const field of selectFields) {
|
||||
expect(Array.isArray(field.options)).toBe(true);
|
||||
expect(field.options!.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("each option has non-empty value and label strings", () => {
|
||||
const all = [...PROJECT_FIELD_CATALOG, ...RESOURCE_FIELD_CATALOG];
|
||||
for (const field of all) {
|
||||
for (const opt of field.options ?? []) {
|
||||
expect(opt.value).toBeTypeOf("string");
|
||||
expect(opt.value.length).toBeGreaterThan(0);
|
||||
expect(opt.label).toBeTypeOf("string");
|
||||
expect(opt.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("non-SELECT/MULTI_SELECT fields do not declare options", () => {
|
||||
const nonSelect = [...PROJECT_FIELD_CATALOG, ...RESOURCE_FIELD_CATALOG].filter(
|
||||
(f) => !selectTypes.has(f.type),
|
||||
);
|
||||
for (const field of nonSelect) {
|
||||
expect(field.options).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// defaultValue presence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("defaultValue on catalog fields", () => {
|
||||
it("clientApprovalRounds has defaultValue 2", () => {
|
||||
const field = PROJECT_FIELD_CATALOG.find((f) => f.key === "clientApprovalRounds");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.defaultValue).toBe(2);
|
||||
});
|
||||
|
||||
it("nda has defaultValue false", () => {
|
||||
const field = PROJECT_FIELD_CATALOG.find((f) => f.key === "nda");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.defaultValue).toBe(false);
|
||||
});
|
||||
|
||||
it("weeklyHours has defaultValue 40", () => {
|
||||
const field = RESOURCE_FIELD_CATALOG.find((f) => f.key === "weeklyHours");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.defaultValue).toBe(40);
|
||||
});
|
||||
|
||||
it("remoteEligible has defaultValue false", () => {
|
||||
const field = RESOURCE_FIELD_CATALOG.find((f) => f.key === "remoteEligible");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.defaultValue).toBe(false);
|
||||
});
|
||||
|
||||
it("fields without explicit defaultValue have undefined defaultValue", () => {
|
||||
const field = PROJECT_FIELD_CATALOG.find((f) => f.key === "clientUnit");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.defaultValue).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unique keys within each catalog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("catalog key uniqueness", () => {
|
||||
it("PROJECT_FIELD_CATALOG has no duplicate keys", () => {
|
||||
const keys = PROJECT_FIELD_CATALOG.map((f) => f.key);
|
||||
const unique = new Set(keys);
|
||||
expect(unique.size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("RESOURCE_FIELD_CATALOG has no duplicate keys", () => {
|
||||
const keys = RESOURCE_FIELD_CATALOG.map((f) => f.key);
|
||||
const unique = new Set(keys);
|
||||
expect(unique.size).toBe(keys.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Category alignment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("catalog field categories match declared category lists", () => {
|
||||
it("every PROJECT_FIELD_CATALOG category exists in PROJECT_CATEGORIES", () => {
|
||||
const validNames = new Set(PROJECT_CATEGORIES.map((c) => c.name));
|
||||
for (const field of PROJECT_FIELD_CATALOG) {
|
||||
expect(validNames.has(field.category)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("every RESOURCE_FIELD_CATALOG category exists in RESOURCE_CATEGORIES", () => {
|
||||
const validNames = new Set(RESOURCE_CATEGORIES.map((c) => c.name));
|
||||
for (const field of RESOURCE_FIELD_CATALOG) {
|
||||
expect(validNames.has(field.category)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("each project category is used by at least one field", () => {
|
||||
const usedCategories = new Set(PROJECT_FIELD_CATALOG.map((f) => f.category));
|
||||
for (const cat of PROJECT_CATEGORIES) {
|
||||
expect(usedCategories.has(cat.name)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("each resource category is used by at least one field", () => {
|
||||
const usedCategories = new Set(RESOURCE_FIELD_CATALOG.map((f) => f.category));
|
||||
for (const cat of RESOURCE_CATEGORIES) {
|
||||
expect(usedCategories.has(cat.name)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CatalogCategory shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("CatalogCategory shape", () => {
|
||||
it("PROJECT_CATEGORIES entries have non-empty name and description", () => {
|
||||
expect(PROJECT_CATEGORIES.length).toBeGreaterThan(0);
|
||||
for (const cat of PROJECT_CATEGORIES) {
|
||||
expect(cat.name).toBeTypeOf("string");
|
||||
expect(cat.name.length).toBeGreaterThan(0);
|
||||
expect(cat.description).toBeTypeOf("string");
|
||||
expect(cat.description.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("RESOURCE_CATEGORIES entries have non-empty name and description", () => {
|
||||
expect(RESOURCE_CATEGORIES.length).toBeGreaterThan(0);
|
||||
for (const cat of RESOURCE_CATEGORIES) {
|
||||
expect(cat.name).toBeTypeOf("string");
|
||||
expect(cat.name.length).toBeGreaterThan(0);
|
||||
expect(cat.description).toBeTypeOf("string");
|
||||
expect(cat.description.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getCatalogForTarget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getCatalogForTarget", () => {
|
||||
it("returns PROJECT_FIELD_CATALOG for BlueprintTarget.PROJECT", () => {
|
||||
expect(getCatalogForTarget(BlueprintTarget.PROJECT)).toBe(PROJECT_FIELD_CATALOG);
|
||||
});
|
||||
|
||||
it("returns RESOURCE_FIELD_CATALOG for BlueprintTarget.RESOURCE", () => {
|
||||
expect(getCatalogForTarget(BlueprintTarget.RESOURCE)).toBe(RESOURCE_FIELD_CATALOG);
|
||||
});
|
||||
|
||||
it("returns PROJECT_FIELD_CATALOG for the string literal 'PROJECT'", () => {
|
||||
expect(getCatalogForTarget("PROJECT")).toBe(PROJECT_FIELD_CATALOG);
|
||||
});
|
||||
|
||||
it("returns RESOURCE_FIELD_CATALOG for any other string (fallback)", () => {
|
||||
expect(getCatalogForTarget("RESOURCE")).toBe(RESOURCE_FIELD_CATALOG);
|
||||
});
|
||||
|
||||
it("returns RESOURCE_FIELD_CATALOG for an unrecognised string", () => {
|
||||
// The implementation falls back to RESOURCE catalog for non-PROJECT targets.
|
||||
expect(getCatalogForTarget("unknown")).toBe(RESOURCE_FIELD_CATALOG);
|
||||
});
|
||||
|
||||
it("returns an array", () => {
|
||||
expect(Array.isArray(getCatalogForTarget(BlueprintTarget.PROJECT))).toBe(true);
|
||||
expect(Array.isArray(getCatalogForTarget(BlueprintTarget.RESOURCE))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getCategoriesForTarget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getCategoriesForTarget", () => {
|
||||
it("returns PROJECT_CATEGORIES for BlueprintTarget.PROJECT", () => {
|
||||
expect(getCategoriesForTarget(BlueprintTarget.PROJECT)).toBe(PROJECT_CATEGORIES);
|
||||
});
|
||||
|
||||
it("returns RESOURCE_CATEGORIES for BlueprintTarget.RESOURCE", () => {
|
||||
expect(getCategoriesForTarget(BlueprintTarget.RESOURCE)).toBe(RESOURCE_CATEGORIES);
|
||||
});
|
||||
|
||||
it("returns PROJECT_CATEGORIES for the string literal 'PROJECT'", () => {
|
||||
expect(getCategoriesForTarget("PROJECT")).toBe(PROJECT_CATEGORIES);
|
||||
});
|
||||
|
||||
it("returns RESOURCE_CATEGORIES for any other string (fallback)", () => {
|
||||
expect(getCategoriesForTarget("RESOURCE")).toBe(RESOURCE_CATEGORIES);
|
||||
});
|
||||
|
||||
it("returns RESOURCE_CATEGORIES for an unrecognised string", () => {
|
||||
expect(getCategoriesForTarget("unknown")).toBe(RESOURCE_CATEGORIES);
|
||||
});
|
||||
|
||||
it("returns an array", () => {
|
||||
expect(Array.isArray(getCategoriesForTarget(BlueprintTarget.PROJECT))).toBe(true);
|
||||
expect(Array.isArray(getCategoriesForTarget(BlueprintTarget.RESOURCE))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findCatalogField
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("findCatalogField", () => {
|
||||
// --- project fields ---
|
||||
it("finds a known project field by key", () => {
|
||||
const field = findCatalogField(BlueprintTarget.PROJECT, "billingModel");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.key).toBe("billingModel");
|
||||
expect(field!.type).toBe(FieldType.SELECT);
|
||||
expect(field!.category).toBe("Client & Billing");
|
||||
});
|
||||
|
||||
it("finds 'deliveryFormat' as MULTI_SELECT in the project catalog", () => {
|
||||
const field = findCatalogField(BlueprintTarget.PROJECT, "deliveryFormat");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.type).toBe(FieldType.MULTI_SELECT);
|
||||
});
|
||||
|
||||
it("finds 'deliveryDate' as DATE in the project catalog", () => {
|
||||
const field = findCatalogField(BlueprintTarget.PROJECT, "deliveryDate");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.type).toBe(FieldType.DATE);
|
||||
});
|
||||
|
||||
it("finds 'nda' as BOOLEAN in the project catalog", () => {
|
||||
const field = findCatalogField(BlueprintTarget.PROJECT, "nda");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.type).toBe(FieldType.BOOLEAN);
|
||||
});
|
||||
|
||||
it("finds 'projectBrief' as URL in the project catalog", () => {
|
||||
const field = findCatalogField(BlueprintTarget.PROJECT, "projectBrief");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.type).toBe(FieldType.URL);
|
||||
});
|
||||
|
||||
// --- resource fields ---
|
||||
it("finds a known resource field by key", () => {
|
||||
const field = findCatalogField(BlueprintTarget.RESOURCE, "contractType");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.key).toBe("contractType");
|
||||
expect(field!.type).toBe(FieldType.SELECT);
|
||||
expect(field!.category).toBe("Contract");
|
||||
});
|
||||
|
||||
it("finds 'personalEmail' as EMAIL in the resource catalog", () => {
|
||||
const field = findCatalogField(BlueprintTarget.RESOURCE, "personalEmail");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.type).toBe(FieldType.EMAIL);
|
||||
});
|
||||
|
||||
it("finds 'linkedInUrl' as URL in the resource catalog", () => {
|
||||
const field = findCatalogField(BlueprintTarget.RESOURCE, "linkedInUrl");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.type).toBe(FieldType.URL);
|
||||
});
|
||||
|
||||
it("finds 'primarySoftware' as MULTI_SELECT in the resource catalog", () => {
|
||||
const field = findCatalogField(BlueprintTarget.RESOURCE, "primarySoftware");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.type).toBe(FieldType.MULTI_SELECT);
|
||||
});
|
||||
|
||||
it("finds 'remoteEligible' as BOOLEAN in the resource catalog", () => {
|
||||
const field = findCatalogField(BlueprintTarget.RESOURCE, "remoteEligible");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.type).toBe(FieldType.BOOLEAN);
|
||||
});
|
||||
|
||||
// --- string target variants ---
|
||||
it("finds a project field when target is the string 'PROJECT'", () => {
|
||||
const field = findCatalogField("PROJECT", "shotCount");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.key).toBe("shotCount");
|
||||
});
|
||||
|
||||
it("finds a resource field when target is the string 'RESOURCE'", () => {
|
||||
const field = findCatalogField("RESOURCE", "timezone");
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.key).toBe("timezone");
|
||||
});
|
||||
|
||||
// --- not found ---
|
||||
it("returns undefined for an unknown key in the project catalog", () => {
|
||||
expect(findCatalogField(BlueprintTarget.PROJECT, "nonExistentKey")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown key in the resource catalog", () => {
|
||||
expect(findCatalogField(BlueprintTarget.RESOURCE, "nonExistentKey")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for an empty string key", () => {
|
||||
expect(findCatalogField(BlueprintTarget.PROJECT, "")).toBeUndefined();
|
||||
expect(findCatalogField(BlueprintTarget.RESOURCE, "")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not find a resource field when searching in the project catalog", () => {
|
||||
// 'contractType' lives only in the resource catalog
|
||||
expect(findCatalogField(BlueprintTarget.PROJECT, "contractType")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not find a project field when searching in the resource catalog", () => {
|
||||
// 'billingModel' lives only in the project catalog
|
||||
expect(findCatalogField(BlueprintTarget.RESOURCE, "billingModel")).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- returned object integrity ---
|
||||
it("returned field contains the expected options array for a SELECT field", () => {
|
||||
const field = findCatalogField(BlueprintTarget.PROJECT, "complexityLevel");
|
||||
expect(field).toBeDefined();
|
||||
const values = field!.options!.map((o) => o.value);
|
||||
expect(values).toContain("low");
|
||||
expect(values).toContain("medium");
|
||||
expect(values).toContain("high");
|
||||
expect(values).toContain("very_high");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Specific well-known fields – spot checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("specific PROJECT_FIELD_CATALOG fields", () => {
|
||||
it("billingModel options include fixed, tm, and hybrid", () => {
|
||||
const field = PROJECT_FIELD_CATALOG.find((f) => f.key === "billingModel")!;
|
||||
const values = field.options!.map((o) => o.value);
|
||||
expect(values).toContain("fixed");
|
||||
expect(values).toContain("tm");
|
||||
expect(values).toContain("hybrid");
|
||||
});
|
||||
|
||||
it("renderEngine includes unreal and arnold", () => {
|
||||
const field = PROJECT_FIELD_CATALOG.find((f) => f.key === "renderEngine")!;
|
||||
const values = field.options!.map((o) => o.value);
|
||||
expect(values).toContain("unreal");
|
||||
expect(values).toContain("arnold");
|
||||
});
|
||||
|
||||
it("deliveryFormat includes exr, mp4, and glb", () => {
|
||||
const field = PROJECT_FIELD_CATALOG.find((f) => f.key === "deliveryFormat")!;
|
||||
const values = field.options!.map((o) => o.value);
|
||||
expect(values).toContain("exr");
|
||||
expect(values).toContain("mp4");
|
||||
expect(values).toContain("glb");
|
||||
});
|
||||
|
||||
it("invoiceCycle includes all 5 expected values", () => {
|
||||
const field = PROJECT_FIELD_CATALOG.find((f) => f.key === "invoiceCycle")!;
|
||||
const values = field.options!.map((o) => o.value);
|
||||
expect(values).toEqual(
|
||||
expect.arrayContaining(["weekly", "biweekly", "monthly", "milestone", "on_completion"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("specific RESOURCE_FIELD_CATALOG fields", () => {
|
||||
it("contractType options include freelance and permanent", () => {
|
||||
const field = RESOURCE_FIELD_CATALOG.find((f) => f.key === "contractType")!;
|
||||
const values = field.options!.map((o) => o.value);
|
||||
expect(values).toContain("freelance");
|
||||
expect(values).toContain("permanent");
|
||||
});
|
||||
|
||||
it("primarySoftware includes houdini and maya", () => {
|
||||
const field = RESOURCE_FIELD_CATALOG.find((f) => f.key === "primarySoftware")!;
|
||||
const values = field.options!.map((o) => o.value);
|
||||
expect(values).toContain("houdini");
|
||||
expect(values).toContain("maya");
|
||||
});
|
||||
|
||||
it("spokenLanguages includes de (German) and en (English)", () => {
|
||||
const field = RESOURCE_FIELD_CATALOG.find((f) => f.key === "spokenLanguages")!;
|
||||
const values = field.options!.map((o) => o.value);
|
||||
expect(values).toContain("de");
|
||||
expect(values).toContain("en");
|
||||
});
|
||||
|
||||
it("specialization includes td (Technical Direction) and generalist", () => {
|
||||
const field = RESOURCE_FIELD_CATALOG.find((f) => f.key === "specialization")!;
|
||||
const values = field.options!.map((o) => o.value);
|
||||
expect(values).toContain("td");
|
||||
expect(values).toContain("generalist");
|
||||
});
|
||||
|
||||
it("timezone includes Europe/Berlin and America/Los_Angeles", () => {
|
||||
const field = RESOURCE_FIELD_CATALOG.find((f) => f.key === "timezone")!;
|
||||
const values = field.options!.map((o) => o.value);
|
||||
expect(values).toContain("Europe/Berlin");
|
||||
expect(values).toContain("America/Los_Angeles");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user