@cueloop/client 0.1.0-alpha.21 → 0.1.0-alpha.22
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/package.json +5 -5
- package/src/App.autoclose.test.tsx +29 -5
- package/src/App.diff.test.tsx +20 -0
- package/src/App.export.test.tsx +12 -8
- package/src/App.plan-review.test.tsx +121 -7
- package/src/App.readonly.test.tsx +19 -5
- package/src/App.rendering.test.tsx +15 -0
- package/src/App.review-panel.test.tsx +29 -6
- package/src/App.submit-confirm.test.tsx +57 -6
- package/src/App.test.tsx +62 -5
- package/src/App.tsx +71 -182
- package/src/App.walk.test.tsx +63 -8
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/ReviewRail.tsx +16 -4
- package/src/components/stories.test.tsx +5 -0
- package/src/config.test.ts +62 -0
- package/src/config.ts +1 -0
- package/src/editor.test.ts +24 -0
- package/src/herdr.test.ts +4 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +12 -0
- package/src/intent-dispatch.test.ts +213 -0
- package/src/intent-dispatch.ts +270 -0
- package/src/key-bindings.test.ts +16 -5
- package/src/keymap.test.ts +97 -5
- package/src/keymap.ts +24 -2
- package/src/serve.test.ts +7 -0
- package/src/session-controller.ts +22 -3
- package/src/share.ts +47 -0
- package/src/view-diff.test.ts +11 -0
- package/src/view-diff.ts +6 -6
- package/src/view-plan.test.ts +81 -0
- package/src/view-plan.ts +3 -0
- package/src/walk.test.ts +6 -0
package/src/config.test.ts
CHANGED
|
@@ -8,20 +8,28 @@ import { DARK } from "./theme";
|
|
|
8
8
|
|
|
9
9
|
describe("loadConfig", () => {
|
|
10
10
|
test("defaults when no file exists", () => {
|
|
11
|
+
// Act
|
|
11
12
|
const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
13
|
+
|
|
14
|
+
// Assert
|
|
12
15
|
expect(config.keys["comment"]).toEqual(["c"]);
|
|
13
16
|
expect(config.theme.accent).toBe(DARK.accent);
|
|
14
17
|
});
|
|
15
18
|
|
|
16
19
|
test("user config rebinds actions and overrides theme tokens", () => {
|
|
20
|
+
// Arrange
|
|
17
21
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-"));
|
|
18
22
|
const path = join(dir, "config.toml");
|
|
19
23
|
writeFileSync(
|
|
20
24
|
path,
|
|
21
25
|
`[keys]\ncomment = "a"\nsubmit = ["return", "S"]\n\n[theme]\naccent = "#ff0000"\n`,
|
|
22
26
|
);
|
|
27
|
+
|
|
23
28
|
try {
|
|
29
|
+
// Act
|
|
24
30
|
const config = loadConfig({ userConfigPath: path });
|
|
31
|
+
|
|
32
|
+
// Assert
|
|
25
33
|
expect(config.keys["comment"]).toEqual(["a"]);
|
|
26
34
|
expect(config.keys["submit"]).toEqual(["return", "S"]);
|
|
27
35
|
expect(config.keys["cut"]).toEqual(["x"]); // untouched defaults survive
|
|
@@ -33,14 +41,19 @@ describe("loadConfig", () => {
|
|
|
33
41
|
});
|
|
34
42
|
|
|
35
43
|
test("repo config layers over user config", () => {
|
|
44
|
+
// Arrange
|
|
36
45
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg2-"));
|
|
37
46
|
const user = join(dir, "user.toml");
|
|
38
47
|
const repoRoot = join(dir, "repo");
|
|
39
48
|
writeFileSync(user, `[keys]\ncomment = "a"\n`);
|
|
40
49
|
Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
|
|
41
50
|
writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[keys]\ncomment = "z"\n`);
|
|
51
|
+
|
|
42
52
|
try {
|
|
53
|
+
// Act
|
|
43
54
|
const config = loadConfig({ userConfigPath: user, repoRoot });
|
|
55
|
+
|
|
56
|
+
// Assert
|
|
44
57
|
expect(config.keys["comment"]).toEqual(["z"]);
|
|
45
58
|
} finally {
|
|
46
59
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -48,11 +61,16 @@ describe("loadConfig", () => {
|
|
|
48
61
|
});
|
|
49
62
|
|
|
50
63
|
test("broken config never blocks a review", () => {
|
|
64
|
+
// Arrange
|
|
51
65
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg3-"));
|
|
52
66
|
const path = join(dir, "config.toml");
|
|
53
67
|
writeFileSync(path, "not [valid toml");
|
|
68
|
+
|
|
54
69
|
try {
|
|
70
|
+
// Act
|
|
55
71
|
const config = loadConfig({ userConfigPath: path });
|
|
72
|
+
|
|
73
|
+
// Assert
|
|
56
74
|
expect(config.keys["comment"]).toEqual(["c"]);
|
|
57
75
|
} finally {
|
|
58
76
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -60,11 +78,16 @@ describe("loadConfig", () => {
|
|
|
60
78
|
});
|
|
61
79
|
|
|
62
80
|
test("[ui] parses auto_close and the editor override", () => {
|
|
81
|
+
// Arrange
|
|
63
82
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg4-"));
|
|
64
83
|
const path = join(dir, "config.toml");
|
|
65
84
|
writeFileSync(path, `[ui]\nauto_close = 3\neditor = "code --wait"\n`);
|
|
85
|
+
|
|
66
86
|
try {
|
|
87
|
+
// Act
|
|
67
88
|
const config = loadConfig({ userConfigPath: path });
|
|
89
|
+
|
|
90
|
+
// Assert
|
|
68
91
|
expect(config.ui.autoClose).toBe(3);
|
|
69
92
|
expect(config.ui.editor).toBe("code --wait");
|
|
70
93
|
} finally {
|
|
@@ -73,10 +96,13 @@ describe("loadConfig", () => {
|
|
|
73
96
|
});
|
|
74
97
|
|
|
75
98
|
test("[ui] editor defaults to undefined and ignores a blank value", () => {
|
|
99
|
+
// Arrange
|
|
76
100
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg5-"));
|
|
77
101
|
const path = join(dir, "config.toml");
|
|
78
102
|
writeFileSync(path, `[ui]\neditor = " "\n`);
|
|
103
|
+
|
|
79
104
|
try {
|
|
105
|
+
// Assert
|
|
80
106
|
expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.editor).toBeUndefined();
|
|
81
107
|
expect(loadConfig({ userConfigPath: path }).ui.editor).toBeUndefined();
|
|
82
108
|
} finally {
|
|
@@ -85,17 +111,25 @@ describe("loadConfig", () => {
|
|
|
85
111
|
});
|
|
86
112
|
|
|
87
113
|
test("[ui] review panel defaults to an expanded rail at the default width", () => {
|
|
114
|
+
// Act
|
|
88
115
|
const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
116
|
+
|
|
117
|
+
// Assert
|
|
89
118
|
expect(config.ui.reviewState).toBe("expanded");
|
|
90
119
|
expect(config.ui.reviewWidth).toBe(REVIEW_DEFAULT_WIDTH);
|
|
91
120
|
});
|
|
92
121
|
|
|
93
122
|
test("[ui] parses review_width (clamped) and review_state", () => {
|
|
123
|
+
// Arrange
|
|
94
124
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg6-"));
|
|
95
125
|
const path = join(dir, "config.toml");
|
|
96
126
|
writeFileSync(path, `[ui]\nreview_width = 999\nreview_state = "compact"\n`);
|
|
127
|
+
|
|
97
128
|
try {
|
|
129
|
+
// Act
|
|
98
130
|
const config = loadConfig({ userConfigPath: path });
|
|
131
|
+
|
|
132
|
+
// Assert
|
|
99
133
|
expect(config.ui.reviewWidth).toBe(REVIEW_MAX_WIDTH); // out-of-range width clamps
|
|
100
134
|
expect(config.ui.reviewState).toBe("compact");
|
|
101
135
|
} finally {
|
|
@@ -104,11 +138,16 @@ describe("loadConfig", () => {
|
|
|
104
138
|
});
|
|
105
139
|
|
|
106
140
|
test("[ui] ignores an unknown review_state and a non-numeric width", () => {
|
|
141
|
+
// Arrange
|
|
107
142
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg7-"));
|
|
108
143
|
const path = join(dir, "config.toml");
|
|
109
144
|
writeFileSync(path, `[ui]\nreview_width = "wide"\nreview_state = "sideways"\n`);
|
|
145
|
+
|
|
110
146
|
try {
|
|
147
|
+
// Act
|
|
111
148
|
const config = loadConfig({ userConfigPath: path });
|
|
149
|
+
|
|
150
|
+
// Assert
|
|
112
151
|
expect(config.ui.reviewWidth).toBe(REVIEW_DEFAULT_WIDTH);
|
|
113
152
|
expect(config.ui.reviewState).toBe("expanded");
|
|
114
153
|
} finally {
|
|
@@ -117,16 +156,25 @@ describe("loadConfig", () => {
|
|
|
117
156
|
});
|
|
118
157
|
|
|
119
158
|
test("persistReviewWidth and persistReviewState round-trip through the config file", () => {
|
|
159
|
+
// Arrange
|
|
120
160
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg8-"));
|
|
121
161
|
const path = join(dir, "config.toml");
|
|
162
|
+
|
|
122
163
|
try {
|
|
164
|
+
// Act
|
|
123
165
|
persistReviewWidth(42, path);
|
|
124
166
|
persistReviewState("hidden", path);
|
|
167
|
+
|
|
168
|
+
// Assert
|
|
125
169
|
const config = loadConfig({ userConfigPath: path });
|
|
126
170
|
expect(config.ui.reviewWidth).toBe(42);
|
|
127
171
|
expect(config.ui.reviewState).toBe("hidden");
|
|
172
|
+
|
|
128
173
|
// a second write replaces the key in place rather than appending a duplicate
|
|
174
|
+
// Act
|
|
129
175
|
persistReviewWidth(30, path);
|
|
176
|
+
|
|
177
|
+
// Assert
|
|
130
178
|
expect(loadConfig({ userConfigPath: path }).ui.reviewWidth).toBe(30);
|
|
131
179
|
} finally {
|
|
132
180
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -136,7 +184,10 @@ describe("loadConfig", () => {
|
|
|
136
184
|
|
|
137
185
|
describe("integrations.obsidian config", () => {
|
|
138
186
|
test("defaults when the section is absent", () => {
|
|
187
|
+
// Act
|
|
139
188
|
const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
189
|
+
|
|
190
|
+
// Assert
|
|
140
191
|
expect(config.integrations.obsidian.vault).toBeUndefined();
|
|
141
192
|
expect(config.integrations.obsidian.folder).toBe("cueloop");
|
|
142
193
|
expect(config.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
|
|
@@ -145,14 +196,19 @@ describe("integrations.obsidian config", () => {
|
|
|
145
196
|
});
|
|
146
197
|
|
|
147
198
|
test("user config sets the section; invalid enum values are ignored", () => {
|
|
199
|
+
// Arrange
|
|
148
200
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs-"));
|
|
149
201
|
const path = join(dir, "config.toml");
|
|
150
202
|
writeFileSync(
|
|
151
203
|
path,
|
|
152
204
|
`[integrations.obsidian]\nvault = "/notes/vault"\nfolder = "plans"\nexportOn = "approve"\nseparator = "comma"\n`,
|
|
153
205
|
);
|
|
206
|
+
|
|
154
207
|
try {
|
|
208
|
+
// Act
|
|
155
209
|
const config = loadConfig({ userConfigPath: path });
|
|
210
|
+
|
|
211
|
+
// Assert
|
|
156
212
|
expect(config.integrations.obsidian.vault).toBe("/notes/vault");
|
|
157
213
|
expect(config.integrations.obsidian.folder).toBe("plans");
|
|
158
214
|
expect(config.integrations.obsidian.exportOn).toBe("approve");
|
|
@@ -164,14 +220,19 @@ describe("integrations.obsidian config", () => {
|
|
|
164
220
|
});
|
|
165
221
|
|
|
166
222
|
test("repo config layers over user config for the section", () => {
|
|
223
|
+
// Arrange
|
|
167
224
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs2-"));
|
|
168
225
|
const user = join(dir, "user.toml");
|
|
169
226
|
const repoRoot = join(dir, "repo");
|
|
170
227
|
writeFileSync(user, `[integrations.obsidian]\nvault = "/user/vault"\nexportOn = "resolve"\n`);
|
|
171
228
|
Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
|
|
172
229
|
writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[integrations.obsidian]\nfolder = "repo-plans"\n`);
|
|
230
|
+
|
|
173
231
|
try {
|
|
232
|
+
// Act
|
|
174
233
|
const config = loadConfig({ userConfigPath: user, repoRoot });
|
|
234
|
+
|
|
235
|
+
// Assert
|
|
175
236
|
expect(config.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
|
|
176
237
|
expect(config.integrations.obsidian.exportOn).toBe("resolve");
|
|
177
238
|
expect(config.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
|
|
@@ -183,6 +244,7 @@ describe("integrations.obsidian config", () => {
|
|
|
183
244
|
|
|
184
245
|
describe("actionFor", () => {
|
|
185
246
|
test("resolves keys and shifted keys to actions", () => {
|
|
247
|
+
// Assert
|
|
186
248
|
expect(actionFor(DEFAULT_KEYS, "j", false)).toBe("down");
|
|
187
249
|
expect(actionFor(DEFAULT_KEYS, "g", true)).toBe("bottom"); // G
|
|
188
250
|
expect(actionFor(DEFAULT_KEYS, "return", false)).toBe("submit");
|
package/src/config.ts
CHANGED
package/src/editor.test.ts
CHANGED
|
@@ -6,35 +6,42 @@ import { editInEditor, resolveEditor, resolveEditorCommand } from "./editor";
|
|
|
6
6
|
|
|
7
7
|
describe("resolveEditor", () => {
|
|
8
8
|
test("a clean environment falls back to nano so editing always works", () => {
|
|
9
|
+
// Assert
|
|
9
10
|
expect(resolveEditor(undefined, {})).toBe("nano");
|
|
10
11
|
});
|
|
11
12
|
|
|
12
13
|
test("the environment chain wins in order: CUELOOP_EDITOR, VISUAL, EDITOR", () => {
|
|
14
|
+
// Assert
|
|
13
15
|
expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code", CUELOOP_EDITOR: "hx" })).toBe("hx");
|
|
14
16
|
expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code" })).toBe("code");
|
|
15
17
|
expect(resolveEditor(undefined, { EDITOR: "vim" })).toBe("vim");
|
|
16
18
|
});
|
|
17
19
|
|
|
18
20
|
test("the [ui] editor config overrides the environment", () => {
|
|
21
|
+
// Assert
|
|
19
22
|
expect(resolveEditor("code --wait", { EDITOR: "vim" })).toBe("code --wait");
|
|
20
23
|
});
|
|
21
24
|
});
|
|
22
25
|
|
|
23
26
|
describe("resolveEditorCommand", () => {
|
|
24
27
|
test("a known GUI editor gets its wait flag appended and is trusted to wait", () => {
|
|
28
|
+
// Assert
|
|
25
29
|
expect(resolveEditorCommand("code")).toEqual({ argv: ["code", "--wait"], waits: true });
|
|
26
30
|
expect(resolveEditorCommand("subl")).toEqual({ argv: ["subl", "--new-window", "--wait"], waits: true });
|
|
27
31
|
});
|
|
28
32
|
|
|
29
33
|
test("an existing wait flag is not duplicated", () => {
|
|
34
|
+
// Assert
|
|
30
35
|
expect(resolveEditorCommand("code --wait").argv).toEqual(["code", "--wait"]);
|
|
31
36
|
});
|
|
32
37
|
|
|
33
38
|
test("a terminal editor is left alone and trusted to hold the tty", () => {
|
|
39
|
+
// Assert
|
|
34
40
|
expect(resolveEditorCommand("vim")).toEqual({ argv: ["vim"], waits: true });
|
|
35
41
|
});
|
|
36
42
|
|
|
37
43
|
test("an unknown editor is neither flagged nor trusted to wait", () => {
|
|
44
|
+
// Assert
|
|
38
45
|
expect(resolveEditorCommand("my-editor")).toEqual({ argv: ["my-editor"], waits: false });
|
|
39
46
|
});
|
|
40
47
|
});
|
|
@@ -62,12 +69,18 @@ describe("editInEditor", () => {
|
|
|
62
69
|
};
|
|
63
70
|
|
|
64
71
|
test("an editor that changes the file reports the new content", () => {
|
|
72
|
+
// Act
|
|
65
73
|
const result = editInEditor("old", "plan.md", { editor: stubEditor("writer.sh", `printf 'new' > "$1"`) });
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
66
76
|
expect(result).toEqual({ content: "new", changed: true });
|
|
67
77
|
});
|
|
68
78
|
|
|
69
79
|
test("a fast, unchanged return opens the gate; confirming re-reads the saved file", () => {
|
|
80
|
+
// Arrange
|
|
70
81
|
let askedLabel = "";
|
|
82
|
+
|
|
83
|
+
// Act
|
|
71
84
|
const result = editInEditor("keep", "plan.md", {
|
|
72
85
|
editor: stubEditor("gui-like.sh", "exit 0"),
|
|
73
86
|
now: instant(),
|
|
@@ -77,21 +90,29 @@ describe("editInEditor", () => {
|
|
|
77
90
|
return true;
|
|
78
91
|
},
|
|
79
92
|
});
|
|
93
|
+
|
|
94
|
+
// Assert
|
|
80
95
|
expect(askedLabel).toContain("gui-like.sh");
|
|
81
96
|
expect(result).toEqual({ content: "saved in the GUI", changed: true });
|
|
82
97
|
});
|
|
83
98
|
|
|
84
99
|
test("declining the gate keeps the original content", () => {
|
|
100
|
+
// Act
|
|
85
101
|
const result = editInEditor("keep", "plan.md", {
|
|
86
102
|
editor: stubEditor("gui-like.sh", "exit 0"),
|
|
87
103
|
now: instant(),
|
|
88
104
|
confirmSaved: () => false,
|
|
89
105
|
});
|
|
106
|
+
|
|
107
|
+
// Assert
|
|
90
108
|
expect(result).toEqual({ content: "keep", changed: false });
|
|
91
109
|
});
|
|
92
110
|
|
|
93
111
|
test("a terminal editor that makes no change never opens the gate", () => {
|
|
112
|
+
// Arrange
|
|
94
113
|
let asked = false;
|
|
114
|
+
|
|
115
|
+
// Act
|
|
95
116
|
const result = editInEditor("keep", "plan.md", {
|
|
96
117
|
editor: stubEditor("vi", "exit 0"),
|
|
97
118
|
now: instant(),
|
|
@@ -100,11 +121,14 @@ describe("editInEditor", () => {
|
|
|
100
121
|
return true;
|
|
101
122
|
},
|
|
102
123
|
});
|
|
124
|
+
|
|
125
|
+
// Assert
|
|
103
126
|
expect(asked).toBeFalse();
|
|
104
127
|
expect(result).toEqual({ content: "keep", changed: false });
|
|
105
128
|
});
|
|
106
129
|
|
|
107
130
|
test("a nonzero editor exit throws", () => {
|
|
131
|
+
// Assert
|
|
108
132
|
expect(() => editInEditor("x", "plan.md", { editor: stubEditor("fail.sh", "exit 3") })).toThrow("editor exited 3");
|
|
109
133
|
});
|
|
110
134
|
});
|
package/src/herdr.test.ts
CHANGED
|
@@ -15,6 +15,7 @@ afterEach(() => {
|
|
|
15
15
|
|
|
16
16
|
describe("focusHerdrPane", () => {
|
|
17
17
|
test("resolves the pane's tab and focuses it", () => {
|
|
18
|
+
// Arrange
|
|
18
19
|
const log = join(dir, "calls.log");
|
|
19
20
|
const stub = join(dir, "herdr");
|
|
20
21
|
writeFileSync(
|
|
@@ -25,6 +26,8 @@ if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
|
|
|
25
26
|
`,
|
|
26
27
|
);
|
|
27
28
|
Bun.spawnSync(["chmod", "+x", stub]);
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
28
31
|
expect(focusHerdrPane(stub, "w1:p1")).toBe(true);
|
|
29
32
|
const calls = readFileSync(log, "utf8").trim().split("\n");
|
|
30
33
|
expect(calls[0]).toBe("pane get w1:p1");
|
|
@@ -32,6 +35,7 @@ if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
|
|
|
32
35
|
});
|
|
33
36
|
|
|
34
37
|
test("a missing binary never throws", () => {
|
|
38
|
+
// Assert
|
|
35
39
|
expect(focusHerdrPane(join(dir, "nope"), "w1:p1")).toBe(false);
|
|
36
40
|
});
|
|
37
41
|
});
|
package/src/index.ts
CHANGED
package/src/integrations.test.ts
CHANGED
|
@@ -9,20 +9,32 @@ function integrations(overrides: Partial<typeof OBSIDIAN_DEFAULTS> = {}): Integr
|
|
|
9
9
|
|
|
10
10
|
describe("loadBundledExporters", () => {
|
|
11
11
|
test("composes the obsidian integration into one named exporter", async () => {
|
|
12
|
+
// Act
|
|
12
13
|
const exporters = await loadBundledExporters(integrations());
|
|
14
|
+
|
|
15
|
+
// Assert
|
|
13
16
|
expect(exporters.map((exporter) => exporter.name)).toEqual(["obsidian"]);
|
|
14
17
|
});
|
|
15
18
|
|
|
16
19
|
test("runsOn mirrors the configured export policy", async () => {
|
|
20
|
+
// Arrange
|
|
17
21
|
const onApprove = (await loadBundledExporters(integrations({ exportOn: "approve" })))[0]!;
|
|
22
|
+
|
|
23
|
+
// Assert
|
|
18
24
|
expect(onApprove.runsOn("approve")).toBeTrue();
|
|
19
25
|
expect(onApprove.runsOn("request_changes")).toBeFalse();
|
|
20
26
|
|
|
27
|
+
// Arrange
|
|
21
28
|
const onResolve = (await loadBundledExporters(integrations({ exportOn: "resolve" })))[0]!;
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
22
31
|
expect(onResolve.runsOn("approve")).toBeTrue();
|
|
23
32
|
expect(onResolve.runsOn("request_changes")).toBeTrue();
|
|
24
33
|
|
|
34
|
+
// Arrange
|
|
25
35
|
const manual = (await loadBundledExporters(integrations({ exportOn: "manual" })))[0]!;
|
|
36
|
+
|
|
37
|
+
// Assert
|
|
26
38
|
expect(manual.runsOn("approve")).toBeFalse();
|
|
27
39
|
expect(manual.runsOn("request_changes")).toBeFalse();
|
|
28
40
|
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The intent reducer in isolation: with the App component's dependencies
|
|
3
|
+
* mocked, each Intent maps to exactly the controller call or state update it
|
|
4
|
+
* should - the navigation clamps, the annotation wrap-around, the verdict
|
|
5
|
+
* cycle, and the submit-reveal fix, none of which needed a rendered TUI.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
9
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
10
|
+
import { createIntentDispatch, type IntentDispatchDeps } from "./intent-dispatch";
|
|
11
|
+
import type { DisplayBlock } from "./view-plan";
|
|
12
|
+
|
|
13
|
+
function block(text: string): DisplayBlock {
|
|
14
|
+
return { text, kind: "text", work: true } as unknown as DisplayBlock;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sessionWith(annotationIds: string[]): ReviewSession {
|
|
18
|
+
return {
|
|
19
|
+
annotations: annotationIds.map((id) => ({ id })),
|
|
20
|
+
workingCopy: undefined,
|
|
21
|
+
} as unknown as ReviewSession;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A deps bag where every effect is a mock and every read has a plain default. */
|
|
25
|
+
function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
|
|
26
|
+
const controller = {
|
|
27
|
+
setStatus: mock(),
|
|
28
|
+
open: mock(),
|
|
29
|
+
cut: mock(),
|
|
30
|
+
annotate: mock(),
|
|
31
|
+
updateAnnotation: mock(),
|
|
32
|
+
removeAnnotation: mock(),
|
|
33
|
+
walkStart: mock(),
|
|
34
|
+
walkForward: mock(),
|
|
35
|
+
walkBack: mock(),
|
|
36
|
+
walkLeave: mock(),
|
|
37
|
+
submit: mock(),
|
|
38
|
+
share: mock(),
|
|
39
|
+
finishReview: mock(),
|
|
40
|
+
optInAutoClose: mock(),
|
|
41
|
+
dismissCompletion: mock(),
|
|
42
|
+
saveReviewPanel: mock(),
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
controller: controller as unknown as IntentDispatchDeps["controller"],
|
|
46
|
+
onExit: mock(),
|
|
47
|
+
isDiff: false,
|
|
48
|
+
display: [],
|
|
49
|
+
rows: [],
|
|
50
|
+
cursor: 0,
|
|
51
|
+
inbox: null,
|
|
52
|
+
inboxCursor: 0,
|
|
53
|
+
mode: { type: "normal" },
|
|
54
|
+
session: null,
|
|
55
|
+
reviewMode: "expanded",
|
|
56
|
+
reviewWidth: 34,
|
|
57
|
+
terminalWidth: 120,
|
|
58
|
+
focusedAnnotationId: undefined,
|
|
59
|
+
liveInput: { current: "" },
|
|
60
|
+
reviewWidthRef: { current: 34 },
|
|
61
|
+
planSheetRef: { current: null },
|
|
62
|
+
setCursor: mock(),
|
|
63
|
+
setInboxCursor: mock(),
|
|
64
|
+
setMode: mock(),
|
|
65
|
+
setReviewMode: mock(),
|
|
66
|
+
setReviewWidth: mock(),
|
|
67
|
+
setRailTab: mock(),
|
|
68
|
+
setFocusedAnnotationId: mock(),
|
|
69
|
+
setPulsedAnnotationId: mock(),
|
|
70
|
+
selectCardFromDocument: mock(),
|
|
71
|
+
runEditorHandOff: mock(),
|
|
72
|
+
openCardEdit: mock(),
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("move", () => {
|
|
78
|
+
test("down clamps at the last row and up clamps at the first", () => {
|
|
79
|
+
// Arrange
|
|
80
|
+
const deps = makeDeps({ display: [block("a"), block("b"), block("c")] });
|
|
81
|
+
const dispatch = createIntentDispatch(deps);
|
|
82
|
+
|
|
83
|
+
// Act
|
|
84
|
+
dispatch({ type: "move", to: "down" });
|
|
85
|
+
|
|
86
|
+
// Assert
|
|
87
|
+
const advance = (deps.setCursor as ReturnType<typeof mock>).mock.calls[0]![0] as (current: number) => number;
|
|
88
|
+
expect(advance(2)).toBe(2); // already at the end, stays
|
|
89
|
+
expect(advance(0)).toBe(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("bottom jumps to the last navigable row", () => {
|
|
93
|
+
// Arrange
|
|
94
|
+
const deps = makeDeps({ display: [block("a"), block("b"), block("c")] });
|
|
95
|
+
const dispatch = createIntentDispatch(deps);
|
|
96
|
+
|
|
97
|
+
// Act
|
|
98
|
+
dispatch({ type: "move", to: "bottom" });
|
|
99
|
+
|
|
100
|
+
// Assert
|
|
101
|
+
expect(deps.setCursor).toHaveBeenCalledWith(2);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("annotation navigation", () => {
|
|
106
|
+
test("nextAnnotation wraps from the last annotation back to the first", () => {
|
|
107
|
+
// Arrange
|
|
108
|
+
const deps = makeDeps({ session: sessionWith(["a1", "a2", "a3"]), focusedAnnotationId: "a3" });
|
|
109
|
+
const dispatch = createIntentDispatch(deps);
|
|
110
|
+
|
|
111
|
+
// Act
|
|
112
|
+
dispatch({ type: "nextAnnotation" });
|
|
113
|
+
|
|
114
|
+
// Assert
|
|
115
|
+
expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a1");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("prevAnnotation wraps from the first annotation to the last", () => {
|
|
119
|
+
// Arrange
|
|
120
|
+
const deps = makeDeps({ session: sessionWith(["a1", "a2", "a3"]), focusedAnnotationId: "a1" });
|
|
121
|
+
const dispatch = createIntentDispatch(deps);
|
|
122
|
+
|
|
123
|
+
// Act
|
|
124
|
+
dispatch({ type: "prevAnnotation" });
|
|
125
|
+
|
|
126
|
+
// Assert
|
|
127
|
+
expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a3");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("cycling skips annotations a revision already addressed", () => {
|
|
131
|
+
// Arrange: a2 is addressed, so next from a1 lands on a3
|
|
132
|
+
const session = sessionWith(["a1", "a2", "a3"]);
|
|
133
|
+
(session.annotations[1] as { resolution?: object }).resolution = { revision: 2, source: "agent" };
|
|
134
|
+
const deps = makeDeps({ session, focusedAnnotationId: "a1" });
|
|
135
|
+
const dispatch = createIntentDispatch(deps);
|
|
136
|
+
|
|
137
|
+
// Act
|
|
138
|
+
dispatch({ type: "nextAnnotation" });
|
|
139
|
+
|
|
140
|
+
// Assert
|
|
141
|
+
expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a3");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("cycleVerdict", () => {
|
|
146
|
+
test("advances through the verdict list in the submit overlay", () => {
|
|
147
|
+
// Arrange
|
|
148
|
+
const deps = makeDeps({ mode: { type: "submit", verdict: "approve", summary: "" } });
|
|
149
|
+
const dispatch = createIntentDispatch(deps);
|
|
150
|
+
|
|
151
|
+
// Act
|
|
152
|
+
dispatch({ type: "cycleVerdict", direction: 1 });
|
|
153
|
+
|
|
154
|
+
// Assert
|
|
155
|
+
expect(deps.setMode).toHaveBeenCalledWith({ type: "submit", verdict: "request_changes", summary: "" });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("openSubmit", () => {
|
|
160
|
+
test("force-opens the review rail so the confirm card can never be hidden", () => {
|
|
161
|
+
// Arrange
|
|
162
|
+
const deps = makeDeps({ session: sessionWith([]), reviewMode: "hidden" });
|
|
163
|
+
const dispatch = createIntentDispatch(deps);
|
|
164
|
+
|
|
165
|
+
// Act
|
|
166
|
+
dispatch({ type: "openSubmit" });
|
|
167
|
+
|
|
168
|
+
// Assert
|
|
169
|
+
expect(deps.setReviewMode).toHaveBeenCalledWith("expanded");
|
|
170
|
+
expect(deps.setRailTab).toHaveBeenCalledWith("review");
|
|
171
|
+
expect(deps.setMode).toHaveBeenCalledWith({ type: "submit", verdict: "approve", summary: "" });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("does nothing without a session", () => {
|
|
175
|
+
// Arrange
|
|
176
|
+
const deps = makeDeps({ session: null });
|
|
177
|
+
const dispatch = createIntentDispatch(deps);
|
|
178
|
+
|
|
179
|
+
// Act
|
|
180
|
+
dispatch({ type: "openSubmit" });
|
|
181
|
+
|
|
182
|
+
// Assert
|
|
183
|
+
expect(deps.setMode).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("share", () => {
|
|
188
|
+
test("dispatches to the controller's share", () => {
|
|
189
|
+
// Arrange
|
|
190
|
+
const deps = makeDeps();
|
|
191
|
+
const dispatch = createIntentDispatch(deps);
|
|
192
|
+
|
|
193
|
+
// Act
|
|
194
|
+
dispatch({ type: "share" });
|
|
195
|
+
|
|
196
|
+
// Assert
|
|
197
|
+
expect(deps.controller.share).toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("exit", () => {
|
|
202
|
+
test("calls the exit hook with 0", () => {
|
|
203
|
+
// Arrange
|
|
204
|
+
const deps = makeDeps();
|
|
205
|
+
const dispatch = createIntentDispatch(deps);
|
|
206
|
+
|
|
207
|
+
// Act
|
|
208
|
+
dispatch({ type: "exit" });
|
|
209
|
+
|
|
210
|
+
// Assert
|
|
211
|
+
expect(deps.onExit).toHaveBeenCalledWith(0);
|
|
212
|
+
});
|
|
213
|
+
});
|