d3f721ce58
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>
494 lines
14 KiB
TypeScript
494 lines
14 KiB
TypeScript
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));
|
||
});
|
||
});
|