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; 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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(scrollEl); return useTimelineKeyboard({ scrollContainerRef, cellWidth: 40, selectedAllocationIds: [], onDeleteSelected, }); }); unmount(); expect(removeSpy).toHaveBeenCalledWith("keydown", expect.any(Function)); }); });