summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/ai-config.el62
-rw-r--r--tests/test-ai-config-build-model-list.el101
-rw-r--r--tests/test-ai-config-current-model-selection.el74
3 files changed, 217 insertions, 20 deletions
diff --git a/modules/ai-config.el b/modules/ai-config.el
index d8e73cf9..651acc74 100644
--- a/modules/ai-config.el
+++ b/modules/ai-config.el
@@ -118,38 +118,60 @@ Ensures gptel and backends are initialized."
((symbolp m) (symbol-name m))
(t (format "%s" m))))
-;; Backend/model switching commands (moved out of use-package so they are commandp)
+;; Backend/model switching helpers (pure logic, extracted for testability)
+
+(defun cj/gptel--build-model-list (backends model-fn)
+ "Build a flat list of all models across BACKENDS.
+BACKENDS is an alist of (NAME . BACKEND-OBJECT). MODEL-FN is called
+with each backend object and should return a list of model identifiers.
+Returns a list of entries: (DISPLAY-STRING BACKEND MODEL-STRING BACKEND-NAME)
+where DISPLAY-STRING is \"Backend: model\" for use in completing-read."
+ (mapcan
+ (lambda (pair)
+ (let* ((backend-name (car pair))
+ (backend (cdr pair))
+ (models (funcall model-fn backend)))
+ (mapcar (lambda (m)
+ (list (format "%s: %s" backend-name (cj/gptel--model-to-string m))
+ backend
+ (cj/gptel--model-to-string m)
+ backend-name))
+ models)))
+ backends))
+
+(defun cj/gptel--current-model-selection (backends current-backend current-model)
+ "Format the current backend/model as a display string.
+BACKENDS is the alist from `cj/gptel--available-backends'.
+CURRENT-BACKEND and CURRENT-MODEL are the active gptel settings.
+Returns a string like \"Anthropic - Claude: claude-opus-4-6\"."
+ (let ((backend-name (car (rassoc current-backend backends))))
+ (format "%s: %s"
+ (or backend-name "AI")
+ (cj/gptel--model-to-string current-model))))
+
+;; Backend/model switching commands
(defun cj/gptel-change-model ()
"Change the GPTel backend and select a model from that backend.
Present all available models from every backend, switching backends when
necessary. Prompt for whether to apply the selection globally or buffer-locally."
(interactive)
(let* ((backends (cj/gptel--available-backends))
- (all-models
- (mapcan
- (lambda (pair)
- (let* ((backend-name (car pair))
- (backend (cdr pair))
- (models (when (fboundp 'gptel-backend-models)
- (gptel-backend-models backend))))
- (mapcar (lambda (m)
- (list (format "%s: %s" backend-name (cj/gptel--model-to-string m))
- backend
- (cj/gptel--model-to-string m)
- backend-name))
- models)))
- backends))
- (current-backend-name (car (rassoc (bound-and-true-p gptel-backend) backends)))
- (current-selection (format "%s: %s"
- (or current-backend-name "AI")
- (cj/gptel--model-to-string (bound-and-true-p gptel-model))))
+ (all-models (cj/gptel--build-model-list
+ backends
+ (lambda (b)
+ (when (fboundp 'gptel-backend-models)
+ (gptel-backend-models b)))))
+ (current-selection (cj/gptel--current-model-selection
+ backends
+ (bound-and-true-p gptel-backend)
+ (bound-and-true-p gptel-model)))
(scope (completing-read "Set model for: " '("buffer" "global") nil t))
(selected (completing-read
(format "Select model (current: %s): " current-selection)
(mapcar #'car all-models) nil t nil nil current-selection)))
(let* ((model-info (assoc selected all-models))
(backend (nth 1 model-info))
- (model (intern (nth 2 model-info))) ;; Convert string to symbol
+ (model (intern (nth 2 model-info)))
(backend-name (nth 3 model-info)))
(if (string= scope "global")
(progn
diff --git a/tests/test-ai-config-build-model-list.el b/tests/test-ai-config-build-model-list.el
new file mode 100644
index 00000000..82703603
--- /dev/null
+++ b/tests/test-ai-config-build-model-list.el
@@ -0,0 +1,101 @@
+;;; test-ai-config-build-model-list.el --- Tests for cj/gptel--build-model-list -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/gptel--build-model-list from ai-config.el.
+;;
+;; Pure function that takes a backends alist and a model-fetching function,
+;; and produces a flat list of (DISPLAY-STRING BACKEND MODEL-STRING BACKEND-NAME)
+;; entries suitable for completing-read. Exercises the mapping and string
+;; formatting logic that was previously embedded in cj/gptel-change-model.
+
+;;; Code:
+
+(require 'ert)
+
+(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)
+(require 'ai-config)
+
+;;; Normal Cases
+
+(ert-deftest test-ai-config-build-model-list-normal-single-backend-single-model ()
+ "One backend with one model should produce one entry."
+ (let* ((backend-obj 'fake-backend)
+ (backends `(("Claude" . ,backend-obj)))
+ (result (cj/gptel--build-model-list backends (lambda (_) '("opus")))))
+ (should (= 1 (length result)))
+ (should (equal (car (nth 0 result)) "Claude: opus"))
+ (should (eq (nth 1 (nth 0 result)) backend-obj))
+ (should (equal (nth 2 (nth 0 result)) "opus"))
+ (should (equal (nth 3 (nth 0 result)) "Claude"))))
+
+(ert-deftest test-ai-config-build-model-list-normal-single-backend-multiple-models ()
+ "One backend with multiple models should produce one entry per model."
+ (let* ((backends '(("Claude" . backend-a)))
+ (result (cj/gptel--build-model-list
+ backends (lambda (_) '("opus" "sonnet" "haiku")))))
+ (should (= 3 (length result)))
+ (should (equal (mapcar #'car result)
+ '("Claude: opus" "Claude: sonnet" "Claude: haiku")))))
+
+(ert-deftest test-ai-config-build-model-list-normal-multiple-backends ()
+ "Multiple backends should interleave their models in backend order."
+ (let* ((backends '(("Claude" . backend-a) ("OpenAI" . backend-b)))
+ (result (cj/gptel--build-model-list
+ backends
+ (lambda (b)
+ (if (eq b 'backend-a) '("opus") '("gpt-4o"))))))
+ (should (= 2 (length result)))
+ (should (equal (car (nth 0 result)) "Claude: opus"))
+ (should (equal (car (nth 1 result)) "OpenAI: gpt-4o"))))
+
+(ert-deftest test-ai-config-build-model-list-normal-preserves-backend-object ()
+ "Each entry should carry the original backend object for later use."
+ (let* ((obj (vector 'struct "Claude"))
+ (backends `(("Claude" . ,obj)))
+ (result (cj/gptel--build-model-list backends (lambda (_) '("opus")))))
+ (should (eq (nth 1 (nth 0 result)) obj))))
+
+(ert-deftest test-ai-config-build-model-list-normal-symbol-models-converted ()
+ "Symbol model identifiers should be converted to strings via model-to-string."
+ (let* ((backends '(("Claude" . backend-a)))
+ (result (cj/gptel--build-model-list
+ backends (lambda (_) '(opus sonnet)))))
+ (should (equal (nth 2 (nth 0 result)) "opus"))
+ (should (equal (nth 2 (nth 1 result)) "sonnet"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-ai-config-build-model-list-boundary-empty-backends ()
+ "Empty backends list should produce empty result."
+ (should (null (cj/gptel--build-model-list nil (lambda (_) '("x"))))))
+
+(ert-deftest test-ai-config-build-model-list-boundary-backend-with-no-models ()
+ "Backend returning no models should contribute no entries."
+ (let* ((backends '(("Claude" . backend-a)))
+ (result (cj/gptel--build-model-list backends (lambda (_) nil))))
+ (should (null result))))
+
+(ert-deftest test-ai-config-build-model-list-boundary-mixed-empty-and-populated ()
+ "Only backends with models should produce entries."
+ (let* ((backends '(("Claude" . backend-a) ("Empty" . backend-b) ("OpenAI" . backend-c)))
+ (result (cj/gptel--build-model-list
+ backends
+ (lambda (b)
+ (cond ((eq b 'backend-a) '("opus"))
+ ((eq b 'backend-b) nil)
+ ((eq b 'backend-c) '("gpt-4o")))))))
+ (should (= 2 (length result)))
+ (should (equal (nth 3 (nth 0 result)) "Claude"))
+ (should (equal (nth 3 (nth 1 result)) "OpenAI"))))
+
+(ert-deftest test-ai-config-build-model-list-boundary-model-with-special-characters ()
+ "Model names with special characters should be preserved in display string."
+ (let* ((backends '(("Claude" . backend-a)))
+ (result (cj/gptel--build-model-list
+ backends (lambda (_) '("claude-haiku-4-5-20251001")))))
+ (should (equal (car (nth 0 result)) "Claude: claude-haiku-4-5-20251001"))))
+
+(provide 'test-ai-config-build-model-list)
+;;; test-ai-config-build-model-list.el ends here
diff --git a/tests/test-ai-config-current-model-selection.el b/tests/test-ai-config-current-model-selection.el
new file mode 100644
index 00000000..14f9391c
--- /dev/null
+++ b/tests/test-ai-config-current-model-selection.el
@@ -0,0 +1,74 @@
+;;; test-ai-config-current-model-selection.el --- Tests for cj/gptel--current-model-selection -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/gptel--current-model-selection from ai-config.el.
+;;
+;; Pure function that formats the active backend and model into a display
+;; string like "Anthropic - Claude: claude-opus-4-6". Used as the default
+;; selection in the model-switching completing-read prompt.
+
+;;; Code:
+
+(require 'ert)
+
+(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)
+(require 'ai-config)
+
+;;; Normal Cases
+
+(ert-deftest test-ai-config-current-model-selection-normal-matching-backend ()
+ "When current backend is in the backends alist, use its display name."
+ (let* ((backend-obj 'my-backend)
+ (backends `(("Anthropic - Claude" . ,backend-obj))))
+ (should (equal (cj/gptel--current-model-selection backends backend-obj "opus")
+ "Anthropic - Claude: opus"))))
+
+(ert-deftest test-ai-config-current-model-selection-normal-symbol-model ()
+ "Symbol model should be converted to string in the output."
+ (let* ((backend-obj 'my-backend)
+ (backends `(("Claude" . ,backend-obj))))
+ (should (equal (cj/gptel--current-model-selection backends backend-obj 'opus)
+ "Claude: opus"))))
+
+(ert-deftest test-ai-config-current-model-selection-normal-multiple-backends ()
+ "Should find the correct backend name among multiple backends."
+ (let* ((backend-a 'backend-a)
+ (backend-b 'backend-b)
+ (backends `(("Claude" . ,backend-a) ("OpenAI" . ,backend-b))))
+ (should (equal (cj/gptel--current-model-selection backends backend-b "gpt-4o")
+ "OpenAI: gpt-4o"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-ai-config-current-model-selection-boundary-nil-backend-shows-ai ()
+ "Nil backend (not in alist) should fall back to \"AI\"."
+ (should (equal (cj/gptel--current-model-selection '(("Claude" . x)) nil "opus")
+ "AI: opus")))
+
+(ert-deftest test-ai-config-current-model-selection-boundary-unknown-backend-shows-ai ()
+ "Backend not found in alist should fall back to \"AI\"."
+ (should (equal (cj/gptel--current-model-selection
+ '(("Claude" . backend-a)) 'unknown-backend "opus")
+ "AI: opus")))
+
+(ert-deftest test-ai-config-current-model-selection-boundary-nil-model ()
+ "Nil model should produce \"nil\" in the model position (symbolp nil)."
+ (let* ((backend 'my-backend)
+ (backends `(("Claude" . ,backend))))
+ (should (equal (cj/gptel--current-model-selection backends backend nil)
+ "Claude: nil"))))
+
+(ert-deftest test-ai-config-current-model-selection-boundary-empty-backends ()
+ "Empty backends alist should fall back to \"AI\" for backend name."
+ (should (equal (cj/gptel--current-model-selection nil 'anything "model")
+ "AI: model")))
+
+(ert-deftest test-ai-config-current-model-selection-boundary-both-nil ()
+ "Nil backend and nil model should produce \"AI: nil\"."
+ (should (equal (cj/gptel--current-model-selection nil nil nil)
+ "AI: nil")))
+
+(provide 'test-ai-config-current-model-selection)
+;;; test-ai-config-current-model-selection.el ends here