aboutsummaryrefslogtreecommitdiff
path: root/tests/test-ai-config-helpers.el
blob: cdbc0f6ebec187192b933f7cf72d11f106e00536 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
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