@aetherwing/fcp-core 0.1.0 → 0.1.2
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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/parsed-op.d.ts +2 -0
- package/dist/parsed-op.d.ts.map +1 -1
- package/dist/parsed-op.js +6 -3
- package/dist/parsed-op.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/tokenizer.d.ts +10 -0
- package/dist/tokenizer.d.ts.map +1 -1
- package/dist/tokenizer.js +31 -4
- package/dist/tokenizer.js.map +1 -1
- package/package.json +17 -3
- package/src/event-log.ts +0 -209
- package/src/formatter.ts +0 -70
- package/src/index.ts +0 -40
- package/src/parsed-op.ts +0 -64
- package/src/server.ts +0 -241
- package/src/session.ts +0 -163
- package/src/tokenizer.ts +0 -108
- package/src/verb-registry.ts +0 -84
- package/tests/event-log.test.ts +0 -177
- package/tests/formatter.test.ts +0 -61
- package/tests/parsed-op.test.ts +0 -95
- package/tests/server.test.ts +0 -94
- package/tests/session.test.ts +0 -210
- package/tests/tokenizer.test.ts +0 -144
- package/tests/verb-registry.test.ts +0 -76
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -7
package/tests/event-log.test.ts
DELETED
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { EventLog } from "../src/event-log.js";
|
|
3
|
-
|
|
4
|
-
describe("EventLog", () => {
|
|
5
|
-
it("appends events and advances cursor", () => {
|
|
6
|
-
const log = new EventLog<string>();
|
|
7
|
-
log.append("a");
|
|
8
|
-
log.append("b");
|
|
9
|
-
expect(log.cursor).toBe(2);
|
|
10
|
-
expect(log.length).toBe(2);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it("returns recent events in chronological order", () => {
|
|
14
|
-
const log = new EventLog<string>();
|
|
15
|
-
log.append("a");
|
|
16
|
-
log.append("b");
|
|
17
|
-
log.append("c");
|
|
18
|
-
expect(log.recent(2)).toEqual(["b", "c"]);
|
|
19
|
-
expect(log.recent()).toEqual(["a", "b", "c"]);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe("undo", () => {
|
|
23
|
-
it("returns most recent event", () => {
|
|
24
|
-
const log = new EventLog<string>();
|
|
25
|
-
log.append("a");
|
|
26
|
-
log.append("b");
|
|
27
|
-
const undone = log.undo();
|
|
28
|
-
expect(undone).toEqual(["b"]);
|
|
29
|
-
expect(log.cursor).toBe(1);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("returns multiple events in reverse order", () => {
|
|
33
|
-
const log = new EventLog<string>();
|
|
34
|
-
log.append("a");
|
|
35
|
-
log.append("b");
|
|
36
|
-
log.append("c");
|
|
37
|
-
const undone = log.undo(2);
|
|
38
|
-
expect(undone).toEqual(["c", "b"]);
|
|
39
|
-
expect(log.cursor).toBe(1);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("returns empty array when nothing to undo", () => {
|
|
43
|
-
const log = new EventLog<string>();
|
|
44
|
-
expect(log.undo()).toEqual([]);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("skips checkpoint sentinels", () => {
|
|
48
|
-
const log = new EventLog<string>();
|
|
49
|
-
log.append("a");
|
|
50
|
-
log.checkpoint("cp1");
|
|
51
|
-
log.append("b");
|
|
52
|
-
const undone = log.undo(2);
|
|
53
|
-
expect(undone).toEqual(["b", "a"]);
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
describe("redo", () => {
|
|
58
|
-
it("re-applies undone events in forward order", () => {
|
|
59
|
-
const log = new EventLog<string>();
|
|
60
|
-
log.append("a");
|
|
61
|
-
log.append("b");
|
|
62
|
-
log.undo(2);
|
|
63
|
-
const redone = log.redo(2);
|
|
64
|
-
expect(redone).toEqual(["a", "b"]);
|
|
65
|
-
expect(log.cursor).toBe(2);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("returns empty array when nothing to redo", () => {
|
|
69
|
-
const log = new EventLog<string>();
|
|
70
|
-
log.append("a");
|
|
71
|
-
expect(log.redo()).toEqual([]);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("skips checkpoint sentinels during redo", () => {
|
|
75
|
-
const log = new EventLog<string>();
|
|
76
|
-
log.append("a");
|
|
77
|
-
log.checkpoint("cp1");
|
|
78
|
-
log.append("b");
|
|
79
|
-
log.undo(2);
|
|
80
|
-
const redone = log.redo(2);
|
|
81
|
-
expect(redone).toEqual(["a", "b"]);
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
describe("redo tail truncation", () => {
|
|
86
|
-
it("truncates redo history on new append", () => {
|
|
87
|
-
const log = new EventLog<string>();
|
|
88
|
-
log.append("a");
|
|
89
|
-
log.append("b");
|
|
90
|
-
log.undo(); // cursor at 1, "b" is in redo tail
|
|
91
|
-
log.append("c"); // should truncate "b"
|
|
92
|
-
expect(log.length).toBe(2); // "a", "c"
|
|
93
|
-
expect(log.redo()).toEqual([]); // no redo available
|
|
94
|
-
expect(log.recent()).toEqual(["a", "c"]);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe("checkpoint", () => {
|
|
99
|
-
it("creates a checkpoint that can be undone to", () => {
|
|
100
|
-
const log = new EventLog<string>();
|
|
101
|
-
log.append("a");
|
|
102
|
-
log.checkpoint("v1");
|
|
103
|
-
log.append("b");
|
|
104
|
-
log.append("c");
|
|
105
|
-
const undone = log.undoTo("v1");
|
|
106
|
-
expect(undone).toEqual(["c", "b"]);
|
|
107
|
-
// cursor should be at checkpoint position
|
|
108
|
-
expect(log.recent()).toEqual(["a"]);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("returns null for unknown checkpoint", () => {
|
|
112
|
-
const log = new EventLog<string>();
|
|
113
|
-
log.append("a");
|
|
114
|
-
expect(log.undoTo("nonexistent")).toBeNull();
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("returns null if checkpoint is at/beyond cursor", () => {
|
|
118
|
-
const log = new EventLog<string>();
|
|
119
|
-
log.append("a");
|
|
120
|
-
log.checkpoint("v1");
|
|
121
|
-
// Undo past the checkpoint
|
|
122
|
-
log.undo(); // undo "a" (checkpoint is skipped)
|
|
123
|
-
// Now cursor is before the checkpoint, so undoTo should still work
|
|
124
|
-
// Actually: checkpoint is at position 1, cursor after undo of "a" is at 0
|
|
125
|
-
// So target(1) >= cursor(0) is false, it should work
|
|
126
|
-
// Let me test a different scenario where checkpoint IS at cursor
|
|
127
|
-
const log2 = new EventLog<string>();
|
|
128
|
-
log2.checkpoint("v1"); // checkpoint at position 0
|
|
129
|
-
log2.append("a");
|
|
130
|
-
// cursor is 2, checkpoint is 0 — should work
|
|
131
|
-
const result = log2.undoTo("v1");
|
|
132
|
-
expect(result).toEqual(["a"]);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("removes checkpoints beyond cursor on truncation", () => {
|
|
136
|
-
const log = new EventLog<string>();
|
|
137
|
-
log.append("a");
|
|
138
|
-
log.checkpoint("v1");
|
|
139
|
-
log.append("b");
|
|
140
|
-
log.undo(2); // undo b and a, cursor before checkpoint
|
|
141
|
-
log.append("x"); // truncates everything including checkpoint
|
|
142
|
-
expect(log.undoTo("v1")).toBeNull(); // checkpoint gone
|
|
143
|
-
});
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
describe("cursor management", () => {
|
|
147
|
-
it("starts at 0", () => {
|
|
148
|
-
const log = new EventLog<string>();
|
|
149
|
-
expect(log.cursor).toBe(0);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("advances on append", () => {
|
|
153
|
-
const log = new EventLog<string>();
|
|
154
|
-
log.append("a");
|
|
155
|
-
expect(log.cursor).toBe(1);
|
|
156
|
-
log.append("b");
|
|
157
|
-
expect(log.cursor).toBe(2);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it("moves back on undo", () => {
|
|
161
|
-
const log = new EventLog<string>();
|
|
162
|
-
log.append("a");
|
|
163
|
-
log.append("b");
|
|
164
|
-
log.undo();
|
|
165
|
-
expect(log.cursor).toBe(1);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it("moves forward on redo", () => {
|
|
169
|
-
const log = new EventLog<string>();
|
|
170
|
-
log.append("a");
|
|
171
|
-
log.append("b");
|
|
172
|
-
log.undo();
|
|
173
|
-
log.redo();
|
|
174
|
-
expect(log.cursor).toBe(2);
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
});
|
package/tests/formatter.test.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { formatResult, suggest } from "../src/formatter.js";
|
|
3
|
-
|
|
4
|
-
describe("formatResult", () => {
|
|
5
|
-
it("formats success with prefix", () => {
|
|
6
|
-
expect(formatResult(true, "svc AuthService", "+")).toBe("+ svc AuthService");
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
it("formats success without prefix", () => {
|
|
10
|
-
expect(formatResult(true, "done")).toBe("done");
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it("formats error", () => {
|
|
14
|
-
expect(formatResult(false, "something broke")).toBe("ERROR: something broke");
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("formats error ignoring prefix", () => {
|
|
18
|
-
expect(formatResult(false, "bad input", "+")).toBe("ERROR: bad input");
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("handles standard prefix conventions", () => {
|
|
22
|
-
expect(formatResult(true, "AuthService", "+")).toBe("+ AuthService"); // created
|
|
23
|
-
expect(formatResult(true, "edge A->B", "~")).toBe("~ edge A->B"); // modified
|
|
24
|
-
expect(formatResult(true, "styled A", "*")).toBe("* styled A"); // changed
|
|
25
|
-
expect(formatResult(true, "A", "-")).toBe("- A"); // removed
|
|
26
|
-
expect(formatResult(true, "group Backend", "!")).toBe("! group Backend"); // meta
|
|
27
|
-
expect(formatResult(true, "layout", "@")).toBe("@ layout"); // bulk
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe("suggest", () => {
|
|
32
|
-
const candidates = ["add", "remove", "connect", "style", "label", "badge"];
|
|
33
|
-
|
|
34
|
-
it("suggests exact match", () => {
|
|
35
|
-
expect(suggest("add", candidates)).toBe("add");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("suggests for single-char typo", () => {
|
|
39
|
-
expect(suggest("ad", candidates)).toBe("add");
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("suggests for transposition", () => {
|
|
43
|
-
expect(suggest("styel", candidates)).toBe("style");
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("suggests for substitution", () => {
|
|
47
|
-
expect(suggest("labek", candidates)).toBe("label");
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("returns null for distant strings", () => {
|
|
51
|
-
expect(suggest("zzzzzzz", candidates)).toBeNull();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("returns null for empty candidates", () => {
|
|
55
|
-
expect(suggest("test", [])).toBeNull();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("picks closest when multiple are close", () => {
|
|
59
|
-
expect(suggest("bade", candidates)).toBe("badge");
|
|
60
|
-
});
|
|
61
|
-
});
|
package/tests/parsed-op.test.ts
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { parseOp, isParseError } from "../src/parsed-op.js";
|
|
3
|
-
|
|
4
|
-
describe("parseOp", () => {
|
|
5
|
-
it("parses a simple verb with positionals", () => {
|
|
6
|
-
const result = parseOp("add svc AuthService");
|
|
7
|
-
expect(isParseError(result)).toBe(false);
|
|
8
|
-
if (isParseError(result)) return;
|
|
9
|
-
expect(result.verb).toBe("add");
|
|
10
|
-
expect(result.positionals).toEqual(["svc", "AuthService"]);
|
|
11
|
-
expect(result.params).toEqual({});
|
|
12
|
-
expect(result.selectors).toEqual([]);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("extracts key:value params", () => {
|
|
16
|
-
const result = parseOp("add svc AuthService theme:blue near:Gateway");
|
|
17
|
-
if (isParseError(result)) return;
|
|
18
|
-
expect(result.verb).toBe("add");
|
|
19
|
-
expect(result.positionals).toEqual(["svc", "AuthService"]);
|
|
20
|
-
expect(result.params).toEqual({ theme: "blue", near: "Gateway" });
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("extracts selectors", () => {
|
|
24
|
-
const result = parseOp("remove @type:db @recent:3");
|
|
25
|
-
if (isParseError(result)) return;
|
|
26
|
-
expect(result.verb).toBe("remove");
|
|
27
|
-
expect(result.selectors).toEqual(["@type:db", "@recent:3"]);
|
|
28
|
-
expect(result.positionals).toEqual([]);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("handles mixed token types", () => {
|
|
32
|
-
const result = parseOp("style @type:svc fill:#ff0000 bold");
|
|
33
|
-
if (isParseError(result)) return;
|
|
34
|
-
expect(result.verb).toBe("style");
|
|
35
|
-
expect(result.selectors).toEqual(["@type:svc"]);
|
|
36
|
-
expect(result.params).toEqual({ fill: "#ff0000" });
|
|
37
|
-
expect(result.positionals).toEqual(["bold"]);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("lowercases the verb", () => {
|
|
41
|
-
const result = parseOp("ADD svc Test");
|
|
42
|
-
if (isParseError(result)) return;
|
|
43
|
-
expect(result.verb).toBe("add");
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("preserves original input as raw", () => {
|
|
47
|
-
const result = parseOp(" add svc Test ");
|
|
48
|
-
if (isParseError(result)) return;
|
|
49
|
-
expect(result.raw).toBe("add svc Test");
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("handles quoted positionals", () => {
|
|
53
|
-
const result = parseOp('label Gateway "API Gateway v2"');
|
|
54
|
-
if (isParseError(result)) return;
|
|
55
|
-
expect(result.positionals).toEqual(["Gateway", "API Gateway v2"]);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("returns error for empty input", () => {
|
|
59
|
-
const result = parseOp("");
|
|
60
|
-
expect(isParseError(result)).toBe(true);
|
|
61
|
-
if (!isParseError(result)) return;
|
|
62
|
-
expect(result.error).toBe("empty operation");
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("returns error for whitespace-only input", () => {
|
|
66
|
-
const result = parseOp(" ");
|
|
67
|
-
expect(isParseError(result)).toBe(true);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("handles arrows as positionals", () => {
|
|
71
|
-
const result = parseOp("connect A -> B");
|
|
72
|
-
if (isParseError(result)) return;
|
|
73
|
-
expect(result.positionals).toEqual(["A", "->", "B"]);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("handles verb-only input", () => {
|
|
77
|
-
const result = parseOp("undo");
|
|
78
|
-
if (isParseError(result)) return;
|
|
79
|
-
expect(result.verb).toBe("undo");
|
|
80
|
-
expect(result.positionals).toEqual([]);
|
|
81
|
-
expect(result.params).toEqual({});
|
|
82
|
-
expect(result.selectors).toEqual([]);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe("isParseError", () => {
|
|
87
|
-
it("returns true for errors", () => {
|
|
88
|
-
expect(isParseError({ success: false, error: "test", raw: "" })).toBe(true);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("returns false for valid ops", () => {
|
|
92
|
-
const result = parseOp("add test");
|
|
93
|
-
expect(isParseError(result)).toBe(false);
|
|
94
|
-
});
|
|
95
|
-
});
|
package/tests/server.test.ts
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { createFcpServer, type FcpDomainAdapter, type OpResult } from "../src/server.js";
|
|
3
|
-
import { EventLog } from "../src/event-log.js";
|
|
4
|
-
import type { ParsedOp } from "../src/parsed-op.js";
|
|
5
|
-
import type { VerbSpec } from "../src/verb-registry.js";
|
|
6
|
-
|
|
7
|
-
// A minimal test domain
|
|
8
|
-
interface TestModel {
|
|
9
|
-
title: string;
|
|
10
|
-
items: string[];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
type TestEvent = { type: "add"; item: string } | { type: "remove"; item: string };
|
|
14
|
-
|
|
15
|
-
const testVerbs: VerbSpec[] = [
|
|
16
|
-
{ verb: "add", syntax: "add ITEM", category: "create", description: "Add an item" },
|
|
17
|
-
{ verb: "remove", syntax: "remove ITEM", category: "modify", description: "Remove an item" },
|
|
18
|
-
{ verb: "list", syntax: "list", category: "query", description: "List all items" },
|
|
19
|
-
];
|
|
20
|
-
|
|
21
|
-
const testAdapter: FcpDomainAdapter<TestModel, TestEvent> = {
|
|
22
|
-
createEmpty(title) {
|
|
23
|
-
return { title, items: [] };
|
|
24
|
-
},
|
|
25
|
-
serialize(model) {
|
|
26
|
-
return JSON.stringify(model);
|
|
27
|
-
},
|
|
28
|
-
deserialize(data) {
|
|
29
|
-
return JSON.parse(typeof data === "string" ? data : data.toString());
|
|
30
|
-
},
|
|
31
|
-
rebuildIndices() {},
|
|
32
|
-
getDigest(model) {
|
|
33
|
-
return `[${model.title}: ${model.items.length} items]`;
|
|
34
|
-
},
|
|
35
|
-
dispatchOp(op: ParsedOp, model: TestModel, log: EventLog<TestEvent>): OpResult {
|
|
36
|
-
switch (op.verb) {
|
|
37
|
-
case "add": {
|
|
38
|
-
const item = op.positionals[0];
|
|
39
|
-
if (!item) return { success: false, message: "add requires an item" };
|
|
40
|
-
model.items.push(item);
|
|
41
|
-
log.append({ type: "add", item });
|
|
42
|
-
return { success: true, message: item, prefix: "+" };
|
|
43
|
-
}
|
|
44
|
-
case "remove": {
|
|
45
|
-
const item = op.positionals[0];
|
|
46
|
-
if (!item) return { success: false, message: "remove requires an item" };
|
|
47
|
-
const idx = model.items.indexOf(item);
|
|
48
|
-
if (idx === -1) return { success: false, message: `"${item}" not found` };
|
|
49
|
-
model.items.splice(idx, 1);
|
|
50
|
-
log.append({ type: "remove", item });
|
|
51
|
-
return { success: true, message: item, prefix: "-" };
|
|
52
|
-
}
|
|
53
|
-
default:
|
|
54
|
-
return { success: false, message: `unhandled verb "${op.verb}"` };
|
|
55
|
-
}
|
|
56
|
-
},
|
|
57
|
-
dispatchQuery(query: string, model: TestModel) {
|
|
58
|
-
if (query === "list") {
|
|
59
|
-
return model.items.length === 0 ? "empty" : model.items.join(", ");
|
|
60
|
-
}
|
|
61
|
-
return `unknown query: ${query}`;
|
|
62
|
-
},
|
|
63
|
-
reverseEvent(event: TestEvent, model: TestModel) {
|
|
64
|
-
if (event.type === "add") {
|
|
65
|
-
const idx = model.items.indexOf(event.item);
|
|
66
|
-
if (idx !== -1) model.items.splice(idx, 1);
|
|
67
|
-
} else if (event.type === "remove") {
|
|
68
|
-
model.items.push(event.item);
|
|
69
|
-
}
|
|
70
|
-
},
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
describe("createFcpServer", () => {
|
|
74
|
-
it("creates an MCP server without errors", () => {
|
|
75
|
-
const server = createFcpServer({
|
|
76
|
-
domain: "test",
|
|
77
|
-
adapter: testAdapter,
|
|
78
|
-
verbs: testVerbs,
|
|
79
|
-
});
|
|
80
|
-
expect(server).toBeDefined();
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("creates a server with reference card sections", () => {
|
|
84
|
-
const server = createFcpServer({
|
|
85
|
-
domain: "test",
|
|
86
|
-
adapter: testAdapter,
|
|
87
|
-
verbs: testVerbs,
|
|
88
|
-
referenceCard: {
|
|
89
|
-
sections: { "Notes": "Some additional notes" },
|
|
90
|
-
},
|
|
91
|
-
});
|
|
92
|
-
expect(server).toBeDefined();
|
|
93
|
-
});
|
|
94
|
-
});
|
package/tests/session.test.ts
DELETED
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { SessionDispatcher, type SessionHooks } from "../src/session.js";
|
|
3
|
-
import { EventLog } from "../src/event-log.js";
|
|
4
|
-
|
|
5
|
-
interface MockModel {
|
|
6
|
-
title: string;
|
|
7
|
-
data: string[];
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function createMockSession() {
|
|
11
|
-
const eventLog = new EventLog<string>();
|
|
12
|
-
const hooks: SessionHooks<MockModel> = {
|
|
13
|
-
onNew: vi.fn((params) => ({
|
|
14
|
-
title: params["title"] ?? "Untitled",
|
|
15
|
-
data: [],
|
|
16
|
-
})),
|
|
17
|
-
onOpen: vi.fn(async (path) => ({
|
|
18
|
-
title: path,
|
|
19
|
-
data: ["loaded"],
|
|
20
|
-
})),
|
|
21
|
-
onSave: vi.fn(async () => {}),
|
|
22
|
-
onRebuildIndices: vi.fn(),
|
|
23
|
-
getDigest: vi.fn((model) => `[${model.title}: ${model.data.length} items]`),
|
|
24
|
-
};
|
|
25
|
-
const reverseEvent = vi.fn();
|
|
26
|
-
const replayEvent = vi.fn();
|
|
27
|
-
const session = new SessionDispatcher<MockModel, string>(hooks, eventLog, {
|
|
28
|
-
reverseEvent,
|
|
29
|
-
replayEvent,
|
|
30
|
-
});
|
|
31
|
-
return { session, hooks, eventLog, reverseEvent, replayEvent };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
describe("SessionDispatcher", () => {
|
|
35
|
-
describe("new", () => {
|
|
36
|
-
it("creates a new model with title", async () => {
|
|
37
|
-
const { session, hooks } = createMockSession();
|
|
38
|
-
const result = await session.dispatch('new "My Song"');
|
|
39
|
-
expect(result).toBe('new "My Song" created');
|
|
40
|
-
expect(session.model).not.toBeNull();
|
|
41
|
-
expect(session.model?.title).toBe("My Song");
|
|
42
|
-
expect(hooks.onNew).toHaveBeenCalledWith({ title: "My Song" });
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("creates a new model with params", async () => {
|
|
46
|
-
const { session, hooks } = createMockSession();
|
|
47
|
-
await session.dispatch('new "Test" tempo:120');
|
|
48
|
-
expect(hooks.onNew).toHaveBeenCalledWith({ title: "Test", tempo: "120" });
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("defaults to Untitled", async () => {
|
|
52
|
-
const { session } = createMockSession();
|
|
53
|
-
const result = await session.dispatch("new");
|
|
54
|
-
expect(result).toBe('new "Untitled" created');
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("clears file path", async () => {
|
|
58
|
-
const { session } = createMockSession();
|
|
59
|
-
await session.dispatch("new Test");
|
|
60
|
-
expect(session.filePath).toBeNull();
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
describe("open", () => {
|
|
65
|
-
it("opens a file and sets model", async () => {
|
|
66
|
-
const { session, hooks } = createMockSession();
|
|
67
|
-
const result = await session.dispatch("open ./test.mid");
|
|
68
|
-
expect(result).toBe('opened "./test.mid"');
|
|
69
|
-
expect(session.model).not.toBeNull();
|
|
70
|
-
expect(session.filePath).toBe("./test.mid");
|
|
71
|
-
expect(hooks.onOpen).toHaveBeenCalledWith("./test.mid");
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("returns error when no path given", async () => {
|
|
75
|
-
const { session } = createMockSession();
|
|
76
|
-
const result = await session.dispatch("open");
|
|
77
|
-
expect(result).toBe("open requires a file path");
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("returns error message on failure", async () => {
|
|
81
|
-
const { session, hooks } = createMockSession();
|
|
82
|
-
(hooks.onOpen as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("file not found"));
|
|
83
|
-
const result = await session.dispatch("open ./missing.mid");
|
|
84
|
-
expect(result).toBe("error: file not found");
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
describe("save", () => {
|
|
89
|
-
it("saves to the open file path", async () => {
|
|
90
|
-
const { session, hooks } = createMockSession();
|
|
91
|
-
await session.dispatch("open ./test.mid");
|
|
92
|
-
const result = await session.dispatch("save");
|
|
93
|
-
expect(result).toBe('saved "./test.mid"');
|
|
94
|
-
expect(hooks.onSave).toHaveBeenCalled();
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("saves to a new path with as:", async () => {
|
|
98
|
-
const { session } = createMockSession();
|
|
99
|
-
await session.dispatch("new Test");
|
|
100
|
-
const result = await session.dispatch("save as:./output.mid");
|
|
101
|
-
expect(result).toBe('saved "./output.mid"');
|
|
102
|
-
expect(session.filePath).toBe("./output.mid");
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("returns error when no model", async () => {
|
|
106
|
-
const { session } = createMockSession();
|
|
107
|
-
const result = await session.dispatch("save");
|
|
108
|
-
expect(result).toBe("error: no model to save");
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("returns error when no path", async () => {
|
|
112
|
-
const { session } = createMockSession();
|
|
113
|
-
await session.dispatch("new Test");
|
|
114
|
-
const result = await session.dispatch("save");
|
|
115
|
-
expect(result).toBe("error: no file path. Use save as:./file");
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
describe("checkpoint", () => {
|
|
120
|
-
it("creates a checkpoint", async () => {
|
|
121
|
-
const { session, eventLog } = createMockSession();
|
|
122
|
-
const result = await session.dispatch("checkpoint v1");
|
|
123
|
-
expect(result).toBe('checkpoint "v1" created');
|
|
124
|
-
expect(eventLog.cursor).toBeGreaterThan(0);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("requires a name", async () => {
|
|
128
|
-
const { session } = createMockSession();
|
|
129
|
-
const result = await session.dispatch("checkpoint");
|
|
130
|
-
expect(result).toBe("checkpoint requires a name");
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
describe("undo", () => {
|
|
135
|
-
it("undoes events", async () => {
|
|
136
|
-
const { session, eventLog } = createMockSession();
|
|
137
|
-
await session.dispatch("new Test");
|
|
138
|
-
eventLog.append("event1");
|
|
139
|
-
eventLog.append("event2");
|
|
140
|
-
const result = await session.dispatch("undo");
|
|
141
|
-
expect(result).toBe("undone 1 event");
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("returns nothing to undo when empty", async () => {
|
|
145
|
-
const { session } = createMockSession();
|
|
146
|
-
const result = await session.dispatch("undo");
|
|
147
|
-
expect(result).toBe("nothing to undo");
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("undoes to a checkpoint", async () => {
|
|
151
|
-
const { session, eventLog } = createMockSession();
|
|
152
|
-
await session.dispatch("new Test");
|
|
153
|
-
eventLog.append("event1");
|
|
154
|
-
await session.dispatch("checkpoint v1");
|
|
155
|
-
eventLog.append("event2");
|
|
156
|
-
eventLog.append("event3");
|
|
157
|
-
const result = await session.dispatch("undo to:v1");
|
|
158
|
-
expect(result).toContain("undone");
|
|
159
|
-
expect(result).toContain("checkpoint");
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("calls onRebuildIndices after undo", async () => {
|
|
163
|
-
const { session, hooks, eventLog } = createMockSession();
|
|
164
|
-
await session.dispatch("new Test");
|
|
165
|
-
eventLog.append("event1");
|
|
166
|
-
await session.dispatch("undo");
|
|
167
|
-
expect(hooks.onRebuildIndices).toHaveBeenCalled();
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
describe("redo", () => {
|
|
172
|
-
it("redoes events", async () => {
|
|
173
|
-
const { session, eventLog } = createMockSession();
|
|
174
|
-
await session.dispatch("new Test");
|
|
175
|
-
eventLog.append("event1");
|
|
176
|
-
eventLog.undo();
|
|
177
|
-
const result = await session.dispatch("redo");
|
|
178
|
-
expect(result).toBe("redone 1 event");
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("returns nothing to redo when at end", async () => {
|
|
182
|
-
const { session } = createMockSession();
|
|
183
|
-
const result = await session.dispatch("redo");
|
|
184
|
-
expect(result).toBe("nothing to redo");
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it("calls onRebuildIndices after redo", async () => {
|
|
188
|
-
const { session, hooks, eventLog } = createMockSession();
|
|
189
|
-
await session.dispatch("new Test");
|
|
190
|
-
eventLog.append("event1");
|
|
191
|
-
eventLog.undo();
|
|
192
|
-
await session.dispatch("redo");
|
|
193
|
-
expect(hooks.onRebuildIndices).toHaveBeenCalled();
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
describe("unknown commands", () => {
|
|
198
|
-
it("returns error for unknown actions", async () => {
|
|
199
|
-
const { session } = createMockSession();
|
|
200
|
-
const result = await session.dispatch("explode");
|
|
201
|
-
expect(result).toBe('unknown session action "explode"');
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it("returns error for empty action", async () => {
|
|
205
|
-
const { session } = createMockSession();
|
|
206
|
-
const result = await session.dispatch("");
|
|
207
|
-
expect(result).toBe("empty action");
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
});
|