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(); }); });