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:
2026-04-10 17:27:35 +02:00
parent a3d75973ee
commit dcac9952ca
9 changed files with 3136 additions and 0 deletions
@@ -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));
});
});