summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-16 02:00:19 -0500
committerCraig Jennings <c@cjennings.net>2026-05-16 02:00:19 -0500
commit3f50f682053dd31d5fac96ecdf2b98aad1ce56d7 (patch)
treee87cdc207cba893ac68ad6dcbbff8da17fbe8232 /tests
parent6ee37e0a68d31909861cf59684d3601bf40f5abe (diff)
downloaddotemacs-3f50f682053dd31d5fac96ecdf2b98aad1ce56d7.tar.gz
dotemacs-3f50f682053dd31d5fac96ecdf2b98aad1ce56d7.zip
feat(ai-conversations-browser): dired-style browser for saved GPTel conversations
`cj/gptel-load-conversation` prompts via `completing-read`. A dedicated browser shows what each conversation is about at a glance and supports single-key load / delete / rename without having to scroll a minibuffer list. New module `modules/ai-conversations-browser.el` + `cj/gptel-browse-conversations` entry point bound to `C-; a b` ("browse conversations"). Opens `*GPTel-Conversations*` in `cj/gptel-browser-mode` (a `special-mode` derivative). Each row shows date, time, topic slug, and a preview of the most recent message (length configurable via `cj/gptel-browser-preview-length`, default 60 chars). Rows sort newest first. In the browser: - `RET` / `l`: load the conversation (delegates to `cj/gptel-load-conversation` with the file pre-selected via a `cl-letf` stub on `completing-read` so the user isn't prompted twice), then bury the window. - `d`: delete the file under point after `y-or-n-p` confirmation, re-render. - `r`: rename the file under point. Preserves the timestamp, slugifies the new topic, refuses unchanged input and existing targets. - `g`: refresh. - `n` / `p`: next / previous row. - `q`: quit-window. 21 tests cover the helpers (topic parsing, header stripping, preview shaping for truncate / short / empty cases, row-for-file with conversation + non-conversation filenames, rows enumeration, render output for empty + populated cases, newest-first sort, rename-target preservation of timestamp + slug, rename-target error on missing timestamp) and the file-touching actions (delete with y, cancel with n, rename, rename-on-empty-line error).
Diffstat (limited to 'tests')
-rw-r--r--tests/test-ai-conversations-browser.el244
1 files changed, 244 insertions, 0 deletions
diff --git a/tests/test-ai-conversations-browser.el b/tests/test-ai-conversations-browser.el
new file mode 100644
index 00000000..d7422b09
--- /dev/null
+++ b/tests/test-ai-conversations-browser.el
@@ -0,0 +1,244 @@
+;;; test-ai-conversations-browser.el --- Tests for ai-conversations-browser -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the saved-conversations browser. Pure helpers (topic
+;; parsing, header stripping, preview, rename target) are tested
+;; against fixed inputs. File-touching actions (load / delete /
+;; rename) are tested against a temp conversations directory.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+(require 'testutil-ai-config)
+;; Force real ai-conversations to override testutil's stub.
+(setq features (delq 'ai-conversations features))
+(require 'ai-conversations)
+(require 'ai-conversations-browser)
+
+;; ----------------------------- temp-dir helper
+
+(defun test-ai-conversations-browser--with-temp-dir (fn)
+ "Run FN inside a fresh conversations directory. Clean up after."
+ (let* ((dir (make-temp-file "test-ai-conversations-browser-" t))
+ (cj/gptel-conversations-directory dir))
+ (unwind-protect
+ (funcall fn dir)
+ (when (file-exists-p dir)
+ (delete-directory dir t)))))
+
+(defun test-ai-conversations-browser--write (dir name content)
+ "Write CONTENT to NAME in DIR. Return the absolute path."
+ (let ((path (expand-file-name name dir)))
+ (with-temp-file path (insert content))
+ path))
+
+;; ----------------------------- topic-from-filename
+
+(ert-deftest test-ai-conversations-browser-topic-normal ()
+ "Normal: topic slug extracted from a well-formed filename."
+ (should (equal (cj/gptel-browser--topic-from-filename
+ "my-topic_20260315-101530.gptel")
+ "my-topic")))
+
+(ert-deftest test-ai-conversations-browser-topic-error-malformed ()
+ "Boundary: malformed filename returns nil."
+ (should-not (cj/gptel-browser--topic-from-filename "garbage.gptel"))
+ (should-not (cj/gptel-browser--topic-from-filename "topic.gptel"))
+ (should-not (cj/gptel-browser--topic-from-filename "topic_20260315.gptel")))
+
+;; ----------------------------- strip-headers
+
+(ert-deftest test-ai-conversations-browser-strip-headers-normal ()
+ "Strip the two visibility headers plus the blank line after them."
+ (should (equal (cj/gptel-browser--strip-headers
+ "#+STARTUP: showeverything\n#+VISIBILITY: all\n\nrest\n")
+ "rest\n")))
+
+(ert-deftest test-ai-conversations-browser-strip-headers-no-headers ()
+ "Boundary: input without headers is unchanged."
+ (should (equal (cj/gptel-browser--strip-headers "plain body\n")
+ "plain body\n")))
+
+;; ----------------------------- last-message
+
+(ert-deftest test-ai-conversations-browser-last-message-normal ()
+ "Last-message picks the body of the last org heading."
+ (let ((text "* user [2026-01-01]\nhello there\n* AI [2026-01-01]\nthe latest reply\n"))
+ (should (equal (cj/gptel-browser--last-message text)
+ "the latest reply"))))
+
+(ert-deftest test-ai-conversations-browser-last-message-no-heading ()
+ "Boundary: text without headings returns the (collapsed) body."
+ (let ((text "just some body\nwith two lines\n"))
+ (should (equal (cj/gptel-browser--last-message text)
+ "just some body with two lines"))))
+
+;; ----------------------------- preview
+
+(ert-deftest test-ai-conversations-browser-preview-truncates ()
+ "Preview is ellipsized when the message is longer than LENGTH."
+ (let ((text "* AI\nthis is a very long response that should get truncated for the preview\n"))
+ (let ((preview (cj/gptel-browser--preview text 30)))
+ (should (= (length preview) 30))
+ (should (string-suffix-p "…" preview)))))
+
+(ert-deftest test-ai-conversations-browser-preview-short ()
+ "Preview is returned verbatim when shorter than LENGTH."
+ (let ((text "* AI\nshort\n"))
+ (should (equal (cj/gptel-browser--preview text 60) "short"))))
+
+(ert-deftest test-ai-conversations-browser-preview-empty ()
+ "Preview of empty body returns empty string."
+ (should (equal (cj/gptel-browser--preview "" 60) "")))
+
+;; ----------------------------- row-for-file
+
+(ert-deftest test-ai-conversations-browser-row-for-file-normal ()
+ "Row contains date, topic, and a preview; carries file metadata."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (let ((file (test-ai-conversations-browser--write
+ dir "alpha_20260315-101530.gptel"
+ "#+STARTUP: showeverything\n\n* AI\nresult body\n")))
+ (let ((row (cj/gptel-browser--row-for-file file dir)))
+ (should row)
+ (should (string-match-p "2026-03-15 10:15" row))
+ (should (string-match-p "alpha" row))
+ (should (string-match-p "result body" row))
+ (should (equal (get-text-property 0 'cj/gptel-browser-file row)
+ "alpha_20260315-101530.gptel")))))))
+
+(ert-deftest test-ai-conversations-browser-row-for-file-non-conversation ()
+ "Files that don't match the conversation pattern return nil."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (let ((file (test-ai-conversations-browser--write
+ dir "not-a-conversation.gptel" "body")))
+ (should-not (cj/gptel-browser--row-for-file file dir))))))
+
+;; ----------------------------- rows / render
+
+(ert-deftest test-ai-conversations-browser-rows-from-empty-dir ()
+ "Empty conversations directory yields no rows."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (_dir)
+ (should-not (cj/gptel-browser--rows)))))
+
+(ert-deftest test-ai-conversations-browser-rows-multiple-conversations ()
+ "Multiple conversations produce a row per file."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations-browser--write
+ dir "a_20260101-100000.gptel" "* AI\nfirst\n")
+ (test-ai-conversations-browser--write
+ dir "b_20260102-100000.gptel" "* AI\nsecond\n")
+ (let ((rows (cj/gptel-browser--rows)))
+ (should (= 2 (length rows)))))))
+
+(ert-deftest test-ai-conversations-browser-render-empty ()
+ "Render shows a 'no conversations' line when directory is empty."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (_dir)
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ (should (string-match-p "no saved conversations" (buffer-string)))))))
+
+(ert-deftest test-ai-conversations-browser-render-newest-first ()
+ "Render sorts rows newest first by timestamp."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations-browser--write
+ dir "old_20260101-100000.gptel" "* AI\nx\n")
+ (test-ai-conversations-browser--write
+ dir "new_20260301-100000.gptel" "* AI\ny\n")
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ (let ((text (buffer-substring-no-properties (point-min) (point-max))))
+ ;; New (March) should appear before old (January) in the buffer.
+ (should (< (string-match "2026-03-01" text)
+ (string-match "2026-01-01" text))))))))
+
+;; ----------------------------- rename-target
+
+(ert-deftest test-ai-conversations-browser-rename-target-normal ()
+ "Rename-target preserves the timestamp and slugifies the new topic."
+ (should (equal (cj/gptel-browser--rename-target
+ "/tmp/old-topic_20260101-100000.gptel"
+ "Brand New Topic")
+ "/tmp/brand-new-topic_20260101-100000.gptel")))
+
+(ert-deftest test-ai-conversations-browser-rename-target-error-no-timestamp ()
+ "Rename-target errors when the filename lacks a timestamp."
+ (should-error (cj/gptel-browser--rename-target "/tmp/no-ts.gptel" "x")))
+
+;; ----------------------------- delete / rename actions
+
+(ert-deftest test-ai-conversations-browser-delete-removes-file ()
+ "Delete with y removes the file under point and re-renders."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (let ((file (test-ai-conversations-browser--write
+ dir "topic_20260101-100000.gptel" "* AI\nx\n")))
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ ;; Point on the only data row
+ (goto-char (point-min))
+ (forward-line 2)
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (&rest _) t)))
+ (cj/gptel-browser-delete))
+ (should-not (file-exists-p file)))))))
+
+(ert-deftest test-ai-conversations-browser-delete-cancel-keeps-file ()
+ "Delete with n leaves the file alone."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (let ((file (test-ai-conversations-browser--write
+ dir "topic_20260101-100000.gptel" "* AI\nx\n")))
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ (goto-char (point-min))
+ (forward-line 2)
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (&rest _) nil)))
+ (cj/gptel-browser-delete))
+ (should (file-exists-p file)))))))
+
+(ert-deftest test-ai-conversations-browser-rename-renames-file ()
+ "Rename moves the file under a new slug while preserving timestamp."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (let* ((file (test-ai-conversations-browser--write
+ dir "old-name_20260101-100000.gptel" "* AI\nx\n")))
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ (goto-char (point-min))
+ (forward-line 2)
+ (cl-letf (((symbol-function 'read-string)
+ (lambda (&rest _) "renamed topic")))
+ (cj/gptel-browser-rename))
+ (should-not (file-exists-p file))
+ (should (file-exists-p
+ (expand-file-name "renamed-topic_20260101-100000.gptel"
+ dir))))))))
+
+(ert-deftest test-ai-conversations-browser-rename-error-on-empty-line ()
+ "Rename errors when point is on the header/empty area."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (_dir)
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ (goto-char (point-min))
+ (should-error (cj/gptel-browser-rename))))))
+
+(provide 'test-ai-conversations-browser)
+;;; test-ai-conversations-browser.el ends here