diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-16 01:34:03 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-16 01:34:03 -0500 |
| commit | 2a98feaf1285b495e7d6d1eed2abf02620188e29 (patch) | |
| tree | 472415397aa9e5d6e0364c4d9a28ffbc9948bea7 /tests | |
| parent | 1ca46c2b477afd014ef993ed0ca5ca50e257adce (diff) | |
| download | dotemacs-2a98feaf1285b495e7d6d1eed2abf02620188e29.tar.gz dotemacs-2a98feaf1285b495e7d6d1eed2abf02620188e29.zip | |
test(ai-conversations): add 36 ERT tests covering helpers and entry points
ai-conversations.el shipped without direct tests. This file covers
every helper and interactive entry point across Normal / Boundary /
Error.
Helpers: `cj/gptel--slugify-topic` (ASCII, empty input, all-special,
unicode stripped, idempotent, trim, digits); `cj/gptel--timestamp-
from-filename` (normal decode, year-edge boundaries, malformed
inputs returning nil); `cj/gptel--existing-topics` and `cj/gptel--
latest-file-for-topic` (multi-topic / multi-timestamp temp dirs,
empty dir, missing dir, prefix-overlap isolation); `cj/gptel--
conversation-candidates` (newest-first and oldest-first sort order,
display-string shape, error on missing dir); `cj/gptel--save-buffer-
to-file` (visibility headers prepended, round-trip through `cj/
gptel--strip-visibility-headers`).
Autosave: post-response hook saves only when gptel-mode + enabled +
filepath are all set; autosave-after-send swallows write errors via
`message` instead of signaling; the install-once guard prevents
double-registration.
Interactive entry points: save/delete exercised via `cl-letf` stubs
on `completing-read` and `y-or-n-p`.
Per-test temp directories; no writes outside them.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test-ai-conversations.el | 409 |
1 files changed, 409 insertions, 0 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 |
