[Markdown] Tools for working with agents.
1
;;; rail-tests.el --- End-to-end tests for the RAIL tools -*- lexical-binding: t; -*-
2
3
;;; Commentary:
4
5
;; These tests exercise the RAIL handler functions directly. The MCP
6
;; framework calls each handler with one alist of arguments, so a direct call
7
;; follows the same path as a tool call. Each test runs against a temporary
8
;; stream file, so no test touches the project stream file.
9
;;
10
;; Run the tests with the runner script in this directory:
11
;;
12
;; ./run-tests.sh
13
14
;;; Code:
15
16
(require 'ert)
17
(require 'json)
18
19
;;; Fixtures
20
21
(defvar rail-tests--preamble
22
(concat "#+TITLE: Test stream\n"
23
"#+TODO: TODO IN-PROGRESS TESTING TESTED BLOCKED | CANCELLED DONE\n"
24
"#+TAGS: [ Kind : feat fix refactor chore docs ]\n"
25
"#+TAGS: [ Scope : core app web ui ]\n"
26
"#+TAGS: [ Impact : minor major ]\n"
27
"\n"
28
"# Local Variables:\n"
29
"# org-log-done: time\n"
30
"# End:\n")
31
"Preamble of the temporary stream file.
32
The `#+TODO:' line gives the keyword sequence. The `#+TAGS:' lines give
33
the tag vocabulary, grouped into axes. The local variable
34
`org-log-done' makes Org write a CLOSED timestamp.")
35
36
(defun rail-tests--decode (json-string)
37
"Return JSON-STRING decoded into Lisp with alists for objects.
38
Decode JSON null to nil and JSON false to `:json-false', which are the
39
same sentinels that the handlers encode from. A round trip therefore
40
gives back the value that the handler started with."
41
(json-parse-string json-string
42
:object-type 'alist
43
:null-object nil
44
:false-object :json-false))
45
46
(defun rail-tests--call (handler args)
47
"Call HANDLER with ARGS and return the decoded result.
48
Signal an error when the handler reports one, so a failure is visible."
49
(let ((result (rail-tests--decode (funcall handler args))))
50
(when (alist-get 'error result)
51
(error "Handler failed: %s" (alist-get 'error result)))
52
result))
53
54
(defmacro rail-tests--with-stream (root &rest body)
55
"Create a temporary project directory, bind ROOT to it, then run BODY.
56
Delete the directory and its buffers after BODY."
57
(declare (indent 1))
58
`(let* ((,root (file-name-as-directory (make-temp-file "rail-test" t)))
59
(file (expand-file-name rail-stream-file-name ,root))
60
(enable-local-variables :all)
61
(org-id-track-globally nil)
62
(create-lockfiles nil))
63
(unwind-protect
64
(progn
65
(with-temp-file file (insert rail-tests--preamble))
66
,@body)
67
(dolist (buf (buffer-list))
68
(when (and (buffer-file-name buf)
69
(string-prefix-p ,root (buffer-file-name buf)))
70
(with-current-buffer buf (set-buffer-modified-p nil))
71
(kill-buffer buf)))
72
(delete-directory ,root t))))
73
74
(defun rail-tests--capture (root title tags &optional body)
75
"Capture a request in ROOT with TITLE, TAGS, and optional BODY.
76
Return the new entry's Org ID."
77
(alist-get 'id (rail-tests--call
78
#'rail-tools--capture-handler
79
(list (cons 'root root)
80
(cons 'title title)
81
(cons 'tags tags)
82
(cons 'body (or body "Request body."))))))
83
84
(defun rail-tests--file-text (root)
85
"Return the text of the stream file in ROOT."
86
(with-temp-buffer
87
(insert-file-contents (expand-file-name rail-stream-file-name root))
88
(buffer-string)))
89
90
;;; Root discovery
91
92
(ert-deftest rail-test-locate-root-finds-the-stream-file ()
93
"`rail-locate-root' finds the root from a nested directory."
94
(rail-tests--with-stream root
95
(let ((nested (expand-file-name "a/b/c/" root)))
96
(make-directory nested t)
97
(should (equal (rail-locate-root nested) root)))))
98
99
(ert-deftest rail-test-locate-root-returns-nil-without-a-stream-file ()
100
"`rail-locate-root' returns nil when no ancestor holds the file."
101
(let ((empty (file-name-as-directory (make-temp-file "rail-empty" t))))
102
(unwind-protect
103
(should (null (rail-locate-root empty)))
104
(delete-directory empty t))))
105
106
(ert-deftest rail-test-root-argument-overrides-the-default ()
107
"An explicit `root' argument selects the file, not `rail-project-root'."
108
(rail-tests--with-stream root
109
(should (equal (rail-tools--file (list (cons 'root root)))
110
(expand-file-name rail-stream-file-name root)))))
111
112
(ert-deftest rail-test-absent-root-uses-the-default ()
113
"An absent `root' argument falls back to `rail-project-root'."
114
(rail-tests--with-stream root
115
(let ((rail-project-root root))
116
(should (equal (rail-tools--file nil)
117
(expand-file-name rail-stream-file-name root))))))
118
119
(ert-deftest rail-test-a-missing-directory-signals-an-error ()
120
"A `root' that is not a directory signals an error."
121
(should-error (rail-tools--file
122
(list (cons 'root "/rail/no/such/directory")))))
123
124
;;; Capture
125
126
(ert-deftest rail-test-capture-creates-an-addressable-todo ()
127
"Capture writes a TODO entry with an ID, a SCHEDULED time, and tags."
128
(rail-tests--with-stream root
129
(let* ((id (rail-tests--capture root "Add a widget" ["feat" "web"]))
130
(entry (rail-tests--call #'rail-tools--show-handler
131
(list (cons 'root root) (cons 'id id)))))
132
(should (stringp id))
133
(should (equal (alist-get 'state entry) "TODO"))
134
(should (equal (alist-get 'heading entry) "Add a widget"))
135
(should (equal (append (alist-get 'tags entry) nil) '("feat" "web")))
136
(should (stringp (alist-get 'scheduled entry)))
137
(should (null (alist-get 'closed entry))))))
138
139
(ert-deftest rail-test-capture-puts-the-newest-request-first ()
140
"Capture inserts each new request above the previous request."
141
(rail-tests--with-stream root
142
(rail-tests--capture root "First request" ["feat" "core"])
143
(rail-tests--capture root "Second request" ["fix" "app"])
144
(let ((rows (rail-tests--decode
145
(rail-tools--list-handler (list (cons 'root root))))))
146
(should (equal (length rows) 2))
147
(should (equal (alist-get 'title (aref rows 0)) "Second request"))
148
(should (equal (alist-get 'title (aref rows 1)) "First request")))))
149
150
(ert-deftest rail-test-capture-rejects-a-tag-outside-the-vocabulary ()
151
"Capture rejects any tag that the closed vocabulary does not hold."
152
(rail-tests--with-stream root
153
(let ((result (rail-tests--decode
154
(rail-tools--capture-handler
155
(list (cons 'root root)
156
(cons 'title "Bad tags")
157
(cons 'tags ["feat" "trivial"]))))))
158
(should (string-match-p "Unknown tag" (alist-get 'error result))))))
159
160
;;; Status
161
162
(ert-deftest rail-test-set-status-moves-through-the-open-keywords ()
163
"Set-status accepts each open keyword from the file's own sequence."
164
(rail-tests--with-stream root
165
(let ((id (rail-tests--capture root "Track status" ["feat" "core"])))
166
(dolist (state '("IN-PROGRESS" "TESTING" "TESTED"))
167
(let ((result (rail-tests--call
168
#'rail-tools--set-status-handler
169
(list (cons 'root root) (cons 'id id)
170
(cons 'state state)))))
171
(should (equal (alist-get 'state result) state))))
172
(should (equal (alist-get 'state (rail-tests--call
173
#'rail-tools--verify-handler
174
(list (cons 'root root) (cons 'id id))))
175
"TESTED")))))
176
177
(ert-deftest rail-test-set-status-refuses-done ()
178
"Set-status refuses DONE, because completion needs result evidence."
179
(rail-tests--with-stream root
180
(let* ((id (rail-tests--capture root "Refuse done" ["feat" "core"]))
181
(result (rail-tests--decode
182
(rail-tools--set-status-handler
183
(list (cons 'root root) (cons 'id id)
184
(cons 'state "DONE"))))))
185
(should (string-match-p "rail-complete" (alist-get 'error result))))))
186
187
(ert-deftest rail-test-block-and-cancel-record-a-reason ()
188
"Block and cancel write the reason, so the decision is never silent."
189
(rail-tests--with-stream root
190
(let ((blocked (rail-tests--capture root "Blocked work" ["feat" "app"]))
191
(dropped (rail-tests--capture root "Dropped work" ["feat" "ui"])))
192
(rail-tests--call #'rail-tools--block-handler
193
(list (cons 'root root) (cons 'id blocked)
194
(cons 'reason "The route does not exist")))
195
(rail-tests--call #'rail-tools--cancel-handler
196
(list (cons 'root root) (cons 'id dropped)
197
(cons 'reason "The user withdrew the request")))
198
(let ((text (rail-tests--file-text root)))
199
(should (string-match-p
200
"- BLOCKED NOTE \\[.*\\] :: The route does not exist" text))
201
(should (string-match-p
202
"- CANCELLED NOTE \\[.*\\] :: The user withdrew" text))
203
;; The note lives in the LOGBOOK drawer, not as a body line.
204
(should-not (string-match-p "- blocked ::" text))
205
(should-not (string-match-p "- cancelled ::" text)))
206
(should (equal "BLOCKED"
207
(alist-get 'state (rail-tests--call
208
#'rail-tools--verify-handler
209
(list (cons 'root root)
210
(cons 'id blocked))))))
211
(should (equal "CANCELLED"
212
(alist-get 'state (rail-tests--call
213
#'rail-tools--verify-handler
214
(list (cons 'root root)
215
(cons 'id dropped)))))))))
216
217
(ert-deftest rail-test-block-requires-a-reason ()
218
"Block refuses a blank reason."
219
(rail-tests--with-stream root
220
(let* ((id (rail-tests--capture root "Needs a reason" ["feat" "app"]))
221
(result (rail-tests--decode
222
(rail-tools--block-handler
223
(list (cons 'root root) (cons 'id id)
224
(cons 'reason " "))))))
225
(should (string-match-p "reason" (alist-get 'error result))))))
226
227
;;; Checklist, logbook, and tags
228
229
(ert-deftest rail-test-checklist-adds-and-toggles-items ()
230
"The checklist adds an item, then toggles it, and reports booleans."
231
(rail-tests--with-stream root
232
(let ((id (rail-tests--capture root "Split the task" ["feat" "core"])))
233
(let ((added (rail-tests--call
234
#'rail-tools--check-handler
235
(list (cons 'root root) (cons 'id id)
236
(cons 'action "add") (cons 'item "step one")))))
237
(should (equal (alist-get 'done (aref (alist-get 'items added) 0))
238
:json-false)))
239
(let ((toggled (rail-tests--call
240
#'rail-tools--check-handler
241
(list (cons 'root root) (cons 'id id)
242
(cons 'action "toggle") (cons 'item "step one")))))
243
(should (eq (alist-get 'done (aref (alist-get 'items toggled) 0)) t)))
244
(should (string-match-p "Checklist \\[1/1\\]" (rail-tests--file-text root))))))
245
246
(ert-deftest rail-test-log-appends-and-keeps-earlier-notes ()
247
"The logbook keeps every note, newest first."
248
(rail-tests--with-stream root
249
(let ((id (rail-tests--capture root "Log progress" ["feat" "core"])))
250
(rail-tests--call #'rail-tools--log-handler
251
(list (cons 'root root) (cons 'id id)
252
(cons 'note "First note")))
253
(rail-tests--call #'rail-tools--log-handler
254
(list (cons 'root root) (cons 'id id)
255
(cons 'note "Second note")))
256
(let* ((entry (rail-tests--call #'rail-tools--show-handler
257
(list (cons 'root root) (cons 'id id))))
258
(notes (append (alist-get 'logbook entry) nil)))
259
(should (equal (length notes) 2))
260
(should (string-match-p "Second note" (nth 0 notes)))
261
(should (string-match-p "First note" (nth 1 notes)))))))
262
263
(ert-deftest rail-test-retag-validates-against-the-vocabulary ()
264
"Retag replaces the tags, and refuses a tag outside the vocabulary."
265
(rail-tests--with-stream root
266
(let ((id (rail-tests--capture root "Retag me" ["feat" "core"])))
267
(let ((result (rail-tests--call
268
#'rail-tools--retag-handler
269
(list (cons 'root root) (cons 'id id)
270
(cons 'tags ["fix" "web" "minor"])))))
271
(should (equal (append (alist-get 'tags result) nil)
272
'("fix" "web" "minor"))))
273
(let ((result (rail-tests--decode
274
(rail-tools--retag-handler
275
(list (cons 'root root) (cons 'id id)
276
(cons 'tags ["nonsense"]))))))
277
(should (string-match-p "Unknown tag" (alist-get 'error result)))))))
278
279
;;; Result and completion
280
281
(ert-deftest rail-test-set-result-writes-model-and-notes ()
282
"The result line holds the model, the commit, the tests, and the notes."
283
(rail-tests--with-stream root
284
(let* ((id (rail-tests--capture root "Record a result" ["fix" "core"]))
285
(result (rail-tests--call
286
#'rail-tools--set-result-handler
287
(list (cons 'root root) (cons 'id id)
288
(cons 'commit "abc1234") (cons 'tests "246 pass")
289
(cons 'model "test-agent")
290
(cons 'notes "the sentinel was wrong")))))
291
(should (equal (alist-get 'result result)
292
"model=test-agent commit=abc1234 tests=246 pass; the sentinel was wrong")))))
293
294
(ert-deftest rail-test-set-result-omits-absent-optional-fields ()
295
"The result line holds only the commit and the tests when nothing else is given."
296
(rail-tests--with-stream root
297
(let* ((id (rail-tests--capture root "Plain result" ["fix" "core"]))
298
(result (rail-tests--call
299
#'rail-tools--set-result-handler
300
(list (cons 'root root) (cons 'id id)
301
(cons 'commit "abc1234") (cons 'tests "246 pass")))))
302
(should (equal (alist-get 'result result)
303
"commit=abc1234 tests=246 pass")))))
304
305
(ert-deftest rail-test-set-result-replaces-an-earlier-line ()
306
"A second result call replaces the earlier result line."
307
(rail-tests--with-stream root
308
(let ((id (rail-tests--capture root "Replace result" ["fix" "core"])))
309
(dolist (commit '("aaa1111" "bbb2222"))
310
(rail-tests--call #'rail-tools--set-result-handler
311
(list (cons 'root root) (cons 'id id)
312
(cons 'commit commit) (cons 'tests "1 pass"))))
313
(let ((text (rail-tests--file-text root)))
314
(should-not (string-match-p "aaa1111" text))
315
(should (string-match-p "bbb2222" text))))))
316
317
(ert-deftest rail-test-complete-sets-done-with-result-and-closed ()
318
"Completion writes the result, sets DONE, and confirms the CLOSED time."
319
(rail-tests--with-stream root
320
(let* ((id (rail-tests--capture root "Finish the work" ["feat" "core"]))
321
(result (rail-tests--call
322
#'rail-tools--complete-handler
323
(list (cons 'root root) (cons 'id id)
324
(cons 'commit "def5678") (cons 'tests "247 pass")
325
(cons 'model "test-agent")))))
326
(should (equal (alist-get 'state result) "DONE"))
327
(should (string-match-p "commit=def5678" (alist-get 'result result)))
328
(should (string-match-p "\\[.*\\]" (alist-get 'closed result)))
329
;; The closing note is a CLOSING NOTE item in the LOGBOOK drawer,
330
;; not a `- result ::' body line.
331
(let ((text (rail-tests--file-text root)))
332
(should (string-match-p ":LOGBOOK:" text))
333
(should (string-match-p
334
"- CLOSING NOTE \\[.*\\] :: model=test-agent commit=def5678"
335
text))
336
(should-not (string-match-p "- result ::" text)))
337
(let ((entry (rail-tests--call #'rail-tools--verify-handler
338
(list (cons 'root root) (cons 'id id)))))
339
(should (equal (alist-get 'state entry) "DONE"))
340
(should (stringp (alist-get 'closed entry)))))))
341
342
(ert-deftest rail-test-complete-refuses-a-file-without-closed-logging ()
343
"Completion refuses to run when the file does not log a CLOSED time.
344
Org must write the CLOSED timestamp, so the tool never writes it."
345
(let* ((root (file-name-as-directory (make-temp-file "rail-nolog" t)))
346
(file (expand-file-name rail-stream-file-name root))
347
(enable-local-variables :all)
348
(org-id-track-globally nil)
349
(create-lockfiles nil)
350
(org-log-done nil))
351
(unwind-protect
352
(progn
353
;; This preamble holds no `org-log-done' local variable.
354
(with-temp-file file
355
(insert "#+TITLE: No logging\n"
356
"#+TODO: TODO IN-PROGRESS | CANCELLED DONE\n"))
357
(let ((id (rail-tests--capture root "No logging" ["feat" "core"])))
358
;; Close the buffer, so the next open reads the local variables
359
;; of the file as it now stands.
360
(dolist (buf (buffer-list))
361
(when (equal (buffer-file-name buf) file)
362
(with-current-buffer buf (set-buffer-modified-p nil))
363
(kill-buffer buf)))
364
(let ((result (rail-tests--decode
365
(rail-tools--complete-handler
366
(list (cons 'root root) (cons 'id id)
367
(cons 'commit "def5678")
368
(cons 'tests "1 pass"))))))
369
(should (stringp (alist-get 'error result)))
370
(should (string-match-p "org-log-done"
371
(alist-get 'error result))))))
372
(dolist (buf (buffer-list))
373
(when (and (buffer-file-name buf)
374
(string-prefix-p root (buffer-file-name buf)))
375
(with-current-buffer buf (set-buffer-modified-p nil))
376
(kill-buffer buf)))
377
(delete-directory root t))))
378
379
;;; Reading
380
381
(ert-deftest rail-test-list-encodes-an-absent-value-as-json-null ()
382
"A heading without an ID reports JSON null, not the text \"null\"."
383
(rail-tests--with-stream root
384
(let ((file (expand-file-name rail-stream-file-name root)))
385
(with-current-buffer (find-file-noselect file)
386
(goto-char (point-min))
387
(insert "* TODO Entry without an identifier :feat:core:\n")
388
(save-buffer))
389
(let* ((json (rail-tools--list-handler (list (cons 'root root))))
390
(rows (rail-tests--decode json)))
391
(should (null (alist-get 'id (aref rows 0))))
392
(should-not (string-match-p "\"null\"" json))))))
393
394
(ert-deftest rail-test-list-filters-by-state-and-tag ()
395
"The list filters narrow the result by TODO keyword and by tag."
396
(rail-tests--with-stream root
397
(let ((first (rail-tests--capture root "Filter one" ["feat" "core"])))
398
(rail-tests--capture root "Filter two" ["fix" "web"])
399
(rail-tests--call #'rail-tools--set-status-handler
400
(list (cons 'root root) (cons 'id first)
401
(cons 'state "IN-PROGRESS")))
402
(let ((by-state (rail-tests--decode
403
(rail-tools--list-handler
404
(list (cons 'root root) (cons 'state "IN-PROGRESS")))))
405
(by-tag (rail-tests--decode
406
(rail-tools--list-handler
407
(list (cons 'root root) (cons 'tag "web"))))))
408
(should (equal (length by-state) 1))
409
(should (equal (alist-get 'title (aref by-state 0)) "Filter one"))
410
(should (equal (length by-tag) 1))
411
(should (equal (alist-get 'title (aref by-tag 0)) "Filter two"))))))
412
413
(ert-deftest rail-test-inspect-reports-the-sequence-and-the-axes ()
414
"Inspect reports the file's keyword sequence and the file's tag axes."
415
(rail-tests--with-stream root
416
(let* ((result (rail-tests--call #'rail-tools--inspect-handler
417
(list (cons 'root root))))
418
(keywords (append (alist-get 'todo_keywords result) nil))
419
(axes (alist-get 'tags result)))
420
(should (member "IN-PROGRESS" keywords))
421
(should (member "DONE" keywords))
422
;; The axes come from the file's own #+TAGS: lines, not from the code.
423
(should (equal (mapcar #'car axes) '(kind scope impact)))
424
(should (equal (append (alist-get 'impact axes) nil) '("minor" "major")))
425
(should (equal (append (alist-get 'scope axes) nil) '("core" "app" "web" "ui"))))))
426
427
(ert-deftest rail-test-a-file-without-tags-accepts-any-tag ()
428
"A file that declares no #+TAGS: vocabulary accepts any tag.
429
The tool carries no vocabulary of its own, so an undeclared file places
430
no restriction on the tags."
431
(let* ((root (file-name-as-directory (make-temp-file "rail-notags" t)))
432
(file (expand-file-name rail-stream-file-name root))
433
(org-id-track-globally nil)
434
(create-lockfiles nil))
435
(unwind-protect
436
(progn
437
(with-temp-file file
438
(insert "#+TITLE: No vocabulary\n"
439
"#+TODO: TODO | DONE\n"))
440
(let* ((cap (rail-tests--call
441
#'rail-tools--capture-handler
442
(list (cons 'root root)
443
(cons 'title "Any tag is fine")
444
(cons 'tags ["anything" "at" "all"]))))
445
(id (alist-get 'id cap)))
446
(should (stringp id))
447
(should (equal (append (alist-get 'tags cap) nil)
448
'("anything" "at" "all")))))
449
(dolist (buf (buffer-list))
450
(when (and (buffer-file-name buf)
451
(string-prefix-p root (buffer-file-name buf)))
452
(with-current-buffer buf (set-buffer-modified-p nil))
453
(kill-buffer buf)))
454
(delete-directory root t))))
455
456
;;; The full path
457
458
(ert-deftest rail-test-the-full-request-path-runs-end-to-end ()
459
"One request moves from capture to DONE through every step."
460
(rail-tests--with-stream root
461
(let ((id (rail-tests--capture root "Ship the feature" ["feat" "web"]
462
"Add a widget to the page.")))
463
(rail-tests--call #'rail-tools--check-handler
464
(list (cons 'root root) (cons 'id id)
465
(cons 'action "add") (cons 'item "write the code")))
466
(rail-tests--call #'rail-tools--set-status-handler
467
(list (cons 'root root) (cons 'id id)
468
(cons 'state "IN-PROGRESS")))
469
(rail-tests--call #'rail-tools--log-handler
470
(list (cons 'root root) (cons 'id id)
471
(cons 'note "Started the work")))
472
(rail-tests--call #'rail-tools--check-handler
473
(list (cons 'root root) (cons 'id id)
474
(cons 'action "toggle")
475
(cons 'item "write the code")))
476
(rail-tests--call #'rail-tools--set-status-handler
477
(list (cons 'root root) (cons 'id id)
478
(cons 'state "TESTED")))
479
(rail-tests--call #'rail-tools--complete-handler
480
(list (cons 'root root) (cons 'id id)
481
(cons 'commit "0badc0de") (cons 'tests "3 pass")
482
(cons 'model "test-agent")))
483
(let ((entry (rail-tests--call #'rail-tools--show-handler
484
(list (cons 'root root) (cons 'id id)))))
485
(should (equal (alist-get 'state entry) "DONE"))
486
(should (stringp (alist-get 'closed entry)))
487
(should (string-match-p "commit=0badc0de" (alist-get 'result entry)))
488
;; The logbook now holds the progress note plus the closing note.
489
(should (equal (length (alist-get 'logbook entry)) 2))
490
(should (seq-some (lambda (n) (string-match-p "CLOSING NOTE" n))
491
(alist-get 'logbook entry)))
492
(should (eq (alist-get 'done (aref (alist-get 'checklist entry) 0)) t))
493
(should (string-match-p "Add a widget" (alist-get 'body entry)))))))
494
495
(provide 'rail-tests)
496
497
;;; rail-tests.el ends here
498