aboutsummaryrefslogtreecommitdiff
path: root/tests/test-ai-term--runtime.el
blob: 7644e46813324240117c7166726d9a3c8f2d448d (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
;;; test-ai-term--runtime.el --- Tests for the ai-term runtime selection -*- lexical-binding: t; -*-

;;; Commentary:
;; Multi-backend launch: a fresh agent session can run Claude, Codex, or a
;; local model through codex --oss (ollama).  The runtime names and launch
;; strings mirror the rulesets bin/ai launcher so the two stay one mental
;; model: "claude", "codex", and "local:<model>".
;;
;; Pure pieces tested here:
;; - `cj/--ai-term-runtime-command' maps a runtime name to the full shell
;;   command (agent CLI + the shared opening prompt).
;; - `cj/--ai-term-parse-runtime-lines' parses `ai --print-runtimes' output
;;   into (NAME . LABEL) choices.
;; - `cj/--ai-term-runtime-choices' shells out to `ai' at its boundary
;;   (mocked here) and falls back to the static list when `ai' is absent.
;; The interactive picker is a thin completing-read wrapper and is not
;; tested (Interactive vs Internal split).

;;; Code:

(require 'ert)
(require 'cl-lib)

(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'ai-term)

;;; ------------------------- runtime -> command ------------------------------

(ert-deftest test-ai-term-runtime-command-claude-is-agent-command ()
  "Normal: \"claude\" (and nil) return `cj/ai-term-agent-command' verbatim."
  (let ((cj/ai-term-agent-command "claude \"do the thing\""))
    (should (equal (cj/--ai-term-runtime-command "claude")
                   "claude \"do the thing\""))
    (should (equal (cj/--ai-term-runtime-command nil)
                   "claude \"do the thing\""))))

(ert-deftest test-ai-term-runtime-command-codex-composes-prompt ()
  "Normal: \"codex\" is the codex CLI plus the shared opening prompt."
  (let ((cj/ai-term-agent-prompt "read the protocols"))
    (should (equal (cj/--ai-term-runtime-command "codex")
                   (concat "codex " (shell-quote-argument "read the protocols"))))))

(ert-deftest test-ai-term-runtime-command-local-model ()
  "Normal: \"local:<model>\" runs codex --oss against that ollama model.
The model is shell-quoted, and POSIX `shell-quote-argument' backslash-escapes
the colons, so the assertion matches the quoted form."
  (let ((cj/ai-term-agent-prompt "read the protocols"))
    (let ((cmd (cj/--ai-term-runtime-command "local:gpt-oss:120b")))
      (should (string-prefix-p "codex --oss --local-provider=ollama -m " cmd))
      (should (string-match-p
               (regexp-quote (shell-quote-argument "gpt-oss:120b")) cmd))
      (should (string-suffix-p (shell-quote-argument "read the protocols") cmd)))))

(ert-deftest test-ai-term-runtime-command-boundary-empty-model ()
  "Boundary: \"local:\" with no model is rejected, not launched half-formed."
  (should-error (cj/--ai-term-runtime-command "local:") :type 'user-error))

(ert-deftest test-ai-term-runtime-command-error-unknown-runtime ()
  "Error: an unknown runtime name signals a `user-error' naming it."
  (should-error (cj/--ai-term-runtime-command "gemini") :type 'user-error)
  (condition-case err
      (cj/--ai-term-runtime-command "gemini")
    (user-error (should (string-match-p "gemini" (cadr err))))))

;;; --------------------------- choice-list parsing ---------------------------

(ert-deftest test-ai-term-parse-runtime-lines-normal ()
  "Normal: `ai --print-runtimes' lines parse into (NAME . LABEL) pairs."
  (should (equal (cj/--ai-term-parse-runtime-lines
                  "claude — Claude Code\ncodex — ChatGPT (Codex CLI)\nlocal:gpt-oss:120b — ollama\n")
                 '(("claude" . "Claude Code")
                   ("codex" . "ChatGPT (Codex CLI)")
                   ("local:gpt-oss:120b" . "ollama")))))

(ert-deftest test-ai-term-parse-runtime-lines-boundary-junk ()
  "Boundary: blank lines and lines without a separator are dropped."
  (should (equal (cj/--ai-term-parse-runtime-lines
                  "\nclaude — Claude Code\nwarning: something\n\n")
                 '(("claude" . "Claude Code")))))

(ert-deftest test-ai-term-parse-runtime-lines-boundary-empty ()
  "Boundary: empty output parses to nil."
  (should (null (cj/--ai-term-parse-runtime-lines ""))))

;;; ------------------------------ choice list --------------------------------

(ert-deftest test-ai-term-runtime-choices-uses-ai-launcher ()
  "Normal: when the `ai' launcher exists, its runtime list is the choice list."
  (cl-letf (((symbol-function 'executable-find)
             (lambda (prog &rest _) (when (equal prog "ai") "/usr/bin/ai")))
            ((symbol-function 'process-file)
             (lambda (_prog _infile buffer _display &rest _args)
               (with-current-buffer (cond ((eq buffer t) (current-buffer))
                                          ((consp buffer) (car buffer))
                                          (t buffer))
                 (insert "claude — Claude Code\nlocal:q — ollama\n"))
               0)))
    (should (equal (cj/--ai-term-runtime-choices)
                   '(("claude" . "Claude Code") ("local:q" . "ollama"))))))

(ert-deftest test-ai-term-runtime-choices-fallback-without-ai ()
  "Boundary: with no `ai' launcher, the static claude-first list stands."
  (cl-letf (((symbol-function 'executable-find) (lambda (&rest _) nil)))
    (let ((choices (cj/--ai-term-runtime-choices)))
      (should (equal (caar choices) "claude"))
      (should (assoc "codex" choices)))))

(ert-deftest test-ai-term-runtime-choices-error-ai-fails ()
  "Error: a nonzero exit from `ai' falls back instead of erroring."
  (cl-letf (((symbol-function 'executable-find)
             (lambda (prog &rest _) (when (equal prog "ai") "/usr/bin/ai")))
            ((symbol-function 'process-file) (lambda (&rest _) 1)))
    (should (equal (caar (cj/--ai-term-runtime-choices)) "claude"))))

;;; --------------------- launch command takes an override --------------------

(ert-deftest test-ai-term-launch-command-runtime-override ()
  "Normal: an explicit agent command is embedded instead of the default.
The inner command is shell-quoted by the launch builder, so the assertion
matches the quoted form."
  (let ((cj/ai-term-agent-command "claude default"))
    (let ((cmd (cj/--ai-term-launch-command "/tmp/proj" "codex prompted")))
      (should (string-match-p
               (regexp-quote (shell-quote-argument "codex prompted; exec bash"))
               cmd))
      (should-not (string-match-p "claude" cmd)))))

(ert-deftest test-ai-term-launch-command-no-override-falls-back ()
  "Boundary: without an override the configured agent command is used."
  (let ((cj/ai-term-agent-command "claude default"))
    (should (string-match-p
             (regexp-quote (shell-quote-argument "claude default; exec bash"))
             (cj/--ai-term-launch-command "/tmp/proj")))))

(provide 'test-ai-term--runtime)
;;; test-ai-term--runtime.el ends here