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,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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user