summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--tests/test-ai-conversations.el409
-rw-r--r--todo.org23
2 files changed, 420 insertions, 12 deletions
diff --git a/tests/test-ai-conversations.el b/tests/test-ai-conversations.el
new file mode 100644
index 00000000..f4d43236
--- /dev/null
+++ b/tests/test-ai-conversations.el
@@ -0,0 +1,409 @@
+;;; test-ai-conversations.el --- Tests for ai-conversations.el -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Normal / Boundary / Error tests for the save/load/delete and
+;; autosave surface in ai-conversations.el. Pure helpers are tested
+;; against fixed inputs; file-touching helpers use per-test temp
+;; directories. Interactive commands are exercised via `cl-letf'
+;; stubs on `completing-read' and `y-or-n-p'.
+
+;;; 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)
+;; testutil-ai-config provides 'ai-conversations as a stub. Force the
+;; real module to override.
+(setq features (delq 'ai-conversations features))
+(require 'ai-conversations)
+
+;; -------------------------------------------------------- temp-dir helper
+
+(defun test-ai-conversations--with-temp-dir (fn)
+ "Run FN inside a fresh conversations directory. Clean up after."
+ (let* ((dir (make-temp-file "test-ai-conversations-" t))
+ (cj/gptel-conversations-directory dir))
+ (unwind-protect
+ (funcall fn dir)
+ (when (file-exists-p dir)
+ (delete-directory dir t)))))
+
+(defun test-ai-conversations--touch (dir name)
+ "Create empty file NAME in DIR."
+ (let ((path (expand-file-name name dir)))
+ (with-temp-file path (insert ""))
+ path))
+
+;; ------------------------------------------------------ slugify-topic
+
+(ert-deftest test-ai-conversations-slugify-topic-normal ()
+ "Normal: ASCII words with spaces become hyphen-joined slug."
+ (should (equal (cj/gptel--slugify-topic "Hello World") "hello-world")))
+
+(ert-deftest test-ai-conversations-slugify-topic-boundary-empty ()
+ "Boundary: empty input returns the literal \"conversation\" placeholder."
+ (should (equal (cj/gptel--slugify-topic "") "conversation"))
+ (should (equal (cj/gptel--slugify-topic nil) "conversation")))
+
+(ert-deftest test-ai-conversations-slugify-topic-boundary-all-special ()
+ "Boundary: input with no slug-safe chars falls back to placeholder."
+ (should (equal (cj/gptel--slugify-topic "!!!@@@###") "conversation"))
+ (should (equal (cj/gptel--slugify-topic " ") "conversation")))
+
+(ert-deftest test-ai-conversations-slugify-topic-boundary-unicode-stripped ()
+ "Boundary: non-ASCII characters drop out (only [a-z0-9] survives)."
+ (should (equal (cj/gptel--slugify-topic "Café Résumé") "caf-r-sum")))
+
+(ert-deftest test-ai-conversations-slugify-topic-boundary-idempotent ()
+ "Boundary: applying twice yields the same result as once."
+ (let ((once (cj/gptel--slugify-topic "Foo Bar 2026!")))
+ (should (equal once (cj/gptel--slugify-topic once)))))
+
+(ert-deftest test-ai-conversations-slugify-topic-boundary-leading-trailing-trim ()
+ "Boundary: leading/trailing separator runs are trimmed."
+ (should (equal (cj/gptel--slugify-topic "---foo---") "foo"))
+ (should (equal (cj/gptel--slugify-topic "**foo**") "foo")))
+
+(ert-deftest test-ai-conversations-slugify-topic-normal-numbers-preserved ()
+ "Normal: digits survive the slug."
+ (should (equal (cj/gptel--slugify-topic "Project 2026 Plan")
+ "project-2026-plan")))
+
+;; ------------------------------------------------------ timestamp-from-filename
+
+(ert-deftest test-ai-conversations-timestamp-from-filename-normal ()
+ "Normal: well-formed filename decodes to a time value."
+ (let ((ts (cj/gptel--timestamp-from-filename
+ "topic_20260315-101530.gptel")))
+ (should ts)
+ (should (equal (format-time-string "%Y-%m-%d %H:%M:%S" ts)
+ "2026-03-15 10:15:30"))))
+
+(ert-deftest test-ai-conversations-timestamp-from-filename-boundary-year-edges ()
+ "Boundary: end-of-year and start-of-year timestamps decode correctly."
+ (let ((eoy (cj/gptel--timestamp-from-filename
+ "topic_20251231-235959.gptel"))
+ (boy (cj/gptel--timestamp-from-filename
+ "topic_20260101-000000.gptel")))
+ (should (equal (format-time-string "%Y-%m-%d %H:%M:%S" eoy)
+ "2025-12-31 23:59:59"))
+ (should (equal (format-time-string "%Y-%m-%d %H:%M:%S" boy)
+ "2026-01-01 00:00:00"))))
+
+(ert-deftest test-ai-conversations-timestamp-from-filename-error-malformed ()
+ "Error: non-matching filename returns nil."
+ (should-not (cj/gptel--timestamp-from-filename "not-a-gptel-file"))
+ (should-not (cj/gptel--timestamp-from-filename "topic.gptel"))
+ (should-not (cj/gptel--timestamp-from-filename "topic_20260315.gptel"))
+ (should-not (cj/gptel--timestamp-from-filename "topic_2026031-101530.gptel")))
+
+;; ------------------------------------------------------ existing-topics
+
+(ert-deftest test-ai-conversations-existing-topics-normal ()
+ "Normal: returns unique topic slugs across multiple-timestamped files."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations--touch dir "foo_20260101-100000.gptel")
+ (test-ai-conversations--touch dir "foo_20260102-100000.gptel")
+ (test-ai-conversations--touch dir "bar_20260102-100000.gptel")
+ (let ((topics (cj/gptel--existing-topics)))
+ (should (member "foo" topics))
+ (should (member "bar" topics))
+ (should (= 2 (length topics)))))))
+
+(ert-deftest test-ai-conversations-existing-topics-boundary-empty-dir ()
+ "Boundary: empty conversations directory returns nil."
+ (test-ai-conversations--with-temp-dir
+ (lambda (_dir)
+ (should-not (cj/gptel--existing-topics)))))
+
+(ert-deftest test-ai-conversations-existing-topics-boundary-missing-dir ()
+ "Boundary: missing directory returns nil instead of erroring."
+ (let ((cj/gptel-conversations-directory
+ (expand-file-name (format "missing-%s" (random)) "/tmp")))
+ (should-not (cj/gptel--existing-topics))))
+
+(ert-deftest test-ai-conversations-existing-topics-boundary-ignores-non-gptel ()
+ "Boundary: files without .gptel extension are ignored."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations--touch dir "foo_20260101-100000.gptel")
+ (test-ai-conversations--touch dir "readme.txt")
+ (test-ai-conversations--touch dir "stray.gptel.bak")
+ (should (equal (cj/gptel--existing-topics) '("foo"))))))
+
+;; ------------------------------------------------------ latest-file-for-topic
+
+(ert-deftest test-ai-conversations-latest-file-for-topic-normal ()
+ "Normal: returns the newest file for the topic by lexical sort."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations--touch dir "foo_20260101-100000.gptel")
+ (test-ai-conversations--touch dir "foo_20260103-100000.gptel")
+ (test-ai-conversations--touch dir "foo_20260102-100000.gptel")
+ (should (equal (cj/gptel--latest-file-for-topic "foo")
+ "foo_20260103-100000.gptel")))))
+
+(ert-deftest test-ai-conversations-latest-file-for-topic-boundary-no-match ()
+ "Boundary: no matching topic returns nil."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations--touch dir "bar_20260101-100000.gptel")
+ (should-not (cj/gptel--latest-file-for-topic "foo")))))
+
+(ert-deftest test-ai-conversations-latest-file-for-topic-boundary-missing-dir ()
+ "Boundary: missing directory returns nil."
+ (let ((cj/gptel-conversations-directory
+ (expand-file-name (format "missing-%s" (random)) "/tmp")))
+ (should-not (cj/gptel--latest-file-for-topic "foo"))))
+
+(ert-deftest test-ai-conversations-latest-file-for-topic-boundary-regex-isolation ()
+ "Boundary: prefix-overlapping topics are not falsely matched."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations--touch dir "foo_20260101-100000.gptel")
+ (test-ai-conversations--touch dir "foobar_20260102-100000.gptel")
+ (should (equal (cj/gptel--latest-file-for-topic "foo")
+ "foo_20260101-100000.gptel")))))
+
+;; ------------------------------------------------------ conversation-candidates
+
+(ert-deftest test-ai-conversations-conversation-candidates-normal-newest-first ()
+ "Normal: candidates are sorted newest-first when configured that way."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations--touch dir "foo_20260101-100000.gptel")
+ (test-ai-conversations--touch dir "foo_20260103-100000.gptel")
+ (test-ai-conversations--touch dir "foo_20260102-100000.gptel")
+ (let ((cj/gptel-conversations-sort-order 'newest-first))
+ (let* ((cands (cj/gptel--conversation-candidates))
+ (files (mapcar #'cdr cands)))
+ (should (equal files
+ '("foo_20260103-100000.gptel"
+ "foo_20260102-100000.gptel"
+ "foo_20260101-100000.gptel"))))))))
+
+(ert-deftest test-ai-conversations-conversation-candidates-normal-oldest-first ()
+ "Normal: candidates respect oldest-first sort order."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations--touch dir "foo_20260101-100000.gptel")
+ (test-ai-conversations--touch dir "foo_20260103-100000.gptel")
+ (test-ai-conversations--touch dir "foo_20260102-100000.gptel")
+ (let ((cj/gptel-conversations-sort-order 'oldest-first))
+ (let* ((cands (cj/gptel--conversation-candidates))
+ (files (mapcar #'cdr cands)))
+ (should (equal files
+ '("foo_20260101-100000.gptel"
+ "foo_20260102-100000.gptel"
+ "foo_20260103-100000.gptel"))))))))
+
+(ert-deftest test-ai-conversations-conversation-candidates-error-missing-dir ()
+ "Error: missing conversations directory signals."
+ (let ((cj/gptel-conversations-directory
+ (expand-file-name (format "missing-%s" (random)) "/tmp")))
+ (should-error (cj/gptel--conversation-candidates))))
+
+(ert-deftest test-ai-conversations-conversation-candidates-display-shape ()
+ "Display string is \"filename [YYYY-MM-DD HH:MM]\"."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations--touch dir "topic_20260315-101530.gptel")
+ (let* ((cands (cj/gptel--conversation-candidates))
+ (display (car (car cands))))
+ (should (string-match-p
+ "\\`topic_20260315-101530\\.gptel \\[2026-03-15 10:15\\]\\'"
+ display))))))
+
+;; ------------------------------------------------------ save-buffer-to-file
+
+(ert-deftest test-ai-conversations-save-buffer-to-file-normal ()
+ "Normal: writes buffer with visibility headers prepended."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (with-temp-buffer
+ (insert "hello world\n")
+ (let ((file (expand-file-name "out.gptel" dir)))
+ (cj/gptel--save-buffer-to-file (current-buffer) file)
+ (should (file-exists-p file))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (should (string-match-p "^#\\+STARTUP: showeverything"
+ (buffer-string)))
+ (should (string-match-p "^#\\+VISIBILITY: all"
+ (buffer-string)))
+ (should (string-match-p "hello world"
+ (buffer-string)))))))))
+
+(ert-deftest test-ai-conversations-save-buffer-to-file-roundtrip-with-strip ()
+ "Round-trip: save then strip-visibility-headers yields original content."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((original "first line\nsecond line\n")
+ (file (expand-file-name "rt.gptel" dir)))
+ (with-temp-buffer
+ (insert original)
+ (cj/gptel--save-buffer-to-file (current-buffer) file))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (cj/gptel--strip-visibility-headers)
+ (should (equal (buffer-string) original)))))))
+
+(ert-deftest test-ai-conversations-strip-visibility-headers-boundary-no-headers ()
+ "Boundary: buffer without headers is unchanged."
+ (with-temp-buffer
+ (insert "plain body\n")
+ (cj/gptel--strip-visibility-headers)
+ (should (equal (buffer-string) "plain body\n"))))
+
+;; ------------------------------------------------------ autosave-after-response
+
+(defmacro test-ai-conversations--with-gptel-mode (&rest body)
+ "Run BODY in a temp buffer with `gptel-mode' bound non-nil."
+ (declare (indent 0))
+ `(with-temp-buffer
+ (setq-local gptel-mode t)
+ ,@body))
+
+(ert-deftest test-ai-conversations-autosave-after-response-saves-when-enabled ()
+ "Hook saves the buffer to the autosave filepath when enabled."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((file (expand-file-name "auto.gptel" dir)))
+ (test-ai-conversations--with-gptel-mode
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath file)
+ (insert "autosaved body")
+ (cj/gptel--autosave-after-response)
+ (should (file-exists-p file)))))))
+
+(ert-deftest test-ai-conversations-autosave-after-response-skips-when-disabled ()
+ "Hook is a no-op when `cj/gptel-autosave-enabled' is nil."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((file (expand-file-name "auto.gptel" dir)))
+ (test-ai-conversations--with-gptel-mode
+ (setq-local cj/gptel-autosave-enabled nil)
+ (setq-local cj/gptel-autosave-filepath file)
+ (cj/gptel--autosave-after-response)
+ (should-not (file-exists-p file)))))))
+
+(ert-deftest test-ai-conversations-autosave-after-response-skips-when-no-filepath ()
+ "Hook is a no-op when filepath is nil or empty."
+ (test-ai-conversations--with-temp-dir
+ (lambda (_dir)
+ (test-ai-conversations--with-gptel-mode
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath nil)
+ ;; Should not error
+ (cj/gptel--autosave-after-response))
+ (test-ai-conversations--with-gptel-mode
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath "")
+ (cj/gptel--autosave-after-response)))))
+
+(ert-deftest test-ai-conversations-autosave-after-response-skips-outside-gptel-mode ()
+ "Hook is a no-op when `gptel-mode' is nil."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((file (expand-file-name "auto.gptel" dir)))
+ (with-temp-buffer
+ (setq-local gptel-mode nil)
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath file)
+ (cj/gptel--autosave-after-response)
+ (should-not (file-exists-p file)))))))
+
+(ert-deftest test-ai-conversations-autosave-after-send-error-is-non-fatal ()
+ "Hook surfaces a save error via `message' rather than signaling."
+ (test-ai-conversations--with-temp-dir
+ (lambda (_dir)
+ (test-ai-conversations--with-gptel-mode
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath "/nonexistent-dir/file.gptel")
+ ;; Must not signal even though the write will fail
+ (cj/gptel--autosave-after-send)))))
+
+;; ------------------------------------------------------ save-conversation
+
+(ert-deftest test-ai-conversations-save-conversation-interactive-new-topic ()
+ "Save-conversation writes file under the topic-slugged name."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((ai-buffer (generate-new-buffer "*AI-Assistant*")))
+ (unwind-protect
+ (progn
+ (with-current-buffer ai-buffer
+ (insert "session content"))
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (&rest _) "Test Topic"))
+ ((symbol-function 'y-or-n-p)
+ (lambda (&rest _) nil)))
+ (cj/gptel-save-conversation)
+ (let ((files (directory-files dir nil "test-topic_.*\\.gptel$")))
+ (should files)
+ (should (= 1 (length files))))
+ ;; Autosave state is set in the AI buffer
+ (with-current-buffer ai-buffer
+ (should cj/gptel-autosave-enabled)
+ (should (stringp cj/gptel-autosave-filepath)))))
+ (kill-buffer ai-buffer))))))
+
+(ert-deftest test-ai-conversations-save-conversation-error-no-buffer ()
+ "Save-conversation errors when *AI-Assistant* doesn't exist."
+ (when (get-buffer "*AI-Assistant*")
+ (kill-buffer "*AI-Assistant*"))
+ (should-error (cj/gptel-save-conversation)))
+
+;; ------------------------------------------------------ delete-conversation
+
+(ert-deftest test-ai-conversations-delete-conversation-interactive ()
+ "Delete-conversation removes the chosen file after confirmation."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((file (test-ai-conversations--touch
+ dir "topic_20260101-100000.gptel")))
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (_p cands &rest _) (caar cands)))
+ ((symbol-function 'y-or-n-p)
+ (lambda (&rest _) t)))
+ (cj/gptel-delete-conversation)
+ (should-not (file-exists-p file)))))))
+
+(ert-deftest test-ai-conversations-delete-conversation-cancelled ()
+ "Delete-conversation preserves the file when the user declines."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((file (test-ai-conversations--touch
+ dir "topic_20260101-100000.gptel")))
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (_p cands &rest _) (caar cands)))
+ ((symbol-function 'y-or-n-p)
+ (lambda (&rest _) nil)))
+ (cj/gptel-delete-conversation)
+ (should (file-exists-p file)))))))
+
+(ert-deftest test-ai-conversations-delete-conversation-error-empty-dir ()
+ "Delete-conversation errors when no saved conversations exist."
+ (test-ai-conversations--with-temp-dir
+ (lambda (_dir)
+ (should-error (cj/gptel-delete-conversation)))))
+
+;; ------------------------------------------------------ install-once
+
+(ert-deftest test-ai-conversations-autosave-after-response-hook-not-duplicated ()
+ "Loading ai-conversations twice does not duplicate the post-response hook."
+ (let ((gptel-post-response-functions
+ (list #'cj/gptel--autosave-after-response)))
+ ;; Re-run the install code
+ (unless (member #'cj/gptel--autosave-after-response gptel-post-response-functions)
+ (add-hook 'gptel-post-response-functions #'cj/gptel--autosave-after-response))
+ (should (= 1 (cl-count #'cj/gptel--autosave-after-response
+ gptel-post-response-functions)))))
+
+(provide 'test-ai-conversations)
+;;; test-ai-conversations.el ends here
diff --git a/todo.org b/todo.org
index d9d3635d..8313d8bc 100644
--- a/todo.org
+++ b/todo.org
@@ -2625,18 +2625,17 @@ guard against the regression is "no entry for =magit=, entries for
=git-commit=, =magit-commit=, =magit-diff=," which is exactly what
the test asserts.
-*** TODO [#B] Add ERT coverage for ai-conversations.el :tests:
-
-Currently zero direct tests on the 274-line module. Cover at least:
-- =cj/gptel--slugify-topic= — Normal / Boundary (empty, all-special-chars, unicode, idempotent slug) / Error.
-- =cj/gptel--timestamp-from-filename= — Normal / Boundary (year/month/day/hour/min/sec edges) / Error (malformed filename → nil).
-- =cj/gptel--existing-topics= and =cj/gptel--latest-file-for-topic= — with a temp conversations directory containing multiple topics + multiple timestamps each.
-- =cj/gptel--conversation-candidates= — sort order honored for both =newest-first= and =oldest-first=.
-- =cj/gptel--save-buffer-to-file= — visibility headers prepended; round-trips back through =cj/gptel--strip-visibility-headers=.
-- The =with-eval-after-load 'gptel= advice/hook installs once and isn't duplicated on re-load.
-- Interactive entry points (=save= / =load= / =delete=) exercised via =cl-letf= stubs on =completing-read= and =y-or-n-p=.
-
-Use a per-test temp conversations directory; no writes outside it. Target ≥80% coverage on the module.
+*** 2026-05-16 Sat @ 01:33:20 -0500 Added ERT coverage for ai-conversations.el
+
+=tests/test-ai-conversations.el= covers every helper in the module
+plus the interactive entry points. 36 tests across Normal / Boundary /
+Error categories: slug normalization, timestamp decoding, file
+enumeration (existing topics, latest-for-topic, candidate ordering for
+both =newest-first= and =oldest-first=), the save-buffer/strip-headers
+round-trip, the autosave-after-send + autosave-after-response hooks,
+the install-once guard for the post-response hook, and the
+save/delete interactive entry points exercised via =cl-letf= stubs.
+Per-test temp directories; no writes outside them.
*** TODO [#B] Add ERT coverage for gptel-tools .el files :tests: