test(builder): cover Ctrl+C/V shortcut id-regeneration and guards
Regression coverage for the safety-sensitive part of keyboard copy/paste: nothing previously tested that Ctrl/Cmd+V regenerates node ids via regenerateTreeIds before actions.addNodeTree -- the exact logic whose absence caused a duplicate-id corruption bug. Also covers sibling-parent targeting (with ROOT fallback), the empty-clipboard no-op, and the existing input/contentEditable focus guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, { data: { parent: string | null } }> = {
|
||||
'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<typeof import('../utils/craft-tree')>();
|
||||
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<KeyboardEventInit> = {}) {
|
||||
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(<Consumer />);
|
||||
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(<Consumer />);
|
||||
|
||||
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(<Consumer />);
|
||||
|
||||
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(<Consumer />);
|
||||
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(<Consumer />);
|
||||
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(<Consumer />);
|
||||
selectedIds = ['selected-1'];
|
||||
|
||||
pressKey('c');
|
||||
expect(getClipboardNodeId()).toBeNull();
|
||||
|
||||
activeElementSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user