@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,23 @@
|
|
|
1
|
+
import { getCommandBinding } from './commandBinding';
|
|
2
|
+
// Why `executeCommand` would refuse, as plain data rather than a message. A separately importable query,
|
|
3
|
+
// so a caller who never asks pays nothing for it and the core carries no prose — the diagnostics inversion
|
|
4
|
+
// rule. Descends into composites, because a composite whose child kind is unregistered dispatches into a
|
|
5
|
+
// no-op that would otherwise look like a successful undo step.
|
|
6
|
+
export function explainCommandDispatch(history, command) {
|
|
7
|
+
const missingKind = findMissingCommandKind(history, command);
|
|
8
|
+
return { missingKind, resolved: missingKind === null };
|
|
9
|
+
}
|
|
10
|
+
function findMissingCommandKind(history, command) {
|
|
11
|
+
if (getCommandBinding(history, command.kind) === null)
|
|
12
|
+
return command.kind;
|
|
13
|
+
const children = command.children;
|
|
14
|
+
if (children === undefined)
|
|
15
|
+
return null;
|
|
16
|
+
for (let i = 0; i < children.length; i++) {
|
|
17
|
+
const missing = findMissingCommandKind(history, children[i]);
|
|
18
|
+
if (missing !== null)
|
|
19
|
+
return missing;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=explainCommandDispatch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"explainCommandDispatch.js","sourceRoot":"","sources":["../src/explainCommandDispatch.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAErD,yGAAyG;AACzG,2GAA2G;AAC3G,yGAAyG;AACzG,+DAA+D;AAC/D,MAAM,UAAU,sBAAsB,CACpC,OAAiC,EACjC,OAA0B;IAE1B,MAAM,WAAW,GAAG,sBAAsB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IAC7D,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,WAAW,KAAK,IAAI,EAAE,CAAC;AACzD,CAAC;AAED,SAAS,sBAAsB,CAAC,OAAiC,EAAE,OAA0B;IAC3F,IAAI,iBAAiB,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI;QAAE,OAAO,OAAO,CAAC,IAAI,CAAC;IAC3E,MAAM,QAAQ,GAAI,OAA+C,CAAC,QAAQ,CAAC;IAC3E,IAAI,QAAQ,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IACxC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACzC,MAAM,OAAO,GAAG,sBAAsB,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;QAC7D,IAAI,OAAO,KAAK,IAAI;YAAE,OAAO,OAAO,CAAC;IACvC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC;AAC1B,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,yBAAyB,CAAC;AACxC,cAAc,sBAAsB,CAAC;AACrC,cAAc,0BAA0B,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC;AAC1B,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,yBAAyB,CAAC;AACxC,cAAc,sBAAsB,CAAC;AACrC,cAAc,0BAA0B,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flighthq/command",
|
|
3
|
+
"version": "0.4.1-edge.28.7f8e221",
|
|
4
|
+
"author": "Joshua Granick and other contributors",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/flighthq/flight.git",
|
|
9
|
+
"directory": "packages/command"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./contract": {
|
|
20
|
+
"types": "./dist/contract.d.ts",
|
|
21
|
+
"default": "./dist/contract.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"src/**/*.test.ts",
|
|
27
|
+
"!dist/**/*.test.js",
|
|
28
|
+
"!dist/**/*.test.d.ts",
|
|
29
|
+
"!dist/**/*.test.js.map",
|
|
30
|
+
"!dist/**/*.test.d.ts.map"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc -b",
|
|
34
|
+
"clean": "tsc -b --clean",
|
|
35
|
+
"test": "vitest run --config vitest.config.ts",
|
|
36
|
+
"test:watch": "vitest --watch --config vitest.config.ts",
|
|
37
|
+
"prepack": "npm run clean && npm run clean:dist && npm run build",
|
|
38
|
+
"clean:dist": "tsx ../../scripts/clean-package-dist.ts"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@flighthq/node": "0.4.1-edge.28.7f8e221",
|
|
42
|
+
"@flighthq/registry": "0.4.1-edge.28.7f8e221",
|
|
43
|
+
"@flighthq/signals": "0.4.1-edge.28.7f8e221",
|
|
44
|
+
"@flighthq/types": "0.4.1-edge.28.7f8e221"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"typescript": "^5.3.0"
|
|
48
|
+
},
|
|
49
|
+
"description": "Undo/redo command history — commands are plain kind-tagged data (property set, add/remove/reorder child, composite) whose execute/undo/merge are free functions registered per kind in a keyed table and resolved at dispatch, with a stack tracking applied/redoable entries, per-kind merge coalescing for drag sequences, nestable transaction brackets that fold many commands into one entry, an optional maxSize cap, and an opt-in onChange signal; no closures on the stack, so entries stay inspectable data",
|
|
50
|
+
"sideEffects": false
|
|
51
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { addNodeChild, createNode, getNodeChildIndex } from '@flighthq/node/contract';
|
|
2
|
+
import type { NodeAny } from '@flighthq/types/contract';
|
|
3
|
+
import {
|
|
4
|
+
AddNodeChildCommandKind,
|
|
5
|
+
CompositeCommandKind,
|
|
6
|
+
RemoveNodeChildCommandKind,
|
|
7
|
+
ReorderNodeChildCommandKind,
|
|
8
|
+
SetNodePropertyCommandKind,
|
|
9
|
+
} from '@flighthq/types/contract';
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
createAddNodeChildCommand,
|
|
14
|
+
createCompositeCommand,
|
|
15
|
+
createRemoveNodeChildCommand,
|
|
16
|
+
createReorderNodeChildCommand,
|
|
17
|
+
createSetNodePropertyCommand,
|
|
18
|
+
createSetNodePropertyCommandBatch,
|
|
19
|
+
} from './command';
|
|
20
|
+
|
|
21
|
+
describe('createAddNodeChildCommand', () => {
|
|
22
|
+
it('is plain data carrying its kind, and appends by default', () => {
|
|
23
|
+
const parent = node();
|
|
24
|
+
const child = node();
|
|
25
|
+
const command = createAddNodeChildCommand('Add sprite', parent, child);
|
|
26
|
+
expect(command).toEqual({ child, index: -1, kind: AddNodeChildCommandKind, label: 'Add sprite', parent });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('records an explicit insertion index', () => {
|
|
30
|
+
expect(createAddNodeChildCommand('Add', node(), node(), 3).index).toBe(3);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('createCompositeCommand', () => {
|
|
35
|
+
it('copies the child list, so a later push by the caller cannot rewrite a recorded entry', () => {
|
|
36
|
+
const children = [createAddNodeChildCommand('Add', node(), node())];
|
|
37
|
+
const command = createCompositeCommand('Align', children);
|
|
38
|
+
children.push(createAddNodeChildCommand('Add again', node(), node()));
|
|
39
|
+
expect(command.kind).toBe(CompositeCommandKind);
|
|
40
|
+
expect(command.children).toHaveLength(1);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('createRemoveNodeChildCommand', () => {
|
|
45
|
+
// The index has to be captured while the child is still attached — this is why removal is a `create*`
|
|
46
|
+
// call before the mutation rather than a literal built afterwards.
|
|
47
|
+
it('captures the index the child currently occupies', () => {
|
|
48
|
+
const parent = node();
|
|
49
|
+
const first = node();
|
|
50
|
+
const second = node();
|
|
51
|
+
addNodeChild(parent, first);
|
|
52
|
+
addNodeChild(parent, second);
|
|
53
|
+
|
|
54
|
+
const command = createRemoveNodeChildCommand('Delete', parent, second);
|
|
55
|
+
expect(command.kind).toBe(RemoveNodeChildCommandKind);
|
|
56
|
+
expect(command.index).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('createReorderNodeChildCommand', () => {
|
|
61
|
+
it('captures where the child started as well as where it is going', () => {
|
|
62
|
+
const parent = node();
|
|
63
|
+
const first = node();
|
|
64
|
+
const second = node();
|
|
65
|
+
addNodeChild(parent, first);
|
|
66
|
+
addNodeChild(parent, second);
|
|
67
|
+
|
|
68
|
+
const command = createReorderNodeChildCommand('Move to front', parent, second, 0);
|
|
69
|
+
expect(command.kind).toBe(ReorderNodeChildCommandKind);
|
|
70
|
+
expect(command.fromIndex).toBe(1);
|
|
71
|
+
expect(command.toIndex).toBe(0);
|
|
72
|
+
// Creating a command must not itself move anything.
|
|
73
|
+
expect(getNodeChildIndex(parent, second)).toBe(1);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('createSetNodePropertyCommand', () => {
|
|
78
|
+
it('reads the current value as before and does not apply the change', () => {
|
|
79
|
+
const target = node();
|
|
80
|
+
(target as unknown as Record<string, unknown>).x = 5;
|
|
81
|
+
|
|
82
|
+
const command = createSetNodePropertyCommand('Move', target, 'x', 100);
|
|
83
|
+
expect(command.kind).toBe(SetNodePropertyCommandKind);
|
|
84
|
+
expect(command.entries).toEqual([{ after: 100, before: 5, property: 'x', target }]);
|
|
85
|
+
expect((target as unknown as Record<string, unknown>).x).toBe(5);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('defaults to no merge window, so every change is its own undo entry', () => {
|
|
89
|
+
const command = createSetNodePropertyCommand('Move', node(), 'x', 1);
|
|
90
|
+
expect(command.mergeWindow).toBe(0);
|
|
91
|
+
expect(command.time).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('createSetNodePropertyCommandBatch', () => {
|
|
96
|
+
it('captures each before value in the order given', () => {
|
|
97
|
+
const target = node();
|
|
98
|
+
const record = target as unknown as Record<string, unknown>;
|
|
99
|
+
record.x = 1;
|
|
100
|
+
record.y = 2;
|
|
101
|
+
|
|
102
|
+
const command = createSetNodePropertyCommandBatch('Move', [
|
|
103
|
+
{ property: 'x', target, value: 10 },
|
|
104
|
+
{ property: 'y', target, value: 20 },
|
|
105
|
+
]);
|
|
106
|
+
expect(command.entries.map((entry) => entry.before)).toEqual([1, 2]);
|
|
107
|
+
expect(command.entries.map((entry) => entry.after)).toEqual([10, 20]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// A bare graph node from `node` itself — the package under test depends on `node` and nothing else in the
|
|
112
|
+
// graph, so a test must not reach for scene2d to get a subject.
|
|
113
|
+
function node(): NodeAny {
|
|
114
|
+
return createNode('test.CommandTarget') as NodeAny;
|
|
115
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { addNodeChild, createNode, getNodeChildCount, getNodeChildIndex } from '@flighthq/node/contract';
|
|
2
|
+
import type { CommandBinding, NodeAny, SetNodePropertyCommand } from '@flighthq/types/contract';
|
|
3
|
+
import { SetNodePropertyCommandKind } from '@flighthq/types/contract';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createAddNodeChildCommand,
|
|
8
|
+
createCompositeCommand,
|
|
9
|
+
createRemoveNodeChildCommand,
|
|
10
|
+
createReorderNodeChildCommand,
|
|
11
|
+
createSetNodePropertyCommand,
|
|
12
|
+
} from './command';
|
|
13
|
+
import {
|
|
14
|
+
createCommandBindingTable,
|
|
15
|
+
getCommandBinding,
|
|
16
|
+
hasCommandBinding,
|
|
17
|
+
registerCommandBinding,
|
|
18
|
+
registerDefaultCommandBindings,
|
|
19
|
+
} from './commandBinding';
|
|
20
|
+
import { createCommandHistory, executeCommand, undoCommand } from './commandHistory';
|
|
21
|
+
|
|
22
|
+
describe('createCommandBindingTable', () => {
|
|
23
|
+
it('starts empty, so nothing dispatches until a caller registers it', () => {
|
|
24
|
+
const history = createCommandHistory();
|
|
25
|
+
history.bindings = createCommandBindingTable();
|
|
26
|
+
expect(hasCommandBinding(history, SetNodePropertyCommandKind)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('getCommandBinding', () => {
|
|
31
|
+
it('returns null for an unregistered kind rather than throwing', () => {
|
|
32
|
+
expect(getCommandBinding(createCommandHistory(), 'acme.Nothing')).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('hasCommandBinding', () => {
|
|
37
|
+
it('reports a registered kind', () => {
|
|
38
|
+
const history = createCommandHistory();
|
|
39
|
+
registerDefaultCommandBindings(history);
|
|
40
|
+
expect(hasCommandBinding(history, SetNodePropertyCommandKind)).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('registerCommandBinding', () => {
|
|
45
|
+
it('accepts a caller-defined vendor-prefixed kind', () => {
|
|
46
|
+
const history = createCommandHistory();
|
|
47
|
+
const seen: string[] = [];
|
|
48
|
+
registerCommandBinding(history, 'acme.Custom', {
|
|
49
|
+
execute: () => seen.push('execute'),
|
|
50
|
+
undo: () => seen.push('undo'),
|
|
51
|
+
});
|
|
52
|
+
executeCommand(history, { kind: 'acme.Custom', label: 'Custom' });
|
|
53
|
+
undoCommand(history);
|
|
54
|
+
expect(seen).toEqual(['execute', 'undo']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Last write wins, which is what lets a consumer replace a built-in binding without forking the package.
|
|
58
|
+
it('overrides an earlier binding for the same kind', () => {
|
|
59
|
+
const history = createCommandHistory();
|
|
60
|
+
const first: CommandBinding = { execute: () => undefined, undo: () => undefined };
|
|
61
|
+
const second: CommandBinding = { execute: () => undefined, undo: () => undefined };
|
|
62
|
+
registerCommandBinding(history, 'acme.Custom', first);
|
|
63
|
+
registerCommandBinding(history, 'acme.Custom', second);
|
|
64
|
+
expect(getCommandBinding(history, 'acme.Custom')).toBe(second);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('registerDefaultCommandBindings', () => {
|
|
69
|
+
it('applies and reverses an added child, restoring membership', () => {
|
|
70
|
+
const history = withDefaults();
|
|
71
|
+
const parent = node();
|
|
72
|
+
const child = node();
|
|
73
|
+
|
|
74
|
+
executeCommand(history, createAddNodeChildCommand('Add', parent, child));
|
|
75
|
+
expect(getNodeChildCount(parent)).toBe(1);
|
|
76
|
+
undoCommand(history);
|
|
77
|
+
expect(getNodeChildCount(parent)).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Position, not merely membership: a delete that undoes to the end of the list has lost information the
|
|
81
|
+
// command captured on purpose.
|
|
82
|
+
it('restores a removed child to the index it came from', () => {
|
|
83
|
+
const history = withDefaults();
|
|
84
|
+
const parent = node();
|
|
85
|
+
const first = node();
|
|
86
|
+
const second = node();
|
|
87
|
+
const third = node();
|
|
88
|
+
addNodeChild(parent, first);
|
|
89
|
+
addNodeChild(parent, second);
|
|
90
|
+
addNodeChild(parent, third);
|
|
91
|
+
|
|
92
|
+
executeCommand(history, createRemoveNodeChildCommand('Delete', parent, second));
|
|
93
|
+
expect(getNodeChildCount(parent)).toBe(2);
|
|
94
|
+
undoCommand(history);
|
|
95
|
+
expect(getNodeChildIndex(parent, second)).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('applies and reverses a reorder', () => {
|
|
99
|
+
const history = withDefaults();
|
|
100
|
+
const parent = node();
|
|
101
|
+
const first = node();
|
|
102
|
+
const second = node();
|
|
103
|
+
addNodeChild(parent, first);
|
|
104
|
+
addNodeChild(parent, second);
|
|
105
|
+
|
|
106
|
+
executeCommand(history, createReorderNodeChildCommand('Front', parent, second, 0));
|
|
107
|
+
expect(getNodeChildIndex(parent, second)).toBe(0);
|
|
108
|
+
undoCommand(history);
|
|
109
|
+
expect(getNodeChildIndex(parent, second)).toBe(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('applies and reverses a property write', () => {
|
|
113
|
+
const history = withDefaults();
|
|
114
|
+
const target = node();
|
|
115
|
+
write(target, 'x', 5);
|
|
116
|
+
|
|
117
|
+
executeCommand(history, createSetNodePropertyCommand('Move', target, 'x', 100));
|
|
118
|
+
expect(read(target, 'x')).toBe(100);
|
|
119
|
+
undoCommand(history);
|
|
120
|
+
expect(read(target, 'x')).toBe(5);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('runs composite children in order and reverses them in reverse order', () => {
|
|
124
|
+
const history = withDefaults();
|
|
125
|
+
const parent = node();
|
|
126
|
+
const child = node();
|
|
127
|
+
|
|
128
|
+
executeCommand(
|
|
129
|
+
history,
|
|
130
|
+
createCompositeCommand('Add and move', [
|
|
131
|
+
createAddNodeChildCommand('Add', parent, child),
|
|
132
|
+
createSetNodePropertyCommand('Move', child, 'x', 10),
|
|
133
|
+
]),
|
|
134
|
+
);
|
|
135
|
+
expect(getNodeChildCount(parent)).toBe(1);
|
|
136
|
+
expect(read(child, 'x')).toBe(10);
|
|
137
|
+
|
|
138
|
+
// Reverse order matters: undoing forward would move a child that the add has not yet taken back.
|
|
139
|
+
undoCommand(history);
|
|
140
|
+
expect(getNodeChildCount(parent)).toBe(0);
|
|
141
|
+
expect(read(child, 'x')).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('merges same-target same-property writes inside the window into one reversible entry', () => {
|
|
145
|
+
const history = withDefaults();
|
|
146
|
+
const target = node();
|
|
147
|
+
write(target, 'x', 0);
|
|
148
|
+
|
|
149
|
+
executeCommand(history, createSetNodePropertyCommand('Drag', target, 'x', 10, 300, 0));
|
|
150
|
+
executeCommand(history, createSetNodePropertyCommand('Drag', target, 'x', 20, 300, 100));
|
|
151
|
+
executeCommand(history, createSetNodePropertyCommand('Drag', target, 'x', 30, 300, 200));
|
|
152
|
+
|
|
153
|
+
expect(history.entries).toHaveLength(1);
|
|
154
|
+
expect(read(target, 'x')).toBe(30);
|
|
155
|
+
// One undo returns to where the gesture STARTED, not to the previous frame.
|
|
156
|
+
undoCommand(history);
|
|
157
|
+
expect(read(target, 'x')).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('does not merge once the window has elapsed', () => {
|
|
161
|
+
const history = withDefaults();
|
|
162
|
+
const target = node();
|
|
163
|
+
write(target, 'x', 0);
|
|
164
|
+
|
|
165
|
+
executeCommand(history, createSetNodePropertyCommand('Drag', target, 'x', 10, 300, 0));
|
|
166
|
+
executeCommand(history, createSetNodePropertyCommand('Drag', target, 'x', 20, 300, 1000));
|
|
167
|
+
expect(history.entries).toHaveLength(2);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('does not merge writes to a different property of the same node', () => {
|
|
171
|
+
const history = withDefaults();
|
|
172
|
+
const target = node();
|
|
173
|
+
|
|
174
|
+
executeCommand(history, createSetNodePropertyCommand('Move', target, 'x', 10, 300, 0));
|
|
175
|
+
executeCommand(history, createSetNodePropertyCommand('Move', target, 'y', 20, 300, 10));
|
|
176
|
+
expect(history.entries).toHaveLength(2);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('does not merge when no window is given, which is the default', () => {
|
|
180
|
+
const history = withDefaults();
|
|
181
|
+
const target = node();
|
|
182
|
+
|
|
183
|
+
executeCommand(history, createSetNodePropertyCommand('Move', target, 'x', 10));
|
|
184
|
+
executeCommand(history, createSetNodePropertyCommand('Move', target, 'x', 20));
|
|
185
|
+
expect(history.entries).toHaveLength(2);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// The merged entry keeps the ORIGINAL before value; reading it back proves the merge is not simply
|
|
189
|
+
// dropping the earlier command.
|
|
190
|
+
it('keeps the original before value in the merged command', () => {
|
|
191
|
+
const history = withDefaults();
|
|
192
|
+
const target = node();
|
|
193
|
+
write(target, 'x', 7);
|
|
194
|
+
|
|
195
|
+
executeCommand(history, createSetNodePropertyCommand('Drag', target, 'x', 10, 300, 0));
|
|
196
|
+
executeCommand(history, createSetNodePropertyCommand('Drag', target, 'x', 20, 300, 50));
|
|
197
|
+
const merged = history.entries[0] as SetNodePropertyCommand;
|
|
198
|
+
expect(merged.entries[0].before).toBe(7);
|
|
199
|
+
expect(merged.entries[0].after).toBe(20);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
function node(): NodeAny {
|
|
204
|
+
return createNode('test.CommandTarget') as NodeAny;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function read(target: Readonly<NodeAny>, property: string): unknown {
|
|
208
|
+
return (target as unknown as Readonly<Record<string, unknown>>)[property];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function withDefaults() {
|
|
212
|
+
const history = createCommandHistory();
|
|
213
|
+
registerDefaultCommandBindings(history);
|
|
214
|
+
return history;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function write(target: NodeAny, property: string, value: unknown): void {
|
|
218
|
+
(target as unknown as Record<string, unknown>)[property] = value;
|
|
219
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { createNode, getNodeChildCount } from '@flighthq/node/contract';
|
|
2
|
+
import { connectSignal } from '@flighthq/signals/contract';
|
|
3
|
+
import type { Command, CommandHistory, NodeAny } from '@flighthq/types/contract';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { createAddNodeChildCommand, createSetNodePropertyCommand } from './command';
|
|
7
|
+
import { registerCommandBinding, registerDefaultCommandBindings } from './commandBinding';
|
|
8
|
+
import {
|
|
9
|
+
canRedoCommand,
|
|
10
|
+
canUndoCommand,
|
|
11
|
+
clearCommandHistory,
|
|
12
|
+
createCommandHistory,
|
|
13
|
+
executeCommand,
|
|
14
|
+
getCommandHistoryEntries,
|
|
15
|
+
getCommandHistoryIndex,
|
|
16
|
+
getCommandHistoryRedoLabel,
|
|
17
|
+
getCommandHistoryUndoLabel,
|
|
18
|
+
notifyCommandHistoryChanged,
|
|
19
|
+
redoCommand,
|
|
20
|
+
undoCommand,
|
|
21
|
+
} from './commandHistory';
|
|
22
|
+
import { enableCommandHistorySignals } from './commandHistorySignals';
|
|
23
|
+
|
|
24
|
+
describe('canRedoCommand', () => {
|
|
25
|
+
it('is false until something has been undone', () => {
|
|
26
|
+
const history = counting();
|
|
27
|
+
expect(canRedoCommand(history)).toBe(false);
|
|
28
|
+
executeCommand(history, counter('One'));
|
|
29
|
+
expect(canRedoCommand(history)).toBe(false);
|
|
30
|
+
undoCommand(history);
|
|
31
|
+
expect(canRedoCommand(history)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('canUndoCommand', () => {
|
|
36
|
+
it('is false on an empty history and true once something is applied', () => {
|
|
37
|
+
const history = counting();
|
|
38
|
+
expect(canUndoCommand(history)).toBe(false);
|
|
39
|
+
executeCommand(history, counter('One'));
|
|
40
|
+
expect(canUndoCommand(history)).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('clearCommandHistory', () => {
|
|
45
|
+
// Clearing FORGETS how to reverse the work; it must not silently reverse it. An editor that cleared
|
|
46
|
+
// history on save would otherwise undo the document.
|
|
47
|
+
it('drops entries without undoing them', () => {
|
|
48
|
+
const history = withDefaults();
|
|
49
|
+
const parent = node();
|
|
50
|
+
executeCommand(history, createAddNodeChildCommand('Add', parent, node()));
|
|
51
|
+
|
|
52
|
+
clearCommandHistory(history);
|
|
53
|
+
expect(getCommandHistoryEntries(history)).toHaveLength(0);
|
|
54
|
+
expect(getCommandHistoryIndex(history)).toBe(0);
|
|
55
|
+
expect(getNodeChildCount(parent)).toBe(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('emits nothing when there was nothing to forget', () => {
|
|
59
|
+
const history = counting();
|
|
60
|
+
let changes = 0;
|
|
61
|
+
connectSignal(enableCommandHistorySignals(history), () => changes++);
|
|
62
|
+
clearCommandHistory(history);
|
|
63
|
+
expect(changes).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('createCommandHistory', () => {
|
|
68
|
+
it('starts empty with no bindings and no signal allocated', () => {
|
|
69
|
+
const history = createCommandHistory();
|
|
70
|
+
expect(history.entries).toHaveLength(0);
|
|
71
|
+
expect(history.index).toBe(0);
|
|
72
|
+
expect(history.maxSize).toBe(0);
|
|
73
|
+
expect(history.onChange).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('executeCommand', () => {
|
|
78
|
+
it('refuses an unregistered kind rather than pushing an entry undo would skip', () => {
|
|
79
|
+
const history = createCommandHistory();
|
|
80
|
+
expect(executeCommand(history, { kind: 'acme.Unbound', label: 'Nope' })).toBe(false);
|
|
81
|
+
expect(getCommandHistoryEntries(history)).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// A new action after an undo forks the timeline: the abandoned redo branch must not survive, or redo
|
|
85
|
+
// would re-apply work the user replaced.
|
|
86
|
+
it('discards the redo branch when a new command arrives after an undo', () => {
|
|
87
|
+
const history = counting();
|
|
88
|
+
executeCommand(history, counter('One'));
|
|
89
|
+
executeCommand(history, counter('Two'));
|
|
90
|
+
undoCommand(history);
|
|
91
|
+
executeCommand(history, counter('Three'));
|
|
92
|
+
|
|
93
|
+
expect(getCommandHistoryEntries(history).map((entry) => entry.label)).toEqual(['One', 'Three']);
|
|
94
|
+
expect(canRedoCommand(history)).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('trims from the oldest end at maxSize, keeping the most recent history', () => {
|
|
98
|
+
const history = counting(2);
|
|
99
|
+
executeCommand(history, counter('One'));
|
|
100
|
+
executeCommand(history, counter('Two'));
|
|
101
|
+
executeCommand(history, counter('Three'));
|
|
102
|
+
|
|
103
|
+
expect(getCommandHistoryEntries(history).map((entry) => entry.label)).toEqual(['Two', 'Three']);
|
|
104
|
+
expect(getCommandHistoryIndex(history)).toBe(2);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('getCommandHistoryEntries', () => {
|
|
109
|
+
it('includes the reversed entries ahead of the cursor', () => {
|
|
110
|
+
const history = counting();
|
|
111
|
+
executeCommand(history, counter('One'));
|
|
112
|
+
undoCommand(history);
|
|
113
|
+
expect(getCommandHistoryEntries(history)).toHaveLength(1);
|
|
114
|
+
expect(getCommandHistoryIndex(history)).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('getCommandHistoryIndex', () => {
|
|
119
|
+
it('counts applied entries', () => {
|
|
120
|
+
const history = counting();
|
|
121
|
+
executeCommand(history, counter('One'));
|
|
122
|
+
executeCommand(history, counter('Two'));
|
|
123
|
+
expect(getCommandHistoryIndex(history)).toBe(2);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('getCommandHistoryRedoLabel', () => {
|
|
128
|
+
it('names the entry redo would re-apply, and is null with nothing to redo', () => {
|
|
129
|
+
const history = counting();
|
|
130
|
+
expect(getCommandHistoryRedoLabel(history)).toBeNull();
|
|
131
|
+
executeCommand(history, counter('One'));
|
|
132
|
+
undoCommand(history);
|
|
133
|
+
expect(getCommandHistoryRedoLabel(history)).toBe('One');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('getCommandHistoryUndoLabel', () => {
|
|
138
|
+
it('names the entry undo would reverse, and is null with nothing to undo', () => {
|
|
139
|
+
const history = counting();
|
|
140
|
+
expect(getCommandHistoryUndoLabel(history)).toBeNull();
|
|
141
|
+
executeCommand(history, counter('One'));
|
|
142
|
+
expect(getCommandHistoryUndoLabel(history)).toBe('One');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('notifyCommandHistoryChanged', () => {
|
|
147
|
+
it('is inert when no signal was enabled', () => {
|
|
148
|
+
expect(() => notifyCommandHistoryChanged(createCommandHistory())).not.toThrow();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('redoCommand', () => {
|
|
153
|
+
it('re-applies through the registered execute and returns false with nothing to redo', () => {
|
|
154
|
+
const history = withDefaults();
|
|
155
|
+
const target = node();
|
|
156
|
+
executeCommand(history, createSetNodePropertyCommand('Move', target, 'x', 100));
|
|
157
|
+
undoCommand(history);
|
|
158
|
+
expect(redoCommand(history)).toBe(true);
|
|
159
|
+
expect((target as unknown as Record<string, unknown>).x).toBe(100);
|
|
160
|
+
expect(redoCommand(history)).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('undoCommand', () => {
|
|
165
|
+
it('returns false with nothing to undo', () => {
|
|
166
|
+
expect(undoCommand(createCommandHistory())).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('reverses entries newest first', () => {
|
|
170
|
+
const history = counting();
|
|
171
|
+
const order: string[] = [];
|
|
172
|
+
registerCommandBinding(history, 'test.Ordered', {
|
|
173
|
+
execute: () => undefined,
|
|
174
|
+
undo: (command) => order.push(command.label),
|
|
175
|
+
});
|
|
176
|
+
executeCommand(history, { kind: 'test.Ordered', label: 'One' });
|
|
177
|
+
executeCommand(history, { kind: 'test.Ordered', label: 'Two' });
|
|
178
|
+
undoCommand(history);
|
|
179
|
+
undoCommand(history);
|
|
180
|
+
expect(order).toEqual(['Two', 'One']);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
function counter(label: string): Command {
|
|
185
|
+
return { kind: 'test.Counter', label };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A history bound to one inert kind, so stack behaviour can be tested without any graph mutation.
|
|
189
|
+
function counting(maxSize = 0): CommandHistory {
|
|
190
|
+
const history = createCommandHistory(maxSize);
|
|
191
|
+
registerCommandBinding(history, 'test.Counter', { execute: () => undefined, undo: () => undefined });
|
|
192
|
+
return history;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function node(): NodeAny {
|
|
196
|
+
return createNode('test.CommandTarget') as NodeAny;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function withDefaults(): CommandHistory {
|
|
200
|
+
const history = createCommandHistory();
|
|
201
|
+
registerDefaultCommandBindings(history);
|
|
202
|
+
return history;
|
|
203
|
+
}
|