Files
site-builder/craft/src/editor/core-commands.test.tsx
T
shadowdao c68e459430 feat(site-builder): complete shortcut set on the command registry with a cheat sheet
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
2026-10-07 11:47:55 -07:00

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');
});
});