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 { useNodeActions, type NodeActions } from './useNodeActions'; /** * Phase B, item 1: `useNodeActions` was extracted out of `ContextMenu.tsx` so * the desktop right-click menu and the mobile selection toolbar * (`MobileSelectionToolbar`) share one implementation. This suite exercises * the hook directly (mocking `@craftjs/core`'s `useEditor`, mirroring the * pattern in `useKeyboardShortcuts.test.tsx`), covering: * - moveUp/moveDown call `actions.move` with the right target index, and * are no-ops at the respective boundary * - canMoveUp/canMoveDown reflect the node's position among its siblings * - duplicate regenerates ids, inserts the copy IMMEDIATELY AFTER the * source node (`actions.addNodeTree(tree, parentId, sourceIndex + 1)`), * and selects the new copy (`actions.selectNode(tree.rootNodeId)`) -- * Phase B fast-follow: the previous append-at-end behavior left the copy * off-screen with the ORIGINAL still selected, so users couldn't see * what "Duplicate" had just done. * - selectParent calls `actions.selectNode` with the parent id * - canSelectParent reflects whether the node's parent is itself real * (not ROOT/missing) -- false at a top-level section, true one level in * - deleteNode routes through `findDeletableTarget` (also covering the * "no deletable target" no-op) * - ROOT/null nodeId disables every action safely (no-ops, all * canMoveUp/canMoveDown/canDelete/canSelectParent false) */ const moveMock = vi.fn(); const addNodeTreeMock = vi.fn(); const selectNodeMock = vi.fn(); const deleteMock = vi.fn(); interface FakeNodeData { parent: string | null; nodes: string[]; } let fakeNodes: Record = {}; const COPIED_TREE: NodeTree = { rootNodeId: 'child-1', nodes: { 'child-1': { id: 'child-1', data: { props: {}, type: { resolvedName: 'Container' }, name: 'Container', displayName: 'Container', isCanvas: false, parent: 'parent-1', linkedNodes: {}, nodes: [], hidden: false }, info: {}, events: { selected: false, dragged: false, hovered: false }, dom: null, related: {}, rules: {}, _hydrationTimestamp: 0, } as unknown as Node, }, }; function makeQuery() { return { node: (id: string) => ({ get: () => fakeNodes[id] ?? null, toNodeTree: () => { if (id !== 'child-1') throw new Error(`unexpected toNodeTree() for "${id}"`); return COPIED_TREE; }, }), }; } vi.mock('@craftjs/core', () => ({ useEditor: (collector?: (state: any, query: any) => any) => { const state = { nodes: Object.fromEntries(Object.entries(fakeNodes).map(([id, n]) => [id, n])), }; const query = makeQuery(); const collected = collector ? collector(state, query) : {}; return { ...collected, actions: { move: moveMock, addNodeTree: addNodeTreeMock, selectNode: selectNodeMock, delete: deleteMock, }, query, }; }, })); 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; let captured: NodeActions | null = null; const Probe: React.FC<{ nodeId: string | null }> = ({ nodeId }) => { captured = useNodeActions(nodeId); return null; }; function render(nodeId: string | null) { container = document.createElement('div'); document.body.appendChild(container); act(() => { root = createRoot(container); root.render(); }); } function rerender(nodeId: string | null) { act(() => { root.render(); }); } function unmount() { act(() => { root.unmount(); }); container.remove(); } beforeEach(() => { moveMock.mockClear(); addNodeTreeMock.mockClear(); selectNodeMock.mockClear(); deleteMock.mockClear(); regenerateTreeIdsMock.mockClear(); captured = null; fakeNodes = { ROOT: { data: { parent: null, nodes: ['parent-1'] } }, 'parent-1': { data: { parent: 'ROOT', nodes: ['child-0', 'child-1', 'child-2'] } }, 'child-0': { data: { parent: 'parent-1', nodes: [] } }, 'child-1': { data: { parent: 'parent-1', nodes: [] } }, 'child-2': { data: { parent: 'parent-1', nodes: [] } }, }; }); afterEach(() => { if (root) unmount(); }); describe('useNodeActions', () => { test('a middle child can move both up and down', () => { render('child-1'); expect(captured!.canMoveUp).toBe(true); expect(captured!.canMoveDown).toBe(true); }); test('the first child cannot move up, but can move down', () => { render('child-0'); expect(captured!.canMoveUp).toBe(false); expect(captured!.canMoveDown).toBe(true); }); test('the last child can move up, but not down', () => { render('child-2'); expect(captured!.canMoveUp).toBe(true); expect(captured!.canMoveDown).toBe(false); }); test('moveUp calls actions.move with idx - 1, moveDown with idx + 2 (Craft.js index semantics)', () => { render('child-1'); act(() => captured!.moveUp()); expect(moveMock).toHaveBeenCalledWith('child-1', 'parent-1', 0); moveMock.mockClear(); act(() => captured!.moveDown()); expect(moveMock).toHaveBeenCalledWith('child-1', 'parent-1', 3); }); test('moveUp/moveDown at a boundary are no-ops', () => { render('child-0'); act(() => captured!.moveUp()); expect(moveMock).not.toHaveBeenCalled(); rerender('child-2'); act(() => captured!.moveDown()); expect(moveMock).not.toHaveBeenCalled(); }); test('duplicate inserts the copy immediately after the source (parentId, sourceIndex + 1) and selects it', () => { render('child-1'); let returned: string | null | undefined; act(() => { returned = captured!.duplicate(); }); expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1); expect(addNodeTreeMock).toHaveBeenCalledTimes(1); const [tree, parentId, index] = addNodeTreeMock.mock.calls[0]; // child-1 is at index 1 among ['child-0', 'child-1', 'child-2'] -> insert at 2. expect(parentId).toBe('parent-1'); expect(index).toBe(2); expect(tree.rootNodeId).not.toBe(COPIED_TREE.rootNodeId); expect(selectNodeMock).toHaveBeenCalledTimes(1); expect(selectNodeMock).toHaveBeenCalledWith(tree.rootNodeId); expect(returned).toBe(tree.rootNodeId); }); test('selectParent calls actions.selectNode with the parent id', () => { render('child-1'); act(() => captured!.selectParent()); expect(selectNodeMock).toHaveBeenCalledWith('parent-1'); }); test('canSelectParent is false at a top-level node (parent is ROOT), true one level deeper', () => { render('parent-1'); // parent-1's own parent is 'ROOT' expect(captured!.canSelectParent).toBe(false); rerender('child-1'); // child-1's parent is 'parent-1', a real node expect(captured!.canSelectParent).toBe(true); }); test('deleteNode calls actions.delete with the node id when deletable', () => { render('child-1'); expect(captured!.canDelete).toBe(true); act(() => captured!.deleteNode()); expect(deleteMock).toHaveBeenCalledWith('child-1'); }); test('ROOT nodeId disables every action and is a safe no-op', () => { render('ROOT'); expect(captured!.canMoveUp).toBe(false); expect(captured!.canMoveDown).toBe(false); expect(captured!.canDelete).toBe(false); expect(captured!.canSelectParent).toBe(false); act(() => { captured!.moveUp(); captured!.moveDown(); captured!.duplicate(); captured!.selectParent(); captured!.deleteNode(); }); expect(moveMock).not.toHaveBeenCalled(); expect(addNodeTreeMock).not.toHaveBeenCalled(); expect(selectNodeMock).not.toHaveBeenCalled(); expect(deleteMock).not.toHaveBeenCalled(); }); test('null nodeId disables every action and is a safe no-op', () => { render(null); expect(captured!.canMoveUp).toBe(false); expect(captured!.canMoveDown).toBe(false); expect(captured!.canDelete).toBe(false); expect(captured!.canSelectParent).toBe(false); }); });