Files
CapaKraken/apps/web/src/hooks/useTimelineKeyboard.test.ts
T
Hartmut d3f721ce58 refactor(web): extract ResourcesClient types + inline components, fix test TS errors
Extract types.ts, FilterDropdown.tsx, BooleanBadge.tsx from
ResourcesClient.tsx into resource-client/ subdirectory.
ResourcesClient reduced from 1,613 to 1,507 lines.

Fix TypeScript strict mode errors across 8 test files:
- Add id/order to BlueprintFieldDefinition test objects
- Use FieldType enum instead of string literals in useFilters
- Add non-null assertions for mock.calls array access
- Type ScrollDiv for jsdom scrollLeft workaround
- Fix exactOptionalPropertyTypes violations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 22:40:24 +02:00

494 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// ---------------------------------------------------------------------------
type ScrollDiv = HTMLDivElement & { _scrollLeft: number };
function makeScrollContainer(): ScrollDiv {
const el = document.createElement("div") as ScrollDiv;
el._scrollLeft = 0;
// jsdom does not actually scroll, but we can track assignments
Object.defineProperty(el, "scrollLeft", {
get() {
return (this as ScrollDiv)._scrollLeft;
},
set(v: number) {
(this as ScrollDiv)._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: ScrollDiv;
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));
});
});