194 lines
7.1 KiB
TypeScript
194 lines
7.1 KiB
TypeScript
|
|
import { describe, test, expect, afterEach } from 'vitest';
|
||
|
|
import React from 'react';
|
||
|
|
import { renderEditorHarness, EditorHarness } from '../editorHarness';
|
||
|
|
import { useNodeActions, NodeActions } from '../../hooks/useNodeActions';
|
||
|
|
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
|
||
|
|
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Real-`@craftjs/core` integration coverage for duplicate/paste.
|
||
|
|
*
|
||
|
|
* Historical bug: `regenerateTreeIds` used `structuredClone(oldNode.data)` to
|
||
|
|
* clone a copied subtree. For a REAL (live) Craft.js node, `data.type` is the
|
||
|
|
* actual component function reference (not a plain `{resolvedName}` string
|
||
|
|
* wrapper) -- `structuredClone` cannot clone a function and throws
|
||
|
|
* `DataCloneError`, which silently broke duplicate/paste for EVERY
|
||
|
|
* component in the real app. The pre-existing mocked unit tests
|
||
|
|
* (`useNodeActions.test.tsx`, `useKeyboardShortcuts.test.tsx`) faked
|
||
|
|
* `query.node(id).toNodeTree()` to return a plain-data object with
|
||
|
|
* `type: { resolvedName: 'Container' }` -- never a function -- so
|
||
|
|
* `structuredClone` never saw anything it couldn't clone there, and the bug
|
||
|
|
* shipped anyway.
|
||
|
|
*
|
||
|
|
* This suite drives the REAL `useNodeActions`/`useKeyboardShortcuts` hooks
|
||
|
|
* (no `vi.mock('@craftjs/core')` anywhere) against a real
|
||
|
|
* `EditorStore`/`query`/`actions` from `editorHarness.tsx`, on a real
|
||
|
|
* `SocialLinks` node (its `.craft` config makes Craft.js store the actual
|
||
|
|
* `SocialLinks` function as `data.type`) -- so a regression back to
|
||
|
|
* `structuredClone(oldNode.data)` throws here exactly as it did live. See
|
||
|
|
* `task-integ-harness-report.md` for the recorded red-proof run.
|
||
|
|
*/
|
||
|
|
|
||
|
|
const INITIAL_STATE = JSON.stringify({
|
||
|
|
ROOT: {
|
||
|
|
type: { resolvedName: 'Container' },
|
||
|
|
isCanvas: true,
|
||
|
|
props: { style: {}, tag: 'div' },
|
||
|
|
displayName: 'Container',
|
||
|
|
custom: {},
|
||
|
|
hidden: false,
|
||
|
|
nodes: ['social-1', 'heading-1'],
|
||
|
|
linkedNodes: {},
|
||
|
|
},
|
||
|
|
'social-1': {
|
||
|
|
type: { resolvedName: 'SocialLinks' },
|
||
|
|
isCanvas: false,
|
||
|
|
props: {
|
||
|
|
links: [{ platform: 'facebook', url: 'https://facebook.com/original' }],
|
||
|
|
iconColor: '#ffffff',
|
||
|
|
},
|
||
|
|
displayName: 'Social Links',
|
||
|
|
custom: {},
|
||
|
|
hidden: false,
|
||
|
|
parent: 'ROOT',
|
||
|
|
nodes: [],
|
||
|
|
linkedNodes: {},
|
||
|
|
},
|
||
|
|
'heading-1': {
|
||
|
|
type: { resolvedName: 'Heading' },
|
||
|
|
isCanvas: false,
|
||
|
|
props: { text: 'Sibling Heading', level: 'h2' },
|
||
|
|
displayName: 'Heading',
|
||
|
|
custom: {},
|
||
|
|
hidden: false,
|
||
|
|
parent: 'ROOT',
|
||
|
|
nodes: [],
|
||
|
|
linkedNodes: {},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
let harness: EditorHarness | null = null;
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
setClipboardNodeId(null);
|
||
|
|
if (harness) {
|
||
|
|
harness.unmount();
|
||
|
|
harness = null;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('duplicate/paste (real @craftjs/core editor)', () => {
|
||
|
|
test('useNodeActions().duplicate() on a real SocialLinks node: no throw, +1 node, inserted right after the source, with an independent props copy', () => {
|
||
|
|
harness = renderEditorHarness({ initialState: INITIAL_STATE });
|
||
|
|
|
||
|
|
const before = Object.keys(harness.query.getNodes());
|
||
|
|
expect(before).toHaveLength(3); // ROOT, social-1, heading-1
|
||
|
|
|
||
|
|
const probeRef: { current: NodeActions | null } = { current: null };
|
||
|
|
const Probe: React.FC = () => {
|
||
|
|
probeRef.current = useNodeActions('social-1');
|
||
|
|
return null;
|
||
|
|
};
|
||
|
|
harness.mountChild(<Probe />);
|
||
|
|
expect(probeRef.current).not.toBeNull();
|
||
|
|
|
||
|
|
let newId: string | null = null;
|
||
|
|
expect(() => {
|
||
|
|
harness!.act(() => {
|
||
|
|
newId = probeRef.current!.duplicate();
|
||
|
|
});
|
||
|
|
}).not.toThrow();
|
||
|
|
|
||
|
|
expect(newId).not.toBeNull();
|
||
|
|
expect(newId).not.toBe('social-1');
|
||
|
|
|
||
|
|
// +1 node overall.
|
||
|
|
const after = Object.keys(harness.query.getNodes());
|
||
|
|
expect(after).toHaveLength(4);
|
||
|
|
|
||
|
|
// Inserted immediately AFTER the source, not appended at the end.
|
||
|
|
const rootChildren: string[] = harness.query.node('ROOT').get().data.nodes;
|
||
|
|
expect(rootChildren).toEqual(['social-1', newId, 'heading-1']);
|
||
|
|
|
||
|
|
// The new node was selected.
|
||
|
|
expect(Array.from(harness.query.getEvent('selected').all())).toEqual([newId]);
|
||
|
|
|
||
|
|
// The copy's props object (and any nested arrays/objects within it) must
|
||
|
|
// be a SEPARATE object from the original -- not shared by reference.
|
||
|
|
const originalProps = harness.query.node('social-1').get().data.props;
|
||
|
|
const copyProps = harness.query.node(newId!).get().data.props;
|
||
|
|
expect(copyProps).not.toBe(originalProps);
|
||
|
|
expect(copyProps.links).not.toBe(originalProps.links);
|
||
|
|
expect(copyProps).toEqual(originalProps); // same VALUES though
|
||
|
|
|
||
|
|
// Mutating the copy via setProp must not affect the original.
|
||
|
|
harness.act(() => {
|
||
|
|
harness!.actions.setProp(newId!, (p: any) => {
|
||
|
|
p.links[0].url = 'https://facebook.com/MUTATED-COPY';
|
||
|
|
});
|
||
|
|
});
|
||
|
|
expect(harness.query.node(newId!).get().data.props.links[0].url).toBe(
|
||
|
|
'https://facebook.com/MUTATED-COPY',
|
||
|
|
);
|
||
|
|
expect(harness.query.node('social-1').get().data.props.links[0].url).toBe(
|
||
|
|
'https://facebook.com/original',
|
||
|
|
);
|
||
|
|
|
||
|
|
// Real DOM assertion, not just serialized state: the Frame actually
|
||
|
|
// re-rendered with a second SocialLinks <a> link on the canvas.
|
||
|
|
expect(harness.container.querySelectorAll('a[href="https://facebook.com/original"]')).toHaveLength(1);
|
||
|
|
expect(harness.container.querySelectorAll('a[href="https://facebook.com/MUTATED-COPY"]')).toHaveLength(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('Ctrl+C / Ctrl+V (useKeyboardShortcuts) on the real editor: pastes a fresh-id sibling copy, original untouched', () => {
|
||
|
|
harness = renderEditorHarness({ initialState: INITIAL_STATE });
|
||
|
|
|
||
|
|
const Consumer: React.FC = () => {
|
||
|
|
useKeyboardShortcuts();
|
||
|
|
return null;
|
||
|
|
};
|
||
|
|
harness.mountChild(<Consumer />);
|
||
|
|
|
||
|
|
// Select + copy social-1.
|
||
|
|
harness.act(() => {
|
||
|
|
harness!.actions.selectNode('social-1');
|
||
|
|
});
|
||
|
|
harness.act(() => {
|
||
|
|
document.dispatchEvent(
|
||
|
|
new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true }),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
expect(getClipboardNodeId()).toBe('social-1');
|
||
|
|
|
||
|
|
// Select heading-1 (a sibling), then paste -- should land as a sibling
|
||
|
|
// of heading-1's parent (ROOT), with brand-new ids.
|
||
|
|
harness.act(() => {
|
||
|
|
harness!.actions.selectNode('heading-1');
|
||
|
|
});
|
||
|
|
|
||
|
|
const before = Object.keys(harness.query.getNodes());
|
||
|
|
expect(() => {
|
||
|
|
harness!.act(() => {
|
||
|
|
document.dispatchEvent(
|
||
|
|
new KeyboardEvent('keydown', { key: 'v', ctrlKey: true, bubbles: true, cancelable: true }),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}).not.toThrow();
|
||
|
|
|
||
|
|
const after = Object.keys(harness.query.getNodes());
|
||
|
|
expect(after).toHaveLength(before.length + 1);
|
||
|
|
|
||
|
|
const pastedId = after.find((id) => !before.includes(id))!;
|
||
|
|
expect(pastedId).toBeDefined();
|
||
|
|
|
||
|
|
const rootChildren: string[] = harness.query.node('ROOT').get().data.nodes;
|
||
|
|
expect(rootChildren).toContain(pastedId);
|
||
|
|
|
||
|
|
// Independent props copy again -- same class of bug, same guard.
|
||
|
|
const pastedProps = harness.query.node(pastedId).get().data.props;
|
||
|
|
const originalProps = harness.query.node('social-1').get().data.props;
|
||
|
|
expect(pastedProps.links).not.toBe(originalProps.links);
|
||
|
|
expect(pastedProps).toEqual(originalProps);
|
||
|
|
});
|
||
|
|
});
|