diff --git a/craft/src/hooks/useKeyboardShortcuts.test.tsx b/craft/src/hooks/useKeyboardShortcuts.test.tsx new file mode 100644 index 0000000..6d1d0f9 --- /dev/null +++ b/craft/src/hooks/useKeyboardShortcuts.test.tsx @@ -0,0 +1,259 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import type { NodeTree, Node } from '@craftjs/core'; +import { useKeyboardShortcuts } from './useKeyboardShortcuts'; +import { getClipboardNodeId, setClipboardNodeId } from './clipboard'; + +/** + * Regression coverage: Ctrl/Cmd+V must run the copied subtree through + * `regenerateTreeIds` BEFORE handing it to `actions.addNodeTree`. Pasting + * with the source node's ORIGINAL ids re-inserted them into the live Craft.js + * node map, producing duplicate ids and corrupting the tree -- that bug is + * exactly what this suite guards against. Also covers the sibling-parent / + * ROOT-fallback targeting, the empty-clipboard no-op, and the existing + * input-focus guard. + * + * Mock pattern mirrors PageContext.pure-updaters.test.tsx / + * PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`, + * mounted via a bare consumer component, with REAL `keydown` events + * dispatched on `document` (where useKeyboardShortcuts attaches its + * listener) to exercise the actual handler rather than calling it directly. + * + * `regenerateTreeIds` itself is NOT mocked -- we partially mock + * `../utils/craft-tree` to wrap the real implementation in a `vi.fn()` so we + * can assert it was called, while still getting genuinely fresh ids back. + */ + +const addNodeTreeMock = vi.fn(); +let selectedIds: string[] = []; + +function makeNode(id: string, parent: string | null, children: string[] = []): Node { + return { + id, + data: { + props: {}, + type: { resolvedName: 'Container' }, + name: 'Container', + displayName: 'Container', + isCanvas: false, + parent, + linkedNodes: {}, + nodes: children, + hidden: false, + }, + info: {}, + events: { selected: false, dragged: false, hovered: false }, + dom: null, + related: {}, + rules: {}, + _hydrationTimestamp: 0, + } as unknown as Node; +} + +// The tree `toNodeTree()` returns for the node that was Ctrl+C'd -- a root +// with one child, so regeneration has more than one id to remap. +const COPIED_TREE: NodeTree = { + rootNodeId: 'copied-root-1', + nodes: { + 'copied-root-1': makeNode('copied-root-1', null, ['copied-child-1']), + 'copied-child-1': makeNode('copied-child-1', 'copied-root-1'), + }, +}; + +const nodeStore: Record = { + 'selected-1': { data: { parent: 'parent-container-1' } }, + ROOT: { data: { parent: null } }, + 'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } }, +}; + +function makeQueryNode(id: string) { + return { + get: () => nodeStore[id] ?? null, + toNodeTree: () => { + if (id !== 'copied-root-1') { + throw new Error(`unexpected toNodeTree() call for "${id}"`); + } + return COPIED_TREE; + }, + }; +} + +vi.mock('@craftjs/core', () => ({ + useEditor: () => ({ + query: { + getEvent: () => ({ all: () => selectedIds }), + node: (id: string) => makeQueryNode(id), + }, + actions: { + addNodeTree: addNodeTreeMock, + history: { undo: vi.fn(), redo: vi.fn() }, + delete: vi.fn(), + clearEvents: vi.fn(), + }, + }), +})); + +// Partial mock: keep the real `regenerateTreeIds` implementation (so pasted +// trees genuinely get fresh ids) but wrap it in a spy so we can assert the +// handler actually calls it, rather than only inferring that from the +// output. +vi.mock('../utils/craft-tree', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + regenerateTreeIds: vi.fn(actual.regenerateTreeIds), + }; +}); + +import { regenerateTreeIds } from '../utils/craft-tree'; +const regenerateTreeIdsMock = vi.mocked(regenerateTreeIds); + +let container: HTMLDivElement; +let root: Root; + +function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render(ui); + }); +} + +function unmount() { + act(() => { + root.unmount(); + }); + container.remove(); +} + +function pressKey(key: string, opts: Partial = {}) { + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key, ctrlKey: true, bubbles: true, cancelable: true, ...opts }), + ); + }); +} + +const Consumer: React.FC = () => { + useKeyboardShortcuts(); + return null; +}; + +beforeEach(() => { + selectedIds = []; + addNodeTreeMock.mockClear(); + regenerateTreeIdsMock.mockClear(); + setClipboardNodeId(null); +}); + +afterEach(() => { + setClipboardNodeId(null); + if (root) unmount(); +}); + +describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => { + test('Ctrl+C copies the selected node id to the clipboard', () => { + render(); + selectedIds = ['selected-1']; + + pressKey('c'); + + expect(getClipboardNodeId()).toBe('selected-1'); + }); + + test('Ctrl+V pastes as a sibling of the selection (selected node`s data.parent) with FRESH ids', () => { + render(); + + selectedIds = ['copied-root-1']; + pressKey('c'); + expect(getClipboardNodeId()).toBe('copied-root-1'); + + selectedIds = ['selected-1']; + pressKey('v'); + + // regenerateTreeIds actually ran before the tree was handed to Craft.js. + expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1); + expect(regenerateTreeIdsMock).toHaveBeenCalledWith(COPIED_TREE); + + expect(addNodeTreeMock).toHaveBeenCalledTimes(1); + const [pastedTree, targetParent] = addNodeTreeMock.mock.calls[0]; + + // Sibling of the current selection: selected-1's data.parent. + expect(targetParent).toBe('parent-container-1'); + + // The regression this guards: pasted ids must be fresh, never reuse the + // ids the copied node already occupies in the live Craft.js tree. + expect(pastedTree.rootNodeId).not.toBe(COPIED_TREE.rootNodeId); + const originalIds = new Set(Object.keys(COPIED_TREE.nodes)); + const pastedIds = new Set(Object.keys(pastedTree.nodes)); + expect(pastedIds.size).toBe(originalIds.size); + for (const id of pastedIds) { + expect(originalIds.has(id)).toBe(false); + } + }); + + test('Ctrl+V with selection at ROOT falls back to ROOT as the insertion parent', () => { + render(); + + selectedIds = ['copied-root-1']; + pressKey('c'); + + selectedIds = ['ROOT']; + pressKey('v'); + + expect(addNodeTreeMock).toHaveBeenCalledTimes(1); + const [, targetParent] = addNodeTreeMock.mock.calls[0]; + expect(targetParent).toBe('ROOT'); + }); + + test('Ctrl+V with an empty clipboard is a no-op', () => { + render(); + selectedIds = ['selected-1']; + + pressKey('v'); + + expect(addNodeTreeMock).not.toHaveBeenCalled(); + expect(regenerateTreeIdsMock).not.toHaveBeenCalled(); + }); + + test('shortcuts are ignored while focus is in an input element', () => { + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + expect(document.activeElement).toBe(input); + + render(); + selectedIds = ['selected-1']; + + pressKey('c'); + expect(getClipboardNodeId()).toBeNull(); + + setClipboardNodeId('copied-root-1'); + pressKey('v'); + expect(addNodeTreeMock).not.toHaveBeenCalled(); + + input.remove(); + }); + + test('shortcuts are ignored while focus is in a contentEditable element', () => { + // jsdom does not implement `HTMLElement.isContentEditable` (it always + // reports false regardless of the contentEditable attribute -- a known + // jsdom limitation), so a real contentEditable + focus() can't exercise + // this branch of the guard. Stub `document.activeElement` directly with + // a fake element that reports isContentEditable: true, matching what + // the guard (`isInputFocused` in useKeyboardShortcuts.ts) actually reads. + const fakeEditable = { tagName: 'DIV', isContentEditable: true } as unknown as Element; + const activeElementSpy = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(fakeEditable); + + render(); + selectedIds = ['selected-1']; + + pressKey('c'); + expect(getClipboardNodeId()).toBeNull(); + + activeElementSpy.mockRestore(); + }); +});