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 before calling `actions.addNodeTree` * - selectParent calls `actions.selectNode` with the parent id * - 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 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 regenerates ids before calling actions.addNodeTree with the parent id', () => { render('child-1'); act(() => captured!.duplicate()); expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1); expect(addNodeTreeMock).toHaveBeenCalledTimes(1); const [tree, parentId] = addNodeTreeMock.mock.calls[0]; expect(parentId).toBe('parent-1'); expect(tree.rootNodeId).not.toBe(COPIED_TREE.rootNodeId); }); test('selectParent calls actions.selectNode with the parent id', () => { render('child-1'); act(() => captured!.selectParent()); expect(selectNodeMock).toHaveBeenCalledWith('parent-1'); }); 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); 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); }); });