@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.
@@ -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
@@ -52,6 +52,7 @@ export const DEFAULT_KEYS: Record<string, string[]> = {
52
52
  prev_annotation: ["p"],
53
53
  delete_annotation: ["backspace"],
54
54
  submit: ["return", "enter"],
55
+ share: ["S"],
55
56
  quit: ["q"],
56
57
  walk: ["w"],
57
58
  review_cycle: ["b"],
@@ -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
@@ -1,2 +1,4 @@
1
1
  export { runClient, type RunClientOptions } from "./run";
2
2
  export { serveClient, type ServeHandle, type ServeOptions } from "./serve";
3
+ export { App, type AppProps } from "./App";
4
+ export { publishShare, type ShareResult, type ShareTarget } from "./share";
@@ -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
+ });