aboutsummaryrefslogtreecommitdiff
path: root/archive/gptel/tests/test-ai-conversations.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-23 20:12:58 -0400
committerCraig Jennings <c@cjennings.net>2026-06-23 20:12:58 -0400
commit10fa6f4e2e7150ad99827721ada1ae4badcc5e90 (patch)
tree960065c8e69f1e7a4150ecf522e2f813c239b5ee /archive/gptel/tests/test-ai-conversations.el
parentf4cc70c69e7707dd4a686637e14885f5443fcca6 (diff)
downloaddotemacs-10fa6f4e2e7150ad99827721ada1ae4badcc5e90.tar.gz
dotemacs-10fa6f4e2e7150ad99827721ada1ae4badcc5e90.zip
chore(ai): archive gptel and remove it from the live config
I archived gptel to archive/gptel/ since I rarely use it. Moved there: the six gptel modules (ai-config, ai-conversations, ai-conversations-browser, ai-mcp, ai-quick-ask, ai-rewrite), the gptel-tools/ directory, custom/gptel-prompts.el, their test files and utilities, and the four gptel-only specs. Scrubbed from the live config: the ai-config require in init.el, which also drops the whole C-; a keymap; the gptel-mode emojify hook in font-config.el; the gptel-tools entries in the Makefile clean target and the coverage runner; and the gptel feature notes in README. Cancelled the open gptel tasks in todo.org (the AI Open Work issues, the feature-extension brainstorm, the velox gptel-magit bug). ai-term stays. It is the ghostel Claude launcher, independent of gptel. Verified: every module loads, a batch init launch reaches completion clean, and the full test suite shows only pre-existing coverage failures unrelated to this change.
Diffstat (limited to 'archive/gptel/tests/test-ai-conversations.el')
-rw-r--r--archive/gptel/tests/test-ai-conversations.el564
1 files changed, 564 insertions, 0 deletions
diff --git a/archive/gptel/tests/test-ai-conversations.el b/archive/gptel/tests/test-ai-conversations.el
new file mode 100644
index 00000000..2d5aefd1
--- /dev/null
+++ b/archive/gptel/tests/test-ai-conversations.el
@@ -0,0 +1,564 @@
+;;; 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)))))
+
+;; ------------------------------------------------------ autosave timer
+
+(ert-deftest test-ai-conversations-autosave-start-timer-normal ()
+ "Normal: starting autosave creates a repeating timer for the current buffer."
+ (with-temp-buffer
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel")
+ (let ((calls nil))
+ (cl-letf (((symbol-function 'run-with-timer)
+ (lambda (secs repeat function &rest args)
+ (push (list secs repeat function args) calls)
+ :fake-timer)))
+ (let ((cj/gptel-autosave-interval 17))
+ (cj/gptel--autosave-start-timer)))
+ (should (eq cj/gptel-autosave--timer :fake-timer))
+ (should (equal (caar calls) 17))
+ (should (equal (cadar calls) 17))
+ (should (eq (nth 2 (car calls)) #'cj/gptel--autosave-timer-callback))
+ (should (eq (car (nth 3 (car calls))) (current-buffer))))))
+
+(ert-deftest test-ai-conversations-autosave-start-timer-idempotent ()
+ "Boundary: starting autosave twice does not create a second timer."
+ (with-temp-buffer
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel")
+ (setq-local cj/gptel-autosave--timer :existing-timer)
+ (let ((created 0))
+ (cl-letf (((symbol-function 'run-with-timer)
+ (lambda (&rest _)
+ (setq created (1+ created))
+ :new-timer)))
+ (cj/gptel--autosave-start-timer))
+ (should (= created 0))
+ (should (eq cj/gptel-autosave--timer :existing-timer)))))
+
+(ert-deftest test-ai-conversations-autosave-stop-timer-cancels ()
+ "Normal: stopping autosave cancels the current buffer's timer."
+ (with-temp-buffer
+ (setq-local cj/gptel-autosave--timer :fake-timer)
+ (let ((cancelled nil))
+ (cl-letf (((symbol-function 'cancel-timer)
+ (lambda (timer) (setq cancelled timer))))
+ (cj/gptel--autosave-stop-timer))
+ (should (eq cancelled :fake-timer))
+ (should-not cj/gptel-autosave--timer))))
+
+(ert-deftest test-ai-conversations-autosave-timer-callback-saves-active-buffer ()
+ "Normal: timer callback saves the live buffer when autosave is active."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((file (expand-file-name "timer.gptel" dir))
+ (buf (generate-new-buffer " *gptel timer test*")))
+ (unwind-protect
+ (with-current-buffer buf
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath file)
+ (insert "timer body")
+ (cj/gptel--autosave-timer-callback buf)
+ (should (file-exists-p file)))
+ (when (buffer-live-p buf)
+ (kill-buffer buf)))))))
+
+(ert-deftest test-ai-conversations-autosave-timer-callback-stops-inactive-buffer ()
+ "Boundary: timer callback cancels itself when autosave is no longer active."
+ (let ((buf (generate-new-buffer " *gptel timer inactive*")))
+ (unwind-protect
+ (with-current-buffer buf
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled nil)
+ (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel")
+ (setq-local cj/gptel-autosave--timer :fake-timer)
+ (let ((cancelled nil))
+ (cl-letf (((symbol-function 'cancel-timer)
+ (lambda (timer) (setq cancelled timer))))
+ (cj/gptel--autosave-timer-callback buf))
+ (should (eq cancelled :fake-timer))
+ (should-not cj/gptel-autosave--timer)))
+ (when (buffer-live-p buf)
+ (kill-buffer buf)))))
+
+;; ------------------------------------------------------ save-conversation
+
+(ert-deftest test-ai-conversations-save-conversation-interactive-new-topic ()
+ "Save-conversation writes file, enables autosave, and starts a timer."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((ai-buffer (generate-new-buffer "*AI-Assistant*")))
+ (unwind-protect
+ (progn
+ (with-current-buffer ai-buffer
+ (setq-local gptel-mode t)
+ (insert "session content"))
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (&rest _) "Test Topic"))
+ ((symbol-function 'y-or-n-p)
+ (lambda (&rest _) nil))
+ ((symbol-function 'run-with-timer)
+ (lambda (&rest _) :save-timer)))
+ (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))
+ (should (eq cj/gptel-autosave--timer :save-timer)))))
+ (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)))))
+
+;; --------------------------------------------- autosave-toggle / indicator
+
+(ert-deftest test-ai-conversations-autosave-toggle-enables-with-filepath ()
+ "Toggle enables autosave when a filepath is set."
+ (with-temp-buffer
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled nil)
+ (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel")
+ (cj/gptel-autosave-toggle)
+ (should cj/gptel-autosave-enabled)))
+
+(ert-deftest test-ai-conversations-autosave-toggle-disables ()
+ "Toggle turns autosave off and cancels the periodic timer when already on."
+ (with-temp-buffer
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel")
+ (setq-local cj/gptel-autosave--timer :fake-timer)
+ (let ((cancelled nil))
+ (cl-letf (((symbol-function 'cancel-timer)
+ (lambda (timer) (setq cancelled timer))))
+ (cj/gptel-autosave-toggle))
+ (should-not cj/gptel-autosave-enabled)
+ (should (eq cancelled :fake-timer))
+ (should-not cj/gptel-autosave--timer))))
+
+(ert-deftest test-ai-conversations-autosave-toggle-prompts-when-no-filepath ()
+ "Toggle prompts to save first when no filepath is configured."
+ (with-temp-buffer
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled nil)
+ (setq-local cj/gptel-autosave-filepath nil)
+ (let ((prompted nil)
+ (save-called nil))
+ (cl-letf (((symbol-function 'y-or-n-p)
+ (lambda (&rest _) (setq prompted t) nil))
+ ((symbol-function 'cj/gptel-save-conversation)
+ (lambda () (setq save-called t))))
+ (cj/gptel-autosave-toggle))
+ (should prompted)
+ (should-not save-called)
+ (should-not cj/gptel-autosave-enabled))))
+
+(ert-deftest test-ai-conversations-autosave-toggle-error-outside-gptel-mode ()
+ "Toggle signals when called outside a gptel buffer."
+ (with-temp-buffer
+ (setq-local gptel-mode nil)
+ (should-error (cj/gptel-autosave-toggle))))
+
+(ert-deftest test-ai-conversations-autosave-mode-line-format-evaluates ()
+ "Mode-line format evaluates to \" [AS]\" only when autosave is enabled."
+ (with-temp-buffer
+ (setq-local cj/gptel-autosave-enabled t)
+ (should (equal (eval (cadr cj/gptel-autosave-mode-line-format))
+ " [AS]")))
+ (with-temp-buffer
+ (setq-local cj/gptel-autosave-enabled nil)
+ (should-not (eval (cadr cj/gptel-autosave-mode-line-format)))))
+
+(ert-deftest test-ai-conversations-install-mode-line-idempotent ()
+ "Repeated installs do not duplicate the construct in mode-line-format."
+ (with-temp-buffer
+ (setq-local mode-line-format '("base"))
+ (cj/gptel--install-autosave-mode-line)
+ (cj/gptel--install-autosave-mode-line)
+ (cj/gptel--install-autosave-mode-line)
+ (should (= 1 (cl-count 'cj/gptel-autosave-mode-line-format mode-line-format)))))
+
+(provide 'test-ai-conversations)
+;;; test-ai-conversations.el ends here