diff options
| -rw-r--r-- | modules/ai-config.el | 62 | ||||
| -rw-r--r-- | tests/test-ai-config-build-model-list.el | 101 | ||||
| -rw-r--r-- | tests/test-ai-config-current-model-selection.el | 74 |
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 |
