import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness'; import { SELECTION_STATE } from './selection-fixture'; import { CORE_COMMANDS, topLevelSelection } from './core-commands'; import { setClipboardTree } from '../hooks/clipboard'; import type { Command, CommandContext } from './commands'; let h: EditorHarness; beforeEach(() => { setClipboardTree(null); h = renderEditorHarness({ initialState: SELECTION_STATE }); }); afterEach(() => { h.unmount(); setClipboardTree(null); }); const cmd = (id: string): Command => CORE_COMMANDS.find((c) => c.id === id)!; const ctx = (ids: string[], extra: Partial = {}): CommandContext => ({ query: h.query, actions: h.actions, selectedIds: ids, setDevice: vi.fn(), ...extra }) as any; const kids = (id: string) => h.query.node(id).get().data.nodes as string[]; const NODE_CMDS = ['edit.delete', 'edit.duplicate', 'edit.copy', 'edit.move-up', 'edit.move-down']; describe('when() guards', () => { test.each(NODE_CMDS)('%s is disabled with no selection or only ROOT selected', (id) => { expect(cmd(id).when!(ctx([]))).toBe(false); expect(cmd(id).when!(ctx(['ROOT']))).toBe(false); expect(cmd(id).when!(ctx(['b1']))).toBe(true); }); test('ROOT selected: running a node command changes nothing', () => { const before = h.getSerialized(); for (const id of NODE_CMDS) h.act(() => { cmd(id).run(ctx(['ROOT'])); }); expect(h.getSerialized()).toBe(before); }); test('paste requires a clipboard tree', () => { expect(cmd('edit.paste').when!(ctx(['b1']))).toBe(false); setClipboardTree(h.query.node('b1').toNodeTree()); expect(cmd('edit.paste').when!(ctx(['b1']))).toBe(true); }); test('undo / redo follow the history', () => { expect(cmd('edit.redo').when!(ctx([]))).toBe(false); h.act(() => { cmd('edit.delete').run(ctx(['b1'])); }); expect(cmd('edit.undo').when!(ctx([]))).toBe(true); h.act(() => { cmd('edit.undo').run(ctx([])); }); expect(kids('col-0')).toEqual(['b1', 'b2']); expect(cmd('edit.redo').when!(ctx([]))).toBe(true); }); }); describe('registry shape', () => { test('ids and shortcuts', () => { const spec = (id: string) => [cmd(id).shortcut, ...(cmd(id).shortcuts ?? [])]; expect(spec('edit.undo')).toEqual(['mod+z']); expect(spec('edit.redo')).toEqual(['mod+shift+z', 'mod+y']); expect(spec('edit.delete')).toEqual(['delete', 'backspace']); expect(spec('edit.duplicate')).toEqual(['mod+d']); expect(spec('edit.move-up')).toEqual(['alt+arrowup']); expect(spec('view.device-mobile')).toEqual(['3']); expect(spec('help.shortcuts')[0]).toBe('mod+/'); expect(new Set(CORE_COMMANDS.map((c) => c.id)).size).toBe(CORE_COMMANDS.length); }); }); describe('multi-select', () => { test('topLevelSelection drops ROOT and nodes under another selected node', () => { expect(topLevelSelection(h.query, ['ROOT', 'sec', 'b1', 'sec2'])).toEqual(['sec', 'sec2']); }); test('a selected node plus its own child resolves to the ancestor only (a populated column is refused)', () => { h.act(() => { cmd('edit.delete').run(ctx(['b1', 'col-0'])); }); expect(kids('col-0')).toEqual(['b1', 'b2']); }); test('delete of two siblings', () => { h.act(() => { cmd('edit.delete').run(ctx(['b1', 'b2'])); }); expect(kids('col-0')).toEqual([]); }); test('duplicate copies each after its source and selects all copies', () => { h.act(() => { cmd('edit.duplicate').run(ctx(['b1', 'b2'])); }); const order = kids('col-0'); expect(order).toHaveLength(4); expect([order[0], order[2]]).toEqual(['b1', 'b2']); expect(h.query.getEvent('selected').all().sort()).toEqual([order[1], order[3]].sort()); }); test('move down keeps a selected run in order', () => { h.act(() => { cmd('edit.duplicate').run(ctx(['b2'])); }); const [, , c] = [0, 1, 2].map((i) => kids('col-0')[i]); expect(kids('col-0')[2]).toBe(c); h.act(() => { cmd('edit.move-down').run(ctx(['b1', 'b2'])); }); // b2 was followed by its copy; b2 steps down first, then b1 expect(kids('col-0')).toHaveLength(3); expect(kids('col-0').indexOf('b1')).toBeGreaterThan(-1); expect(kids('col-0').indexOf('b1')).toBeLessThan(kids('col-0').indexOf('b2')); }); test('linked nodes (columns) are not duplicated or moved', () => { const before = h.getSerialized(); h.act(() => { cmd('edit.duplicate').run(ctx(['col-0'])); cmd('edit.move-down').run(ctx(['col-0'])); }); expect(h.getSerialized()).toBe(before); }); }); describe('device commands', () => { test('call setDevice with their mode', () => { const setDevice = vi.fn(); cmd('view.device-tablet').run(ctx([], { setDevice })); expect(setDevice).toHaveBeenCalledWith('tablet'); }); });