Core editor shortcuts become registry commands run by the single useCommandShortcuts dispatcher: undo/redo, delete, duplicate (after + select), copy/paste, Alt+arrow move, 1/2/3 device, Cmd+S/Shift+P/P site commands and Cmd+/ cheat sheet. Tab/Esc no longer fire from buttons or dialogs. Context menu labels come from formatShortcut. Refs #39
112 lines
4.8 KiB
TypeScript
112 lines
4.8 KiB
TypeScript
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> = {}): 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');
|
|
});
|
|
});
|