summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-13 18:22:54 -0500
committerCraig Jennings <c@cjennings.net>2026-05-13 18:22:54 -0500
commit0d5a28d8f8b12332a132005fcf4ef4e2a86f8904 (patch)
treea48f40bf4c38559c948a90f259dfe9685d0dabd6
parent1b9343bd9918e9d43dc1f3325fe42c5f437b2084 (diff)
downloaddotemacs-0d5a28d8f8b12332a132005fcf4ef4e2a86f8904.tar.gz
dotemacs-0d5a28d8f8b12332a132005fcf4ef4e2a86f8904.zip
test(ai-config): cover auth-source, api-key caching, context, clear-buffer
The big-module coverage push starts with ai-config (was 53/191, 27.7%). New test file covers seven helpers that don't depend on a live gptel install: - `cj/auth-source-secret`: returns string secrets, funcall's function secrets, errors on missing. - `cj/anthropic-api-key` / `cj/openai-api-key`: cache the result so subsequent calls don't re-hit auth-source. - `cj/gptel--add-file-to-context`: adds existing files, skips nil and missing paths. - `cj/gptel-clear-buffer`: erases in a gptel-org buffer and reinserts the fresh prefix; no-ops + messages elsewhere. - `cj/gptel-context-clear`: three cond branches (`remove-all`, `clear`, alist-fallback) with appropriate `fboundp` stubs. - `cj/gptel-insert-model-heading`: inserts at the response-begin position with the backend/model heading. External primitives (`auth-source-search`, `gptel-add-file`, `gptel-context-remove-all`, etc.) are stubbed. The fallback test declares `gptel-context--alist` as a top-level `defvar` so `setq` inside the function reaches the dynamic binding under lexical scoping.
-rw-r--r--tests/test-ai-config-helpers.el183
1 files changed, 183 insertions, 0 deletions
diff --git a/tests/test-ai-config-helpers.el b/tests/test-ai-config-helpers.el
new file mode 100644
index 00000000..cdbc0f6e
--- /dev/null
+++ b/tests/test-ai-config-helpers.el
@@ -0,0 +1,183 @@
+;;; test-ai-config-helpers.el --- Tests for ai-config helper functions -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Covers helpers that don't depend on a live gptel install:
+;;
+;; cj/auth-source-secret
+;; cj/anthropic-api-key (caching wrapper)
+;; cj/openai-api-key (caching wrapper)
+;; cj/gptel--add-file-to-context
+;; cj/gptel-clear-buffer
+;; cj/gptel-context-clear
+;; cj/gptel-insert-model-heading
+;;
+;; External primitives (`auth-source-search', `gptel-add-file', etc.)
+;; are stubbed so the tests never touch the keyring or the network.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-config)
+
+;; Make `gptel-context--alist' a real dynamic variable for the fallback
+;; test below. Under lexical-binding a plain `let' is lexical, so the
+;; `setq' inside `cj/gptel-context-clear' would otherwise miss it.
+(defvar gptel-context--alist nil
+ "Dynamic stand-in for the gptel-context alist (gptel not loaded here).")
+
+;;; cj/auth-source-secret
+
+(ert-deftest test-ai-config-auth-source-secret-returns-string ()
+ "Normal: a plain-string secret comes back as-is."
+ (cl-letf (((symbol-function 'auth-source-search)
+ (lambda (&rest _) '((:secret "plaintext")))))
+ (should (equal (cj/auth-source-secret "example.com" "user")
+ "plaintext"))))
+
+(ert-deftest test-ai-config-auth-source-secret-unwraps-function ()
+ "Normal: a function secret is funcall'd to retrieve the value."
+ (cl-letf (((symbol-function 'auth-source-search)
+ (lambda (&rest _) (list (list :secret (lambda () "called"))))))
+ (should (equal (cj/auth-source-secret "example.com" "user")
+ "called"))))
+
+(ert-deftest test-ai-config-auth-source-secret-errors-when-missing ()
+ "Error: an empty result raises a clear error."
+ (cl-letf (((symbol-function 'auth-source-search)
+ (lambda (&rest _) nil)))
+ (should-error (cj/auth-source-secret "nope.example.com" "user")
+ :type 'error)))
+
+;;; cj/anthropic-api-key / cj/openai-api-key
+
+(ert-deftest test-ai-config-anthropic-api-key-caches-after-first-call ()
+ "Normal: a subsequent call returns the cached value without re-fetching."
+ (let ((cj/anthropic-api-key-cached nil)
+ (call-count 0))
+ (cl-letf (((symbol-function 'auth-source-search)
+ (lambda (&rest _)
+ (cl-incf call-count)
+ '((:secret "anth-key")))))
+ (should (equal (cj/anthropic-api-key) "anth-key"))
+ (should (equal (cj/anthropic-api-key) "anth-key"))
+ (should (= call-count 1)))))
+
+(ert-deftest test-ai-config-openai-api-key-caches-after-first-call ()
+ "Normal: same caching contract as the anthropic key."
+ (let ((cj/openai-api-key-cached nil)
+ (call-count 0))
+ (cl-letf (((symbol-function 'auth-source-search)
+ (lambda (&rest _)
+ (cl-incf call-count)
+ '((:secret "oai-key")))))
+ (should (equal (cj/openai-api-key) "oai-key"))
+ (should (equal (cj/openai-api-key) "oai-key"))
+ (should (= call-count 1)))))
+
+;;; cj/gptel--add-file-to-context
+
+(ert-deftest test-ai-config-add-file-to-context-adds-existing-file ()
+ "Normal: an existing file is added and the function returns t."
+ (let ((tmp (make-temp-file "ai-config-add-file-")))
+ (unwind-protect
+ (let ((gptel-context--alist nil)
+ (added nil))
+ (cl-letf (((symbol-function 'gptel-add-file)
+ (lambda (f) (setq added f)))
+ ((symbol-function 'message) #'ignore))
+ (should (eq (cj/gptel--add-file-to-context tmp) t))
+ (should (equal added tmp))))
+ (delete-file tmp))))
+
+(ert-deftest test-ai-config-add-file-to-context-skips-missing-file ()
+ "Boundary: a non-existent path returns nil and doesn't call gptel-add-file."
+ (let ((called nil))
+ (cl-letf (((symbol-function 'gptel-add-file)
+ (lambda (_) (setq called t))))
+ (should-not (cj/gptel--add-file-to-context "/no/such/path"))
+ (should-not called))))
+
+(ert-deftest test-ai-config-add-file-to-context-skips-nil-path ()
+ "Boundary: a nil path returns nil without calling gptel-add-file."
+ (let ((called nil))
+ (cl-letf (((symbol-function 'gptel-add-file)
+ (lambda (_) (setq called t))))
+ (should-not (cj/gptel--add-file-to-context nil))
+ (should-not called))))
+
+;;; cj/gptel-clear-buffer
+
+(ert-deftest test-ai-config-clear-buffer-erases-in-gptel-org-buffer ()
+ "Normal: a gptel-mode org buffer is erased and the fresh org prefix is reinserted."
+ (with-temp-buffer
+ (delay-mode-hooks (org-mode))
+ (setq-local gptel-mode t)
+ (insert "* Existing conversation\nstuff\n")
+ (let ((msg nil))
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (setq msg (apply #'format fmt args)))))
+ (cj/gptel-clear-buffer))
+ (should (string-match-p "cleared" msg)))
+ ;; The fresh prefix is an org heading starting with "* ".
+ (should (string-prefix-p "* " (buffer-string)))
+ (should-not (string-match-p "Existing conversation" (buffer-string)))))
+
+(ert-deftest test-ai-config-clear-buffer-noop-when-not-gptel-org ()
+ "Boundary: in a non-gptel buffer the function messages and changes nothing."
+ (with-temp-buffer
+ (insert "untouched\n")
+ (let ((msg nil))
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (setq msg (apply #'format fmt args)))))
+ (cj/gptel-clear-buffer))
+ (should (string-match-p "Not a GPTel buffer" msg))
+ (should (equal (buffer-string) "untouched\n")))))
+
+;;; cj/gptel-context-clear
+
+(ert-deftest test-ai-config-context-clear-uses-remove-all-when-available ()
+ "Normal: when `gptel-context-remove-all' is bound, it wins the cond.
+The stub must be a command because `cj/gptel-context-clear' invokes it
+via `call-interactively'."
+ (let ((called nil)
+ (msg nil))
+ (cl-letf (((symbol-function 'gptel-context-remove-all)
+ (lambda () (interactive) (setq called 'remove-all)))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
+ (cj/gptel-context-clear))
+ (should (eq called 'remove-all))
+ (should (string-match-p "cleared" msg))))
+
+(ert-deftest test-ai-config-context-clear-falls-back-to-alist-setq ()
+ "Boundary: when no clearing function exists, the alist is set to nil."
+ (let ((gptel-context--alist '((:dummy)))
+ (msg nil))
+ (cl-letf (((symbol-function 'fboundp)
+ (lambda (sym)
+ (not (memq sym '(gptel-context-remove-all gptel-context-clear)))))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
+ (cj/gptel-context-clear))
+ (should (null gptel-context--alist))
+ (should (string-match-p "cleared" msg))))
+
+;;; cj/gptel-insert-model-heading
+
+(ert-deftest test-ai-config-insert-model-heading-inserts-at-given-position ()
+ "Normal: an Org heading is inserted at RESPONSE-BEGIN-POS."
+ (with-temp-buffer
+ (insert "response text")
+ (cl-letf (((symbol-function 'cj/gptel-backend-and-model)
+ (lambda () "Anthropic: claude-test [2026-05-13 12:00:00]")))
+ (cj/gptel-insert-model-heading (point-min) (point-max)))
+ (should (string-prefix-p "* Anthropic: claude-test" (buffer-string)))
+ (should (string-match-p "\nresponse text" (buffer-string)))))
+
+(provide 'test-ai-config-helpers)
+;;; test-ai-config-helpers.el ends here