@flighthq/command 0.4.1-edge.28.7f8e221
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +9 -0
- package/README.md +17 -0
- package/dist/command.d.ts +14 -0
- package/dist/command.d.ts.map +1 -0
- package/dist/command.js +63 -0
- package/dist/command.js.map +1 -0
- package/dist/commandBinding.d.ts +9 -0
- package/dist/commandBinding.d.ts.map +1 -0
- package/dist/commandBinding.js +147 -0
- package/dist/commandBinding.js.map +1 -0
- package/dist/commandHistory.d.ts +22 -0
- package/dist/commandHistory.d.ts.map +1 -0
- package/dist/commandHistory.js +127 -0
- package/dist/commandHistory.js.map +1 -0
- package/dist/commandHistorySignals.d.ts +5 -0
- package/dist/commandHistorySignals.d.ts.map +1 -0
- package/dist/commandHistorySignals.js +15 -0
- package/dist/commandHistorySignals.js.map +1 -0
- package/dist/commandTransaction.d.ts +7 -0
- package/dist/commandTransaction.d.ts.map +1 -0
- package/dist/commandTransaction.js +64 -0
- package/dist/commandTransaction.js.map +1 -0
- package/dist/contract.d.ts +7 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +7 -0
- package/dist/contract.js.map +1 -0
- package/dist/explainCommandDispatch.d.ts +3 -0
- package/dist/explainCommandDispatch.d.ts.map +1 -0
- package/dist/explainCommandDispatch.js +23 -0
- package/dist/explainCommandDispatch.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/src/command.test.ts +115 -0
- package/src/commandBinding.test.ts +219 -0
- package/src/commandHistory.test.ts +203 -0
- package/src/commandHistorySignals.test.ts +46 -0
- package/src/commandTransaction.test.ts +174 -0
- package/src/explainCommandDispatch.test.ts +42 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { connectSignal } from '@flighthq/signals/contract';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { registerCommandBinding } from './commandBinding';
|
|
5
|
+
import { createCommandHistory, executeCommand, undoCommand } from './commandHistory';
|
|
6
|
+
import { enableCommandHistorySignals, getCommandHistorySignals } from './commandHistorySignals';
|
|
7
|
+
|
|
8
|
+
describe('enableCommandHistorySignals', () => {
|
|
9
|
+
it('allocates once and returns the same signal on a second call', () => {
|
|
10
|
+
const history = createCommandHistory();
|
|
11
|
+
expect(history.onChange).toBeNull();
|
|
12
|
+
const signal = enableCommandHistorySignals(history);
|
|
13
|
+
expect(enableCommandHistorySignals(history)).toBe(signal);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('emits after an execute and after an undo', () => {
|
|
17
|
+
const history = createCommandHistory();
|
|
18
|
+
registerCommandBinding(history, 'test.Counter', { execute: () => undefined, undo: () => undefined });
|
|
19
|
+
let changes = 0;
|
|
20
|
+
connectSignal(enableCommandHistorySignals(history), () => changes++);
|
|
21
|
+
|
|
22
|
+
executeCommand(history, { kind: 'test.Counter', label: 'One' });
|
|
23
|
+
expect(changes).toBe(1);
|
|
24
|
+
undoCommand(history);
|
|
25
|
+
expect(changes).toBe(2);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// A refused command changed nothing, so reporting a change would teach a panel to distrust the signal.
|
|
29
|
+
it('does not emit when a command was refused for want of a binding', () => {
|
|
30
|
+
const history = createCommandHistory();
|
|
31
|
+
let changes = 0;
|
|
32
|
+
connectSignal(enableCommandHistorySignals(history), () => changes++);
|
|
33
|
+
executeCommand(history, { kind: 'acme.Unbound', label: 'Nope' });
|
|
34
|
+
expect(changes).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('getCommandHistorySignals', () => {
|
|
39
|
+
it('returns null until signals are enabled, and never allocates', () => {
|
|
40
|
+
const history = createCommandHistory();
|
|
41
|
+
expect(getCommandHistorySignals(history)).toBeNull();
|
|
42
|
+
expect(history.onChange).toBeNull();
|
|
43
|
+
const signal = enableCommandHistorySignals(history);
|
|
44
|
+
expect(getCommandHistorySignals(history)).toBe(signal);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createNode, getNodeChildCount } from '@flighthq/node/contract';
|
|
2
|
+
import type { CommandHistory, CompositeCommand, NodeAny } from '@flighthq/types/contract';
|
|
3
|
+
import { CompositeCommandKind } from '@flighthq/types/contract';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { createAddNodeChildCommand, createSetNodePropertyCommand } from './command';
|
|
7
|
+
import { registerDefaultCommandBindings } from './commandBinding';
|
|
8
|
+
import {
|
|
9
|
+
createCommandHistory,
|
|
10
|
+
executeCommand,
|
|
11
|
+
getCommandHistoryEntries,
|
|
12
|
+
getCommandHistoryUndoLabel,
|
|
13
|
+
undoCommand,
|
|
14
|
+
} from './commandHistory';
|
|
15
|
+
import {
|
|
16
|
+
abortCommandTransaction,
|
|
17
|
+
beginCommandTransaction,
|
|
18
|
+
endCommandTransaction,
|
|
19
|
+
isCommandTransactionOpen,
|
|
20
|
+
} from './commandTransaction';
|
|
21
|
+
|
|
22
|
+
describe('abortCommandTransaction', () => {
|
|
23
|
+
it('returns false when no bracket is open', () => {
|
|
24
|
+
expect(abortCommandTransaction(createCommandHistory())).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// A half-finished paste must leave the graph as it was found, and must leave no undo entry behind.
|
|
28
|
+
it('unwinds every command executed since begin and leaves no entry', () => {
|
|
29
|
+
const history = withDefaults();
|
|
30
|
+
const parent = node();
|
|
31
|
+
const target = node();
|
|
32
|
+
write(target, 'x', 1);
|
|
33
|
+
|
|
34
|
+
beginCommandTransaction(history, 'Paste');
|
|
35
|
+
executeCommand(history, createAddNodeChildCommand('Add', parent, node()));
|
|
36
|
+
executeCommand(history, createSetNodePropertyCommand('Move', target, 'x', 50));
|
|
37
|
+
abortCommandTransaction(history);
|
|
38
|
+
|
|
39
|
+
expect(getNodeChildCount(parent)).toBe(0);
|
|
40
|
+
expect(read(target, 'x')).toBe(1);
|
|
41
|
+
expect(getCommandHistoryEntries(history)).toHaveLength(0);
|
|
42
|
+
expect(isCommandTransactionOpen(history)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('leaves entries recorded before the bracket alone', () => {
|
|
46
|
+
const history = withDefaults();
|
|
47
|
+
const target = node();
|
|
48
|
+
executeCommand(history, createSetNodePropertyCommand('Before', target, 'x', 10));
|
|
49
|
+
|
|
50
|
+
beginCommandTransaction(history, 'Paste');
|
|
51
|
+
executeCommand(history, createSetNodePropertyCommand('Inside', target, 'x', 20));
|
|
52
|
+
abortCommandTransaction(history);
|
|
53
|
+
|
|
54
|
+
expect(getCommandHistoryEntries(history).map((entry) => entry.label)).toEqual(['Before']);
|
|
55
|
+
expect(read(target, 'x')).toBe(10);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('beginCommandTransaction', () => {
|
|
60
|
+
it('opens a bracket', () => {
|
|
61
|
+
const history = withDefaults();
|
|
62
|
+
beginCommandTransaction(history, 'Paste');
|
|
63
|
+
expect(isCommandTransactionOpen(history)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Nesting is COUNTED, not stacked: a helper that brackets its own work must not fragment a caller's
|
|
67
|
+
// larger transaction into several undo entries.
|
|
68
|
+
it('counts nesting so only the outermost close folds', () => {
|
|
69
|
+
const history = withDefaults();
|
|
70
|
+
const target = node();
|
|
71
|
+
|
|
72
|
+
beginCommandTransaction(history, 'Outer');
|
|
73
|
+
beginCommandTransaction(history, 'Inner');
|
|
74
|
+
executeCommand(history, createSetNodePropertyCommand('One', target, 'x', 1));
|
|
75
|
+
endCommandTransaction(history);
|
|
76
|
+
expect(isCommandTransactionOpen(history)).toBe(true);
|
|
77
|
+
executeCommand(history, createSetNodePropertyCommand('Two', target, 'y', 2));
|
|
78
|
+
endCommandTransaction(history);
|
|
79
|
+
|
|
80
|
+
expect(getCommandHistoryEntries(history)).toHaveLength(1);
|
|
81
|
+
expect(getCommandHistoryUndoLabel(history)).toBe('Outer');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('endCommandTransaction', () => {
|
|
86
|
+
it('returns false when no bracket is open', () => {
|
|
87
|
+
expect(endCommandTransaction(createCommandHistory())).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('folds several commands into one composite entry carrying the bracket label', () => {
|
|
91
|
+
const history = withDefaults();
|
|
92
|
+
const target = node();
|
|
93
|
+
write(target, 'x', 0);
|
|
94
|
+
write(target, 'y', 0);
|
|
95
|
+
|
|
96
|
+
beginCommandTransaction(history, 'Transform');
|
|
97
|
+
executeCommand(history, createSetNodePropertyCommand('One', target, 'x', 10));
|
|
98
|
+
executeCommand(history, createSetNodePropertyCommand('Two', target, 'y', 20));
|
|
99
|
+
endCommandTransaction(history);
|
|
100
|
+
|
|
101
|
+
const entries = getCommandHistoryEntries(history);
|
|
102
|
+
expect(entries).toHaveLength(1);
|
|
103
|
+
expect(entries[0].kind).toBe(CompositeCommandKind);
|
|
104
|
+
expect(entries[0].label).toBe('Transform');
|
|
105
|
+
expect((entries[0] as CompositeCommand).children).toHaveLength(2);
|
|
106
|
+
|
|
107
|
+
// One undo takes back the whole gesture.
|
|
108
|
+
undoCommand(history);
|
|
109
|
+
expect(read(target, 'x')).toBe(0);
|
|
110
|
+
expect(read(target, 'y')).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// A composite of one is indirection a history panel would have to see through, and its bracket label
|
|
114
|
+
// would replace a more specific one.
|
|
115
|
+
it('leaves a single collected command unwrapped, keeping its own label', () => {
|
|
116
|
+
const history = withDefaults();
|
|
117
|
+
const target = node();
|
|
118
|
+
|
|
119
|
+
beginCommandTransaction(history, 'Transform');
|
|
120
|
+
executeCommand(history, createSetNodePropertyCommand('Move x', target, 'x', 10));
|
|
121
|
+
endCommandTransaction(history);
|
|
122
|
+
|
|
123
|
+
const entries = getCommandHistoryEntries(history);
|
|
124
|
+
expect(entries).toHaveLength(1);
|
|
125
|
+
expect(entries[0].kind).not.toBe(CompositeCommandKind);
|
|
126
|
+
expect(entries[0].label).toBe('Move x');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// An empty undo step makes one undo press do nothing visible, which reads as a broken undo.
|
|
130
|
+
it('records nothing when the bracket collected nothing', () => {
|
|
131
|
+
const history = withDefaults();
|
|
132
|
+
beginCommandTransaction(history, 'Empty');
|
|
133
|
+
endCommandTransaction(history);
|
|
134
|
+
expect(getCommandHistoryEntries(history)).toHaveLength(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('suppresses merging inside the bracket, so the fold is the only coalescing', () => {
|
|
138
|
+
const history = withDefaults();
|
|
139
|
+
const target = node();
|
|
140
|
+
|
|
141
|
+
beginCommandTransaction(history, 'Drag');
|
|
142
|
+
executeCommand(history, createSetNodePropertyCommand('One', target, 'x', 10, 300, 0));
|
|
143
|
+
executeCommand(history, createSetNodePropertyCommand('Two', target, 'x', 20, 300, 10));
|
|
144
|
+
endCommandTransaction(history);
|
|
145
|
+
|
|
146
|
+
const entries = getCommandHistoryEntries(history);
|
|
147
|
+
expect(entries).toHaveLength(1);
|
|
148
|
+
expect((entries[0] as CompositeCommand).children).toHaveLength(2);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('isCommandTransactionOpen', () => {
|
|
153
|
+
it('is false on a fresh history', () => {
|
|
154
|
+
expect(isCommandTransactionOpen(createCommandHistory())).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
function node(): NodeAny {
|
|
159
|
+
return createNode('test.CommandTarget') as NodeAny;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function read(target: Readonly<NodeAny>, property: string): unknown {
|
|
163
|
+
return (target as unknown as Readonly<Record<string, unknown>>)[property];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function withDefaults(): CommandHistory {
|
|
167
|
+
const history = createCommandHistory();
|
|
168
|
+
registerDefaultCommandBindings(history);
|
|
169
|
+
return history;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function write(target: NodeAny, property: string, value: unknown): void {
|
|
173
|
+
(target as unknown as Record<string, unknown>)[property] = value;
|
|
174
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createCompositeCommand } from './command';
|
|
4
|
+
import { registerCommandBinding } from './commandBinding';
|
|
5
|
+
import { createCommandHistory } from './commandHistory';
|
|
6
|
+
import { explainCommandDispatch } from './explainCommandDispatch';
|
|
7
|
+
|
|
8
|
+
describe('explainCommandDispatch', () => {
|
|
9
|
+
it('names the unregistered kind behind a refusal', () => {
|
|
10
|
+
const history = createCommandHistory();
|
|
11
|
+
expect(explainCommandDispatch(history, { kind: 'acme.Unbound', label: 'Nope' })).toEqual({
|
|
12
|
+
missingKind: 'acme.Unbound',
|
|
13
|
+
resolved: false,
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('resolves when the kind is registered', () => {
|
|
18
|
+
const history = createCommandHistory();
|
|
19
|
+
registerCommandBinding(history, 'acme.Bound', { execute: () => undefined, undo: () => undefined });
|
|
20
|
+
expect(explainCommandDispatch(history, { kind: 'acme.Bound', label: 'Yes' })).toEqual({
|
|
21
|
+
missingKind: null,
|
|
22
|
+
resolved: true,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// A composite whose CHILD kind is unbound executes into a no-op that looks like a successful undo step,
|
|
27
|
+
// which is exactly the silence this query exists to break.
|
|
28
|
+
it('descends into a composite and names an unbound child kind', () => {
|
|
29
|
+
const history = createCommandHistory();
|
|
30
|
+
registerCommandBinding(history, 'CompositeCommand', { execute: () => undefined, undo: () => undefined });
|
|
31
|
+
const composite = createCompositeCommand('Group', [{ kind: 'acme.Unbound', label: 'Child' }]);
|
|
32
|
+
expect(explainCommandDispatch(history, composite).missingKind).toBe('acme.Unbound');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('resolves a composite whose children are all bound', () => {
|
|
36
|
+
const history = createCommandHistory();
|
|
37
|
+
registerCommandBinding(history, 'CompositeCommand', { execute: () => undefined, undo: () => undefined });
|
|
38
|
+
registerCommandBinding(history, 'acme.Bound', { execute: () => undefined, undo: () => undefined });
|
|
39
|
+
const composite = createCompositeCommand('Group', [{ kind: 'acme.Bound', label: 'Child' }]);
|
|
40
|
+
expect(explainCommandDispatch(history, composite).resolved).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
});
|