revspec 0.1.0

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.
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ isValidStatus,
4
+ isValidThread,
5
+ isValidReviewFile,
6
+ } from "../../src/protocol/types";
7
+
8
+ describe("isValidStatus", () => {
9
+ it("accepts open", () => expect(isValidStatus("open")).toBe(true));
10
+ it("accepts pending", () => expect(isValidStatus("pending")).toBe(true));
11
+ it("accepts resolved", () => expect(isValidStatus("resolved")).toBe(true));
12
+ it("accepts outdated", () => expect(isValidStatus("outdated")).toBe(true));
13
+ it("rejects addressed", () => expect(isValidStatus("addressed")).toBe(false));
14
+ it("rejects empty string", () => expect(isValidStatus("")).toBe(false));
15
+ it("rejects OPEN (uppercase)", () => expect(isValidStatus("OPEN")).toBe(false));
16
+ });
17
+
18
+ describe("isValidThread", () => {
19
+ it("accepts a minimal valid thread", () => {
20
+ expect(
21
+ isValidThread({
22
+ id: "t1",
23
+ line: 5,
24
+ status: "open",
25
+ messages: [{ author: "human", text: "hello" }],
26
+ })
27
+ ).toBe(true);
28
+ });
29
+
30
+ it("rejects thread missing line", () => {
31
+ expect(
32
+ isValidThread({
33
+ id: "t1",
34
+ status: "open",
35
+ messages: [],
36
+ })
37
+ ).toBe(false);
38
+ });
39
+
40
+ it("rejects thread missing messages", () => {
41
+ expect(
42
+ isValidThread({
43
+ id: "t1",
44
+ line: 3,
45
+ status: "open",
46
+ })
47
+ ).toBe(false);
48
+ });
49
+
50
+ it("rejects thread with invalid status", () => {
51
+ expect(
52
+ isValidThread({
53
+ id: "t1",
54
+ line: 3,
55
+ status: "addressed",
56
+ messages: [],
57
+ })
58
+ ).toBe(false);
59
+ });
60
+ });
61
+
62
+ describe("isValidReviewFile", () => {
63
+ it("accepts a valid review file", () => {
64
+ expect(
65
+ isValidReviewFile({
66
+ file: "spec.md",
67
+ threads: [
68
+ {
69
+ id: "t1",
70
+ line: 1,
71
+ status: "open",
72
+ messages: [{ author: "human", text: "comment" }],
73
+ },
74
+ ],
75
+ })
76
+ ).toBe(true);
77
+ });
78
+
79
+ it("accepts a review file with empty threads array", () => {
80
+ expect(
81
+ isValidReviewFile({
82
+ file: "spec.md",
83
+ threads: [],
84
+ })
85
+ ).toBe(true);
86
+ });
87
+
88
+ it("rejects a review file missing the file field", () => {
89
+ expect(
90
+ isValidReviewFile({
91
+ threads: [],
92
+ })
93
+ ).toBe(false);
94
+ });
95
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { mkdtempSync, rmSync, readFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { writeReviewFile, writeDraftFile } from "../../src/protocol/write";
5
+ import type { ReviewFile, DraftFile } from "../../src/protocol/types";
6
+
7
+ function tmpDir() {
8
+ return mkdtempSync("/tmp/revspec-test-");
9
+ }
10
+
11
+ describe("writeReviewFile", () => {
12
+ it("writes valid JSON that can be parsed back to ReviewFile", () => {
13
+ const dir = tmpDir();
14
+ const filePath = join(dir, "review.json");
15
+ const data: ReviewFile = {
16
+ file: "spec.md",
17
+ threads: [
18
+ {
19
+ id: "t1",
20
+ line: 3,
21
+ status: "open",
22
+ messages: [{ author: "human", text: "Is this right?" }],
23
+ },
24
+ ],
25
+ };
26
+ writeReviewFile(filePath, data);
27
+ const raw = readFileSync(filePath, "utf8");
28
+ expect(JSON.parse(raw)).toEqual(data);
29
+ rmSync(dir, { recursive: true });
30
+ });
31
+
32
+ it("writes pretty-printed JSON (contains newlines)", () => {
33
+ const dir = tmpDir();
34
+ const filePath = join(dir, "review.json");
35
+ const data: ReviewFile = { file: "spec.md", threads: [] };
36
+ writeReviewFile(filePath, data);
37
+ const raw = readFileSync(filePath, "utf8");
38
+ expect(raw).toContain("\n");
39
+ rmSync(dir, { recursive: true });
40
+ });
41
+ });
42
+
43
+ describe("writeDraftFile", () => {
44
+ it("writes an approval draft", () => {
45
+ const dir = tmpDir();
46
+ const filePath = join(dir, "draft.json");
47
+ const data: DraftFile = { approved: true };
48
+ writeDraftFile(filePath, data);
49
+ const raw = readFileSync(filePath, "utf8");
50
+ expect(JSON.parse(raw)).toEqual(data);
51
+ rmSync(dir, { recursive: true });
52
+ });
53
+
54
+ it("writes a threads draft", () => {
55
+ const dir = tmpDir();
56
+ const filePath = join(dir, "draft.json");
57
+ const data: DraftFile = {
58
+ threads: [
59
+ {
60
+ id: "t2",
61
+ line: 7,
62
+ status: "resolved",
63
+ messages: [{ author: "ai", text: "Fixed." }],
64
+ },
65
+ ],
66
+ };
67
+ writeDraftFile(filePath, data);
68
+ const raw = readFileSync(filePath, "utf8");
69
+ expect(JSON.parse(raw)).toEqual(data);
70
+ rmSync(dir, { recursive: true });
71
+ });
72
+ });
@@ -0,0 +1,326 @@
1
+ import { describe, expect, it, beforeEach } from "bun:test";
2
+ import { ReviewState } from "../../src/state/review-state";
3
+ import type { Thread } from "../../src/protocol/types";
4
+
5
+ const SPEC = ["line one", "line two", "line three", "line four", "line five"];
6
+
7
+ function makeThread(
8
+ id: string,
9
+ line: number,
10
+ status: Thread["status"],
11
+ messages: Thread["messages"] = [{ author: "human", text: "comment" }]
12
+ ): Thread {
13
+ return { id, line, status, messages };
14
+ }
15
+
16
+ describe("ReviewState", () => {
17
+ describe("initializes with spec lines and no threads", () => {
18
+ it("sets lineCount from specLines", () => {
19
+ const state = new ReviewState(SPEC, []);
20
+ expect(state.lineCount).toBe(5);
21
+ });
22
+
23
+ it("starts cursor at line 1", () => {
24
+ const state = new ReviewState(SPEC, []);
25
+ expect(state.cursorLine).toBe(1);
26
+ });
27
+
28
+ it("starts with empty threads array", () => {
29
+ const state = new ReviewState(SPEC, []);
30
+ expect(state.threads).toHaveLength(0);
31
+ });
32
+ });
33
+
34
+ describe("addComment", () => {
35
+ it("adds a new thread with status open", () => {
36
+ const state = new ReviewState(SPEC, []);
37
+ state.addComment(3, "needs clarification");
38
+ expect(state.threads).toHaveLength(1);
39
+ expect(state.threads[0].line).toBe(3);
40
+ expect(state.threads[0].status).toBe("open");
41
+ expect(state.threads[0].messages).toHaveLength(1);
42
+ expect(state.threads[0].messages[0]).toEqual({
43
+ author: "human",
44
+ text: "needs clarification",
45
+ });
46
+ });
47
+
48
+ it("assigns auto-incremented id", () => {
49
+ const state = new ReviewState(SPEC, [makeThread("t3", 1, "open")]);
50
+ state.addComment(2, "another comment");
51
+ expect(state.threads[1].id).toBe("t4");
52
+ });
53
+ });
54
+
55
+ describe("replyToThread", () => {
56
+ it("appends human message to existing thread", () => {
57
+ const state = new ReviewState(SPEC, [makeThread("t1", 2, "pending")]);
58
+ state.replyToThread("t1", "my reply");
59
+ expect(state.threads[0].messages).toHaveLength(2);
60
+ expect(state.threads[0].messages[1]).toEqual({
61
+ author: "human",
62
+ text: "my reply",
63
+ });
64
+ });
65
+
66
+ it("flips status to open", () => {
67
+ const state = new ReviewState(SPEC, [makeThread("t1", 2, "pending")]);
68
+ state.replyToThread("t1", "my reply");
69
+ expect(state.threads[0].status).toBe("open");
70
+ });
71
+
72
+ it("does nothing for unknown threadId", () => {
73
+ const state = new ReviewState(SPEC, [makeThread("t1", 2, "pending")]);
74
+ state.replyToThread("t99", "ghost reply");
75
+ expect(state.threads[0].messages).toHaveLength(1);
76
+ });
77
+ });
78
+
79
+ describe("resolveThread", () => {
80
+ it("sets thread status to resolved", () => {
81
+ const state = new ReviewState(SPEC, [makeThread("t1", 1, "open")]);
82
+ state.resolveThread("t1");
83
+ expect(state.threads[0].status).toBe("resolved");
84
+ });
85
+
86
+ it("toggles resolved thread back to open", () => {
87
+ const state = new ReviewState(SPEC, [makeThread("t1", 1, "resolved")]);
88
+ state.resolveThread("t1");
89
+ expect(state.threads[0].status).toBe("open");
90
+ });
91
+
92
+ it("does nothing for unknown threadId", () => {
93
+ const state = new ReviewState(SPEC, [makeThread("t1", 1, "open")]);
94
+ state.resolveThread("t99");
95
+ expect(state.threads[0].status).toBe("open");
96
+ });
97
+ });
98
+
99
+ describe("resolveAllPending", () => {
100
+ it("resolves all pending threads", () => {
101
+ const state = new ReviewState(SPEC, [
102
+ makeThread("t1", 1, "pending"),
103
+ makeThread("t2", 2, "pending"),
104
+ makeThread("t3", 3, "open"),
105
+ ]);
106
+ state.resolveAllPending();
107
+ expect(state.threads[0].status).toBe("resolved");
108
+ expect(state.threads[1].status).toBe("resolved");
109
+ });
110
+
111
+ it("leaves open threads unchanged", () => {
112
+ const state = new ReviewState(SPEC, [
113
+ makeThread("t1", 1, "pending"),
114
+ makeThread("t2", 2, "open"),
115
+ ]);
116
+ state.resolveAllPending();
117
+ expect(state.threads[1].status).toBe("open");
118
+ });
119
+ });
120
+
121
+ describe("threadAtLine", () => {
122
+ it("returns thread when found", () => {
123
+ const thread = makeThread("t1", 3, "open");
124
+ const state = new ReviewState(SPEC, [thread]);
125
+ expect(state.threadAtLine(3)).toEqual(thread);
126
+ });
127
+
128
+ it("returns null when not found", () => {
129
+ const state = new ReviewState(SPEC, [makeThread("t1", 3, "open")]);
130
+ expect(state.threadAtLine(5)).toBeNull();
131
+ });
132
+ });
133
+
134
+ describe("nextActiveThread", () => {
135
+ it("returns next open/pending thread line after cursor", () => {
136
+ const state = new ReviewState(SPEC, [
137
+ makeThread("t1", 2, "open"),
138
+ makeThread("t2", 4, "pending"),
139
+ ]);
140
+ state.cursorLine = 3;
141
+ expect(state.nextActiveThread()).toBe(4);
142
+ });
143
+
144
+ it("wraps around to first active thread when none after cursor", () => {
145
+ const state = new ReviewState(SPEC, [
146
+ makeThread("t1", 1, "open"),
147
+ makeThread("t2", 3, "pending"),
148
+ ]);
149
+ state.cursorLine = 4;
150
+ expect(state.nextActiveThread()).toBe(1);
151
+ });
152
+
153
+ it("skips resolved threads", () => {
154
+ const state = new ReviewState(SPEC, [
155
+ makeThread("t1", 2, "resolved"),
156
+ makeThread("t2", 4, "open"),
157
+ ]);
158
+ state.cursorLine = 1;
159
+ expect(state.nextActiveThread()).toBe(4);
160
+ });
161
+
162
+ it("returns null when no active threads", () => {
163
+ const state = new ReviewState(SPEC, [
164
+ makeThread("t1", 2, "resolved"),
165
+ makeThread("t2", 4, "outdated"),
166
+ ]);
167
+ expect(state.nextActiveThread()).toBeNull();
168
+ });
169
+ });
170
+
171
+ describe("prevActiveThread", () => {
172
+ it("returns previous open/pending thread line before cursor", () => {
173
+ const state = new ReviewState(SPEC, [
174
+ makeThread("t1", 1, "open"),
175
+ makeThread("t2", 3, "pending"),
176
+ ]);
177
+ state.cursorLine = 4;
178
+ expect(state.prevActiveThread()).toBe(3);
179
+ });
180
+
181
+ it("wraps around to last active thread when none before cursor", () => {
182
+ const state = new ReviewState(SPEC, [
183
+ makeThread("t1", 2, "open"),
184
+ makeThread("t2", 4, "pending"),
185
+ ]);
186
+ state.cursorLine = 1;
187
+ expect(state.prevActiveThread()).toBe(4);
188
+ });
189
+
190
+ it("skips resolved threads", () => {
191
+ const state = new ReviewState(SPEC, [
192
+ makeThread("t1", 1, "open"),
193
+ makeThread("t2", 3, "resolved"),
194
+ ]);
195
+ state.cursorLine = 5;
196
+ expect(state.prevActiveThread()).toBe(1);
197
+ });
198
+
199
+ it("returns null when no active threads", () => {
200
+ const state = new ReviewState(SPEC, [makeThread("t1", 2, "resolved")]);
201
+ expect(state.prevActiveThread()).toBeNull();
202
+ });
203
+ });
204
+
205
+ describe("canApprove", () => {
206
+ it("returns true when all threads are resolved", () => {
207
+ const state = new ReviewState(SPEC, [
208
+ makeThread("t1", 1, "resolved"),
209
+ makeThread("t2", 2, "resolved"),
210
+ ]);
211
+ expect(state.canApprove()).toBe(true);
212
+ });
213
+
214
+ it("returns true when threads are resolved or outdated", () => {
215
+ const state = new ReviewState(SPEC, [
216
+ makeThread("t1", 1, "resolved"),
217
+ makeThread("t2", 2, "outdated"),
218
+ ]);
219
+ expect(state.canApprove()).toBe(true);
220
+ });
221
+
222
+ it("returns false when any thread is open", () => {
223
+ const state = new ReviewState(SPEC, [
224
+ makeThread("t1", 1, "resolved"),
225
+ makeThread("t2", 2, "open"),
226
+ ]);
227
+ expect(state.canApprove()).toBe(false);
228
+ });
229
+
230
+ it("returns false when any thread is pending", () => {
231
+ const state = new ReviewState(SPEC, [
232
+ makeThread("t1", 1, "resolved"),
233
+ makeThread("t2", 2, "pending"),
234
+ ]);
235
+ expect(state.canApprove()).toBe(false);
236
+ });
237
+
238
+ it("returns false when there are no threads", () => {
239
+ const state = new ReviewState(SPEC, []);
240
+ expect(state.canApprove()).toBe(false);
241
+ });
242
+ });
243
+
244
+ describe("nextThreadId", () => {
245
+ it("returns t1 when no threads exist", () => {
246
+ const state = new ReviewState(SPEC, []);
247
+ expect(state.nextThreadId()).toBe("t1");
248
+ });
249
+
250
+ it("increments from the highest existing id", () => {
251
+ const state = new ReviewState(SPEC, [
252
+ makeThread("t3", 1, "open"),
253
+ makeThread("t1", 2, "open"),
254
+ makeThread("t7", 3, "open"),
255
+ ]);
256
+ expect(state.nextThreadId()).toBe("t8");
257
+ });
258
+ });
259
+
260
+ describe("deleteLastDraftMessage", () => {
261
+ it("removes the last human message from the thread", () => {
262
+ const state = new ReviewState(SPEC, [
263
+ {
264
+ id: "t1",
265
+ line: 1,
266
+ status: "open",
267
+ messages: [
268
+ { author: "ai", text: "AI response" },
269
+ { author: "human", text: "my draft" },
270
+ ],
271
+ },
272
+ ]);
273
+ state.deleteLastDraftMessage("t1");
274
+ expect(state.threads[0].messages).toHaveLength(1);
275
+ expect(state.threads[0].messages[0].author).toBe("ai");
276
+ });
277
+
278
+ it("removes the thread entirely when it becomes empty", () => {
279
+ const state = new ReviewState(SPEC, [
280
+ {
281
+ id: "t1",
282
+ line: 1,
283
+ status: "open",
284
+ messages: [{ author: "human", text: "only message" }],
285
+ },
286
+ ]);
287
+ state.deleteLastDraftMessage("t1");
288
+ expect(state.threads).toHaveLength(0);
289
+ });
290
+
291
+ it("does nothing for unknown threadId", () => {
292
+ const state = new ReviewState(SPEC, [makeThread("t1", 1, "open")]);
293
+ state.deleteLastDraftMessage("t99");
294
+ expect(state.threads).toHaveLength(1);
295
+ expect(state.threads[0].messages).toHaveLength(1);
296
+ });
297
+ });
298
+
299
+ describe("toDraft", () => {
300
+ it("serializes current threads", () => {
301
+ const threads = [makeThread("t1", 2, "open")];
302
+ const state = new ReviewState(SPEC, threads);
303
+ expect(state.toDraft()).toEqual({ threads });
304
+ });
305
+ });
306
+
307
+ describe("activeThreadCount", () => {
308
+ it("counts open and pending threads separately", () => {
309
+ const state = new ReviewState(SPEC, [
310
+ makeThread("t1", 1, "open"),
311
+ makeThread("t2", 2, "pending"),
312
+ makeThread("t3", 3, "open"),
313
+ makeThread("t4", 4, "resolved"),
314
+ ]);
315
+ expect(state.activeThreadCount()).toEqual({ open: 2, pending: 1 });
316
+ });
317
+
318
+ it("returns zeros when no active threads", () => {
319
+ const state = new ReviewState(SPEC, [
320
+ makeThread("t1", 1, "resolved"),
321
+ makeThread("t2", 2, "outdated"),
322
+ ]);
323
+ expect(state.activeThreadCount()).toEqual({ open: 0, pending: 0 });
324
+ });
325
+ });
326
+ });
@@ -0,0 +1,184 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { buildPagerContent } from "../../src/tui/pager";
3
+ import { ReviewState } from "../../src/state/review-state";
4
+ import type { Thread } from "../../src/protocol/types";
5
+
6
+ const SPEC = ["# Title", "Some text", "More text", "Final line"];
7
+
8
+ function makeThread(
9
+ id: string,
10
+ line: number,
11
+ status: Thread["status"],
12
+ messages: Thread["messages"] = [{ author: "human", text: "comment" }]
13
+ ): Thread {
14
+ return { id, line, status, messages };
15
+ }
16
+
17
+ describe("buildPagerContent", () => {
18
+ it("renders lines with line numbers", () => {
19
+ const state = new ReviewState(SPEC, []);
20
+ const content = buildPagerContent(state);
21
+ const lines = content.split("\n");
22
+
23
+ expect(lines).toHaveLength(4);
24
+ // Line 1 is the cursor line, gets ">" prefix
25
+ expect(lines[0]).toContain(" 1");
26
+ expect(lines[0]).toContain("# Title");
27
+ // Other lines get " " prefix
28
+ expect(lines[1]).toContain(" 2");
29
+ expect(lines[1]).toContain("Some text");
30
+ expect(lines[2]).toContain(" 3");
31
+ expect(lines[3]).toContain(" 4");
32
+ });
33
+
34
+ it("marks cursor line with > prefix", () => {
35
+ const state = new ReviewState(SPEC, []);
36
+ state.cursorLine = 3;
37
+ const content = buildPagerContent(state);
38
+ const lines = content.split("\n");
39
+
40
+ // Line 3 (index 2) should have ">" prefix
41
+ expect(lines[2]).toMatch(/^>/);
42
+ // Other lines should have " " prefix
43
+ expect(lines[0]).toMatch(/^ /);
44
+ expect(lines[1]).toMatch(/^ /);
45
+ expect(lines[3]).toMatch(/^ /);
46
+ });
47
+
48
+ it("shows status indicator for open threads", () => {
49
+ const state = new ReviewState(SPEC, [makeThread("t1", 2, "open")]);
50
+ const content = buildPagerContent(state);
51
+ const lines = content.split("\n");
52
+
53
+ // Line 2 should contain the open icon (*)
54
+ expect(lines[1]).toContain("*");
55
+ });
56
+
57
+ it("shows status indicator for pending threads", () => {
58
+ const state = new ReviewState(SPEC, [makeThread("t1", 2, "pending")]);
59
+ const content = buildPagerContent(state);
60
+ const lines = content.split("\n");
61
+
62
+ expect(lines[1]).toContain("~");
63
+ });
64
+
65
+ it("shows status indicator for resolved threads", () => {
66
+ const state = new ReviewState(SPEC, [makeThread("t1", 2, "resolved")]);
67
+ const content = buildPagerContent(state);
68
+ const lines = content.split("\n");
69
+
70
+ expect(lines[1]).toContain("\u2714");
71
+ });
72
+
73
+ it("shows status indicator for outdated threads", () => {
74
+ const state = new ReviewState(SPEC, [makeThread("t1", 2, "outdated")]);
75
+ const content = buildPagerContent(state);
76
+ const lines = content.split("\n");
77
+
78
+ expect(lines[1]).toContain("\u26A0");
79
+ });
80
+
81
+ it("shows different icons for different statuses", () => {
82
+ const state = new ReviewState(SPEC, [
83
+ makeThread("t1", 1, "open"),
84
+ makeThread("t2", 2, "pending"),
85
+ makeThread("t3", 3, "resolved"),
86
+ makeThread("t4", 4, "outdated"),
87
+ ]);
88
+ const content = buildPagerContent(state);
89
+ const lines = content.split("\n");
90
+
91
+ expect(lines[0]).toContain("*");
92
+ expect(lines[1]).toContain("~");
93
+ expect(lines[2]).toContain("\u2714");
94
+ expect(lines[3]).toContain("\u26A0");
95
+ });
96
+
97
+ it("shows thread hint with latest message text", () => {
98
+ const state = new ReviewState(SPEC, [
99
+ makeThread("t1", 2, "open", [
100
+ { author: "ai", text: "AI said something" },
101
+ { author: "human", text: "My response" },
102
+ ]),
103
+ ]);
104
+ const content = buildPagerContent(state);
105
+ const lines = content.split("\n");
106
+
107
+ expect(lines[1]).toContain("My response");
108
+ });
109
+
110
+ it("truncates long comment text to 40 chars", () => {
111
+ const longText =
112
+ "This is a very long comment that should be truncated because it exceeds the maximum hint length";
113
+ const state = new ReviewState(SPEC, [
114
+ makeThread("t1", 2, "open", [{ author: "human", text: longText }]),
115
+ ]);
116
+ const content = buildPagerContent(state);
117
+ const lines = content.split("\n");
118
+
119
+ // Should be truncated to 39 chars + ellipsis
120
+ expect(lines[1]).toContain("\u2026");
121
+ // The full text should NOT appear
122
+ expect(lines[1]).not.toContain(longText);
123
+ });
124
+
125
+ it("does not show status indicator for lines without threads", () => {
126
+ const state = new ReviewState(SPEC, [makeThread("t1", 2, "open")]);
127
+ const content = buildPagerContent(state);
128
+ const lines = content.split("\n");
129
+
130
+ // Line 1 (no thread) should not contain any status icons
131
+ expect(lines[0]).not.toContain("\u{1F4AC}");
132
+ expect(lines[0]).not.toContain("\u{1F535}");
133
+ expect(lines[0]).not.toContain("\u2714");
134
+ expect(lines[0]).not.toContain("\u26A0");
135
+ });
136
+
137
+ it("pads line numbers to 4 characters", () => {
138
+ const state = new ReviewState(SPEC, []);
139
+ const content = buildPagerContent(state);
140
+ const lines = content.split("\n");
141
+
142
+ // Line numbers are wrapped in ANSI gray codes, so check the raw number is present
143
+ expect(lines[0]).toContain(" 1");
144
+ expect(lines[0]).toContain("# Title");
145
+ });
146
+
147
+ it("handles empty spec", () => {
148
+ const state = new ReviewState([], []);
149
+ const content = buildPagerContent(state);
150
+ expect(content).toBe("");
151
+ });
152
+
153
+ it("highlights search matches with >> << markers", () => {
154
+ const state = new ReviewState(SPEC, []);
155
+ const content = buildPagerContent(state, "text");
156
+ const lines = content.split("\n");
157
+
158
+ // Lines 2 and 3 contain "text" — should be highlighted
159
+ expect(lines[1]).toContain("Some >>text<<");
160
+ expect(lines[2]).toContain("More >>text<<");
161
+ // Lines 1 and 4 do not contain "text"
162
+ expect(lines[0]).not.toContain(">>");
163
+ expect(lines[3]).not.toContain(">>");
164
+ });
165
+
166
+ it("highlights search matches case-insensitively", () => {
167
+ const state = new ReviewState(["Hello World", "hello again"], []);
168
+ const content = buildPagerContent(state, "hello");
169
+ const lines = content.split("\n");
170
+
171
+ expect(lines[0]).toContain(">>Hello<<");
172
+ expect(lines[1]).toContain(">>hello<<");
173
+ });
174
+
175
+ it("does not highlight when searchQuery is null", () => {
176
+ const state = new ReviewState(SPEC, []);
177
+ const content = buildPagerContent(state, null);
178
+ const lines = content.split("\n");
179
+
180
+ expect(lines[1]).toContain("Some text");
181
+ expect(lines[1]).not.toContain(">>");
182
+ });
183
+ });
184
+
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "target": "ESNext",
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": ".",
11
+ "types": ["bun-types"]
12
+ },
13
+ "include": ["src/**/*.ts", "bin/**/*.ts", "test/**/*.ts"]
14
+ }