From e41c25068d0cec9434895a6d3e3a25d3a26f645f Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 23 Jun 2026 20:12:58 -0400 Subject: chore(ai): archive gptel and remove it from the live config I archived gptel to archive/gptel/ since I rarely use it. Moved there: the six gptel modules (ai-config, ai-conversations, ai-conversations-browser, ai-mcp, ai-quick-ask, ai-rewrite), the gptel-tools/ directory, custom/gptel-prompts.el, their test files and utilities, and the four gptel-only specs. Scrubbed from the live config: the ai-config require in init.el, which also drops the whole C-; a keymap; the gptel-mode emojify hook in font-config.el; the gptel-tools entries in the Makefile clean target and the coverage runner; and the gptel feature notes in README. Cancelled the open gptel tasks in todo.org (the AI Open Work issues, the feature-extension brainstorm, the velox gptel-magit bug). ai-term stays. It is the ghostel Claude launcher, independent of gptel. Verified: every module loads, a batch init launch reaches completion clean, and the full test suite shows only pre-existing coverage failures unrelated to this change. --- tests/run-coverage-file.el | 1 - tests/test-ai-config--apply-model-selection.el | 45 -- tests/test-ai-config-auth-source-secret.el | 27 -- tests/test-ai-config-backend-and-model.el | 78 ---- tests/test-ai-config-build-model-list.el | 101 ---- tests/test-ai-config-commands.el | 160 ------- tests/test-ai-config-current-model-selection.el | 74 --- tests/test-ai-config-fresh-org-prefix.el | 65 --- tests/test-ai-config-gptel-backend-libs.el | 58 --- tests/test-ai-config-gptel-commands.el | 155 ------- tests/test-ai-config-gptel-local-tools.el | 57 --- tests/test-ai-config-gptel-magit-lazy-loading.el | 151 ------ tests/test-ai-config-helpers.el | 183 -------- tests/test-ai-config-model-to-string.el | 60 --- tests/test-ai-config-model-to-symbol.el | 61 --- tests/test-ai-conversations-browser.el | 244 ---------- tests/test-ai-conversations.el | 564 ----------------------- tests/test-ai-mcp-helpers.el | 419 ----------------- tests/test-ai-quick-ask.el | 149 ------ tests/test-ai-rewrite.el | 159 ------- tests/test-gptel-tools-git-diff.el | 163 ------- tests/test-gptel-tools-git-log.el | 183 -------- tests/test-gptel-tools-git-status.el | 124 ----- tests/test-gptel-tools-list-directory-files.el | 257 ----------- tests/test-gptel-tools-move-to-trash.el | 219 --------- tests/test-gptel-tools-read-buffer.el | 74 --- tests/test-gptel-tools-read-text-file.el | 201 -------- tests/test-gptel-tools-web-fetch.el | 230 --------- tests/test-gptel-tools-write-text-file.el | 223 --------- tests/test-init-module-headers.el | 1 - tests/test-update-text-file.el | 473 ------------------- tests/testutil-ai-config.el | 81 ---- tests/testutil-filesystem.el | 180 -------- 33 files changed, 5220 deletions(-) delete mode 100644 tests/test-ai-config--apply-model-selection.el delete mode 100644 tests/test-ai-config-auth-source-secret.el delete mode 100644 tests/test-ai-config-backend-and-model.el delete mode 100644 tests/test-ai-config-build-model-list.el delete mode 100644 tests/test-ai-config-commands.el delete mode 100644 tests/test-ai-config-current-model-selection.el delete mode 100644 tests/test-ai-config-fresh-org-prefix.el delete mode 100644 tests/test-ai-config-gptel-backend-libs.el delete mode 100644 tests/test-ai-config-gptel-commands.el delete mode 100644 tests/test-ai-config-gptel-local-tools.el delete mode 100644 tests/test-ai-config-gptel-magit-lazy-loading.el delete mode 100644 tests/test-ai-config-helpers.el delete mode 100644 tests/test-ai-config-model-to-string.el delete mode 100644 tests/test-ai-config-model-to-symbol.el delete mode 100644 tests/test-ai-conversations-browser.el delete mode 100644 tests/test-ai-conversations.el delete mode 100644 tests/test-ai-mcp-helpers.el delete mode 100644 tests/test-ai-quick-ask.el delete mode 100644 tests/test-ai-rewrite.el delete mode 100644 tests/test-gptel-tools-git-diff.el delete mode 100644 tests/test-gptel-tools-git-log.el delete mode 100644 tests/test-gptel-tools-git-status.el delete mode 100644 tests/test-gptel-tools-list-directory-files.el delete mode 100644 tests/test-gptel-tools-move-to-trash.el delete mode 100644 tests/test-gptel-tools-read-buffer.el delete mode 100644 tests/test-gptel-tools-read-text-file.el delete mode 100644 tests/test-gptel-tools-web-fetch.el delete mode 100644 tests/test-gptel-tools-write-text-file.el delete mode 100644 tests/test-update-text-file.el delete mode 100644 tests/testutil-ai-config.el delete mode 100644 tests/testutil-filesystem.el (limited to 'tests') diff --git a/tests/run-coverage-file.el b/tests/run-coverage-file.el index 0d96f1918..0cbfed4f5 100644 --- a/tests/run-coverage-file.el +++ b/tests/run-coverage-file.el @@ -32,7 +32,6 @@ (setq undercover-force-coverage t) (undercover "modules/*.el" - "gptel-tools/*.el" (:report-format 'simplecov) (:report-file ".coverage/simplecov.json") (:merge-report t) diff --git a/tests/test-ai-config--apply-model-selection.el b/tests/test-ai-config--apply-model-selection.el deleted file mode 100644 index 4ccd6d7a0..000000000 --- a/tests/test-ai-config--apply-model-selection.el +++ /dev/null @@ -1,45 +0,0 @@ -;;; test-ai-config--apply-model-selection.el --- Tests for cj/--gptel-apply-model-selection -*- lexical-binding: t; -*- - -;;; Commentary: -;; cj/--gptel-apply-model-selection is the apply step extracted from the -;; interactive cj/gptel-change-model: it sets gptel-backend/gptel-model globally -;; or buffer-locally and returns the confirmation message. The extraction also -;; dropped a dead `(if (stringp model) ...)' branch (model is always a symbol by -;; that point). - -;;; Code: - -(require 'ert) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-config) - -(defvar gptel-backend) -(defvar gptel-model) - -(ert-deftest test-ai-config-apply-model-global-sets-globals () - "Normal: global scope assigns the global vars and reports (global)." - (let ((gptel-backend nil) (gptel-model nil)) - (let ((msg (cj/--gptel-apply-model-selection "global" 'mybackend 'mymodel "MyAI"))) - (should (eq gptel-backend 'mybackend)) - (should (eq gptel-model 'mymodel)) - (should (string-match-p "MyAI" msg)) - (should (string-match-p "mymodel" msg)) - (should (string-match-p "global" msg))))) - -(ert-deftest test-ai-config-apply-model-buffer-sets-buffer-locals () - "Normal: buffer scope makes the vars buffer-local and reports (buffer-local)." - (let ((gptel-backend 'orig) (gptel-model 'origm)) - (with-temp-buffer - (let ((msg (cj/--gptel-apply-model-selection "buffer" 'be 'mo "Name"))) - (should (local-variable-p 'gptel-backend)) - (should (local-variable-p 'gptel-model)) - (should (eq gptel-backend 'be)) - (should (eq gptel-model 'mo)) - (should (string-match-p "buffer-local" msg)))) - ;; outside the temp buffer the globals are untouched - (should (eq gptel-backend 'orig)) - (should (eq gptel-model 'origm)))) - -(provide 'test-ai-config--apply-model-selection) -;;; test-ai-config--apply-model-selection.el ends here diff --git a/tests/test-ai-config-auth-source-secret.el b/tests/test-ai-config-auth-source-secret.el deleted file mode 100644 index bab506e5f..000000000 --- a/tests/test-ai-config-auth-source-secret.el +++ /dev/null @@ -1,27 +0,0 @@ -;;; test-ai-config-auth-source-secret.el --- Tests for the required-secret wrapper -*- lexical-binding: t; -*- - -;;; Commentary: -;; `cj/auth-source-secret' is the required-secret layer over the shared -;; `cj/auth-source-secret-value' primitive: it returns the secret, or errors -;; when none is found. These tests stub the primitive to exercise both paths. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-config) - -(ert-deftest test-ai-config-auth-source-secret-returns-value () - "Normal: returns the value the primitive resolves." - (cl-letf (((symbol-function 'cj/auth-source-secret-value) (lambda (&rest _) "sk-x"))) - (should (equal "sk-x" (cj/auth-source-secret "api.example.com" "apikey"))))) - -(ert-deftest test-ai-config-auth-source-secret-errors-on-miss () - "Error: signals when the primitive finds no secret." - (cl-letf (((symbol-function 'cj/auth-source-secret-value) (lambda (&rest _) nil))) - (should-error (cj/auth-source-secret "api.example.com" "apikey")))) - -(provide 'test-ai-config-auth-source-secret) -;;; test-ai-config-auth-source-secret.el ends here diff --git a/tests/test-ai-config-backend-and-model.el b/tests/test-ai-config-backend-and-model.el deleted file mode 100644 index c03c58a2d..000000000 --- a/tests/test-ai-config-backend-and-model.el +++ /dev/null @@ -1,78 +0,0 @@ -;;; test-ai-config-backend-and-model.el --- Tests for cj/gptel-backend-and-model -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for cj/gptel-backend-and-model from ai-config.el. -;; -;; Returns a formatted string "backend: model [timestamp]" for use in -;; org headings marking AI responses. Uses pcase to extract the display -;; name from vector backends, falling back to "AI" otherwise. - -;;; 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-backend-and-model-normal-vector-backend-extracts-name () - "Vector backend should use element at index 1 as display name." - (let ((gptel-backend (vector 'cl-struct "Claude")) - (gptel-model "claude-opus-4-6")) - (let ((result (cj/gptel-backend-and-model))) - (should (string-match-p "^Claude:" result)) - (should (string-match-p "claude-opus-4-6" result))))) - -(ert-deftest test-ai-config-backend-and-model-normal-contains-timestamp () - "Result should contain a bracketed timestamp." - (let ((gptel-backend nil) - (gptel-model nil)) - (should (string-match-p "\\[[-0-9]+ [0-9]+:[0-9]+:[0-9]+\\]" - (cj/gptel-backend-and-model))))) - -(ert-deftest test-ai-config-backend-and-model-normal-format-structure () - "Result should follow 'backend: model [timestamp]' format." - (let ((gptel-backend (vector 'cl-struct "TestBackend")) - (gptel-model "test-model")) - (should (string-match-p "^TestBackend: test-model \\[" - (cj/gptel-backend-and-model))))) - -;;; Boundary Cases - -(ert-deftest test-ai-config-backend-and-model-boundary-nil-backend-shows-ai () - "Nil backend should fall back to \"AI\" display name." - (let ((gptel-backend nil) - (gptel-model "some-model")) - (should (string-match-p "^AI:" (cj/gptel-backend-and-model))))) - -(ert-deftest test-ai-config-backend-and-model-boundary-nil-model-shows-empty () - "Nil model should produce empty string in model position." - (let ((gptel-backend nil) - (gptel-model nil)) - (should (string-match-p "^AI: \\[" (cj/gptel-backend-and-model))))) - -(ert-deftest test-ai-config-backend-and-model-boundary-string-backend-shows-ai () - "String backend (not vector) should fall back to \"AI\"." - (let ((gptel-backend "just-a-string") - (gptel-model "model")) - (should (string-match-p "^AI:" (cj/gptel-backend-and-model))))) - -(ert-deftest test-ai-config-backend-and-model-boundary-symbol-model-formatted () - "Symbol model should be formatted as its print representation." - (let ((gptel-backend nil) - (gptel-model 'some-model)) - (should (string-match-p "some-model" (cj/gptel-backend-and-model))))) - -(ert-deftest test-ai-config-backend-and-model-boundary-timestamp-reflects-today () - "Timestamp should contain today's date." - (let ((gptel-backend nil) - (gptel-model nil) - (today (format-time-string "%Y-%m-%d"))) - (should (string-match-p (regexp-quote today) - (cj/gptel-backend-and-model))))) - -(provide 'test-ai-config-backend-and-model) -;;; test-ai-config-backend-and-model.el ends here diff --git a/tests/test-ai-config-build-model-list.el b/tests/test-ai-config-build-model-list.el deleted file mode 100644 index 827036038..000000000 --- a/tests/test-ai-config-build-model-list.el +++ /dev/null @@ -1,101 +0,0 @@ -;;; 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-commands.el b/tests/test-ai-config-commands.el deleted file mode 100644 index fed06d82b..000000000 --- a/tests/test-ai-config-commands.el +++ /dev/null @@ -1,160 +0,0 @@ -;;; test-ai-config-commands.el --- Tests for ai-config interactive commands -*- lexical-binding: t; -*- - -;;; Commentary: -;; Sibling tests cover the pure helpers (model-to-string, build-model-list, -;; current-model-selection, fresh-org-prefix, backend-and-model). This -;; file covers the user-facing wrappers: -;; -;; cj/gptel--available-backends -;; cj/gptel-change-model -;; cj/gptel-add-file -;; cj/gptel-add-this-buffer -;; cj/toggle-gptel -;; cj/gptel-context-clear - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-config) - -;; Top-level defvars so let-bindings reach the dynamic binding under -;; lexical scope. -(defvar gptel-backend nil) -(defvar gptel-model nil) -(defvar gptel-claude-backend nil) -(defvar gptel-chatgpt-backend nil) -(defvar gptel-context--alist nil) - -;;; cj/gptel--available-backends - -(ert-deftest test-ai-available-backends-returns-claude-and-chatgpt () - "Normal: both backends present become alist entries." - (let ((gptel-claude-backend 'claude-obj) - (gptel-chatgpt-backend 'chatgpt-obj)) - (cl-letf (((symbol-function 'require) (lambda (&rest _) t)) - ((symbol-function 'cj/ensure-gptel-backends) #'ignore)) - (let ((result (cj/gptel--available-backends))) - (should (equal (assoc "Anthropic - Claude" result) - '("Anthropic - Claude" . claude-obj))) - (should (equal (assoc "OpenAI - ChatGPT" result) - '("OpenAI - ChatGPT" . chatgpt-obj))))))) - -(ert-deftest test-ai-available-backends-skips-nil-entries () - "Boundary: only configured backends appear in the alist." - (let ((gptel-claude-backend nil) - (gptel-chatgpt-backend 'chatgpt-only)) - (cl-letf (((symbol-function 'require) (lambda (&rest _) t)) - ((symbol-function 'cj/ensure-gptel-backends) #'ignore)) - (let ((result (cj/gptel--available-backends))) - (should-not (assoc "Anthropic - Claude" result)) - (should (assoc "OpenAI - ChatGPT" result)))))) - -;;; cj/gptel-change-model - -(ert-deftest test-ai-change-model-global-sets-globals-and-messages () - "Normal: choosing 'global' sets `gptel-backend' and `gptel-model' -globally and reports via `message'." - (let ((gptel-backend 'old-backend) - (gptel-model 'old-model) - (gptel-claude-backend 'claude-obj) - (gptel-chatgpt-backend nil) - msg) - (cl-letf (((symbol-function 'require) (lambda (&rest _) t)) - ((symbol-function 'cj/ensure-gptel-backends) #'ignore) - ((symbol-function 'gptel-backend-models) - (lambda (_) '("claude-opus-4-7"))) - ((symbol-function 'completing-read) - (lambda (prompt &rest _) - (if (string-prefix-p "Set model for" prompt) - "global" - "Anthropic - Claude: claude-opus-4-7"))) - ((symbol-function 'message) - (lambda (fmt &rest args) (setq msg (apply #'format fmt args))))) - (cj/gptel-change-model)) - (should (eq gptel-backend 'claude-obj)) - (should (eq gptel-model 'claude-opus-4-7)) - (should (string-match-p "global" msg)))) - -;;; cj/gptel-add-file - -(ert-deftest test-ai-add-file-outside-projectile-uses-read-file-name () - "Normal: without projectile, add-file routes through read-file-name." - (let* ((target (make-temp-file "cj-ai-add-file-" nil ".org")) - added) - (unwind-protect - (cl-letf (((symbol-function 'featurep) - (lambda (sym &rest _) (not (eq sym 'projectile)))) - ((symbol-function 'read-file-name) - (lambda (&rest _) target)) - ((symbol-function 'gptel-add-file) - (lambda (f) (setq added f))) - ((symbol-function 'message) #'ignore)) - (cj/gptel-add-file)) - (delete-file target)) - (should (equal added target)))) - -;;; cj/gptel-add-this-buffer - -(ert-deftest test-ai-add-this-buffer-calls-gptel-add-with-prefix () - "Normal: add-this-buffer calls `gptel-add' with the prefix-arg form." - (let (gptel-add-args msg) - (cl-letf (((symbol-function 'require) (lambda (&rest _) t)) - ((symbol-function 'gptel-add) - (lambda (&rest args) (setq gptel-add-args args))) - ((symbol-function 'message) - (lambda (fmt &rest args) (setq msg (apply #'format fmt args))))) - (cj/gptel-add-this-buffer)) - (should (equal gptel-add-args '((4)))) - (should (string-match-p "to GPTel context" msg)))) - -;;; cj/toggle-gptel - -(ert-deftest test-ai-toggle-gptel-hides-when-visible () - "Normal: when the AI buffer is showing in a window, toggle hides it." - (let ((buffer (get-buffer-create "*AI-Assistant*")) - deleted-window) - (unwind-protect - (cl-letf (((symbol-function 'get-buffer-window) - (lambda (&rest _) 'fake-window)) - ((symbol-function 'delete-window) - (lambda (w) (setq deleted-window w)))) - (cj/toggle-gptel)) - (kill-buffer buffer)) - (should (eq deleted-window 'fake-window)))) - -;;; cj/gptel-context-clear - -(ert-deftest test-ai-context-clear-uses-remove-all-when-available () - "Normal: with `gptel-context-remove-all' present, it is called." - (let (called msg) - (cl-letf (((symbol-function 'gptel-context-remove-all) - (lambda () (setq called t))) - ((symbol-function 'call-interactively) - (lambda (fn &rest _) (funcall fn))) - ((symbol-function 'message) - (lambda (fmt &rest args) (setq msg (apply #'format fmt args))))) - (cj/gptel-context-clear)) - (should called) - (should (string-match-p "cleared" msg)))) - -(ert-deftest test-ai-context-clear-resets-alist-as-fallback () - "Boundary: when no clear function exists but the alist does, it gets -nilled directly." - (let ((gptel-context--alist '("item1" "item2")) - msg) - ;; Make sure the fboundp branches are skipped. - (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-not gptel-context--alist) - (should (string-match-p "cleared" msg)))) - -(provide 'test-ai-config-commands) -;;; test-ai-config-commands.el ends here diff --git a/tests/test-ai-config-current-model-selection.el b/tests/test-ai-config-current-model-selection.el deleted file mode 100644 index 14f9391c8..000000000 --- a/tests/test-ai-config-current-model-selection.el +++ /dev/null @@ -1,74 +0,0 @@ -;;; 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 diff --git a/tests/test-ai-config-fresh-org-prefix.el b/tests/test-ai-config-fresh-org-prefix.el deleted file mode 100644 index 16a3211cf..000000000 --- a/tests/test-ai-config-fresh-org-prefix.el +++ /dev/null @@ -1,65 +0,0 @@ -;;; test-ai-config-fresh-org-prefix.el --- Tests for cj/gptel--fresh-org-prefix -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for cj/gptel--fresh-org-prefix from ai-config.el. -;; -;; Generates an org-mode level-1 heading containing the user's login -;; name and a bracketed timestamp, used as the user message prefix in -;; gptel org-mode conversations. - -;;; 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-fresh-org-prefix-normal-starts-with-org-heading () - "Result should start with '* ' for an org level-1 heading." - (should (string-prefix-p "* " (cj/gptel--fresh-org-prefix)))) - -(ert-deftest test-ai-config-fresh-org-prefix-normal-contains-username () - "Result should contain the current user's login name." - (should (string-match-p (regexp-quote user-login-name) - (cj/gptel--fresh-org-prefix)))) - -(ert-deftest test-ai-config-fresh-org-prefix-normal-contains-timestamp () - "Result should contain a bracketed timestamp in YYYY-MM-DD HH:MM:SS format." - (should (string-match-p "\\[[-0-9]+ [0-9]+:[0-9]+:[0-9]+\\]" - (cj/gptel--fresh-org-prefix)))) - -(ert-deftest test-ai-config-fresh-org-prefix-normal-ends-with-newline () - "Result should end with a newline." - (should (string-suffix-p "\n" (cj/gptel--fresh-org-prefix)))) - -(ert-deftest test-ai-config-fresh-org-prefix-normal-format-order () - "Result should have star, then username, then timestamp in order." - (let ((result (cj/gptel--fresh-org-prefix))) - (should (string-match - (format "^\\* %s \\[" (regexp-quote user-login-name)) - result)))) - -;;; Boundary Cases - -(ert-deftest test-ai-config-fresh-org-prefix-boundary-timestamp-reflects-today () - "Timestamp should contain today's date." - (let ((today (format-time-string "%Y-%m-%d"))) - (should (string-match-p (regexp-quote today) - (cj/gptel--fresh-org-prefix))))) - -(ert-deftest test-ai-config-fresh-org-prefix-boundary-overridden-username () - "Result should reflect a dynamically-bound user-login-name." - (let ((user-login-name "testuser")) - (should (string-match-p "testuser" (cj/gptel--fresh-org-prefix))))) - -(ert-deftest test-ai-config-fresh-org-prefix-boundary-empty-username () - "Empty user-login-name should produce heading with empty name slot." - (let ((user-login-name "")) - (should (string-match-p "^\\* \\[" (cj/gptel--fresh-org-prefix))))) - -(provide 'test-ai-config-fresh-org-prefix) -;;; test-ai-config-fresh-org-prefix.el ends here diff --git a/tests/test-ai-config-gptel-backend-libs.el b/tests/test-ai-config-gptel-backend-libs.el deleted file mode 100644 index cbf48f444..000000000 --- a/tests/test-ai-config-gptel-backend-libs.el +++ /dev/null @@ -1,58 +0,0 @@ -;;; test-ai-config-gptel-backend-libs.el --- Tests for gptel backend-lib loading -*- lexical-binding: t; -*- - -;;; Commentary: -;; Regression coverage for the "gptel-make-anthropic void" bug. The local -;; gptel fork (:load-path "~/code/gptel", :ensure nil) ships no generated -;; autoloads, so (require 'gptel) alone never loads gptel-anthropic / -;; gptel-openai where the gptel-make-* constructors live. The fix is to -;; require those backend libraries explicitly before constructing backends. -;; -;; These tests don't load gptel itself (it isn't reliably loadable in batch); -;; they stub `require' and the constructors to verify the loader requires both -;; libs and that `cj/ensure-gptel-backends' calls it before building backends. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-config) - -;; gptel defvars these at runtime; declare them here so the wiring test can -;; let-bind them in a batch session where gptel itself is not loaded. -(defvar gptel-backend) -(defvar gptel-model) - -(ert-deftest test-ai-config-gptel-load-backend-libs-requires-both () - "Normal: the loader requires gptel-anthropic and gptel-openai so the fork's -make-* constructors exist despite the missing autoloads." - (let ((required '())) - (cl-letf (((symbol-function 'require) - (lambda (feature &rest _) (push feature required) feature))) - (cj/--gptel-load-backend-libs)) - (should (memq 'gptel-anthropic required)) - (should (memq 'gptel-openai required)))) - -(ert-deftest test-ai-config-ensure-gptel-backends-loads-libs-first () - "Regression: `cj/ensure-gptel-backends' loads the backend libs before it -calls the constructors, so a fork without autoloads no longer signals -`void-function gptel-make-anthropic'." - (let ((loaded nil) - (gptel-claude-backend nil) - (gptel-chatgpt-backend nil) - (gptel-backend nil) - (gptel-model nil)) - (cl-letf (((symbol-function 'cj/--gptel-load-backend-libs) - (lambda () (setq loaded t))) - ((symbol-function 'gptel-make-anthropic) (lambda (&rest _) 'claude)) - ((symbol-function 'gptel-make-openai) (lambda (&rest _) 'chatgpt)) - ((symbol-function 'cj/anthropic-api-key) (lambda () "k")) - ((symbol-function 'cj/openai-api-key) (lambda () "k"))) - (cj/ensure-gptel-backends)) - (should loaded) - (should (eq gptel-claude-backend 'claude)) - (should (eq gptel-chatgpt-backend 'chatgpt)))) - -(provide 'test-ai-config-gptel-backend-libs) -;;; test-ai-config-gptel-backend-libs.el ends here diff --git a/tests/test-ai-config-gptel-commands.el b/tests/test-ai-config-gptel-commands.el deleted file mode 100644 index cab23572e..000000000 --- a/tests/test-ai-config-gptel-commands.el +++ /dev/null @@ -1,155 +0,0 @@ -;;; test-ai-config-gptel-commands.el --- Tests for ai-config gptel command wrappers -*- lexical-binding: t; -*- - -;;; Commentary: -;; Second pass on ai-config. The first batch covered the helpers -;; (auth-source, api-key caching, add-file, clear-buffer, context- -;; clear, insert-model-heading). This file covers the gptel command -;; wrappers and a few small pure helpers: -;; -;; cj/gptel--refresh-org-prefix -;; cj/gptel-backend-and-model -;; cj/gptel-switch-backend -;; cj/gptel-add-buffer-file -;; cj/gptel-add-this-buffer -;; cj/toggle-gptel -;; -;; The gptel/projectile primitives are stubbed throughout. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-config) - -;; Dynamic vars gptel would normally own. -(defvar gptel-backend nil) -(defvar gptel-model nil) -(defvar gptel-prompt-prefix-alist nil) - -;;; cj/gptel--refresh-org-prefix - -(ert-deftest test-ai-config-refresh-org-prefix-updates-alist-entry () - "Normal: the advice refreshes the org-mode entry in the prefix alist." - (let ((gptel-prompt-prefix-alist '((org-mode . "stale\n")))) - (cj/gptel--refresh-org-prefix) - (let ((entry (alist-get 'org-mode gptel-prompt-prefix-alist))) - (should (stringp entry)) - ;; Fresh prefix includes the user-login-name + a timestamp bracket. - (should (string-match-p "\\[" entry))))) - -;;; cj/gptel-backend-and-model - -(ert-deftest test-ai-config-backend-and-model-formats-with-vector-backend () - "Normal: a vector backend's name element comes through formatted." - (let ((gptel-backend [unused-slot "Claude" other]) - (gptel-model 'claude-opus-4-6)) - (let ((s (cj/gptel-backend-and-model))) - (should (string-match-p "Claude" s)) - (should (string-match-p "claude-opus-4-6" s))))) - -(ert-deftest test-ai-config-backend-and-model-falls-back-to-ai-when-no-backend () - "Boundary: with no backend bound, the string starts with the AI fallback." - (let ((gptel-backend nil) - (gptel-model nil)) - (should (string-prefix-p "AI:" (cj/gptel-backend-and-model))))) - -;;; cj/gptel-switch-backend - -(ert-deftest test-ai-config-switch-backend-sets-backend-and-model () - "Normal: switch picks a backend + model, then updates the gptel vars." - (let ((gptel-backend nil) - (gptel-model nil) - (msg nil)) - (cl-letf (((symbol-function 'cj/gptel--available-backends) - (lambda () - '(("Anthropic - Claude" . anthropic-backend)))) - ((symbol-function 'gptel-backend-models) - (lambda (_b) '(claude-opus claude-sonnet))) - ((symbol-function 'completing-read) - (lambda (prompt collection &rest _) - ;; First call -> backend choice; second -> model. - (cond - ((string-match-p "backend" prompt) "Anthropic - Claude") - (t "claude-opus")))) - ((symbol-function 'message) - (lambda (fmt &rest args) (setq msg (apply #'format fmt args))))) - (cj/gptel-switch-backend)) - (should (eq gptel-backend 'anthropic-backend)) - ;; gptel-model must be a symbol, not the raw completing-read string: - ;; gptel's modeline calls `symbolp' on it and hangs redisplay otherwise. - (should (symbolp gptel-model)) - (should (eq gptel-model 'claude-opus)) - (should (string-match-p "Anthropic - Claude" msg)))) - -(ert-deftest test-ai-config-switch-backend-error-invalid-choice () - "Error: an unrecognized backend name signals user-error." - (cl-letf (((symbol-function 'cj/gptel--available-backends) - (lambda () '(("Anthropic - Claude" . backend-a)))) - ((symbol-function 'completing-read) - (lambda (&rest _) "Something Else"))) - (should-error (cj/gptel-switch-backend) :type 'user-error))) - -;;; cj/gptel-add-buffer-file - -(ert-deftest test-ai-config-add-buffer-file-adds-when-buffer-has-file () - "Normal: a buffer that visits a file -> the file is added to context." - (let ((added nil)) - (with-temp-buffer - (setq buffer-file-name "/tmp/sample.org") - (cl-letf (((symbol-function 'completing-read) - (lambda (&rest _) (buffer-name))) - ((symbol-function 'cj/gptel--add-file-to-context) - (lambda (f) (setq added f) t)) - ((symbol-function 'message) #'ignore)) - (cj/gptel-add-buffer-file)) - (setq buffer-file-name nil)) - (should (equal added "/tmp/sample.org")))) - -(ert-deftest test-ai-config-add-buffer-file-messages-when-no-file () - "Boundary: a buffer not visiting a file -> message, no add call." - (let ((added nil) - (msg nil)) - (with-temp-buffer - (cl-letf (((symbol-function 'completing-read) - (lambda (&rest _) (buffer-name))) - ((symbol-function 'cj/gptel--add-file-to-context) - (lambda (f) (setq added f) t)) - ((symbol-function 'message) - (lambda (fmt &rest args) - (setq msg (apply #'format fmt args))))) - (cj/gptel-add-buffer-file))) - (should-not added) - (should (string-match-p "not visiting" msg)))) - -;;; cj/gptel-add-this-buffer - -(ert-deftest test-ai-config-add-this-buffer-calls-gptel-add-with-prefix () - "Normal: `cj/gptel-add-this-buffer' calls `gptel-add' with the (4) prefix arg." - (let ((arg nil)) - (cl-letf (((symbol-function 'featurep) (lambda (_ &rest _) t)) - ((symbol-function 'gptel-add) - (lambda (a) (setq arg a))) - ((symbol-function 'message) #'ignore)) - (with-temp-buffer - (cj/gptel-add-this-buffer))) - (should (equal arg '(4))))) - -;;; cj/toggle-gptel - -(ert-deftest test-ai-config-toggle-gptel-closes-when-window-shown () - "Normal: with a window already displaying *AI-Assistant*, toggle deletes it." - (let* ((buf (generate-new-buffer "*AI-Assistant*")) - (deleted nil)) - (unwind-protect - (cl-letf (((symbol-function 'get-buffer-window) - (lambda (_b &rest _) 'fake-window)) - ((symbol-function 'delete-window) - (lambda (w) (setq deleted w)))) - (cj/toggle-gptel)) - (when (buffer-live-p buf) (kill-buffer buf))) - (should (eq deleted 'fake-window)))) - -(provide 'test-ai-config-gptel-commands) -;;; test-ai-config-gptel-commands.el ends here diff --git a/tests/test-ai-config-gptel-local-tools.el b/tests/test-ai-config-gptel-local-tools.el deleted file mode 100644 index 8d3a45ac4..000000000 --- a/tests/test-ai-config-gptel-local-tools.el +++ /dev/null @@ -1,57 +0,0 @@ -;;; test-ai-config-gptel-local-tools.el --- Tests for local GPTel tool loading -*- lexical-binding: t; -*- - -;;; Commentary: - -;; Tests for optional local GPTel tool loading from ai-config.el. - -;;; 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)) -(setq load-prefer-newer t) -(require 'testutil-ai-config) -(require 'ai-config) - -(defun test-ai-config-gptel-local-tools--write-tool (dir feature) - "Write a temporary tool module named FEATURE into DIR." - (let ((file (expand-file-name (format "%s.el" feature) dir))) - (write-region - (format ";;; %s.el --- test tool -*- lexical-binding: t; -*-\n(provide '%s)\n" - feature feature) - nil - file - nil - 'silent))) - -(ert-deftest test-ai-config-gptel-local-tools-missing-directory-is-non-fatal () - "Missing optional tool directory should not signal or load anything." - (let ((dir (expand-file-name "missing-gptel-tools/" - (make-temp-file "gptel-tools-home-" t)))) - (should-not (cj/gptel-load-local-tools dir '(test_missing_tool))))) - -(ert-deftest test-ai-config-gptel-local-tools-loads-present-tools () - "Present tool modules should be loaded and returned in request order." - (let ((dir (make-temp-file "gptel-tools-" t)) - (features '(test_gptel_tool_one test_gptel_tool_two))) - (dolist (feature features) - (test-ai-config-gptel-local-tools--write-tool dir feature)) - (should (equal (cj/gptel-load-local-tools dir features) - features)) - (dolist (feature features) - (should (featurep feature))))) - -(ert-deftest test-ai-config-gptel-local-tools-skips-missing-tool-files () - "Missing optional tool files should not prevent present tools from loading." - (let ((dir (make-temp-file "gptel-tools-" t)) - (present 'test_gptel_present_tool) - (missing 'test_gptel_missing_tool)) - (test-ai-config-gptel-local-tools--write-tool dir present) - (should (equal (cj/gptel-load-local-tools dir (list present missing)) - (list present))) - (should (featurep present)) - (should-not (featurep missing)))) - -(provide 'test-ai-config-gptel-local-tools) -;;; test-ai-config-gptel-local-tools.el ends here diff --git a/tests/test-ai-config-gptel-magit-lazy-loading.el b/tests/test-ai-config-gptel-magit-lazy-loading.el deleted file mode 100644 index 6eac0d193..000000000 --- a/tests/test-ai-config-gptel-magit-lazy-loading.el +++ /dev/null @@ -1,151 +0,0 @@ -;;; test-ai-config-gptel-magit-lazy-loading.el --- Tests for gptel-magit lazy loading -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for the per-feature lazy gptel-magit integration in ai-config.el. -;; -;; ai-config.el uses three separate `with-eval-after-load' blocks -- -;; one per actual dependency -- to wire up its bindings: -;; git-commit -> M-g in `git-commit-mode-map' -;; magit-commit -> "g" suffix in the `magit-commit' transient -;; magit-diff -> "x" suffix in the `magit-diff' transient -;; -;; This shape matters: `magit.el' calls `(provide 'magit)' before its -;; `cl-eval-when (load eval) ...' block requires `magit-commit' and -;; `magit-stash', so a single `with-eval-after-load 'magit' would fire -;; while the transient prefixes the wiring references are still -;; undefined. `transient-append-suffix' silently no-ops on missing -;; prefixes, which is how that bug stayed invisible. -;; -;; Testing approach. In Emacs 30, `provide' does NOT fire registered -;; `eval-after-load' callbacks in batch mode -- only an actual `load' -;; does. Rather than work around that with disk-backed stub files, the -;; tests inspect `after-load-alist' directly to verify which features -;; the wiring is gated on. That's stronger evidence than running the -;; callbacks anyway: the regression we're guarding against is "wiring -;; hooked on `magit'," and the right shape of that check is "no entry -;; for `magit', entries for `git-commit', `magit-commit', `magit-diff'." - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) - -;; Load gptel stubs. This does NOT provide any of the magit features, -;; so the eval-after-load blocks in ai-config stay dormant. -(require 'testutil-ai-config) - -;; Stub the keymap used by the M-g binding. -(defvar git-commit-mode-map (make-sparse-keymap) - "Stub keymap standing in for magit's git-commit-mode-map.") - -;; Stub transient-append-suffix as a recorder. We don't invoke it -;; through provide in this test file, but the symbol must be fbound so -;; ai-config.el byte-compiles cleanly through `(require 'ai-config)'. -(unless (fboundp 'transient-append-suffix) - (defun transient-append-suffix (&rest _) nil)) - -(require 'ai-config) - -;; ----------------------------- Regression check ------------------------------ - -(ert-deftest test-ai-config-gptel-magit-regression-no-after-load-on-magit () - "ai-config must NOT register a `with-eval-after-load 'magit' hook. -`magit.el' provides itself BEFORE it loads `magit-commit' and -`magit-stash', so wiring keyed on `magit' would fire while the -transient prefixes are still undefined and `transient-append-suffix' -would silently no-op. The per-feature hooks side-step the race -entirely -- this test guards against any future regression that -re-introduces a single `'magit' hook." - ;; Forge installs an after-load entry for 'magit-mode'; magit's own - ;; code does not register anything keyed on the bare 'magit' symbol. - ;; Our wiring must not either. - (let ((entry (assoc 'magit after-load-alist))) - ;; If something else (e.g. another package) registers under 'magit - ;; the entry will exist, but it must not contain a closure that - ;; refers to gptel-magit symbols. Stringify the entry and grep. - (when entry - (should-not (string-match-p "gptel-magit" (format "%s" entry)))))) - -;; ------------------------------ Wiring registration -------------------------- - -(ert-deftest test-ai-config-gptel-magit-lazy-loading-git-commit-hook-registered () - "ai-config registers an `eval-after-load' hook keyed on `git-commit'. -The hook body binds M-g in `git-commit-mode-map' to -`gptel-magit-generate-message', so the printed closure mentions both." - (let ((entry (assoc 'git-commit after-load-alist))) - (should entry) - (let ((printed (format "%s" entry))) - (should (string-match-p "git-commit-mode-map" printed)) - (should (string-match-p "gptel-magit-generate-message" printed))))) - -(ert-deftest test-ai-config-gptel-magit-lazy-loading-magit-commit-hook-registered () - "ai-config registers an `eval-after-load' hook keyed on `magit-commit'. -The hook body calls `transient-append-suffix' for `magit-commit', so -the printed closure mentions both." - (let ((entry (assoc 'magit-commit after-load-alist))) - (should entry) - (let ((printed (format "%s" entry))) - (should (string-match-p "transient-append-suffix" printed)) - (should (string-match-p "magit-commit" printed)) - (should (string-match-p "gptel-magit-commit-generate" printed))))) - -(ert-deftest test-ai-config-gptel-magit-lazy-loading-magit-diff-hook-registered () - "ai-config registers an `eval-after-load' hook keyed on `magit-diff'. -The hook body calls `transient-append-suffix' for `magit-diff', so the -printed closure mentions both." - (let ((entry (assoc 'magit-diff after-load-alist))) - (should entry) - (let ((printed (format "%s" entry))) - (should (string-match-p "transient-append-suffix" printed)) - (should (string-match-p "magit-diff" printed)) - (should (string-match-p "gptel-magit-diff-explain" printed))))) - -;;; Normal Cases — Autoloads - -(ert-deftest test-ai-config-gptel-magit-lazy-loading-normal-generate-message-is-autoload () - "After ai-config loads, `gptel-magit-generate-message' is an autoload. -An autoload means the function is registered but `gptel-magit.el' has -not been loaded yet -- it loads only when the function is first -called." - (should (fboundp 'gptel-magit-generate-message)) - (should (autoloadp (symbol-function 'gptel-magit-generate-message)))) - -(ert-deftest test-ai-config-gptel-magit-lazy-loading-normal-commit-generate-is-autoload () - "After ai-config loads, `gptel-magit-commit-generate' is an autoload." - (should (fboundp 'gptel-magit-commit-generate)) - (should (autoloadp (symbol-function 'gptel-magit-commit-generate)))) - -(ert-deftest test-ai-config-gptel-magit-lazy-loading-normal-diff-explain-is-autoload () - "After ai-config loads, `gptel-magit-diff-explain' is an autoload." - (should (fboundp 'gptel-magit-diff-explain)) - (should (autoloadp (symbol-function 'gptel-magit-diff-explain)))) - -;;; Boundary Cases - -(ert-deftest test-ai-config-gptel-magit-lazy-loading-boundary-gptel-magit-not-loaded () - "After ai-config loads, `gptel-magit' itself stays unloaded. -The autoloads are registered so the package only loads when one of its -entry points is invoked." - (should-not (featurep 'gptel-magit))) - -;;; Error Cases — Install behavior - -(ert-deftest test-ai-config-gptel-magit-declared-via-use-package () - "ai-config declares gptel-magit via `use-package' so it gets installed. -Raw `(autoload ...)' calls register the function name but leave the -package uninstalled on machines that never ran `package-install'. The -\\=`use-package' form inherits `use-package-always-ensure' from -early-init, which is how every other package in this config gets -onto `load-path' before its autoloads fire." - (let ((source-file (expand-file-name "modules/ai-config.el" - user-emacs-directory))) - (with-temp-buffer - (insert-file-contents source-file) - (goto-char (point-min)) - (should (re-search-forward "(use-package gptel-magit\\b" nil t))))) - -(provide 'test-ai-config-gptel-magit-lazy-loading) -;;; test-ai-config-gptel-magit-lazy-loading.el ends here diff --git a/tests/test-ai-config-helpers.el b/tests/test-ai-config-helpers.el deleted file mode 100644 index cdbc0f6eb..000000000 --- a/tests/test-ai-config-helpers.el +++ /dev/null @@ -1,183 +0,0 @@ -;;; 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 diff --git a/tests/test-ai-config-model-to-string.el b/tests/test-ai-config-model-to-string.el deleted file mode 100644 index aa1149272..000000000 --- a/tests/test-ai-config-model-to-string.el +++ /dev/null @@ -1,60 +0,0 @@ -;;; test-ai-config-model-to-string.el --- Tests for cj/gptel--model-to-string -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for cj/gptel--model-to-string from ai-config.el. -;; -;; Pure function that converts a model identifier (string, symbol, or -;; other type) to a string representation. Branches on input type: -;; string (identity), symbol (symbol-name), fallback (format). - -;;; 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-model-to-string-normal-string-returns-string () - "String model name should be returned unchanged." - (should (equal (cj/gptel--model-to-string "claude-opus-4-6") "claude-opus-4-6"))) - -(ert-deftest test-ai-config-model-to-string-normal-symbol-returns-symbol-name () - "Symbol model name should return its symbol-name." - (should (equal (cj/gptel--model-to-string 'gpt-4o) "gpt-4o"))) - -(ert-deftest test-ai-config-model-to-string-normal-number-returns-formatted () - "Numeric input should be formatted as a string." - (should (equal (cj/gptel--model-to-string 42) "42"))) - -;;; Boundary Cases - -(ert-deftest test-ai-config-model-to-string-boundary-empty-string-returns-empty () - "Empty string should be returned as empty string." - (should (equal (cj/gptel--model-to-string "") ""))) - -(ert-deftest test-ai-config-model-to-string-boundary-nil-returns-nil-string () - "Nil is a symbol, so should return \"nil\"." - (should (equal (cj/gptel--model-to-string nil) "nil"))) - -(ert-deftest test-ai-config-model-to-string-boundary-keyword-symbol-includes-colon () - "Keyword symbol should return its name including the colon." - (should (equal (cj/gptel--model-to-string :some-model) ":some-model"))) - -(ert-deftest test-ai-config-model-to-string-boundary-list-uses-format-fallback () - "List input should hit the fallback format branch." - (should (equal (cj/gptel--model-to-string '(a b)) "(a b)"))) - -(ert-deftest test-ai-config-model-to-string-boundary-vector-uses-format-fallback () - "Vector input should hit the fallback format branch." - (should (equal (cj/gptel--model-to-string [1 2]) "[1 2]"))) - -(ert-deftest test-ai-config-model-to-string-boundary-string-with-spaces-unchanged () - "String with spaces should be returned unchanged." - (should (equal (cj/gptel--model-to-string "model with spaces") "model with spaces"))) - -(provide 'test-ai-config-model-to-string) -;;; test-ai-config-model-to-string.el ends here diff --git a/tests/test-ai-config-model-to-symbol.el b/tests/test-ai-config-model-to-symbol.el deleted file mode 100644 index de6f18ff8..000000000 --- a/tests/test-ai-config-model-to-symbol.el +++ /dev/null @@ -1,61 +0,0 @@ -;;; test-ai-config-model-to-symbol.el --- Tests for cj/gptel--model-to-symbol -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for cj/gptel--model-to-symbol from ai-config.el. -;; -;; Pure function that coerces a model identifier (string, symbol, or other -;; type) to a symbol. `gptel-model' MUST be a symbol -- gptel's modeline -;; code calls `symbolp' on it and signals wrong-type-argument on a string, -;; which manifests as a redisplay hang. The function's invariant is that -;; the result is always a symbol, so a value coerced through it is safe to -;; assign to `gptel-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-model-to-symbol-normal-string-interns () - "Normal: a string model name is interned to the matching symbol." - (should (eq (cj/gptel--model-to-symbol "claude-opus-4-8") 'claude-opus-4-8))) - -(ert-deftest test-ai-config-model-to-symbol-normal-symbol-returns-symbol () - "Normal: a symbol model name is returned unchanged." - (should (eq (cj/gptel--model-to-symbol 'gpt-4o) 'gpt-4o))) - -(ert-deftest test-ai-config-model-to-symbol-normal-result-always-symbol () - "Normal: the invariant -- the result is always a symbol (the crash guard)." - (should (symbolp (cj/gptel--model-to-symbol "gpt-5.5"))) - (should (symbolp (cj/gptel--model-to-symbol 'gpt-5.5)))) - -;;; Boundary Cases - -(ert-deftest test-ai-config-model-to-symbol-boundary-empty-string-is-symbol () - "Boundary: empty string interns to a symbol (still satisfies the invariant)." - (should (symbolp (cj/gptel--model-to-symbol "")))) - -(ert-deftest test-ai-config-model-to-symbol-boundary-nil-returns-nil () - "Boundary: nil is already a symbol, returned unchanged." - (should (eq (cj/gptel--model-to-symbol nil) nil)) - (should (symbolp (cj/gptel--model-to-symbol nil)))) - -(ert-deftest test-ai-config-model-to-symbol-boundary-string-with-spaces-interns () - "Boundary: a string with spaces interns to a single symbol with that name." - (should (eq (cj/gptel--model-to-symbol "model with spaces") - (intern "model with spaces")))) - -;;; Error/Odd Cases - -(ert-deftest test-ai-config-model-to-symbol-number-formats-then-interns () - "Error: a non-string, non-symbol value is formatted then interned to a symbol." - (should (eq (cj/gptel--model-to-symbol 42) (intern "42"))) - (should (symbolp (cj/gptel--model-to-symbol 42)))) - -(provide 'test-ai-config-model-to-symbol) -;;; test-ai-config-model-to-symbol.el ends here diff --git a/tests/test-ai-conversations-browser.el b/tests/test-ai-conversations-browser.el deleted file mode 100644 index d7422b096..000000000 --- a/tests/test-ai-conversations-browser.el +++ /dev/null @@ -1,244 +0,0 @@ -;;; test-ai-conversations-browser.el --- Tests for ai-conversations-browser -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for the saved-conversations browser. Pure helpers (topic -;; parsing, header stripping, preview, rename target) are tested -;; against fixed inputs. File-touching actions (load / delete / -;; rename) are tested against a temp conversations directory. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(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) -;; Force real ai-conversations to override testutil's stub. -(setq features (delq 'ai-conversations features)) -(require 'ai-conversations) -(require 'ai-conversations-browser) - -;; ----------------------------- temp-dir helper - -(defun test-ai-conversations-browser--with-temp-dir (fn) - "Run FN inside a fresh conversations directory. Clean up after." - (let* ((dir (make-temp-file "test-ai-conversations-browser-" t)) - (cj/gptel-conversations-directory dir)) - (unwind-protect - (funcall fn dir) - (when (file-exists-p dir) - (delete-directory dir t))))) - -(defun test-ai-conversations-browser--write (dir name content) - "Write CONTENT to NAME in DIR. Return the absolute path." - (let ((path (expand-file-name name dir))) - (with-temp-file path (insert content)) - path)) - -;; ----------------------------- topic-from-filename - -(ert-deftest test-ai-conversations-browser-topic-normal () - "Normal: topic slug extracted from a well-formed filename." - (should (equal (cj/gptel-browser--topic-from-filename - "my-topic_20260315-101530.gptel") - "my-topic"))) - -(ert-deftest test-ai-conversations-browser-topic-error-malformed () - "Boundary: malformed filename returns nil." - (should-not (cj/gptel-browser--topic-from-filename "garbage.gptel")) - (should-not (cj/gptel-browser--topic-from-filename "topic.gptel")) - (should-not (cj/gptel-browser--topic-from-filename "topic_20260315.gptel"))) - -;; ----------------------------- strip-headers - -(ert-deftest test-ai-conversations-browser-strip-headers-normal () - "Strip the two visibility headers plus the blank line after them." - (should (equal (cj/gptel-browser--strip-headers - "#+STARTUP: showeverything\n#+VISIBILITY: all\n\nrest\n") - "rest\n"))) - -(ert-deftest test-ai-conversations-browser-strip-headers-no-headers () - "Boundary: input without headers is unchanged." - (should (equal (cj/gptel-browser--strip-headers "plain body\n") - "plain body\n"))) - -;; ----------------------------- last-message - -(ert-deftest test-ai-conversations-browser-last-message-normal () - "Last-message picks the body of the last org heading." - (let ((text "* user [2026-01-01]\nhello there\n* AI [2026-01-01]\nthe latest reply\n")) - (should (equal (cj/gptel-browser--last-message text) - "the latest reply")))) - -(ert-deftest test-ai-conversations-browser-last-message-no-heading () - "Boundary: text without headings returns the (collapsed) body." - (let ((text "just some body\nwith two lines\n")) - (should (equal (cj/gptel-browser--last-message text) - "just some body with two lines")))) - -;; ----------------------------- preview - -(ert-deftest test-ai-conversations-browser-preview-truncates () - "Preview is ellipsized when the message is longer than LENGTH." - (let ((text "* AI\nthis is a very long response that should get truncated for the preview\n")) - (let ((preview (cj/gptel-browser--preview text 30))) - (should (= (length preview) 30)) - (should (string-suffix-p "…" preview))))) - -(ert-deftest test-ai-conversations-browser-preview-short () - "Preview is returned verbatim when shorter than LENGTH." - (let ((text "* AI\nshort\n")) - (should (equal (cj/gptel-browser--preview text 60) "short")))) - -(ert-deftest test-ai-conversations-browser-preview-empty () - "Preview of empty body returns empty string." - (should (equal (cj/gptel-browser--preview "" 60) ""))) - -;; ----------------------------- row-for-file - -(ert-deftest test-ai-conversations-browser-row-for-file-normal () - "Row contains date, topic, and a preview; carries file metadata." - (test-ai-conversations-browser--with-temp-dir - (lambda (dir) - (let ((file (test-ai-conversations-browser--write - dir "alpha_20260315-101530.gptel" - "#+STARTUP: showeverything\n\n* AI\nresult body\n"))) - (let ((row (cj/gptel-browser--row-for-file file dir))) - (should row) - (should (string-match-p "2026-03-15 10:15" row)) - (should (string-match-p "alpha" row)) - (should (string-match-p "result body" row)) - (should (equal (get-text-property 0 'cj/gptel-browser-file row) - "alpha_20260315-101530.gptel"))))))) - -(ert-deftest test-ai-conversations-browser-row-for-file-non-conversation () - "Files that don't match the conversation pattern return nil." - (test-ai-conversations-browser--with-temp-dir - (lambda (dir) - (let ((file (test-ai-conversations-browser--write - dir "not-a-conversation.gptel" "body"))) - (should-not (cj/gptel-browser--row-for-file file dir)))))) - -;; ----------------------------- rows / render - -(ert-deftest test-ai-conversations-browser-rows-from-empty-dir () - "Empty conversations directory yields no rows." - (test-ai-conversations-browser--with-temp-dir - (lambda (_dir) - (should-not (cj/gptel-browser--rows))))) - -(ert-deftest test-ai-conversations-browser-rows-multiple-conversations () - "Multiple conversations produce a row per file." - (test-ai-conversations-browser--with-temp-dir - (lambda (dir) - (test-ai-conversations-browser--write - dir "a_20260101-100000.gptel" "* AI\nfirst\n") - (test-ai-conversations-browser--write - dir "b_20260102-100000.gptel" "* AI\nsecond\n") - (let ((rows (cj/gptel-browser--rows))) - (should (= 2 (length rows))))))) - -(ert-deftest test-ai-conversations-browser-render-empty () - "Render shows a 'no conversations' line when directory is empty." - (test-ai-conversations-browser--with-temp-dir - (lambda (_dir) - (with-temp-buffer - (cj/gptel-browser-mode) - (cj/gptel-browser--render) - (should (string-match-p "no saved conversations" (buffer-string))))))) - -(ert-deftest test-ai-conversations-browser-render-newest-first () - "Render sorts rows newest first by timestamp." - (test-ai-conversations-browser--with-temp-dir - (lambda (dir) - (test-ai-conversations-browser--write - dir "old_20260101-100000.gptel" "* AI\nx\n") - (test-ai-conversations-browser--write - dir "new_20260301-100000.gptel" "* AI\ny\n") - (with-temp-buffer - (cj/gptel-browser-mode) - (cj/gptel-browser--render) - (let ((text (buffer-substring-no-properties (point-min) (point-max)))) - ;; New (March) should appear before old (January) in the buffer. - (should (< (string-match "2026-03-01" text) - (string-match "2026-01-01" text)))))))) - -;; ----------------------------- rename-target - -(ert-deftest test-ai-conversations-browser-rename-target-normal () - "Rename-target preserves the timestamp and slugifies the new topic." - (should (equal (cj/gptel-browser--rename-target - "/tmp/old-topic_20260101-100000.gptel" - "Brand New Topic") - "/tmp/brand-new-topic_20260101-100000.gptel"))) - -(ert-deftest test-ai-conversations-browser-rename-target-error-no-timestamp () - "Rename-target errors when the filename lacks a timestamp." - (should-error (cj/gptel-browser--rename-target "/tmp/no-ts.gptel" "x"))) - -;; ----------------------------- delete / rename actions - -(ert-deftest test-ai-conversations-browser-delete-removes-file () - "Delete with y removes the file under point and re-renders." - (test-ai-conversations-browser--with-temp-dir - (lambda (dir) - (let ((file (test-ai-conversations-browser--write - dir "topic_20260101-100000.gptel" "* AI\nx\n"))) - (with-temp-buffer - (cj/gptel-browser-mode) - (cj/gptel-browser--render) - ;; Point on the only data row - (goto-char (point-min)) - (forward-line 2) - (cl-letf (((symbol-function 'y-or-n-p) (lambda (&rest _) t))) - (cj/gptel-browser-delete)) - (should-not (file-exists-p file))))))) - -(ert-deftest test-ai-conversations-browser-delete-cancel-keeps-file () - "Delete with n leaves the file alone." - (test-ai-conversations-browser--with-temp-dir - (lambda (dir) - (let ((file (test-ai-conversations-browser--write - dir "topic_20260101-100000.gptel" "* AI\nx\n"))) - (with-temp-buffer - (cj/gptel-browser-mode) - (cj/gptel-browser--render) - (goto-char (point-min)) - (forward-line 2) - (cl-letf (((symbol-function 'y-or-n-p) (lambda (&rest _) nil))) - (cj/gptel-browser-delete)) - (should (file-exists-p file))))))) - -(ert-deftest test-ai-conversations-browser-rename-renames-file () - "Rename moves the file under a new slug while preserving timestamp." - (test-ai-conversations-browser--with-temp-dir - (lambda (dir) - (let* ((file (test-ai-conversations-browser--write - dir "old-name_20260101-100000.gptel" "* AI\nx\n"))) - (with-temp-buffer - (cj/gptel-browser-mode) - (cj/gptel-browser--render) - (goto-char (point-min)) - (forward-line 2) - (cl-letf (((symbol-function 'read-string) - (lambda (&rest _) "renamed topic"))) - (cj/gptel-browser-rename)) - (should-not (file-exists-p file)) - (should (file-exists-p - (expand-file-name "renamed-topic_20260101-100000.gptel" - dir)))))))) - -(ert-deftest test-ai-conversations-browser-rename-error-on-empty-line () - "Rename errors when point is on the header/empty area." - (test-ai-conversations-browser--with-temp-dir - (lambda (_dir) - (with-temp-buffer - (cj/gptel-browser-mode) - (cj/gptel-browser--render) - (goto-char (point-min)) - (should-error (cj/gptel-browser-rename)))))) - -(provide 'test-ai-conversations-browser) -;;; test-ai-conversations-browser.el ends here diff --git a/tests/test-ai-conversations.el b/tests/test-ai-conversations.el deleted file mode 100644 index 2d5aefd13..000000000 --- a/tests/test-ai-conversations.el +++ /dev/null @@ -1,564 +0,0 @@ -;;; test-ai-conversations.el --- Tests for ai-conversations.el -*- lexical-binding: t; -*- - -;;; Commentary: -;; Normal / Boundary / Error tests for the save/load/delete and -;; autosave surface in ai-conversations.el. Pure helpers are tested -;; against fixed inputs; file-touching helpers use per-test temp -;; directories. Interactive commands are exercised via `cl-letf' -;; stubs on `completing-read' and `y-or-n-p'. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(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) -;; testutil-ai-config provides 'ai-conversations as a stub. Force the -;; real module to override. -(setq features (delq 'ai-conversations features)) -(require 'ai-conversations) - -;; -------------------------------------------------------- temp-dir helper - -(defun test-ai-conversations--with-temp-dir (fn) - "Run FN inside a fresh conversations directory. Clean up after." - (let* ((dir (make-temp-file "test-ai-conversations-" t)) - (cj/gptel-conversations-directory dir)) - (unwind-protect - (funcall fn dir) - (when (file-exists-p dir) - (delete-directory dir t))))) - -(defun test-ai-conversations--touch (dir name) - "Create empty file NAME in DIR." - (let ((path (expand-file-name name dir))) - (with-temp-file path (insert "")) - path)) - -;; ------------------------------------------------------ slugify-topic - -(ert-deftest test-ai-conversations-slugify-topic-normal () - "Normal: ASCII words with spaces become hyphen-joined slug." - (should (equal (cj/gptel--slugify-topic "Hello World") "hello-world"))) - -(ert-deftest test-ai-conversations-slugify-topic-boundary-empty () - "Boundary: empty input returns the literal \"conversation\" placeholder." - (should (equal (cj/gptel--slugify-topic "") "conversation")) - (should (equal (cj/gptel--slugify-topic nil) "conversation"))) - -(ert-deftest test-ai-conversations-slugify-topic-boundary-all-special () - "Boundary: input with no slug-safe chars falls back to placeholder." - (should (equal (cj/gptel--slugify-topic "!!!@@@###") "conversation")) - (should (equal (cj/gptel--slugify-topic " ") "conversation"))) - -(ert-deftest test-ai-conversations-slugify-topic-boundary-unicode-stripped () - "Boundary: non-ASCII characters drop out (only [a-z0-9] survives)." - (should (equal (cj/gptel--slugify-topic "Café Résumé") "caf-r-sum"))) - -(ert-deftest test-ai-conversations-slugify-topic-boundary-idempotent () - "Boundary: applying twice yields the same result as once." - (let ((once (cj/gptel--slugify-topic "Foo Bar 2026!"))) - (should (equal once (cj/gptel--slugify-topic once))))) - -(ert-deftest test-ai-conversations-slugify-topic-boundary-leading-trailing-trim () - "Boundary: leading/trailing separator runs are trimmed." - (should (equal (cj/gptel--slugify-topic "---foo---") "foo")) - (should (equal (cj/gptel--slugify-topic "**foo**") "foo"))) - -(ert-deftest test-ai-conversations-slugify-topic-normal-numbers-preserved () - "Normal: digits survive the slug." - (should (equal (cj/gptel--slugify-topic "Project 2026 Plan") - "project-2026-plan"))) - -;; ------------------------------------------------------ timestamp-from-filename - -(ert-deftest test-ai-conversations-timestamp-from-filename-normal () - "Normal: well-formed filename decodes to a time value." - (let ((ts (cj/gptel--timestamp-from-filename - "topic_20260315-101530.gptel"))) - (should ts) - (should (equal (format-time-string "%Y-%m-%d %H:%M:%S" ts) - "2026-03-15 10:15:30")))) - -(ert-deftest test-ai-conversations-timestamp-from-filename-boundary-year-edges () - "Boundary: end-of-year and start-of-year timestamps decode correctly." - (let ((eoy (cj/gptel--timestamp-from-filename - "topic_20251231-235959.gptel")) - (boy (cj/gptel--timestamp-from-filename - "topic_20260101-000000.gptel"))) - (should (equal (format-time-string "%Y-%m-%d %H:%M:%S" eoy) - "2025-12-31 23:59:59")) - (should (equal (format-time-string "%Y-%m-%d %H:%M:%S" boy) - "2026-01-01 00:00:00")))) - -(ert-deftest test-ai-conversations-timestamp-from-filename-error-malformed () - "Error: non-matching filename returns nil." - (should-not (cj/gptel--timestamp-from-filename "not-a-gptel-file")) - (should-not (cj/gptel--timestamp-from-filename "topic.gptel")) - (should-not (cj/gptel--timestamp-from-filename "topic_20260315.gptel")) - (should-not (cj/gptel--timestamp-from-filename "topic_2026031-101530.gptel"))) - -;; ------------------------------------------------------ existing-topics - -(ert-deftest test-ai-conversations-existing-topics-normal () - "Normal: returns unique topic slugs across multiple-timestamped files." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (test-ai-conversations--touch dir "foo_20260101-100000.gptel") - (test-ai-conversations--touch dir "foo_20260102-100000.gptel") - (test-ai-conversations--touch dir "bar_20260102-100000.gptel") - (let ((topics (cj/gptel--existing-topics))) - (should (member "foo" topics)) - (should (member "bar" topics)) - (should (= 2 (length topics))))))) - -(ert-deftest test-ai-conversations-existing-topics-boundary-empty-dir () - "Boundary: empty conversations directory returns nil." - (test-ai-conversations--with-temp-dir - (lambda (_dir) - (should-not (cj/gptel--existing-topics))))) - -(ert-deftest test-ai-conversations-existing-topics-boundary-missing-dir () - "Boundary: missing directory returns nil instead of erroring." - (let ((cj/gptel-conversations-directory - (expand-file-name (format "missing-%s" (random)) "/tmp"))) - (should-not (cj/gptel--existing-topics)))) - -(ert-deftest test-ai-conversations-existing-topics-boundary-ignores-non-gptel () - "Boundary: files without .gptel extension are ignored." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (test-ai-conversations--touch dir "foo_20260101-100000.gptel") - (test-ai-conversations--touch dir "readme.txt") - (test-ai-conversations--touch dir "stray.gptel.bak") - (should (equal (cj/gptel--existing-topics) '("foo")))))) - -;; ------------------------------------------------------ latest-file-for-topic - -(ert-deftest test-ai-conversations-latest-file-for-topic-normal () - "Normal: returns the newest file for the topic by lexical sort." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (test-ai-conversations--touch dir "foo_20260101-100000.gptel") - (test-ai-conversations--touch dir "foo_20260103-100000.gptel") - (test-ai-conversations--touch dir "foo_20260102-100000.gptel") - (should (equal (cj/gptel--latest-file-for-topic "foo") - "foo_20260103-100000.gptel"))))) - -(ert-deftest test-ai-conversations-latest-file-for-topic-boundary-no-match () - "Boundary: no matching topic returns nil." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (test-ai-conversations--touch dir "bar_20260101-100000.gptel") - (should-not (cj/gptel--latest-file-for-topic "foo"))))) - -(ert-deftest test-ai-conversations-latest-file-for-topic-boundary-missing-dir () - "Boundary: missing directory returns nil." - (let ((cj/gptel-conversations-directory - (expand-file-name (format "missing-%s" (random)) "/tmp"))) - (should-not (cj/gptel--latest-file-for-topic "foo")))) - -(ert-deftest test-ai-conversations-latest-file-for-topic-boundary-regex-isolation () - "Boundary: prefix-overlapping topics are not falsely matched." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (test-ai-conversations--touch dir "foo_20260101-100000.gptel") - (test-ai-conversations--touch dir "foobar_20260102-100000.gptel") - (should (equal (cj/gptel--latest-file-for-topic "foo") - "foo_20260101-100000.gptel"))))) - -;; ------------------------------------------------------ conversation-candidates - -(ert-deftest test-ai-conversations-conversation-candidates-normal-newest-first () - "Normal: candidates are sorted newest-first when configured that way." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (test-ai-conversations--touch dir "foo_20260101-100000.gptel") - (test-ai-conversations--touch dir "foo_20260103-100000.gptel") - (test-ai-conversations--touch dir "foo_20260102-100000.gptel") - (let ((cj/gptel-conversations-sort-order 'newest-first)) - (let* ((cands (cj/gptel--conversation-candidates)) - (files (mapcar #'cdr cands))) - (should (equal files - '("foo_20260103-100000.gptel" - "foo_20260102-100000.gptel" - "foo_20260101-100000.gptel")))))))) - -(ert-deftest test-ai-conversations-conversation-candidates-normal-oldest-first () - "Normal: candidates respect oldest-first sort order." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (test-ai-conversations--touch dir "foo_20260101-100000.gptel") - (test-ai-conversations--touch dir "foo_20260103-100000.gptel") - (test-ai-conversations--touch dir "foo_20260102-100000.gptel") - (let ((cj/gptel-conversations-sort-order 'oldest-first)) - (let* ((cands (cj/gptel--conversation-candidates)) - (files (mapcar #'cdr cands))) - (should (equal files - '("foo_20260101-100000.gptel" - "foo_20260102-100000.gptel" - "foo_20260103-100000.gptel")))))))) - -(ert-deftest test-ai-conversations-conversation-candidates-error-missing-dir () - "Error: missing conversations directory signals." - (let ((cj/gptel-conversations-directory - (expand-file-name (format "missing-%s" (random)) "/tmp"))) - (should-error (cj/gptel--conversation-candidates)))) - -(ert-deftest test-ai-conversations-conversation-candidates-display-shape () - "Display string is \"filename [YYYY-MM-DD HH:MM]\"." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (test-ai-conversations--touch dir "topic_20260315-101530.gptel") - (let* ((cands (cj/gptel--conversation-candidates)) - (display (car (car cands)))) - (should (string-match-p - "\\`topic_20260315-101530\\.gptel \\[2026-03-15 10:15\\]\\'" - display)))))) - -;; ------------------------------------------------------ save-buffer-to-file - -(ert-deftest test-ai-conversations-save-buffer-to-file-normal () - "Normal: writes buffer with visibility headers prepended." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (with-temp-buffer - (insert "hello world\n") - (let ((file (expand-file-name "out.gptel" dir))) - (cj/gptel--save-buffer-to-file (current-buffer) file) - (should (file-exists-p file)) - (with-temp-buffer - (insert-file-contents file) - (should (string-match-p "^#\\+STARTUP: showeverything" - (buffer-string))) - (should (string-match-p "^#\\+VISIBILITY: all" - (buffer-string))) - (should (string-match-p "hello world" - (buffer-string))))))))) - -(ert-deftest test-ai-conversations-save-buffer-to-file-roundtrip-with-strip () - "Round-trip: save then strip-visibility-headers yields original content." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (let ((original "first line\nsecond line\n") - (file (expand-file-name "rt.gptel" dir))) - (with-temp-buffer - (insert original) - (cj/gptel--save-buffer-to-file (current-buffer) file)) - (with-temp-buffer - (insert-file-contents file) - (cj/gptel--strip-visibility-headers) - (should (equal (buffer-string) original))))))) - -(ert-deftest test-ai-conversations-strip-visibility-headers-boundary-no-headers () - "Boundary: buffer without headers is unchanged." - (with-temp-buffer - (insert "plain body\n") - (cj/gptel--strip-visibility-headers) - (should (equal (buffer-string) "plain body\n")))) - -;; ------------------------------------------------------ autosave-after-response - -(defmacro test-ai-conversations--with-gptel-mode (&rest body) - "Run BODY in a temp buffer with `gptel-mode' bound non-nil." - (declare (indent 0)) - `(with-temp-buffer - (setq-local gptel-mode t) - ,@body)) - -(ert-deftest test-ai-conversations-autosave-after-response-saves-when-enabled () - "Hook saves the buffer to the autosave filepath when enabled." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (let ((file (expand-file-name "auto.gptel" dir))) - (test-ai-conversations--with-gptel-mode - (setq-local cj/gptel-autosave-enabled t) - (setq-local cj/gptel-autosave-filepath file) - (insert "autosaved body") - (cj/gptel--autosave-after-response) - (should (file-exists-p file))))))) - -(ert-deftest test-ai-conversations-autosave-after-response-skips-when-disabled () - "Hook is a no-op when `cj/gptel-autosave-enabled' is nil." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (let ((file (expand-file-name "auto.gptel" dir))) - (test-ai-conversations--with-gptel-mode - (setq-local cj/gptel-autosave-enabled nil) - (setq-local cj/gptel-autosave-filepath file) - (cj/gptel--autosave-after-response) - (should-not (file-exists-p file))))))) - -(ert-deftest test-ai-conversations-autosave-after-response-skips-when-no-filepath () - "Hook is a no-op when filepath is nil or empty." - (test-ai-conversations--with-temp-dir - (lambda (_dir) - (test-ai-conversations--with-gptel-mode - (setq-local cj/gptel-autosave-enabled t) - (setq-local cj/gptel-autosave-filepath nil) - ;; Should not error - (cj/gptel--autosave-after-response)) - (test-ai-conversations--with-gptel-mode - (setq-local cj/gptel-autosave-enabled t) - (setq-local cj/gptel-autosave-filepath "") - (cj/gptel--autosave-after-response))))) - -(ert-deftest test-ai-conversations-autosave-after-response-skips-outside-gptel-mode () - "Hook is a no-op when `gptel-mode' is nil." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (let ((file (expand-file-name "auto.gptel" dir))) - (with-temp-buffer - (setq-local gptel-mode nil) - (setq-local cj/gptel-autosave-enabled t) - (setq-local cj/gptel-autosave-filepath file) - (cj/gptel--autosave-after-response) - (should-not (file-exists-p file))))))) - -(ert-deftest test-ai-conversations-autosave-after-send-error-is-non-fatal () - "Hook surfaces a save error via `message' rather than signaling." - (test-ai-conversations--with-temp-dir - (lambda (_dir) - (test-ai-conversations--with-gptel-mode - (setq-local cj/gptel-autosave-enabled t) - (setq-local cj/gptel-autosave-filepath "/nonexistent-dir/file.gptel") - ;; Must not signal even though the write will fail - (cj/gptel--autosave-after-send))))) - -;; ------------------------------------------------------ autosave timer - -(ert-deftest test-ai-conversations-autosave-start-timer-normal () - "Normal: starting autosave creates a repeating timer for the current buffer." - (with-temp-buffer - (setq-local gptel-mode t) - (setq-local cj/gptel-autosave-enabled t) - (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel") - (let ((calls nil)) - (cl-letf (((symbol-function 'run-with-timer) - (lambda (secs repeat function &rest args) - (push (list secs repeat function args) calls) - :fake-timer))) - (let ((cj/gptel-autosave-interval 17)) - (cj/gptel--autosave-start-timer))) - (should (eq cj/gptel-autosave--timer :fake-timer)) - (should (equal (caar calls) 17)) - (should (equal (cadar calls) 17)) - (should (eq (nth 2 (car calls)) #'cj/gptel--autosave-timer-callback)) - (should (eq (car (nth 3 (car calls))) (current-buffer)))))) - -(ert-deftest test-ai-conversations-autosave-start-timer-idempotent () - "Boundary: starting autosave twice does not create a second timer." - (with-temp-buffer - (setq-local gptel-mode t) - (setq-local cj/gptel-autosave-enabled t) - (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel") - (setq-local cj/gptel-autosave--timer :existing-timer) - (let ((created 0)) - (cl-letf (((symbol-function 'run-with-timer) - (lambda (&rest _) - (setq created (1+ created)) - :new-timer))) - (cj/gptel--autosave-start-timer)) - (should (= created 0)) - (should (eq cj/gptel-autosave--timer :existing-timer))))) - -(ert-deftest test-ai-conversations-autosave-stop-timer-cancels () - "Normal: stopping autosave cancels the current buffer's timer." - (with-temp-buffer - (setq-local cj/gptel-autosave--timer :fake-timer) - (let ((cancelled nil)) - (cl-letf (((symbol-function 'cancel-timer) - (lambda (timer) (setq cancelled timer)))) - (cj/gptel--autosave-stop-timer)) - (should (eq cancelled :fake-timer)) - (should-not cj/gptel-autosave--timer)))) - -(ert-deftest test-ai-conversations-autosave-timer-callback-saves-active-buffer () - "Normal: timer callback saves the live buffer when autosave is active." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (let ((file (expand-file-name "timer.gptel" dir)) - (buf (generate-new-buffer " *gptel timer test*"))) - (unwind-protect - (with-current-buffer buf - (setq-local gptel-mode t) - (setq-local cj/gptel-autosave-enabled t) - (setq-local cj/gptel-autosave-filepath file) - (insert "timer body") - (cj/gptel--autosave-timer-callback buf) - (should (file-exists-p file))) - (when (buffer-live-p buf) - (kill-buffer buf))))))) - -(ert-deftest test-ai-conversations-autosave-timer-callback-stops-inactive-buffer () - "Boundary: timer callback cancels itself when autosave is no longer active." - (let ((buf (generate-new-buffer " *gptel timer inactive*"))) - (unwind-protect - (with-current-buffer buf - (setq-local gptel-mode t) - (setq-local cj/gptel-autosave-enabled nil) - (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel") - (setq-local cj/gptel-autosave--timer :fake-timer) - (let ((cancelled nil)) - (cl-letf (((symbol-function 'cancel-timer) - (lambda (timer) (setq cancelled timer)))) - (cj/gptel--autosave-timer-callback buf)) - (should (eq cancelled :fake-timer)) - (should-not cj/gptel-autosave--timer))) - (when (buffer-live-p buf) - (kill-buffer buf))))) - -;; ------------------------------------------------------ save-conversation - -(ert-deftest test-ai-conversations-save-conversation-interactive-new-topic () - "Save-conversation writes file, enables autosave, and starts a timer." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (let ((ai-buffer (generate-new-buffer "*AI-Assistant*"))) - (unwind-protect - (progn - (with-current-buffer ai-buffer - (setq-local gptel-mode t) - (insert "session content")) - (cl-letf (((symbol-function 'completing-read) - (lambda (&rest _) "Test Topic")) - ((symbol-function 'y-or-n-p) - (lambda (&rest _) nil)) - ((symbol-function 'run-with-timer) - (lambda (&rest _) :save-timer))) - (cj/gptel-save-conversation) - (let ((files (directory-files dir nil "test-topic_.*\\.gptel$"))) - (should files) - (should (= 1 (length files)))) - ;; Autosave state is set in the AI buffer - (with-current-buffer ai-buffer - (should cj/gptel-autosave-enabled) - (should (stringp cj/gptel-autosave-filepath)) - (should (eq cj/gptel-autosave--timer :save-timer))))) - (kill-buffer ai-buffer)))))) - -(ert-deftest test-ai-conversations-save-conversation-error-no-buffer () - "Save-conversation errors when *AI-Assistant* doesn't exist." - (when (get-buffer "*AI-Assistant*") - (kill-buffer "*AI-Assistant*")) - (should-error (cj/gptel-save-conversation))) - -;; ------------------------------------------------------ delete-conversation - -(ert-deftest test-ai-conversations-delete-conversation-interactive () - "Delete-conversation removes the chosen file after confirmation." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (let ((file (test-ai-conversations--touch - dir "topic_20260101-100000.gptel"))) - (cl-letf (((symbol-function 'completing-read) - (lambda (_p cands &rest _) (caar cands))) - ((symbol-function 'y-or-n-p) - (lambda (&rest _) t))) - (cj/gptel-delete-conversation) - (should-not (file-exists-p file))))))) - -(ert-deftest test-ai-conversations-delete-conversation-cancelled () - "Delete-conversation preserves the file when the user declines." - (test-ai-conversations--with-temp-dir - (lambda (dir) - (let ((file (test-ai-conversations--touch - dir "topic_20260101-100000.gptel"))) - (cl-letf (((symbol-function 'completing-read) - (lambda (_p cands &rest _) (caar cands))) - ((symbol-function 'y-or-n-p) - (lambda (&rest _) nil))) - (cj/gptel-delete-conversation) - (should (file-exists-p file))))))) - -(ert-deftest test-ai-conversations-delete-conversation-error-empty-dir () - "Delete-conversation errors when no saved conversations exist." - (test-ai-conversations--with-temp-dir - (lambda (_dir) - (should-error (cj/gptel-delete-conversation))))) - -;; ------------------------------------------------------ install-once - -(ert-deftest test-ai-conversations-autosave-after-response-hook-not-duplicated () - "Loading ai-conversations twice does not duplicate the post-response hook." - (let ((gptel-post-response-functions - (list #'cj/gptel--autosave-after-response))) - ;; Re-run the install code - (unless (member #'cj/gptel--autosave-after-response gptel-post-response-functions) - (add-hook 'gptel-post-response-functions #'cj/gptel--autosave-after-response)) - (should (= 1 (cl-count #'cj/gptel--autosave-after-response - gptel-post-response-functions))))) - -;; --------------------------------------------- autosave-toggle / indicator - -(ert-deftest test-ai-conversations-autosave-toggle-enables-with-filepath () - "Toggle enables autosave when a filepath is set." - (with-temp-buffer - (setq-local gptel-mode t) - (setq-local cj/gptel-autosave-enabled nil) - (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel") - (cj/gptel-autosave-toggle) - (should cj/gptel-autosave-enabled))) - -(ert-deftest test-ai-conversations-autosave-toggle-disables () - "Toggle turns autosave off and cancels the periodic timer when already on." - (with-temp-buffer - (setq-local gptel-mode t) - (setq-local cj/gptel-autosave-enabled t) - (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel") - (setq-local cj/gptel-autosave--timer :fake-timer) - (let ((cancelled nil)) - (cl-letf (((symbol-function 'cancel-timer) - (lambda (timer) (setq cancelled timer)))) - (cj/gptel-autosave-toggle)) - (should-not cj/gptel-autosave-enabled) - (should (eq cancelled :fake-timer)) - (should-not cj/gptel-autosave--timer)))) - -(ert-deftest test-ai-conversations-autosave-toggle-prompts-when-no-filepath () - "Toggle prompts to save first when no filepath is configured." - (with-temp-buffer - (setq-local gptel-mode t) - (setq-local cj/gptel-autosave-enabled nil) - (setq-local cj/gptel-autosave-filepath nil) - (let ((prompted nil) - (save-called nil)) - (cl-letf (((symbol-function 'y-or-n-p) - (lambda (&rest _) (setq prompted t) nil)) - ((symbol-function 'cj/gptel-save-conversation) - (lambda () (setq save-called t)))) - (cj/gptel-autosave-toggle)) - (should prompted) - (should-not save-called) - (should-not cj/gptel-autosave-enabled)))) - -(ert-deftest test-ai-conversations-autosave-toggle-error-outside-gptel-mode () - "Toggle signals when called outside a gptel buffer." - (with-temp-buffer - (setq-local gptel-mode nil) - (should-error (cj/gptel-autosave-toggle)))) - -(ert-deftest test-ai-conversations-autosave-mode-line-format-evaluates () - "Mode-line format evaluates to \" [AS]\" only when autosave is enabled." - (with-temp-buffer - (setq-local cj/gptel-autosave-enabled t) - (should (equal (eval (cadr cj/gptel-autosave-mode-line-format)) - " [AS]"))) - (with-temp-buffer - (setq-local cj/gptel-autosave-enabled nil) - (should-not (eval (cadr cj/gptel-autosave-mode-line-format))))) - -(ert-deftest test-ai-conversations-install-mode-line-idempotent () - "Repeated installs do not duplicate the construct in mode-line-format." - (with-temp-buffer - (setq-local mode-line-format '("base")) - (cj/gptel--install-autosave-mode-line) - (cj/gptel--install-autosave-mode-line) - (cj/gptel--install-autosave-mode-line) - (should (= 1 (cl-count 'cj/gptel-autosave-mode-line-format mode-line-format))))) - -(provide 'test-ai-conversations) -;;; test-ai-conversations.el ends here diff --git a/tests/test-ai-mcp-helpers.el b/tests/test-ai-mcp-helpers.el deleted file mode 100644 index 5a995ff2d..000000000 --- a/tests/test-ai-mcp-helpers.el +++ /dev/null @@ -1,419 +0,0 @@ -;;; test-ai-mcp-helpers.el --- Tests for pure helpers in ai-mcp.el -*- lexical-binding: t; -*- - -;;; Commentary: -;; Normal / Boundary / Error tests for the side-effect-free helpers in -;; ai-mcp.el: secrets redaction, confirm-policy classifier, description -;; normalizer, Claude-config reader (mtime-cached), env / secret-args -;; resolution, server-alist builder. No real `~/.claude.json' reads; -;; fixtures are written to per-test temp files. No real subprocesses -;; or network calls. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-mcp) - -;; -------------------------------------------------------- fixtures - -(defconst test-ai-mcp--sentinel "REDACTED_TEST_SECRET" - "Sentinel that must never appear in any user-facing output.") - -(defconst test-ai-mcp--fixture-json - "{ - \"mcpServers\": { - \"drawio\": { - \"type\": \"stdio\", - \"command\": \"npx\", - \"args\": [\"-y\", \"@drawio/mcp\"] - }, - \"google-calendar\": { - \"type\": \"stdio\", - \"command\": \"npx\", - \"args\": [\"-y\", \"@cocal/google-calendar-mcp\"], - \"env\": { - \"GOOGLE_OAUTH_CREDENTIALS\": \"REDACTED_TEST_SECRET\" - } - }, - \"google-docs-personal\": { - \"type\": \"stdio\", - \"command\": \"npx\", - \"args\": [\"-y\", \"@a-bonus/google-docs-mcp\"], - \"env\": { - \"GOOGLE_CLIENT_ID\": \"REDACTED_TEST_SECRET\", - \"GOOGLE_CLIENT_SECRET\": \"REDACTED_TEST_SECRET\", - \"GOOGLE_MCP_PROFILE\": \"personal\" - } - }, - \"figma\": { - \"type\": \"stdio\", - \"command\": \"npx\", - \"args\": [\"-y\", \"figma-developer-mcp\", \"--figma-api-key=REDACTED_TEST_SECRET\", \"--stdio\"] - }, - \"linear\": { - \"type\": \"http\", - \"url\": \"https://mcp.linear.app/mcp\" - }, - \"slack-deepsat\": { - \"type\": \"sse\", - \"url\": \"http://127.0.0.1:13080/sse\" - } - } -}" - "Fixture matching the shape of a real ~/.claude.json mcpServers tree.") - -(defun test-ai-mcp--write-fixture (&optional content) - "Write CONTENT (defaults to the standard fixture) to a temp file. -Return the file path." - (let ((tmp (make-temp-file "test-ai-mcp-" nil ".json"))) - (with-temp-file tmp - (insert (or content test-ai-mcp--fixture-json))) - tmp)) - -(defmacro test-ai-mcp--with-fixture (var &rest body) - "Bind VAR to a fresh fixture file path and BODY-eval. Clean up after." - (declare (indent 1)) - `(let ((,var (test-ai-mcp--write-fixture)) - (cj/mcp--config-cache nil)) - (unwind-protect (progn ,@body) - (when (file-exists-p ,var) (delete-file ,var))))) - -;; -------------------------------------------------------- redact - -(ert-deftest test-ai-mcp-redact-token-eq-normal () - "Normal: --token=VALUE has the value replaced by ***." - (should (equal (cj/mcp--redact "--token=abc123") "--token=***"))) - -(ert-deftest test-ai-mcp-redact-token-spaced-boundary () - "Boundary: --token VALUE (space separator) is also redacted." - (should (equal (cj/mcp--redact "--token abc123") "--token ***"))) - -(ert-deftest test-ai-mcp-redact-secret-flag-normal () - "Normal: --secret=VALUE is redacted." - (should (equal (cj/mcp--redact "--secret=topsecret") "--secret=***"))) - -(ert-deftest test-ai-mcp-redact-password-flag-normal () - "Normal: --password=VALUE is redacted." - (should (equal (cj/mcp--redact "--password=hunter2") "--password=***"))) - -(ert-deftest test-ai-mcp-redact-figma-api-key-normal () - "Normal: --figma-api-key=VALUE is redacted (covers the figma case)." - (should (equal (cj/mcp--redact "--figma-api-key=figd_xyz") - "--figma-api-key=***"))) - -(ert-deftest test-ai-mcp-redact-authorization-header-normal () - "Normal: Authorization header value (scheme + token) is masked." - (should (equal (cj/mcp--redact "Authorization: Bearer ghp_xyz123") - "Authorization: ***"))) - -(ert-deftest test-ai-mcp-redact-url-token-normal () - "Normal: ?token=VALUE in a URL is masked." - (should (equal (cj/mcp--redact "https://api.example/v1?token=abc123&page=2") - "https://api.example/v1?token=***&page=2"))) - -(ert-deftest test-ai-mcp-redact-no-secrets-boundary () - "Boundary: a string with no known secrets is returned unchanged." - (should (equal (cj/mcp--redact "hello world, nothing secret here") - "hello world, nothing secret here"))) - -(ert-deftest test-ai-mcp-redact-empty-string-boundary () - "Boundary: empty string returns empty string." - (should (equal (cj/mcp--redact "") ""))) - -(ert-deftest test-ai-mcp-redact-multiple-secrets-boundary () - "Boundary: multiple secrets in one string are all redacted." - (let* ((input "--token=abc --secret=xyz --password=qwe") - (out (cj/mcp--redact input))) - (should (equal out "--token=*** --secret=*** --password=***")))) - -(ert-deftest test-ai-mcp-redact-nil-input-error () - "Error: nil input returns nil rather than signaling." - (should (null (cj/mcp--redact nil)))) - -(ert-deftest test-ai-mcp-redact-sentinel-never-leaks () - "Sentinel REDACTED_TEST_SECRET is replaced wherever it lives in a secret slot." - (dolist (input (list (format "--token=%s" test-ai-mcp--sentinel) - (format "--figma-api-key=%s" test-ai-mcp--sentinel) - (format "Authorization: Bearer %s" test-ai-mcp--sentinel) - (format "https://x/y?token=%s" test-ai-mcp--sentinel))) - (let ((out (cj/mcp--redact input))) - (should-not (string-match-p test-ai-mcp--sentinel out))))) - -;; -------------------------------------------------------- confirm-p - -(ert-deftest test-ai-mcp-confirm-p-write-pattern-normal () - "Normal: a write-prefixed tool name returns t." - (should (cj/mcp--confirm-p "mcp__linear__create_issue"))) - -(ert-deftest test-ai-mcp-confirm-p-read-pattern-normal () - "Normal: a read-prefixed tool name returns nil." - (should-not (cj/mcp--confirm-p "mcp__linear__list_issues"))) - -(ert-deftest test-ai-mcp-confirm-p-unknown-fails-closed-boundary () - "Boundary: a name matching neither read nor write defaults to t (fail closed)." - (should (cj/mcp--confirm-p "mcp__linear__frobnicate"))) - -(ert-deftest test-ai-mcp-confirm-p-explicit-remote-name-boundary () - "Boundary: REMOTE-NAME arg overrides the prefix-strip of GPTEL-NAME." - ;; The gptel-name claims read, but the explicit remote-name is a write - ;; verb, so confirm should still fire. - (should (cj/mcp--confirm-p "mcp__linear__list_issues" "create_issue"))) - -(ert-deftest test-ai-mcp-confirm-p-override-wins-boundary () - "Boundary: cj/mcp-tool-confirm-overrides wins over the classifier." - (let ((cj/mcp-tool-confirm-overrides - '(("mcp__linear__create_issue" . nil)))) - (should-not (cj/mcp--confirm-p "mcp__linear__create_issue")))) - -;; -------------------------------------------------------- normalize-description - -(ert-deftest test-ai-mcp-normalize-description-read-normal () - "Normal: a read tool gets the bare [SERVER] prefix." - (should (equal - (cj/mcp--normalize-description - "linear" - '(:name "list_issues" :description "List issues in a Linear team.")) - "[linear] List issues in a Linear team."))) - -(ert-deftest test-ai-mcp-normalize-description-write-normal () - "Normal: a write tool gets [SERVER WRITE] prefix." - (should (equal - (cj/mcp--normalize-description - "linear" - '(:name "create_issue" :description "Create a new Linear issue.")) - "[linear WRITE] Create a new Linear issue."))) - -(ert-deftest test-ai-mcp-normalize-description-unknown-boundary () - "Boundary: a tool matching neither classifier gets [SERVER ?] prefix." - (should (equal - (cj/mcp--normalize-description - "google-keep" - '(:name "frobnicate" :description "Do the frob thing.")) - "[google-keep ?] Do the frob thing."))) - -(ert-deftest test-ai-mcp-normalize-description-missing-upstream-boundary () - "Boundary: missing upstream description falls back to a placeholder." - (should (equal - (cj/mcp--normalize-description - "linear" - '(:name "list_issues")) - "[linear] (no description provided by server)"))) - -;; -------------------------------------------------------- read-claude-config - -(ert-deftest test-ai-mcp-read-claude-config-good-fixture-normal () - "Normal: parsing a well-formed fixture returns :ok t and the parsed data." - (test-ai-mcp--with-fixture path - (let ((result (cj/mcp--read-claude-config path))) - (should (plist-get result :ok)) - (should (plist-get (plist-get result :data) :mcpServers))))) - -(ert-deftest test-ai-mcp-read-claude-config-missing-file-error () - "Error: missing file returns :ok nil with :reason missing-file." - (let ((cj/mcp--config-cache nil) - (path "/nonexistent/path/never-will-exist.json")) - (let ((result (cj/mcp--read-claude-config path))) - (should-not (plist-get result :ok)) - (should (eq (plist-get result :reason) 'missing-file))))) - -(ert-deftest test-ai-mcp-read-claude-config-malformed-json-error () - "Error: malformed JSON returns :ok nil with :reason malformed-json and a message." - (let ((cj/mcp--config-cache nil) - (tmp (make-temp-file "test-ai-mcp-malformed-" nil ".json"))) - (unwind-protect - (progn - (with-temp-file tmp (insert "{ this is not valid json ::: ")) - (let ((result (cj/mcp--read-claude-config tmp))) - (should-not (plist-get result :ok)) - (should (eq (plist-get result :reason) 'malformed-json)) - (should (stringp (plist-get result :message))))) - (delete-file tmp)))) - -(ert-deftest test-ai-mcp-read-claude-config-empty-object-boundary () - "Boundary: an empty JSON object parses to ok with empty data plist." - (let ((cj/mcp--config-cache nil) - (tmp (make-temp-file "test-ai-mcp-empty-" nil ".json"))) - (unwind-protect - (progn - (with-temp-file tmp (insert "{}")) - (let ((result (cj/mcp--read-claude-config tmp))) - (should (plist-get result :ok)) - ;; :mcpServers is absent; plist-get returns nil. - (should-not (plist-get (plist-get result :data) :mcpServers)))) - (delete-file tmp)))) - -(ert-deftest test-ai-mcp-read-claude-config-cache-hit-boundary () - "Boundary: a second read with the same mtime reuses the cache. -We detect cache reuse by mutating the cached :data alist after the first -read and verifying the second read returns the mutated value." - (test-ai-mcp--with-fixture path - (let* ((first (cj/mcp--read-claude-config path)) - (cache cj/mcp--config-cache)) - (should (plist-get first :ok)) - ;; Mutate the cached :data so a cache-hit returns the marker. - (plist-put cache :data '(:sentinel cache-was-hit)) - (let ((second (cj/mcp--read-claude-config path))) - (should (equal (plist-get second :data) '(:sentinel cache-was-hit))))))) - -(ert-deftest test-ai-mcp-read-claude-config-cache-invalidate-on-mtime-boundary () - "Boundary: changing the file's mtime forces a reparse." - (test-ai-mcp--with-fixture path - (let* ((first (cj/mcp--read-claude-config path)) - (cache cj/mcp--config-cache)) - (should (plist-get first :ok)) - ;; Poison the cache, then bump mtime; the next read should reparse. - (plist-put cache :data '(:sentinel cache-was-hit)) - (set-file-times path (time-add (current-time) 2)) - ;; Update the cache var since set-file-times changed file mtime. - (setq cj/mcp--config-cache cache) - (let ((second (cj/mcp--read-claude-config path))) - ;; Real reparse should give us the real data, not the sentinel. - (should (plist-get (plist-get second :data) :mcpServers)))))) - -(ert-deftest test-ai-mcp-read-claude-config-missing-mcpservers-boundary () - "Boundary: a valid JSON without :mcpServers parses but the subtree is nil." - (let ((cj/mcp--config-cache nil) - (tmp (make-temp-file "test-ai-mcp-no-mcp-" nil ".json"))) - (unwind-protect - (progn - (with-temp-file tmp (insert "{\"other\": 1}")) - (let ((result (cj/mcp--read-claude-config tmp))) - (should (plist-get result :ok)) - (should-not (plist-get (plist-get result :data) :mcpServers)))) - (delete-file tmp)))) - -;; -------------------------------------------------------- get-env / get-secret-arg - -(ert-deftest test-ai-mcp-get-env-known-server-with-env-normal () - "Normal: env-bearing server returns its env plist." - (test-ai-mcp--with-fixture path - (let* ((cj/mcp-claude-config path) - (env (cj/mcp--get-env "google-calendar"))) - (should (equal (plist-get env :GOOGLE_OAUTH_CREDENTIALS) - test-ai-mcp--sentinel))))) - -(ert-deftest test-ai-mcp-get-env-known-server-without-env-boundary () - "Boundary: a server with no env subtree returns nil." - (test-ai-mcp--with-fixture path - (let* ((cj/mcp-claude-config path)) - (should-not (cj/mcp--get-env "drawio"))))) - -(ert-deftest test-ai-mcp-get-env-unknown-server-error () - "Error: unknown server returns nil without signaling." - (test-ai-mcp--with-fixture path - (let* ((cj/mcp-claude-config path)) - (should-not (cj/mcp--get-env "no-such-server"))))) - -(ert-deftest test-ai-mcp-get-secret-arg-figma-normal () - "Normal: figma's --figma-api-key= value is extracted from args." - (test-ai-mcp--with-fixture path - (let* ((cj/mcp-claude-config path) - (value (cj/mcp--get-secret-arg "figma" "--figma-api-key"))) - (should (equal value test-ai-mcp--sentinel))))) - -(ert-deftest test-ai-mcp-get-secret-arg-missing-flag-error () - "Error: a flag not in the server's args returns nil." - (test-ai-mcp--with-fixture path - (let* ((cj/mcp-claude-config path) - (value (cj/mcp--get-secret-arg "figma" "--no-such-flag"))) - (should (null value))))) - -;; -------------------------------------------------------- build-server-alist - -(ert-deftest test-ai-mcp-build-server-alist-all-enabled-normal () - "Normal: with default specs and all-enabled list, alist has all 9 entries." - (test-ai-mcp--with-fixture path - (let* ((cj/mcp-claude-config path) - (alist (cj/mcp--build-server-alist))) - (should (= (length alist) 9)) - ;; Every name appears. - (dolist (name '("linear" "notion" "figma" "slack-deepsat" "drawio" - "google-calendar" "google-docs-personal" - "google-docs-work" "google-keep")) - (should (assoc name alist)))))) - -(ert-deftest test-ai-mcp-build-server-alist-filter-by-enabled-boundary () - "Boundary: enabled subset of names produces a subset alist." - (test-ai-mcp--with-fixture path - (let* ((cj/mcp-claude-config path) - (alist (cj/mcp--build-server-alist - cj/mcp-server-specs - '("drawio" "linear")))) - (should (= (length alist) 2)) - (should (assoc "drawio" alist)) - (should (assoc "linear" alist)) - (should-not (assoc "figma" alist))))) - -(ert-deftest test-ai-mcp-build-server-alist-stdio-shape-normal () - "Normal: a stdio entry has :type, :command, :args (no :url)." - (test-ai-mcp--with-fixture path - (let* ((cj/mcp-claude-config path) - (alist (cj/mcp--build-server-alist - cj/mcp-server-specs '("drawio")))) - (let ((entry (cdr (assoc "drawio" alist)))) - (should (equal (plist-get entry :type) "stdio")) - (should (equal (plist-get entry :command) "npx")) - (should (listp (plist-get entry :args))) - (should-not (plist-get entry :url)))))) - -(ert-deftest test-ai-mcp-build-server-alist-http-shape-normal () - "Normal: an http entry has :type and :url (no :command)." - (test-ai-mcp--with-fixture path - (let* ((cj/mcp-claude-config path) - (alist (cj/mcp--build-server-alist - cj/mcp-server-specs '("linear")))) - (let ((entry (cdr (assoc "linear" alist)))) - (should (equal (plist-get entry :type) "http")) - (should (equal (plist-get entry :url) "https://mcp.linear.app/mcp")) - (should-not (plist-get entry :command)))))) - -(ert-deftest test-ai-mcp-build-server-alist-sse-shape-normal () - "Normal: an sse entry has :type and :url." - (test-ai-mcp--with-fixture path - (let* ((cj/mcp-claude-config path) - (alist (cj/mcp--build-server-alist - cj/mcp-server-specs '("slack-deepsat")))) - (let ((entry (cdr (assoc "slack-deepsat" alist)))) - (should (equal (plist-get entry :type) "sse")) - (should (equal (plist-get entry :url) - "http://127.0.0.1:13080/sse")))))) - -(ert-deftest test-ai-mcp-build-server-alist-env-merge-normal () - "Normal: env-bearing server has its env plist merged into the entry." - (test-ai-mcp--with-fixture path - (let* ((cj/mcp-claude-config path) - (alist (cj/mcp--build-server-alist - cj/mcp-server-specs '("google-calendar")))) - (let* ((entry (cdr (assoc "google-calendar" alist))) - (env (plist-get entry :env))) - (should env) - (should (equal (plist-get env :GOOGLE_OAUTH_CREDENTIALS) - test-ai-mcp--sentinel)))))) - -(ert-deftest test-ai-mcp-build-server-alist-secret-args-splice-normal () - "Normal: figma's --figma-api-key= is spliced into :args from Claude config." - (test-ai-mcp--with-fixture path - (let* ((cj/mcp-claude-config path) - (alist (cj/mcp--build-server-alist - cj/mcp-server-specs '("figma")))) - (let* ((entry (cdr (assoc "figma" alist))) - (args (plist-get entry :args)) - (api-arg (cl-find-if - (lambda (a) (string-prefix-p "--figma-api-key=" a)) - args))) - (should api-arg) - (should (equal api-arg (format "--figma-api-key=%s" - test-ai-mcp--sentinel))))))) - -(ert-deftest test-ai-mcp-build-server-alist-no-mutation-boundary () - "Boundary: building the alist does not mutate `cj/mcp-server-specs'." - (test-ai-mcp--with-fixture path - (let* ((cj/mcp-claude-config path) - (snapshot (copy-tree cj/mcp-server-specs))) - (cj/mcp--build-server-alist) - (should (equal cj/mcp-server-specs snapshot))))) - -(provide 'test-ai-mcp-helpers) -;;; test-ai-mcp-helpers.el ends here diff --git a/tests/test-ai-quick-ask.el b/tests/test-ai-quick-ask.el deleted file mode 100644 index 3e1f6460f..000000000 --- a/tests/test-ai-quick-ask.el +++ /dev/null @@ -1,149 +0,0 @@ -;;; test-ai-quick-ask.el --- Tests for ai-quick-ask -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for the helpers and orchestration in ai-quick-ask.el. The -;; quick-ask buffer is exercised via `cl-letf' stubs on -;; `gptel-request' and friends so no network call ever happens. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(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) -;; Stub gptel-request so cj/gptel-quick-ask doesn't try to hit the network. -(unless (fboundp 'gptel-request) - (defun gptel-request (&rest _args) nil)) - -(require 'ai-quick-ask) - -;; The quick-ask escalation reopens *AI-Assistant* through -;; cj/side-window-display, which reads the panel-width state ai-config owns. -;; ai-config isn't loaded here (it would pull in gptel), so declare those vars -;; globally to stand in for it -- a value-less defvar in the module is only -;; file-local to the byte-compiler, so the function reads them dynamically and -;; would otherwise hit void-variable. -(defvar cj/ai-assistant-window-width 0.4) -(defvar cj/--ai-assistant-width nil) - -;; ------------------------------ pure helpers - -(ert-deftest test-ai-quick-ask-initial-text-shape () - "Initial text is Q: blank line then the response marker." - (should (equal (cj/gptel-quick--initial-text "hello?") - "Q: hello?\n\nA: "))) - -(ert-deftest test-ai-quick-ask-extract-response-normal () - "Extracts text after the response marker." - (should (equal (cj/gptel-quick--extract-response "Q: x\n\nA: hello world") - "hello world"))) - -(ert-deftest test-ai-quick-ask-extract-response-multiline () - "Multi-line response is returned in full." - (should (equal (cj/gptel-quick--extract-response - "Q: x\n\nA: first line\nsecond line\n") - "first line\nsecond line\n"))) - -(ert-deftest test-ai-quick-ask-extract-response-no-marker () - "Buffer without the marker returns nil." - (should-not (cj/gptel-quick--extract-response "no marker here"))) - -(ert-deftest test-ai-quick-ask-extract-response-empty () - "Empty buffer returns nil." - (should-not (cj/gptel-quick--extract-response ""))) - -(ert-deftest test-ai-quick-ask-seed-text-shape () - "Seed text has user heading, prompt, AI heading, response." - (let ((seed (cj/gptel-quick--seed-text "ask" "reply"))) - (should (string-match-p "^\\* .* \\[" seed)) - (should (string-match-p "ask" seed)) - (should (string-match-p "^\\* AI" seed)) - (should (string-match-p "reply" seed)))) - -(ert-deftest test-ai-quick-ask-seed-text-nil-response () - "Seed text with a nil response leaves an empty body for the AI side." - (let ((seed (cj/gptel-quick--seed-text "ask" nil))) - (should (string-match-p "^\\* AI" seed)))) - -;; ------------------------------ ask - -(ert-deftest test-ai-quick-ask-creates-buffer () - "Ask creates the *GPTel-Quick* buffer in cj/gptel-quick-mode." - (when (get-buffer cj/gptel-quick--buffer-name) - (kill-buffer cj/gptel-quick--buffer-name)) - (let (request-called) - (cl-letf (((symbol-function 'gptel-request) - (lambda (&rest _) (setq request-called t))) - ((symbol-function 'display-buffer) - (lambda (&rest _) nil))) - (cj/gptel-quick-ask "test prompt") - (let ((buf (get-buffer cj/gptel-quick--buffer-name))) - (should buf) - (with-current-buffer buf - (should (eq major-mode 'cj/gptel-quick-mode)) - (should (equal cj/gptel-quick--prompt "test prompt")) - (should (string-match-p "Q: test prompt" (buffer-string)))) - (kill-buffer buf)) - (should request-called)))) - -(ert-deftest test-ai-quick-ask-error-empty-prompt () - "Empty prompt signals." - (should-error (cj/gptel-quick-ask ""))) - -;; ------------------------------ dismiss - -(ert-deftest test-ai-quick-ask-dismiss-kills-buffer () - "Dismiss kills the *GPTel-Quick* buffer." - (let ((buf (get-buffer-create cj/gptel-quick--buffer-name))) - (should (buffer-live-p buf)) - (cj/gptel-quick-dismiss) - (should-not (buffer-live-p buf)))) - -(ert-deftest test-ai-quick-ask-dismiss-no-op-when-absent () - "Dismiss with no quick buffer is a no-op." - (when (get-buffer cj/gptel-quick--buffer-name) - (kill-buffer cj/gptel-quick--buffer-name)) - ;; Should not error - (cj/gptel-quick-dismiss)) - -;; ------------------------------ continue - -(ert-deftest test-ai-quick-ask-continue-seeds-ai-assistant () - "Continue seeds *AI-Assistant* with prompt + response and kills quick buffer." - (when (get-buffer cj/gptel-quick--buffer-name) - (kill-buffer cj/gptel-quick--buffer-name)) - (when (get-buffer "*AI-Assistant*") - (kill-buffer "*AI-Assistant*")) - (let ((display-called nil)) - (cl-letf (((symbol-function 'display-buffer-in-side-window) - (lambda (&rest _) (setq display-called t)))) - ;; Prepare a quick buffer with prompt + response - (with-current-buffer (get-buffer-create cj/gptel-quick--buffer-name) - (cj/gptel-quick-mode) - (let ((inhibit-read-only t)) - (insert (cj/gptel-quick--initial-text "what is X?")) - (insert "X is a thing.")) - (setq-local cj/gptel-quick--prompt "what is X?") - ;; Provide a stub *AI-Assistant* so continue doesn't try to call gptel. - (get-buffer-create "*AI-Assistant*") - (cj/gptel-quick-continue)) - (should display-called) - ;; *AI-Assistant* got the seed - (with-current-buffer "*AI-Assistant*" - (let ((body (buffer-string))) - (should (string-match-p "what is X?" body)) - (should (string-match-p "X is a thing\\." body)))) - ;; Quick buffer was dismissed - (should-not (get-buffer cj/gptel-quick--buffer-name)))) - (kill-buffer "*AI-Assistant*")) - -(ert-deftest test-ai-quick-ask-continue-error-outside-quick-buffer () - "Continue signals when called outside a quick-ask buffer." - (with-temp-buffer - (should-error (cj/gptel-quick-continue)))) - -(provide 'test-ai-quick-ask) -;;; test-ai-quick-ask.el ends here diff --git a/tests/test-ai-rewrite.el b/tests/test-ai-rewrite.el deleted file mode 100644 index ddb831339..000000000 --- a/tests/test-ai-rewrite.el +++ /dev/null @@ -1,159 +0,0 @@ -;;; test-ai-rewrite.el --- Tests for ai-rewrite.el -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for the directive-picker wrappers around `gptel-rewrite'. -;; `gptel-rewrite' itself is stubbed so the tests verify what the -;; wrappers do (which directive body lands in the hook, which region -;; was captured) without touching the real rewrite UI. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(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) - -;; Stub the gptel-rewrite surface so the wrapper can dispatch to it -;; without loading the real package. testutil-ai-config provides a -;; non-interactive stub of `gptel-rewrite'; we override it with an -;; interactive recorder that captures the hook-derived directive body -;; and the active region. -(defvar gptel-rewrite-directives-hook nil) -(defvar test-ai-rewrite--captured-directive nil - "Last system-message body produced by the hook during a stub rewrite.") -(defvar test-ai-rewrite--captured-region nil - "Cons (BEG . END) captured from `mark' and `point' at stub-rewrite time.") -(defun gptel-rewrite () - "Stub: capture the directive body and the active region." - (interactive) - (setq test-ai-rewrite--captured-directive - (run-hook-with-args-until-success 'gptel-rewrite-directives-hook)) - (setq test-ai-rewrite--captured-region - (cons (region-beginning) (region-end)))) - -(require 'ai-rewrite) - -;; ---------------------------- defcustom shape - -(ert-deftest test-ai-rewrite-directives-defcustom-has-named-entries () - "Default directives include the names called out in the spec." - (let ((names (mapcar #'car cj/gptel-rewrite-directives))) - (dolist (expected '("terse" "fix-grammar" "refactor-readability" - "add-docstring" "explain-as-comment" "shorten")) - (should (member expected names))))) - -(ert-deftest test-ai-rewrite-directives-bodies-are-strings () - "Every directive body is a non-empty string." - (dolist (entry cj/gptel-rewrite-directives) - (should (stringp (cdr entry))) - (should (> (length (cdr entry)) 0)))) - -;; ---------------------------- with-directive - -(ert-deftest test-ai-rewrite-with-directive-normal () - "Wrapper injects the directive body and runs gptel-rewrite on the region." - (with-temp-buffer - (insert "first body line\nsecond body line\n") - (let ((test-ai-rewrite--captured-directive nil) - (test-ai-rewrite--captured-region nil) - (cj/gptel-rewrite-directives - '(("test" . "BODY FOR TEST DIRECTIVE")))) - ;; Activate the region across both lines - (set-mark (point-min)) - (goto-char (point-max)) - (activate-mark) - (cj/gptel-rewrite-with-directive "test") - (should (equal test-ai-rewrite--captured-directive - "BODY FOR TEST DIRECTIVE")) - (should test-ai-rewrite--captured-region)))) - -(ert-deftest test-ai-rewrite-with-directive-error-no-region () - "No active region signals." - (with-temp-buffer - (insert "no region") - (deactivate-mark) - (should-error (call-interactively #'cj/gptel-rewrite-with-directive)))) - -(ert-deftest test-ai-rewrite-with-directive-error-unknown-directive () - "Unknown directive name signals." - (with-temp-buffer - (insert "body") - (set-mark (point-min)) - (goto-char (point-max)) - (activate-mark) - (let ((cj/gptel-rewrite-directives '(("known" . "x")))) - (should-error - (cj/gptel-rewrite--call-with-directive - "unknown" (point-min) (point-max)))))) - -(ert-deftest test-ai-rewrite-with-directive-records-last-state () - "Wrapper records the region and directive name for later redo." - (with-temp-buffer - (insert "abc\ndef\n") - (let ((cj/gptel-rewrite-directives - '(("first" . "FIRST BODY"))) - (test-ai-rewrite--captured-directive nil)) - (set-mark (point-min)) - (goto-char (point-max)) - (activate-mark) - (cj/gptel-rewrite-with-directive "first") - (should (equal cj/gptel-rewrite--last-directive "first")) - (should (consp cj/gptel-rewrite--last-region)) - (should (markerp (car cj/gptel-rewrite--last-region))) - (should (markerp (cdr cj/gptel-rewrite--last-region)))))) - -;; ---------------------------- redo - -(ert-deftest test-ai-rewrite-redo-normal () - "Redo replays the last region with a new directive." - (with-temp-buffer - (insert "line1\nline2\nline3\n") - (let* ((cj/gptel-rewrite-directives - '(("first" . "FIRST BODY") - ("second" . "SECOND BODY"))) - (test-ai-rewrite--captured-directive nil) - (test-ai-rewrite--captured-region nil)) - (set-mark (point-min)) - (goto-char (point-max)) - (activate-mark) - (cj/gptel-rewrite-with-directive "first") - (should (equal test-ai-rewrite--captured-directive "FIRST BODY")) - (let ((first-region test-ai-rewrite--captured-region)) - (setq test-ai-rewrite--captured-directive nil) - (setq test-ai-rewrite--captured-region nil) - (cl-letf (((symbol-function 'completing-read) - (lambda (_p choices &rest _) (car choices)))) - (cj/gptel-rewrite-redo-with-different-directive)) - (should (equal test-ai-rewrite--captured-directive "SECOND BODY")) - (should (equal test-ai-rewrite--captured-region first-region)))))) - -(ert-deftest test-ai-rewrite-redo-error-no-previous () - "Redo without prior rewrite signals." - (with-temp-buffer - (setq-local cj/gptel-rewrite--last-region nil) - (should-error (cj/gptel-rewrite-redo-with-different-directive)))) - -(ert-deftest test-ai-rewrite-redo-excludes-current-directive () - "Redo's completing-read prompt offers every directive except the last." - (with-temp-buffer - (insert "body") - (let ((cj/gptel-rewrite-directives - '(("a" . "A") ("b" . "B") ("c" . "C"))) - (offered nil)) - (set-mark (point-min)) - (goto-char (point-max)) - (activate-mark) - (cj/gptel-rewrite-with-directive "b") - (cl-letf (((symbol-function 'completing-read) - (lambda (_p choices &rest _) - (setq offered choices) - (car choices)))) - (cj/gptel-rewrite-redo-with-different-directive)) - (should (equal (sort (copy-sequence offered) #'string<) - '("a" "c")))))) - -(provide 'test-ai-rewrite) -;;; test-ai-rewrite.el ends here diff --git a/tests/test-gptel-tools-git-diff.el b/tests/test-gptel-tools-git-diff.el deleted file mode 100644 index 114fec293..000000000 --- a/tests/test-gptel-tools-git-diff.el +++ /dev/null @@ -1,163 +0,0 @@ -;;; test-gptel-tools-git-diff.el --- Tests for git_diff gptel tool -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests run against real temp git repos under HOME via `process-file'. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) - (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) - (setq load-prefer-newer t) - (unless (featurep 'gptel) - (defvar gptel-tools nil) - (defun gptel-make-tool (&rest _args) nil) - (defun gptel-get-tool (&rest _args) nil) - (provide 'gptel))) - -(require 'git_diff) - -;; ---------- helpers - -(defun test-gptel-tools-git-diff--with-repo (fn) - "Create a temp git repo under HOME with one committed file, call FN." - (let* ((name (format ".test-gptel-tools-git-diff-%s" - (format-time-string "%s%N"))) - (dir (expand-file-name name "~"))) - (unwind-protect - (progn - (make-directory dir) - (let ((default-directory dir)) - (call-process "git" nil nil nil "init" "--quiet") - (call-process "git" nil nil nil "config" "user.email" "test@x") - (call-process "git" nil nil nil "config" "user.name" "Test") - (with-temp-file (expand-file-name "f.txt" dir) - (insert "original\n")) - (call-process "git" nil nil nil "add" "f.txt") - (call-process "git" nil nil nil "commit" "--quiet" "-m" "initial")) - (funcall fn dir)) - (when (file-exists-p dir) (delete-directory dir t))))) - -;; ---------- build-args - -(ert-deftest test-gptel-tools-git-diff-build-args-no-refs () - "Normal: no refs / no file → bare diff args." - (should (equal (cj/gptel-git-diff--build-args nil nil nil) - '("-c" "color.ui=false" "diff")))) - -(ert-deftest test-gptel-tools-git-diff-build-args-with-ref1 () - "Normal: REF1 appended." - (should (equal (cj/gptel-git-diff--build-args "HEAD~1" nil nil) - '("-c" "color.ui=false" "diff" "HEAD~1")))) - -(ert-deftest test-gptel-tools-git-diff-build-args-with-both-refs () - "Normal: REF1 and REF2 both appended." - (should (equal (cj/gptel-git-diff--build-args "HEAD~1" "HEAD" nil) - '("-c" "color.ui=false" "diff" "HEAD~1" "HEAD")))) - -(ert-deftest test-gptel-tools-git-diff-build-args-with-file () - "Normal: FILE appended after `--'." - (should (equal (cj/gptel-git-diff--build-args nil nil "foo.txt") - '("-c" "color.ui=false" "diff" "--" "foo.txt")))) - -(ert-deftest test-gptel-tools-git-diff-build-args-boundary-empty-strings () - "Boundary: empty-string REF/FILE values are ignored." - (should (equal (cj/gptel-git-diff--build-args "" "" "") - '("-c" "color.ui=false" "diff")))) - -;; ---------- truncate - -(ert-deftest test-gptel-tools-git-diff-truncate-under-cap () - "Normal: short input returns unchanged." - (should (equal (cj/gptel-git-diff--truncate "small diff") "small diff"))) - -(ert-deftest test-gptel-tools-git-diff-truncate-over-cap () - "Boundary: output exceeding the cap is truncated with a marker." - (let* ((cap cj/gptel-git-diff--max-output-bytes) - (huge (make-string (+ cap 1000) ?x)) - (out (cj/gptel-git-diff--truncate huge))) - (should (string-match-p "\\[truncated:" out)) - (should (> (length huge) (length out))))) - -;; ---------- validate-path - -(ert-deftest test-gptel-tools-git-diff-validate-path-normal () - "Normal: validator accepts a git working tree." - (test-gptel-tools-git-diff--with-repo - (lambda (dir) - (should (equal (cj/gptel-git-diff--validate-path dir) dir))))) - -(ert-deftest test-gptel-tools-git-diff-validate-path-error-outside-home () - "Error: path outside HOME signals." - (should-error (cj/gptel-git-diff--validate-path "/etc"))) - -(ert-deftest test-gptel-tools-git-diff-validate-path-error-not-a-repo () - "Error: non-git directory signals." - (let ((dir (make-temp-file - (expand-file-name ".test-gptel-tools-git-diff-" "~") t))) - (unwind-protect - (should-error (cj/gptel-git-diff--validate-path dir)) - (when (file-exists-p dir) (delete-directory dir t))))) - -(ert-deftest test-gptel-tools-git-diff-validate-path-error-not-a-directory () - "Error: file paths are rejected." - (let ((file (make-temp-file - (expand-file-name ".test-gptel-tools-git-diff-file-" "~")))) - (unwind-protect - (should-error (cj/gptel-git-diff--validate-path file)) - (when (file-exists-p file) (delete-file file))))) - -(ert-deftest test-gptel-tools-git-diff-validate-path-error-symlink-outside-home () - "Error: symlinked directories resolving outside HOME are rejected." - (let ((link (expand-file-name - (format ".test-gptel-tools-git-diff-link-%s" - (format-time-string "%s%N")) - "~"))) - (unwind-protect - (progn - (make-symbolic-link "/tmp" link t) - (should-error (cj/gptel-git-diff--validate-path link))) - (when (file-symlink-p link) (delete-file link))))) - -;; ---------- run - -(ert-deftest test-gptel-tools-git-diff-run-no-changes () - "Boundary: a clean tree with no refs returns the no-diff marker." - (test-gptel-tools-git-diff--with-repo - (lambda (dir) - (let ((out (cj/gptel-git-diff--run dir))) - (should (string-match-p "No diff" out)))))) - -(ert-deftest test-gptel-tools-git-diff-run-unstaged-change () - "Normal: an unstaged edit appears as a real diff." - (test-gptel-tools-git-diff--with-repo - (lambda (dir) - (with-temp-file (expand-file-name "f.txt" dir) - (insert "changed\n")) - (let ((out (cj/gptel-git-diff--run dir))) - (should (string-match-p "^-original" out)) - (should (string-match-p "^\\+changed" out)))))) - -(ert-deftest test-gptel-tools-git-diff-run-narrow-to-file () - "Normal: FILE argument narrows the diff." - (test-gptel-tools-git-diff--with-repo - (lambda (dir) - (with-temp-file (expand-file-name "f.txt" dir) - (insert "changed\n")) - (with-temp-file (expand-file-name "g.txt" dir) - (insert "second file\n")) - (let ((out (cj/gptel-git-diff--run dir nil nil "f.txt"))) - (should (string-match-p "f.txt" out)) - (should-not (string-match-p "g.txt" out)))))) - -(ert-deftest test-gptel-tools-git-diff-run-error-on-bad-ref () - "Error: git diff exits other than 0/1 are surfaced." - (test-gptel-tools-git-diff--with-repo - (lambda (dir) - (should-error (cj/gptel-git-diff--run dir "does-not-exist"))))) - -(provide 'test-gptel-tools-git-diff) -;;; test-gptel-tools-git-diff.el ends here diff --git a/tests/test-gptel-tools-git-log.el b/tests/test-gptel-tools-git-log.el deleted file mode 100644 index c0503039a..000000000 --- a/tests/test-gptel-tools-git-log.el +++ /dev/null @@ -1,183 +0,0 @@ -;;; test-gptel-tools-git-log.el --- Tests for git_log gptel tool -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests run against real temp git repos under HOME via `process-file'. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) - (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) - (setq load-prefer-newer t) - (unless (featurep 'gptel) - (defvar gptel-tools nil) - (defun gptel-make-tool (&rest _args) nil) - (defun gptel-get-tool (&rest _args) nil) - (provide 'gptel))) - -(require 'git_log) - -;; ---------- helpers - -(defun test-gptel-tools-git-log--with-repo (commit-count fn) - "Create a temp git repo under HOME with COMMIT-COUNT empty commits. -Call FN with the absolute path, clean up after." - (let* ((name (format ".test-gptel-tools-git-log-%s" - (format-time-string "%s%N"))) - (dir (expand-file-name name "~"))) - (unwind-protect - (progn - (make-directory dir) - (let ((default-directory dir)) - (call-process "git" nil nil nil "init" "--quiet") - (call-process "git" nil nil nil "config" "user.email" "test@x") - (call-process "git" nil nil nil "config" "user.name" "Test") - (dotimes (i commit-count) - (let ((process-environment - (append - (list "GIT_AUTHOR_DATE=2000-01-01T00:00:00+0000" - "GIT_COMMITTER_DATE=2000-01-01T00:00:00+0000") - process-environment))) - (call-process "git" nil nil nil "commit" "--allow-empty" - "--quiet" "-m" (format "commit %d" i))))) - (funcall fn dir)) - (when (file-exists-p dir) (delete-directory dir t))))) - -;; ---------- effective-count - -(ert-deftest test-gptel-tools-git-log-effective-count-defaults-on-nil () - "Boundary: nil N → default count." - (should (= (cj/gptel-git-log--effective-count nil) - cj/gptel-git-log--default-count))) - -(ert-deftest test-gptel-tools-git-log-effective-count-defaults-on-non-integer () - "Boundary: non-integer N → default count." - (should (= (cj/gptel-git-log--effective-count "ten") - cj/gptel-git-log--default-count)) - (should (= (cj/gptel-git-log--effective-count 0.5) - cj/gptel-git-log--default-count))) - -(ert-deftest test-gptel-tools-git-log-effective-count-clamps-low () - "Boundary: N below 1 → default count." - (should (= (cj/gptel-git-log--effective-count 0) - cj/gptel-git-log--default-count)) - (should (= (cj/gptel-git-log--effective-count -5) - cj/gptel-git-log--default-count))) - -(ert-deftest test-gptel-tools-git-log-effective-count-caps-high () - "Boundary: N above max → max." - (should (= (cj/gptel-git-log--effective-count 1000) - cj/gptel-git-log--max-count))) - -(ert-deftest test-gptel-tools-git-log-effective-count-normal () - "Normal: a valid N passes through." - (should (= (cj/gptel-git-log--effective-count 5) 5))) - -;; ---------- validate-path - -(ert-deftest test-gptel-tools-git-log-validate-path-normal () - "Normal: validator accepts a git working tree." - (test-gptel-tools-git-log--with-repo - 1 - (lambda (dir) - (should (equal (cj/gptel-git-log--validate-path dir) dir))))) - -(ert-deftest test-gptel-tools-git-log-validate-path-error-outside-home () - "Error: path outside HOME signals." - (should-error (cj/gptel-git-log--validate-path "/etc"))) - -(ert-deftest test-gptel-tools-git-log-validate-path-error-not-a-repo () - "Error: directory outside any git working tree signals." - (let ((dir (make-temp-file - (expand-file-name ".test-gptel-tools-git-log-" "~") t))) - (unwind-protect - (should-error (cj/gptel-git-log--validate-path dir)) - (when (file-exists-p dir) (delete-directory dir t))))) - -(ert-deftest test-gptel-tools-git-log-validate-path-error-not-a-directory () - "Error: file paths are rejected." - (let ((file (make-temp-file - (expand-file-name ".test-gptel-tools-git-log-file-" "~")))) - (unwind-protect - (should-error (cj/gptel-git-log--validate-path file)) - (when (file-exists-p file) (delete-file file))))) - -(ert-deftest test-gptel-tools-git-log-validate-path-error-symlink-outside-home () - "Error: symlinked directories resolving outside HOME are rejected." - (let ((link (expand-file-name - (format ".test-gptel-tools-git-log-link-%s" - (format-time-string "%s%N")) - "~"))) - (unwind-protect - (progn - (make-symbolic-link "/tmp" link t) - (should-error (cj/gptel-git-log--validate-path link))) - (when (file-symlink-p link) (delete-file link))))) - -;; ---------- run - -(ert-deftest test-gptel-tools-git-log-run-default-count () - "Normal: default count limits output to that many commits." - (test-gptel-tools-git-log--with-repo - 30 - (lambda (dir) - (let* ((out (cj/gptel-git-log--run dir)) - (lines (split-string (string-trim out) "\n"))) - (should (= (length lines) cj/gptel-git-log--default-count)))))) - -(ert-deftest test-gptel-tools-git-log-run-honors-n () - "Normal: an explicit N limits output to N commits." - (test-gptel-tools-git-log--with-repo - 10 - (lambda (dir) - (let* ((out (cj/gptel-git-log--run dir 3)) - (lines (split-string (string-trim out) "\n"))) - (should (= (length lines) 3)))))) - -(ert-deftest test-gptel-tools-git-log-run-since-no-match () - "Boundary: --since filter with no matching commits returns marker." - (test-gptel-tools-git-log--with-repo - 1 - (lambda (dir) - (let ((out (cj/gptel-git-log--run dir 10 "2001-01-01"))) - (should (string-match-p "No commits" out)))))) - -(ert-deftest test-gptel-tools-git-log-run-error-on-git-log-failure () - "Error: non-zero git log exits are surfaced." - (test-gptel-tools-git-log--with-repo - 1 - (lambda (dir) - (cl-letf (((symbol-function 'process-file) - (lambda (program infile destination display &rest args) - (if (member "log" args) - (progn - (when (bufferp destination) - (with-current-buffer destination (insert "bad log"))) - 2) - (apply #'call-process program infile destination display args))))) - (should-error (cj/gptel-git-log--run dir)))))) - -(ert-deftest test-gptel-tools-git-log-run-empty-repo () - "Boundary: a repo with no commits returns the empty-result marker." - (let* ((name (format ".test-gptel-tools-git-log-empty-%s" - (format-time-string "%s%N"))) - (dir (expand-file-name name "~"))) - (unwind-protect - (progn - (make-directory dir) - (let ((default-directory dir)) - (call-process "git" nil nil nil "init" "--quiet")) - ;; git log on a no-commits repo errors in some versions, but - ;; our wrapper turns "no commits" into the no-match marker. - (let ((res (ignore-errors (cj/gptel-git-log--run dir)))) - ;; Either path is acceptable: error captured (nil) or the - ;; explicit "No commits matching" marker. - (should (or (null res) - (string-match-p "No commits" res))))) - (when (file-exists-p dir) (delete-directory dir t))))) - -(provide 'test-gptel-tools-git-log) -;;; test-gptel-tools-git-log.el ends here diff --git a/tests/test-gptel-tools-git-status.el b/tests/test-gptel-tools-git-status.el deleted file mode 100644 index 471938535..000000000 --- a/tests/test-gptel-tools-git-status.el +++ /dev/null @@ -1,124 +0,0 @@ -;;; test-gptel-tools-git-status.el --- Tests for git_status gptel tool -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests run against real temp git repos under HOME via `process-file'. -;; The tool is read-only so repos are torn down per test. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) - (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) - (setq load-prefer-newer t) - (unless (featurep 'gptel) - (defvar gptel-tools nil) - (defun gptel-make-tool (&rest _args) nil) - (defun gptel-get-tool (&rest _args) nil) - (provide 'gptel))) - -(require 'git_status) - -;; ---------- helpers - -(defun test-gptel-tools-git-status--with-repo (fn) - "Create a temp git repo under HOME, call FN with its absolute path, clean up." - (let* ((name (format ".test-gptel-tools-git-status-%s" - (format-time-string "%s%N"))) - (dir (expand-file-name name "~"))) - (unwind-protect - (progn - (make-directory dir) - (let ((default-directory dir)) - (call-process "git" nil nil nil "init" "--quiet") - (call-process "git" nil nil nil "config" "user.email" "test@x") - (call-process "git" nil nil nil "config" "user.name" "Test") - (call-process "git" nil nil nil "commit" "--allow-empty" - "--quiet" "-m" "initial")) - (funcall fn dir)) - (when (file-exists-p dir) (delete-directory dir t))))) - -;; ---------- validate-path - -(ert-deftest test-gptel-tools-git-status-validate-path-normal () - "Normal: validator accepts a directory inside a git working tree." - (test-gptel-tools-git-status--with-repo - (lambda (dir) - (should (equal (cj/gptel-git-status--validate-path dir) dir))))) - -(ert-deftest test-gptel-tools-git-status-validate-path-error-outside-home () - "Error: path outside HOME signals." - (should-error (cj/gptel-git-status--validate-path "/etc"))) - -(ert-deftest test-gptel-tools-git-status-validate-path-error-not-a-directory () - "Error: path that's not a directory signals." - (let ((file (make-temp-file - (expand-file-name ".test-gptel-tools-git-status-" "~")))) - (unwind-protect - (should-error (cj/gptel-git-status--validate-path file)) - (when (file-exists-p file) (delete-file file))))) - -(ert-deftest test-gptel-tools-git-status-validate-path-error-not-a-repo () - "Error: directory outside any git working tree signals." - (let ((dir (make-temp-file - (expand-file-name ".test-gptel-tools-git-status-" "~") t))) - (unwind-protect - (should-error (cj/gptel-git-status--validate-path dir)) - (when (file-exists-p dir) (delete-directory dir t))))) - -(ert-deftest test-gptel-tools-git-status-validate-path-error-symlink-outside-home () - "Error: symlinked directories resolving outside HOME are rejected." - (let ((link (expand-file-name - (format ".test-gptel-tools-git-status-link-%s" - (format-time-string "%s%N")) - "~"))) - (unwind-protect - (progn - (make-symbolic-link "/tmp" link t) - (should-error (cj/gptel-git-status--validate-path link))) - (when (file-symlink-p link) (delete-file link))))) - -;; ---------- run - -(ert-deftest test-gptel-tools-git-status-run-clean-tree () - "Normal: a clean repo returns the clean-tree marker." - (test-gptel-tools-git-status--with-repo - (lambda (dir) - (let ((out (cj/gptel-git-status--run dir))) - (should (string-match-p "Clean working tree" out)))))) - -(ert-deftest test-gptel-tools-git-status-run-dirty-tree-includes-file () - "Normal: an untracked file appears in the output." - (test-gptel-tools-git-status--with-repo - (lambda (dir) - (with-temp-file (expand-file-name "new.txt" dir) (insert "x")) - (let ((out (cj/gptel-git-status--run dir))) - (should (string-match-p "new.txt" out)) - (should (string-match-p "^\\?\\?" out)))))) - -(ert-deftest test-gptel-tools-git-status-run-includes-branch () - "Normal: the `--branch' line surfaces in the output." - (test-gptel-tools-git-status--with-repo - (lambda (dir) - (with-temp-file (expand-file-name "f.txt" dir) (insert "x")) - (let ((out (cj/gptel-git-status--run dir))) - (should (string-match-p "^## " out)))))) - -(ert-deftest test-gptel-tools-git-status-run-error-on-git-status-failure () - "Error: non-zero git status exits are surfaced." - (test-gptel-tools-git-status--with-repo - (lambda (dir) - (cl-letf (((symbol-function 'process-file) - (lambda (program infile destination display &rest args) - (if (member "status" args) - (progn - (when (bufferp destination) - (with-current-buffer destination (insert "bad status"))) - 2) - (apply #'call-process program infile destination display args))))) - (should-error (cj/gptel-git-status--run dir)))))) - -(provide 'test-gptel-tools-git-status) -;;; test-gptel-tools-git-status.el ends here diff --git a/tests/test-gptel-tools-list-directory-files.el b/tests/test-gptel-tools-list-directory-files.el deleted file mode 100644 index 9588ce8be..000000000 --- a/tests/test-gptel-tools-list-directory-files.el +++ /dev/null @@ -1,257 +0,0 @@ -;;; test-gptel-tools-list-directory-files.el --- Tests for list_directory_files -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for the helpers in list_directory_files.el. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) - (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) - (setq load-prefer-newer t) - (unless (featurep 'gptel) - (defvar gptel-tools nil) - (defun gptel-make-tool (&rest _args) nil) - (defun gptel-get-tool (&rest _args) nil) - (provide 'gptel))) - -(require 'list_directory_files) - -;; -------------------------- helpers - -(defun test-gptel-tools-list--with-tree (fn) - "Create a small directory tree, call FN with its root, clean up." - (let ((root (make-temp-file "test-gptel-tools-list-" t))) - (unwind-protect - (progn - (with-temp-file (expand-file-name "a.txt" root) (insert "a")) - (with-temp-file (expand-file-name "b.org" root) (insert "b")) - (make-directory (expand-file-name "sub" root)) - (with-temp-file (expand-file-name "sub/c.txt" root) (insert "c")) - (funcall fn root)) - (delete-directory root t)))) - -;; -------------------------- mode-to-permissions - -(ert-deftest test-gptel-tools-list-mode-to-permissions-regular-file () - "Mode 0644 on a regular file: -rw-r--r--." - (should (equal (list-directory-files--mode-to-permissions #o0644) - "-rw-r--r--"))) - -(ert-deftest test-gptel-tools-list-mode-to-permissions-directory () - "Mode 0755 + dir bit: drwxr-xr-x." - (should (equal (list-directory-files--mode-to-permissions - (logior #o40000 #o0755)) - "drwxr-xr-x"))) - -(ert-deftest test-gptel-tools-list-mode-to-permissions-executable () - "Mode 0700: -rwx------." - (should (equal (list-directory-files--mode-to-permissions #o0700) - "-rwx------"))) - -;; -------------------------- get-file-info - -(ert-deftest test-gptel-tools-list-get-file-info-success () - "Success: returns a plist with :success t and metadata." - (test-gptel-tools-list--with-tree - (lambda (root) - (let ((info (list-directory-files--get-file-info - (expand-file-name "a.txt" root)))) - (should (plist-get info :success)) - (should (numberp (plist-get info :size))) - (should (stringp (plist-get info :permissions))))))) - -(ert-deftest test-gptel-tools-list-get-file-info-directory () - "Directory info: :is-directory is t." - (test-gptel-tools-list--with-tree - (lambda (root) - (let ((info (list-directory-files--get-file-info - (expand-file-name "sub" root)))) - (should (plist-get info :is-directory)))))) - -(ert-deftest test-gptel-tools-list-get-file-info-error () - "Error: metadata failures are returned as failed info plists." - (cl-letf (((symbol-function 'file-attributes) - (lambda (&rest _args) (error "stat failed")))) - (let ((info (list-directory-files--get-file-info "/tmp/nope"))) - (should-not (plist-get info :success)) - (should (string-match-p "stat failed" (plist-get info :error)))))) - -;; -------------------------- filter-by-extension - -(ert-deftest test-gptel-tools-list-filter-by-extension-keeps-match () - "Filter for txt keeps txt files." - (let* ((filter (list-directory-files--filter-by-extension "txt")) - (info '(:success t :path "/x/foo.txt" :is-directory nil))) - (should (funcall filter info)))) - -(ert-deftest test-gptel-tools-list-filter-by-extension-drops-non-match () - "Filter for txt drops non-txt files." - (let* ((filter (list-directory-files--filter-by-extension "txt")) - (info '(:success t :path "/x/foo.org" :is-directory nil))) - (should-not (funcall filter info)))) - -(ert-deftest test-gptel-tools-list-filter-by-extension-always-keeps-directories () - "Filter keeps directories regardless of extension." - (let* ((filter (list-directory-files--filter-by-extension "txt")) - (info '(:success t :path "/x/sub" :is-directory t))) - (should (funcall filter info)))) - -(ert-deftest test-gptel-tools-list-filter-by-extension-no-extension-is-nil () - "No extension produces a nil filter (i.e. no filtering)." - (should-not (list-directory-files--filter-by-extension nil))) - -(ert-deftest test-gptel-tools-list-filter-by-extension-case-insensitive () - "Boundary: extension filtering is case-insensitive." - (let* ((filter (list-directory-files--filter-by-extension "txt")) - (info '(:success t :path "/x/FOO.TXT" :is-directory nil))) - (should (funcall filter info)))) - -(ert-deftest test-gptel-tools-list-filter-by-extension-drops-failed-file-info () - "Boundary: failed file info entries do not pass file extension filters." - (let* ((filter (list-directory-files--filter-by-extension "txt")) - (info '(:success nil :path "/x/foo.txt" :is-directory nil))) - (should-not (funcall filter info)))) - -;; -------------------------- format-file-entry - -(ert-deftest test-gptel-tools-list-format-file-entry-shape () - "Formatted entry contains permissions, size, mtime, and relative path." - (let* ((info (list (cons :path "/home/u/foo.txt") - (cons :permissions "-rw-r--r--") - (cons :executable nil) - (cons :size 42) - (cons :last-modified (current-time)))) - ;; Build as plist by flattening the cons list. - (info-plist (cl-loop for (k . v) in info append (list k v))) - (out (list-directory-files--format-file-entry info-plist "/home/u"))) - (should (string-match-p "-rw-r--r--" out)) - (should (string-match-p "foo.txt" out)))) - -;; -------------------------- list-directory - -(ert-deftest test-gptel-tools-list-list-directory-flat () - "Non-recursive listing returns only entries in the top level." - (test-gptel-tools-list--with-tree - (lambda (root) - (let* ((result (list-directory-files--list-directory root nil nil)) - (files (plist-get result :files))) - (should files) - (let ((paths (mapcar (lambda (i) (plist-get i :path)) files))) - (should (cl-some (lambda (p) (string-match-p "/a\\.txt\\'" p)) paths)) - (should-not (cl-some (lambda (p) (string-match-p "/c\\.txt\\'" p)) paths))))))) - -(ert-deftest test-gptel-tools-list-list-directory-recursive () - "Recursive listing also returns sub-directory contents." - (test-gptel-tools-list--with-tree - (lambda (root) - (let* ((result (list-directory-files--list-directory root t nil)) - (files (plist-get result :files)) - (paths (mapcar (lambda (i) (plist-get i :path)) files))) - (should (cl-some (lambda (p) (string-match-p "/c\\.txt\\'" p)) paths)))))) - -(ert-deftest test-gptel-tools-list-list-directory-max-depth () - "Boundary: max-depth limits recursive traversal." - (test-gptel-tools-list--with-tree - (lambda (root) - (let* ((result (list-directory-files--list-directory root t nil 0)) - (files (plist-get result :files)) - (paths (mapcar (lambda (i) (plist-get i :path)) files))) - (should-not (cl-some (lambda (p) (string-match-p "/c\\.txt\\'" p)) paths)))))) - -(ert-deftest test-gptel-tools-list-list-directory-filtered-recursive-keeps-matching-files () - "Normal: recursive extension filter returns matching nested files." - (test-gptel-tools-list--with-tree - (lambda (root) - (let* ((filter (list-directory-files--filter-by-extension "txt")) - (result (list-directory-files--list-directory root t filter)) - (files (plist-get result :files)) - (paths (mapcar (lambda (i) (plist-get i :path)) files))) - (should (cl-some (lambda (p) (string-match-p "/a\\.txt\\'" p)) paths)) - (should (cl-some (lambda (p) (string-match-p "/c\\.txt\\'" p)) paths)) - (should-not (cl-some (lambda (p) (string-match-p "/b\\.org\\'" p)) paths)))))) - -(ert-deftest test-gptel-tools-list-list-directory-records-entry-errors () - "Error: per-entry metadata failures are collected." - (test-gptel-tools-list--with-tree - (lambda (root) - (cl-letf (((symbol-function 'list-directory-files--get-file-info) - (lambda (path) - (if (string-match-p "/a\\.txt\\'" path) - (list :success nil :path path :error "denied") - (let* ((attrs (file-attributes path 'string)) - (dirp (eq t (file-attribute-type attrs)))) - (list :success t - :path path - :size 0 - :last-modified (current-time) - :is-directory dirp - :permissions "-rw-r--r--" - :executable nil)))))) - (let ((errors (plist-get (list-directory-files--list-directory root nil nil) - :errors))) - (should errors) - (should (string-match-p "denied" (car errors)))))))) - -(ert-deftest test-gptel-tools-list-list-directory-error-not-a-directory () - "Non-directory path returns errors entry." - (test-gptel-tools-list--with-tree - (lambda (root) - (let* ((result (list-directory-files--list-directory - (expand-file-name "a.txt" root) nil nil)) - (errors (plist-get result :errors))) - (should errors))))) - -(ert-deftest test-gptel-tools-list-list-directory-error-accessing-directory () - "Error: directory access failures are collected." - (test-gptel-tools-list--with-tree - (lambda (root) - (cl-letf (((symbol-function 'directory-files) - (lambda (&rest _args) (error "cannot list")))) - (let ((errors (plist-get (list-directory-files--list-directory root nil nil) - :errors))) - (should errors) - (should (string-match-p "cannot list" (car errors)))))))) - -;; -------------------------- format-output - -(ert-deftest test-gptel-tools-list-format-output-has-files-section () - "Format-output includes a \"Found N file(s)\" line when files present." - (test-gptel-tools-list--with-tree - (lambda (root) - (let* ((result (list-directory-files--list-directory root nil nil)) - (out (list-directory-files--format-output root result))) - (should (string-match-p "Found [0-9]+ file" out)))))) - -(ert-deftest test-gptel-tools-list-format-output-empty () - "Empty result: \"No files found\"." - (let ((out (list-directory-files--format-output - "/nowhere" '(:files nil :errors nil)))) - (should (string-match-p "No files found" out)))) - -(ert-deftest test-gptel-tools-list-format-output-errors-only () - "Format-output includes errors when no files are present." - (let ((out (list-directory-files--format-output - "/nowhere" '(:files nil :errors ("boom"))))) - (should (string-match-p "Errors encountered" out)) - (should (string-match-p "boom" out)))) - -(ert-deftest test-gptel-tools-list-format-output-files-and-errors () - "Format-output separates file listings and errors." - (let* ((info (list :success t - :path (expand-file-name "foo.txt" "~") - :size 1 - :last-modified (current-time) - :is-directory nil - :permissions "-rw-r--r--" - :executable nil)) - (out (list-directory-files--format-output - "~" (list :files (list info) :errors (list "boom"))))) - (should (string-match-p "Found 1 file" out)) - (should (string-match-p "Errors encountered" out)))) - -(provide 'test-gptel-tools-list-directory-files) -;;; test-gptel-tools-list-directory-files.el ends here diff --git a/tests/test-gptel-tools-move-to-trash.el b/tests/test-gptel-tools-move-to-trash.el deleted file mode 100644 index 77f886277..000000000 --- a/tests/test-gptel-tools-move-to-trash.el +++ /dev/null @@ -1,219 +0,0 @@ -;;; test-gptel-tools-move-to-trash.el --- Tests for move_to_trash gptel tool -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for the helpers in move_to_trash.el. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) - (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) - (setq load-prefer-newer t) - (unless (featurep 'gptel) - (defvar gptel-tools nil) - (defun gptel-make-tool (&rest _args) nil) - (defun gptel-get-tool (&rest _args) nil) - (provide 'gptel))) - -(require 'move_to_trash) - -;; -------------------------- helpers - -(defun test-gptel-tools-trash--with-tmp-tree (fn) - "Create a temp source dir and trash dir; run FN with both; clean up." - (let* ((src (make-temp-file "test-gptel-tools-trash-src-" t)) - (trash (make-temp-file "test-gptel-tools-trash-dst-" t))) - (unwind-protect - (funcall fn src trash) - (when (file-exists-p src) (delete-directory src t)) - (when (file-exists-p trash) (delete-directory trash t))))) - -;; -------------------------- generate-unique-name - -(ert-deftest test-gptel-tools-trash-generate-unique-name-no-conflict () - "No conflict: returns the plain base name in trash." - (test-gptel-tools-trash--with-tmp-tree - (lambda (_src trash) - (let ((out (gptel--move-to-trash-generate-unique-name - "/anywhere/foo.txt" trash))) - (should (equal (file-name-nondirectory out) "foo.txt")))))) - -(ert-deftest test-gptel-tools-trash-generate-unique-name-conflict-timestamps () - "Name conflict: returns a name with a timestamp suffix." - (test-gptel-tools-trash--with-tmp-tree - (lambda (_src trash) - (with-temp-file (expand-file-name "foo.txt" trash) (insert "")) - (let* ((out (gptel--move-to-trash-generate-unique-name - "/anywhere/foo.txt" trash)) - (name (file-name-nondirectory out))) - (should-not (equal name "foo.txt")) - (should (string-match-p "\\`foo-[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\.txt\\'" - name)))))) - -(ert-deftest test-gptel-tools-trash-generate-unique-name-no-extension () - "Conflict on a name without extension: timestamp appended to the bare name." - (test-gptel-tools-trash--with-tmp-tree - (lambda (_src trash) - (with-temp-file (expand-file-name "noext" trash) (insert "")) - (let* ((out (gptel--move-to-trash-generate-unique-name - "/anywhere/noext" trash)) - (name (file-name-nondirectory out))) - (should-not (equal name "noext")) - (should (string-match-p "\\`noext-[0-9]" name)))))) - -;; -------------------------- validate-path - -(ert-deftest test-gptel-tools-trash-validate-path-normal-home () - "Normal: an existing path under HOME validates." - (let ((path (expand-file-name - (format ".test-gptel-tools-trash-home-%s.tmp" - (format-time-string "%s%N")) - "~"))) - (unwind-protect - (progn - (with-temp-file path (insert "")) - (should (equal (gptel--move-to-trash-validate-path path) - (expand-file-name path)))) - (when (file-exists-p path) (delete-file path))))) - -(ert-deftest test-gptel-tools-trash-validate-path-normal-tmp () - "Normal: an existing path under /tmp validates." - (let ((path (make-temp-file "test-gptel-tools-trash-tmpvalidate-"))) - (unwind-protect - (should (equal (gptel--move-to-trash-validate-path path) - (expand-file-name path))) - (when (file-exists-p path) (delete-file path))))) - -(ert-deftest test-gptel-tools-trash-validate-path-error-outside-allowed () - "Error: a path outside HOME or /tmp signals." - (should-error (gptel--move-to-trash-validate-path "/etc/hostname"))) - -(ert-deftest test-gptel-tools-trash-validate-path-error-tmp-prefix-trick () - "Error: paths that merely start with /tmp are not treated as /tmp children." - (should-error (gptel--move-to-trash-validate-path "/tmpnotreally/file"))) - -(ert-deftest test-gptel-tools-trash-validate-path-error-critical-dir () - "Error: critical directories (home root, .emacs.d, .config, /tmp) signal." - (should-error (gptel--move-to-trash-validate-path "~")) - (should-error (gptel--move-to-trash-validate-path "~/.emacs.d")) - (should-error (gptel--move-to-trash-validate-path "~/.config")) - (should-error (gptel--move-to-trash-validate-path "/tmp"))) - -(ert-deftest test-gptel-tools-trash-validate-path-error-missing () - "Error: missing path signals." - (let ((path (expand-file-name - (format ".test-gptel-tools-trash-missing-%s.tmp" - (format-time-string "%s%N")) - "~"))) - (when (file-exists-p path) (delete-file path)) - (should-error (gptel--move-to-trash-validate-path path)))) - -(ert-deftest test-gptel-tools-trash-validate-path-error-symlink-outside-allowed () - "Error: allowed-location symlinks resolving outside allowed roots are rejected." - (let ((link (expand-file-name - (format ".test-gptel-tools-trash-outside-link-%s.tmp" - (format-time-string "%s%N")) - "~"))) - (unwind-protect - (progn - (make-symbolic-link "/etc/hostname" link t) - (should-error (gptel--move-to-trash-validate-path link))) - (when (file-symlink-p link) (delete-file link))))) - -;; -------------------------- perform - -(ert-deftest test-gptel-tools-trash-perform-moves-file () - "Perform: moves the file out of the source dir into the trash dir." - (test-gptel-tools-trash--with-tmp-tree - (lambda (src trash) - (let ((file (expand-file-name "doomed.txt" src))) - (with-temp-file file (insert "trash me")) - (let ((status (gptel--move-to-trash-perform file trash))) - (should (string-match-p "moved to trash" status)) - (should-not (file-exists-p file)) - (should (file-exists-p (expand-file-name "doomed.txt" trash)))))))) - -(ert-deftest test-gptel-tools-trash-perform-handles-directory () - "Perform: moves a directory as a unit." - (test-gptel-tools-trash--with-tmp-tree - (lambda (src trash) - (let ((dir (expand-file-name "subdir" src))) - (make-directory dir) - (with-temp-file (expand-file-name "inside.txt" dir) (insert "x")) - (let ((status (gptel--move-to-trash-perform dir trash))) - (should (string-match-p "Directory moved to trash" status)) - (should-not (file-exists-p dir)) - (should (file-exists-p (expand-file-name "subdir/inside.txt" trash)))))))) - -(ert-deftest test-gptel-tools-trash-perform-handles-symlink () - "Perform: moving a symlink moves the link, not its target." - (test-gptel-tools-trash--with-tmp-tree - (lambda (src trash) - (let ((target (expand-file-name "target.txt" src)) - (link (expand-file-name "link.txt" src))) - (with-temp-file target (insert "target")) - (make-symbolic-link target link t) - (let ((status (gptel--move-to-trash-perform link trash))) - (should (string-match-p "Symlink moved to trash" status)) - (should (file-exists-p target)) - (should-not (file-symlink-p link)) - (should (file-symlink-p (expand-file-name "link.txt" trash)))))))) - -(ert-deftest test-gptel-tools-trash-perform-error-rename-failure () - "Error: rename failures are reported with context." - (test-gptel-tools-trash--with-tmp-tree - (lambda (src trash) - (let ((file (expand-file-name "doomed.txt" src))) - (with-temp-file file (insert "trash me")) - (cl-letf (((symbol-function 'rename-file) - (lambda (&rest _args) (error "rename failed")))) - (should-error (gptel--move-to-trash-perform file trash))) - (should (file-exists-p file)))))) - -(ert-deftest test-gptel-tools-trash-perform-error-permission-denied () - "Error: permission-denied rename failures get a specific message." - (test-gptel-tools-trash--with-tmp-tree - (lambda (src trash) - (let ((file (expand-file-name "denied.txt" src))) - (with-temp-file file (insert "trash me")) - (cl-letf (((symbol-function 'rename-file) - (lambda (&rest _args) - (signal 'permission-denied '("denied"))))) - (should-error (gptel--move-to-trash-perform file trash) - :type 'error)) - (should (file-exists-p file)))))) - -(ert-deftest test-gptel-tools-trash-perform-error-original-still-exists () - "Error: post-move verification catches a source path that remains." - (test-gptel-tools-trash--with-tmp-tree - (lambda (src trash) - (let ((file (expand-file-name "still-there.txt" src))) - (with-temp-file file (insert "trash me")) - (cl-letf (((symbol-function 'rename-file) - (lambda (&rest _args) nil))) - (should-error (gptel--move-to-trash-perform file trash))) - (should (file-exists-p file)))))) - -(ert-deftest test-gptel-tools-trash-perform-error-trash-missing-after-move () - "Error: post-move verification catches a missing trash target." - (test-gptel-tools-trash--with-tmp-tree - (lambda (src trash) - (let ((file (expand-file-name "missing-trash.txt" src)) - (real-file-exists-p (symbol-function 'file-exists-p))) - (with-temp-file file (insert "trash me")) - (cl-letf (((symbol-function 'rename-file) - (lambda (&rest _args) nil)) - ((symbol-function 'file-exists-p) - (lambda (path) - (cond - ((equal path file) nil) - ((string-prefix-p trash path) nil) - (t (funcall real-file-exists-p path)))))) - (should-error (gptel--move-to-trash-perform file trash))) - (should (funcall real-file-exists-p file)))))) - -(provide 'test-gptel-tools-move-to-trash) -;;; test-gptel-tools-move-to-trash.el ends here diff --git a/tests/test-gptel-tools-read-buffer.el b/tests/test-gptel-tools-read-buffer.el deleted file mode 100644 index 0a8548359..000000000 --- a/tests/test-gptel-tools-read-buffer.el +++ /dev/null @@ -1,74 +0,0 @@ -;;; test-gptel-tools-read-buffer.el --- Tests for read_buffer gptel tool -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for `cj/read-buffer--get-content', the testable helper that -;; backs the read_buffer gptel tool. - -;;; Code: - -(require 'ert) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) - (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) - (setq load-prefer-newer t) - (unless (featurep 'gptel) - (defvar gptel-tools nil) - (defun gptel-make-tool (&rest _args) nil) - (defun gptel-get-tool (&rest _args) nil) - (provide 'gptel))) - -(require 'read_buffer) - -(ert-deftest test-gptel-tools-read-buffer-normal () - "Normal: returns the contents of an existing buffer." - (with-temp-buffer - (rename-buffer "test-gptel-tools-read-buffer-normal" t) - (insert "hello world") - (should (equal (cj/read-buffer--get-content (buffer-name)) "hello world")))) - -(ert-deftest test-gptel-tools-read-buffer-boundary-empty-buffer () - "Boundary: empty buffer returns the empty string." - (with-temp-buffer - (rename-buffer "test-gptel-tools-read-buffer-empty" t) - (should (equal (cj/read-buffer--get-content (buffer-name)) "")))) - -(ert-deftest test-gptel-tools-read-buffer-boundary-buffer-object () - "Boundary: accepts a buffer object as well as a name string." - (with-temp-buffer - (insert "from buffer object") - (should (equal (cj/read-buffer--get-content (current-buffer)) - "from buffer object")))) - -(ert-deftest test-gptel-tools-read-buffer-boundary-widened-content () - "Boundary: returns the whole buffer even when the buffer is narrowed." - (with-temp-buffer - (insert "visible\nhidden\n") - (narrow-to-region (point-min) (line-end-position)) - (should (equal (cj/read-buffer--get-content (current-buffer)) - "visible\nhidden\n")))) - -(ert-deftest test-gptel-tools-read-buffer-boundary-strips-text-properties () - "Boundary: the returned string has no text properties." - (with-temp-buffer - (rename-buffer "test-gptel-tools-read-buffer-props" t) - (insert (propertize "fontified" 'face 'bold)) - (let ((content (cj/read-buffer--get-content (buffer-name)))) - (should (equal content "fontified")) - (should-not (text-properties-at 0 content))))) - -(ert-deftest test-gptel-tools-read-buffer-error-missing-buffer () - "Error: nonexistent buffer name signals." - (when (get-buffer "test-gptel-tools-read-buffer-absent") - (kill-buffer "test-gptel-tools-read-buffer-absent")) - (should-error (cj/read-buffer--get-content - "test-gptel-tools-read-buffer-absent"))) - -(ert-deftest test-gptel-tools-read-buffer-error-killed-buffer-object () - "Error: a killed buffer object signals clearly." - (let ((buffer (generate-new-buffer "test-gptel-tools-read-buffer-killed"))) - (kill-buffer buffer) - (should-error (cj/read-buffer--get-content buffer)))) - -(provide 'test-gptel-tools-read-buffer) -;;; test-gptel-tools-read-buffer.el ends here diff --git a/tests/test-gptel-tools-read-text-file.el b/tests/test-gptel-tools-read-text-file.el deleted file mode 100644 index db3d6e7ed..000000000 --- a/tests/test-gptel-tools-read-text-file.el +++ /dev/null @@ -1,201 +0,0 @@ -;;; test-gptel-tools-read-text-file.el --- Tests for read_text_file gptel tool -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for the helpers in read_text_file.el. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) - (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) - (setq load-prefer-newer t) - (unless (featurep 'gptel) - (defvar gptel-tools nil) - (defun gptel-make-tool (&rest _args) nil) - (defun gptel-get-tool (&rest _args) nil) - (provide 'gptel))) - -(require 'read_text_file) - -;; -------------------------- helpers - -(defun test-gptel-tools-read-text-file--in-home (suffix content fn) - "Run FN with a temp file (containing CONTENT) under HOME using SUFFIX." - (let* ((name (format ".test-gptel-tools-read-text-file-%s-%s.tmp" - suffix (format-time-string "%s%N"))) - (path (expand-file-name name "~"))) - (unwind-protect - (progn - (with-temp-file path (insert content)) - (funcall fn path)) - (when (file-exists-p path) (delete-file path))))) - -;; -------------------------- validate-file-path - -(ert-deftest test-gptel-tools-read-text-file-validate-path-normal () - "Normal: an existing readable file under HOME passes." - (test-gptel-tools-read-text-file--in-home - "normal" "hi" - (lambda (path) - (should (equal (cj/validate-file-path path) (file-truename path)))))) - -(ert-deftest test-gptel-tools-read-text-file-validate-path-error-outside-home () - "Error: path outside HOME signals." - (should-error (cj/validate-file-path "/etc/hostname"))) - -(ert-deftest test-gptel-tools-read-text-file-validate-path-error-missing () - "Error: missing file signals." - (let ((path (expand-file-name - (format ".test-gptel-tools-read-text-file-missing-%s.tmp" - (format-time-string "%s%N")) - "~"))) - (when (file-exists-p path) (delete-file path)) - (should-error (cj/validate-file-path path)))) - -(ert-deftest test-gptel-tools-read-text-file-validate-path-error-directory () - "Error: a directory signals." - (should-error (cj/validate-file-path "~"))) - -(ert-deftest test-gptel-tools-read-text-file-validate-path-error-unreadable () - "Error: unreadable files signal." - (test-gptel-tools-read-text-file--in-home - "unreadable" "secret" - (lambda (path) - (cl-letf (((symbol-function 'file-readable-p) (lambda (_) nil))) - (should-error (cj/validate-file-path path)))))) - -(ert-deftest test-gptel-tools-read-text-file-validate-path-boundary-relative-home-path () - "Boundary: relative paths resolve under HOME." - (test-gptel-tools-read-text-file--in-home - "relative" "hi" - (lambda (path) - (let ((relative (file-relative-name path (expand-file-name "~")))) - (should (equal (cj/validate-file-path relative) - (file-truename path))))))) - -(ert-deftest test-gptel-tools-read-text-file-validate-path-boundary-symlink-inside-home () - "Boundary: symlinks inside HOME resolving inside HOME are accepted." - (test-gptel-tools-read-text-file--in-home - "symlink-target" "hi" - (lambda (target) - (let ((link (expand-file-name - (format ".test-gptel-tools-read-text-file-link-%s.tmp" - (format-time-string "%s%N")) - "~"))) - (unwind-protect - (progn - (make-symbolic-link target link t) - (should (equal (cj/validate-file-path link) - (file-truename target)))) - (when (file-symlink-p link) (delete-file link))))))) - -(ert-deftest test-gptel-tools-read-text-file-validate-path-error-symlink-outside-home () - "Error: symlinks inside HOME pointing outside HOME are rejected." - (let ((outside (make-temp-file "test-gptel-tools-read-text-file-outside-")) - (link (expand-file-name - (format ".test-gptel-tools-read-text-file-outside-link-%s.tmp" - (format-time-string "%s%N")) - "~"))) - (unwind-protect - (progn - (make-symbolic-link outside link t) - (should-error (cj/validate-file-path link))) - (when (file-exists-p outside) (delete-file outside)) - (when (file-symlink-p link) (delete-file link))))) - -;; -------------------------- get-file-metadata - -(ert-deftest test-gptel-tools-read-text-file-get-metadata-shape () - "Returns a plist with :size and :string keys." - (test-gptel-tools-read-text-file--in-home - "meta" "abc" - (lambda (path) - (let ((meta (cj/get-file-metadata path))) - (should (plist-get meta :size)) - (should (= 3 (plist-get meta :size))) - (should (stringp (plist-get meta :string))) - (should (string-match-p "modified" (plist-get meta :string))))))) - -;; -------------------------- check-file-size-limits - -(ert-deftest test-gptel-tools-read-text-file-size-limits-normal () - "Small size below warning limit is a no-op." - (should-not (cj/check-file-size-limits 1024 nil))) - -(ert-deftest test-gptel-tools-read-text-file-size-limits-error-hard-cap () - "Sizes above 100MB always signal." - (should-error (cj/check-file-size-limits (* 101 1024 1024) t)) - (should-error (cj/check-file-size-limits (* 101 1024 1024) nil))) - -(ert-deftest test-gptel-tools-read-text-file-size-limits-warning-with-no-confirm () - "Above 10MB but below 100MB with no-confirm passes through silently." - (should-not (cj/check-file-size-limits (* 11 1024 1024) t))) - -(ert-deftest test-gptel-tools-read-text-file-size-limits-warning-user-accepts () - "Above warning limit proceeds when the user accepts." - (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) t))) - (should-not (cj/check-file-size-limits (* 11 1024 1024) nil)))) - -(ert-deftest test-gptel-tools-read-text-file-size-limits-warning-user-declines () - "Above warning limit signals when the user declines." - (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) nil))) - (should-error (cj/check-file-size-limits (* 11 1024 1024) nil)))) - -;; -------------------------- detect-binary-file - -(ert-deftest test-gptel-tools-read-text-file-detect-binary-text-file () - "Text file: detect-binary returns nil." - (test-gptel-tools-read-text-file--in-home - "text" "plain ascii content" - (lambda (path) - (should-not (cj/detect-binary-file path))))) - -(ert-deftest test-gptel-tools-read-text-file-detect-binary-with-null-byte () - "File with NUL in first 1024 bytes returns truthy." - (test-gptel-tools-read-text-file--in-home - "bin" (concat "head\0tail") - (lambda (path) - (should (cj/detect-binary-file path))))) - -;; -------------------------- handle-special-file-types - -(ert-deftest test-gptel-tools-read-text-file-handle-special-epub-error () - "EPUB special-type handler signals \"not yet implemented\"." - (should-error (cj/handle-special-file-types "/tmp/foo.epub" t))) - -(ert-deftest test-gptel-tools-read-text-file-handle-special-epub-cancel () - "EPUB special-type handler signals when user declines extraction." - (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) nil))) - (should-error (cj/handle-special-file-types "/tmp/foo.epub" nil)))) - -(ert-deftest test-gptel-tools-read-text-file-handle-special-pdf-cancel () - "PDF special-type handler signals when user declines extraction." - (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) nil))) - (should-error (cj/handle-special-file-types "/tmp/foo.pdf" nil)))) - -(ert-deftest test-gptel-tools-read-text-file-handle-special-pdf-empty-extraction () - "PDF special-type handler signals when extraction returns empty text." - (cl-letf (((symbol-function 'shell-command-to-string) (lambda (_cmd) ""))) - (should-error (cj/handle-special-file-types "/tmp/foo.pdf" t)))) - -(ert-deftest test-gptel-tools-read-text-file-handle-special-pdf-text () - "PDF special-type handler returns extracted text." - (cl-letf (((symbol-function 'shell-command-to-string) - (lambda (_cmd) "pdf text\n"))) - (should (equal (cj/handle-special-file-types "/tmp/foo.pdf" t) - "pdf text\n")))) - -(ert-deftest test-gptel-tools-read-text-file-handle-special-binary-cancel () - "Generic binary handler signals when user declines." - (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) nil))) - (should-error (cj/handle-special-file-types "/tmp/foo.bin" nil)))) - -(ert-deftest test-gptel-tools-read-text-file-handle-special-binary-returns-nil () - "Generic binary file with no-confirm returns nil to indicate normal read." - (should-not (cj/handle-special-file-types "/tmp/foo.bin" t))) - -(provide 'test-gptel-tools-read-text-file) -;;; test-gptel-tools-read-text-file.el ends here diff --git a/tests/test-gptel-tools-web-fetch.el b/tests/test-gptel-tools-web-fetch.el deleted file mode 100644 index 10abe6eba..000000000 --- a/tests/test-gptel-tools-web-fetch.el +++ /dev/null @@ -1,230 +0,0 @@ -;;; test-gptel-tools-web-fetch.el --- Tests for web_fetch gptel tool -*- lexical-binding: t; -*- - -;;; Commentary: -;; Validators and helpers tested directly. The orchestrator's network -;; call is stubbed via `cl-letf' on `url-retrieve-synchronously' / the -;; module's `--retrieve' helper; HTML stripping runs against real -;; pandoc / w3m (both are installed in this dev environment, and -;; verifying they don't mangle inputs is the point). - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) - (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) - (setq load-prefer-newer t) - (unless (featurep 'gptel) - (defvar gptel-tools nil) - (defun gptel-make-tool (&rest _args) nil) - (defun gptel-get-tool (&rest _args) nil) - (provide 'gptel))) - -(require 'web_fetch) - -;; ---------- validate-url - -(ert-deftest test-gptel-tools-web-fetch-validate-url-http () - "Normal: http URL passes." - (should (equal (cj/gptel-web-fetch--validate-url "http://example.com") - "http://example.com"))) - -(ert-deftest test-gptel-tools-web-fetch-validate-url-https () - "Normal: https URL passes." - (should (equal (cj/gptel-web-fetch--validate-url "https://example.com/path") - "https://example.com/path"))) - -(ert-deftest test-gptel-tools-web-fetch-validate-url-error-non-string () - "Error: non-string URL signals." - (should-error (cj/gptel-web-fetch--validate-url nil)) - (should-error (cj/gptel-web-fetch--validate-url 42))) - -(ert-deftest test-gptel-tools-web-fetch-validate-url-error-empty () - "Error: empty URL signals." - (should-error (cj/gptel-web-fetch--validate-url ""))) - -(ert-deftest test-gptel-tools-web-fetch-validate-url-error-non-http-scheme () - "Error: schemes other than http/https are rejected." - (should-error (cj/gptel-web-fetch--validate-url "file:///etc/hostname")) - (should-error (cj/gptel-web-fetch--validate-url "ftp://example.com")) - (should-error (cj/gptel-web-fetch--validate-url "javascript:alert(1)")) - (should-error (cj/gptel-web-fetch--validate-url "example.com"))) ; no scheme - -;; ---------- effective-max-bytes - -(ert-deftest test-gptel-tools-web-fetch-max-bytes-default-on-nil () - "Boundary: nil falls back to the default cap." - (should (= (cj/gptel-web-fetch--effective-max-bytes nil) - cj/gptel-web-fetch--default-max-bytes))) - -(ert-deftest test-gptel-tools-web-fetch-max-bytes-clamp-low () - "Boundary: zero / negative fall back to the default." - (should (= (cj/gptel-web-fetch--effective-max-bytes 0) - cj/gptel-web-fetch--default-max-bytes)) - (should (= (cj/gptel-web-fetch--effective-max-bytes -1) - cj/gptel-web-fetch--default-max-bytes))) - -(ert-deftest test-gptel-tools-web-fetch-max-bytes-cap-high () - "Boundary: values above the hard cap are clamped." - (should (= (cj/gptel-web-fetch--effective-max-bytes (* 10 1024 1024)) - cj/gptel-web-fetch--hard-max-bytes))) - -(ert-deftest test-gptel-tools-web-fetch-max-bytes-normal () - "Normal: a sensible value passes through." - (should (= (cj/gptel-web-fetch--effective-max-bytes 50000) 50000))) - -;; ---------- truncate - -(ert-deftest test-gptel-tools-web-fetch-truncate-under-cap () - "Normal: small input returns unchanged." - (should (equal (cj/gptel-web-fetch--truncate "short" 1000) "short"))) - -(ert-deftest test-gptel-tools-web-fetch-truncate-at-cap () - "Boundary: input exactly at cap returns unchanged." - (let ((s (make-string 10 ?x))) - (should (equal (cj/gptel-web-fetch--truncate s 10) s)))) - -(ert-deftest test-gptel-tools-web-fetch-truncate-over-cap () - "Boundary: oversize input is truncated and marked." - (let* ((s (make-string 1000 ?x)) - (out (cj/gptel-web-fetch--truncate s 100))) - (should (string-match-p "\\[truncated:" out)) - (should (string-match-p "1000 bytes total" out)))) - -;; ---------- html-to-text - -(ert-deftest test-gptel-tools-web-fetch-html-to-text-strips-tags () - "Normal: pandoc / w3m strip HTML tags from real markup." - (let ((out (cj/gptel-web-fetch--html-to-text - "

Hello

World

"))) - (should (string-match-p "Hello" out)) - (should (string-match-p "World" out)) - (should-not (string-match-p "

" out)) - (should-not (string-match-p "

" out)))) - -(ert-deftest test-gptel-tools-web-fetch-html-to-text-error-when-neither-on-path () - "Error: when neither pandoc nor w3m is on PATH, signals user-error." - (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) nil))) - (should-error (cj/gptel-web-fetch--html-to-text "

x

")))) - -(ert-deftest test-gptel-tools-web-fetch-html-to-text-error-on-tool-failure () - "Error: a failing HTML stripping command is reported." - (cl-letf (((symbol-function 'executable-find) - (lambda (program &rest _) (and (equal program "pandoc") "/bin/pandoc"))) - ((symbol-function 'call-process-region) - (lambda (&rest _args) 9))) - (should-error (cj/gptel-web-fetch--html-to-text "

x

")))) - -(ert-deftest test-gptel-tools-web-fetch-html-to-text-falls-back-to-w3m () - "Boundary: w3m is used when pandoc is unavailable." - (let (called-program) - (cl-letf (((symbol-function 'executable-find) - (lambda (program &rest _) (and (equal program "w3m") "/bin/w3m"))) - ((symbol-function 'call-process-region) - (lambda (start end program delete output display &rest _args) - (setq called-program program) - (should delete) - (should output) - (should-not display) - (delete-region start end) - (insert "w3m text") - 0))) - (should (equal (cj/gptel-web-fetch--html-to-text "

x

") - "w3m text")) - (should (equal called-program "w3m"))))) - -;; ---------- retrieve - -(ert-deftest test-gptel-tools-web-fetch-retrieve-normal-crlf-headers () - "Normal: retrieval parses status and body after CRLF headers." - (let ((buffer (generate-new-buffer " *web-fetch-crlf*"))) - (with-current-buffer buffer - (insert "HTTP/1.1 201 Created\r\nContent-Type: text/plain\r\n\r\nhello")) - (cl-letf (((symbol-function 'url-retrieve-synchronously) - (lambda (&rest _args) buffer))) - (should (equal (cj/gptel-web-fetch--retrieve "https://example.com") - '(201 . "hello")))) - (should-not (buffer-live-p buffer)))) - -(ert-deftest test-gptel-tools-web-fetch-retrieve-boundary-lf-headers () - "Boundary: retrieval also handles LF-only headers." - (let ((buffer (generate-new-buffer " *web-fetch-lf*"))) - (with-current-buffer buffer - (insert "HTTP/1.1 200 OK\nContent-Type: text/plain\n\nhello")) - (cl-letf (((symbol-function 'url-retrieve-synchronously) - (lambda (&rest _args) buffer))) - (should (equal (cj/gptel-web-fetch--retrieve "https://example.com") - '(200 . "hello")))))) - -(ert-deftest test-gptel-tools-web-fetch-retrieve-boundary-no-header-separator () - "Boundary: unseparated responses return the full buffer as body." - (let ((buffer (generate-new-buffer " *web-fetch-no-separator*"))) - (with-current-buffer buffer - (insert "not an http response")) - (cl-letf (((symbol-function 'url-retrieve-synchronously) - (lambda (&rest _args) buffer))) - (should (equal (cj/gptel-web-fetch--retrieve "https://example.com") - '(nil . "not an http response")))))) - -(ert-deftest test-gptel-tools-web-fetch-retrieve-error-no-response () - "Error: nil retrieval buffer signals network failure." - (cl-letf (((symbol-function 'url-retrieve-synchronously) - (lambda (&rest _args) nil))) - (should-error (cj/gptel-web-fetch--retrieve "https://example.com")))) - -;; ---------- run (orchestrator) - -(ert-deftest test-gptel-tools-web-fetch-run-normal-strips-html () - "Normal: orchestrator returns stripped text by default." - (cl-letf (((symbol-function 'cj/gptel-web-fetch--retrieve) - (lambda (_url) - (cons 200 "

fetched

")))) - (let ((out (cj/gptel-web-fetch--run "https://example.com"))) - (should (string-match-p "fetched" out)) - (should-not (string-match-p "

" out))))) - -(ert-deftest test-gptel-tools-web-fetch-run-raw-returns-body-verbatim () - "Normal: raw=t returns the response body without HTML stripping." - (cl-letf (((symbol-function 'cj/gptel-web-fetch--retrieve) - (lambda (_url) - (cons 200 "

raw

")))) - (let ((out (cj/gptel-web-fetch--run "https://example.com" t))) - (should (string-match-p "

raw

" out))))) - -(ert-deftest test-gptel-tools-web-fetch-run-error-on-4xx () - "Error: HTTP 4xx response signals." - (cl-letf (((symbol-function 'cj/gptel-web-fetch--retrieve) - (lambda (_url) (cons 404 "not found")))) - (should-error (cj/gptel-web-fetch--run "https://example.com")))) - -(ert-deftest test-gptel-tools-web-fetch-run-error-on-5xx () - "Error: HTTP 5xx response signals." - (cl-letf (((symbol-function 'cj/gptel-web-fetch--retrieve) - (lambda (_url) (cons 503 "service unavailable")))) - (should-error (cj/gptel-web-fetch--run "https://example.com")))) - -(ert-deftest test-gptel-tools-web-fetch-run-boundary-nil-status () - "Boundary: an unparseable status line does not trigger HTTP error handling." - (cl-letf (((symbol-function 'cj/gptel-web-fetch--retrieve) - (lambda (_url) (cons nil "raw body")))) - (should (equal (cj/gptel-web-fetch--run "https://example.com" t) - "raw body")))) - -(ert-deftest test-gptel-tools-web-fetch-run-truncates-oversized-body () - "Boundary: an oversize body is truncated by the run wrapper." - (let ((big (concat "" - (make-string 1000 ?x) - ""))) - (cl-letf (((symbol-function 'cj/gptel-web-fetch--retrieve) - (lambda (_url) (cons 200 big)))) - (let ((out (cj/gptel-web-fetch--run "https://example.com" t 200))) - (should (string-match-p "\\[truncated:" out)))))) - -(ert-deftest test-gptel-tools-web-fetch-run-error-on-bad-scheme () - "Error: non-http URL fails fast at the validator." - (should-error (cj/gptel-web-fetch--run "file:///etc/passwd"))) - -(provide 'test-gptel-tools-web-fetch) -;;; test-gptel-tools-web-fetch.el ends here diff --git a/tests/test-gptel-tools-write-text-file.el b/tests/test-gptel-tools-write-text-file.el deleted file mode 100644 index 14bcb2a51..000000000 --- a/tests/test-gptel-tools-write-text-file.el +++ /dev/null @@ -1,223 +0,0 @@ -;;; test-gptel-tools-write-text-file.el --- Tests for write_text_file gptel tool -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for `cj/write-text-file--run' and its helpers. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) - (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) - (setq load-prefer-newer t) - (unless (featurep 'gptel) - (defvar gptel-tools nil) - (defun gptel-make-tool (&rest _args) nil) - (defun gptel-get-tool (&rest _args) nil) - (provide 'gptel))) - -(require 'write_text_file) - -;; ------------------------------------------------------- helpers - -(defun test-gptel-tools-write-text-file--in-home (suffix fn) - "Run FN with a fresh path under HOME using SUFFIX. Clean up after." - (let* ((name (format ".test-gptel-tools-write-text-file-%s-%s.tmp" - suffix (format-time-string "%s%N"))) - (path (expand-file-name name "~"))) - (unwind-protect - (funcall fn path) - (when (file-exists-p path) (delete-file path)) - (dolist (b (file-expand-wildcards (concat path "-*.bak"))) - (when (file-exists-p b) (delete-file b)))))) - -;; --------------------------------------------- validate-path - -(ert-deftest test-gptel-tools-write-text-file-validate-path-normal () - "Normal: returns the expanded path for a HOME-relative input." - (let ((result (cj/write-text-file--validate-path "foo.txt"))) - (should (string-prefix-p (expand-file-name "~") result)) - (should (string-suffix-p "/foo.txt" result)))) - -(ert-deftest test-gptel-tools-write-text-file-validate-path-error-outside-home () - "Error: a path outside HOME signals." - (should-error (cj/write-text-file--validate-path "/etc/hostname"))) - -(ert-deftest test-gptel-tools-write-text-file-validate-path-boundary-absolute-home-path () - "Boundary: absolute HOME paths are accepted." - (test-gptel-tools-write-text-file--in-home - "absolute" - (lambda (path) - (should (equal (cj/write-text-file--validate-path path) path))))) - -(ert-deftest test-gptel-tools-write-text-file-validate-path-error-existing-symlink-outside-home () - "Error: an existing symlink inside HOME pointing outside HOME is rejected." - (let ((outside (make-temp-file "test-gptel-tools-write-text-file-outside-")) - (link (expand-file-name - (format ".test-gptel-tools-write-text-file-outside-link-%s.tmp" - (format-time-string "%s%N")) - "~"))) - (unwind-protect - (progn - (make-symbolic-link outside link t) - (should-error (cj/write-text-file--validate-path link))) - (when (file-exists-p outside) (delete-file outside)) - (when (file-symlink-p link) (delete-file link))))) - -(ert-deftest test-gptel-tools-write-text-file-validate-path-error-parent-symlink-outside-home () - "Error: a parent symlink inside HOME pointing outside HOME is rejected." - (let ((outside-dir (make-temp-file "test-gptel-tools-write-text-file-outside-dir-" t)) - (link-dir (expand-file-name - (format ".test-gptel-tools-write-text-file-outside-dir-link-%s" - (format-time-string "%s%N")) - "~"))) - (unwind-protect - (progn - (make-symbolic-link outside-dir link-dir t) - (should-error - (cj/write-text-file--validate-path - (expand-file-name "child.txt" link-dir)))) - (when (file-symlink-p link-dir) (delete-file link-dir)) - (when (file-exists-p outside-dir) (delete-directory outside-dir t))))) - -;; --------------------------------------------- backup-name - -(ert-deftest test-gptel-tools-write-text-file-backup-name-shape () - "Backup names append a YYYY-MM-DD-HHMMSS suffix and .bak." - (let ((name (cj/write-text-file--backup-name "/home/user/foo.txt"))) - (should (string-prefix-p "/home/user/foo.txt-" name)) - (should (string-suffix-p ".bak" name)) - (should (string-match-p "-[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-[0-9]\\{6\\}\\.bak\\'" - name)))) - -;; --------------------------------------------- ensure-parent - -(ert-deftest test-gptel-tools-write-text-file-ensure-parent-creates-missing () - "Normal: creates missing parent directories." - (let* ((base (make-temp-file "test-gptel-tools-write-text-file-" t)) - (deep (expand-file-name "a/b/c/file.txt" base))) - (unwind-protect - (progn - (cj/write-text-file--ensure-parent deep) - (should (file-directory-p (file-name-directory deep)))) - (delete-directory base t)))) - -(ert-deftest test-gptel-tools-write-text-file-ensure-parent-error-unwritable () - "Error: an unwritable parent signals." - (let* ((parent (make-temp-file "test-gptel-tools-write-text-file-ro-" t)) - (target (expand-file-name "child.txt" parent))) - (unwind-protect - (progn - (set-file-modes parent #o500) - (should-error (cj/write-text-file--ensure-parent target))) - (set-file-modes parent #o700) - (delete-directory parent t)))) - -(ert-deftest test-gptel-tools-write-text-file-ensure-parent-error-create-fails () - "Error: directory creation failures are wrapped with context." - (cl-letf (((symbol-function 'make-directory) - (lambda (&rest _args) (error "boom")))) - (should-error - (cj/write-text-file--ensure-parent - (expand-file-name "missing/child.txt" temporary-file-directory))))) - -;; --------------------------------------------- run - -(ert-deftest test-gptel-tools-write-text-file-run-normal () - "Normal: writes new content and returns a status string." - (test-gptel-tools-write-text-file--in-home - "new" - (lambda (path) - (let ((result (cj/write-text-file--run - (file-name-nondirectory path) "hello\n" nil))) - (should (string-match-p "Successfully wrote" result)) - (with-temp-buffer - (insert-file-contents path) - (should (equal (buffer-string) "hello\n"))))))) - -(ert-deftest test-gptel-tools-write-text-file-run-error-existing-no-overwrite () - "Error: existing file without overwrite signals." - (test-gptel-tools-write-text-file--in-home - "existing" - (lambda (path) - (with-temp-file path (insert "old content\n")) - (should-error (cj/write-text-file--run - (file-name-nondirectory path) "new content\n" nil)) - ;; File preserved - (with-temp-buffer - (insert-file-contents path) - (should (equal (buffer-string) "old content\n")))))) - -(ert-deftest test-gptel-tools-write-text-file-run-overwrite-creates-backup () - "Overwrite path makes a timestamped backup before writing." - (test-gptel-tools-write-text-file--in-home - "overwrite" - (lambda (path) - (with-temp-file path (insert "old content\n")) - (cj/write-text-file--run - (file-name-nondirectory path) "new content\n" t) - ;; New content landed - (with-temp-buffer - (insert-file-contents path) - (should (equal (buffer-string) "new content\n"))) - ;; Backup exists with old content - (let ((backups (file-expand-wildcards (concat path "-*.bak")))) - (should (= 1 (length backups))) - (with-temp-buffer - (insert-file-contents (car backups)) - (should (equal (buffer-string) "old content\n"))))))) - -(ert-deftest test-gptel-tools-write-text-file-run-boundary-empty-content () - "Boundary: nil content writes an empty file." - (test-gptel-tools-write-text-file--in-home - "empty" - (lambda (path) - (cj/write-text-file--run (file-name-nondirectory path) nil nil) - (should (file-exists-p path)) - (should (= 0 (file-attribute-size (file-attributes path))))))) - -(ert-deftest test-gptel-tools-write-text-file-run-large-user-accepts () - "Boundary: large writes proceed when the user accepts." - (test-gptel-tools-write-text-file--in-home - "large-accept" - (lambda (path) - (let ((cj/write-text-file--size-limit 3)) - (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) t))) - (cj/write-text-file--run (file-name-nondirectory path) "abcdef" nil))) - (with-temp-buffer - (insert-file-contents path) - (should (equal (buffer-string) "abcdef")))))) - -(ert-deftest test-gptel-tools-write-text-file-run-large-user-declines () - "Error: large writes cancel cleanly when the user declines." - (test-gptel-tools-write-text-file--in-home - "large-decline" - (lambda (path) - (let ((cj/write-text-file--size-limit 3)) - (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) nil))) - (should-error - (cj/write-text-file--run (file-name-nondirectory path) "abcdef" nil)))) - (should-not (file-exists-p path))))) - -(ert-deftest test-gptel-tools-write-text-file-run-error-overwrite-backup-failure-preserves-file () - "Error: backup failure prevents overwrite and preserves existing file." - (test-gptel-tools-write-text-file--in-home - "backup-fails" - (lambda (path) - (with-temp-file path (insert "old\n")) - (cl-letf (((symbol-function 'copy-file) - (lambda (&rest _args) (error "copy failed")))) - (should-error - (cj/write-text-file--run (file-name-nondirectory path) "new\n" t))) - (with-temp-buffer - (insert-file-contents path) - (should (equal (buffer-string) "old\n")))))) - -(ert-deftest test-gptel-tools-write-text-file-run-error-outside-home () - "Error: a path outside HOME signals." - (should-error (cj/write-text-file--run "/etc/test-write.txt" "x" nil))) - -(provide 'test-gptel-tools-write-text-file) -;;; test-gptel-tools-write-text-file.el ends here diff --git a/tests/test-init-module-headers.el b/tests/test-init-module-headers.el index a5b331f4d..4b6ac05c4 100644 --- a/tests/test-init-module-headers.el +++ b/tests/test-init-module-headers.el @@ -94,7 +94,6 @@ "org-webclipper" "hugo-config" ;; Batch 8 — Domain / integration / optional modules (Layer 2-4) - "ai-config" "ai-term" "browser-config" "calendar-sync" diff --git a/tests/test-update-text-file.el b/tests/test-update-text-file.el deleted file mode 100644 index fc4f8c36a..000000000 --- a/tests/test-update-text-file.el +++ /dev/null @@ -1,473 +0,0 @@ -;;; test-update-text-file.el --- Tests for update_text_file gptel tool -*- lexical-binding: t; -*- - -;;; Commentary: -;; Normal / Boundary / Error tests for each operation in -;; gptel-tools/update_text_file.el, plus file-level wrapper tests. -;; The pure-string helpers carry most of the coverage; the wrapper -;; only adds the I/O surface (backup, write, validation). - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) - (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) - (setq load-prefer-newer t) - ;; Stub gptel so the tool file can be loaded without the real package. - (unless (featurep 'gptel) - (defvar gptel-tools nil) - (defun gptel-make-tool (&rest _args) nil) - (defun gptel-get-tool (&rest _args) nil) - (provide 'gptel))) - -(require 'update_text_file) - -;; ----------------------------------------------------- helpers - -(defun test-update-text-file--with-temp (content fn) - "Write CONTENT to a temp file, call FN with its path, then delete." - (let ((path (make-temp-file "test-update-text-file-"))) - (unwind-protect - (progn - (with-temp-file path (insert content)) - (funcall fn path)) - (when (file-exists-p path) (delete-file path))))) - -;; ----------------------------------------------------- replace - -(ert-deftest test-update-text-file-replace-normal () - "Normal: replace all occurrences of the literal pattern." - (should (equal (cj/update-text-file--replace "foo bar foo" "foo" "BAZ") - "BAZ bar BAZ"))) - -(ert-deftest test-update-text-file-replace-boundary-no-match () - "Boundary: pattern absent returns content unchanged." - (should (equal (cj/update-text-file--replace "abc" "xyz" "QQ") "abc"))) - -(ert-deftest test-update-text-file-replace-boundary-special-chars () - "Boundary: regex metacharacters in pattern are treated as literals." - (should (equal (cj/update-text-file--replace "a.b.c" "." "-") "a-b-c")) - (should (equal (cj/update-text-file--replace "(x)(y)" "(x)" "_") "_(y)")) - (should (equal (cj/update-text-file--replace "a$b" "$" "S") "aSb"))) - -(ert-deftest test-update-text-file-replace-boundary-unicode () - "Boundary: unicode in both pattern and replacement." - (should (equal (cj/update-text-file--replace "café résumé" "café" "thé") - "thé résumé"))) - -(ert-deftest test-update-text-file-replace-boundary-replacement-with-backref-like () - "Boundary: replacement strings with \\1 etc. are literal, not back-refs." - (should (equal (cj/update-text-file--replace "foo" "foo" "\\1bar") - "\\1bar"))) - -(ert-deftest test-update-text-file-replace-error-empty-pattern () - "Error: empty pattern signals." - (should-error (cj/update-text-file--replace "abc" "" "x"))) - -(ert-deftest test-update-text-file-replace-error-nil-pattern () - "Error: nil pattern signals." - (should-error (cj/update-text-file--replace "abc" nil "x"))) - -(ert-deftest test-update-text-file-replace-error-nil-replacement () - "Error: nil replacement signals." - (should-error (cj/update-text-file--replace "abc" "a" nil))) - -;; ----------------------------------------------------- append - -(ert-deftest test-update-text-file-append-normal () - "Normal: append adds text plus a trailing newline." - (should (equal (cj/update-text-file--append "line1\n" "line2") - "line1\nline2\n"))) - -(ert-deftest test-update-text-file-append-boundary-no-trailing-newline () - "Boundary: appends still produce a newline when content has none." - (should (equal (cj/update-text-file--append "abc" "def") - "abc\ndef\n"))) - -(ert-deftest test-update-text-file-append-boundary-empty-content () - "Boundary: appending to empty content yields just the new text + newline." - (should (equal (cj/update-text-file--append "" "hello") "hello\n"))) - -(ert-deftest test-update-text-file-append-boundary-text-with-trailing-newline () - "Boundary: text that already ends in newline isn't duplicated." - (should (equal (cj/update-text-file--append "a\n" "b\n") "a\nb\n"))) - -(ert-deftest test-update-text-file-append-error-empty-text () - "Error: empty text signals." - (should-error (cj/update-text-file--append "foo" ""))) - -(ert-deftest test-update-text-file-append-error-nil-text () - "Error: nil text signals." - (should-error (cj/update-text-file--append "foo" nil))) - -;; ----------------------------------------------------- prepend - -(ert-deftest test-update-text-file-prepend-normal () - "Normal: prepend adds text plus a separator newline." - (should (equal (cj/update-text-file--prepend "line1\n" "line0") - "line0\nline1\n"))) - -(ert-deftest test-update-text-file-prepend-boundary-empty-content () - "Boundary: prepending to empty content keeps just the new text + sep." - (should (equal (cj/update-text-file--prepend "" "hello") "hello\n"))) - -(ert-deftest test-update-text-file-prepend-boundary-text-with-trailing-newline () - "Boundary: text already terminated by newline is not double-broken." - (should (equal (cj/update-text-file--prepend "rest" "first\n") - "first\nrest"))) - -(ert-deftest test-update-text-file-prepend-error-empty-text () - "Error: empty text signals." - (should-error (cj/update-text-file--prepend "foo" ""))) - -(ert-deftest test-update-text-file-prepend-error-nil-text () - "Error: nil text signals." - (should-error (cj/update-text-file--prepend "foo" nil))) - -;; ----------------------------------------------------- insert-at-line - -(ert-deftest test-update-text-file-insert-at-line-normal () - "Normal: insert before line 2 of a 3-line file." - (should (equal (cj/update-text-file--insert-at-line "a\nb\nc\n" 2 "X") - "a\nX\nb\nc\n"))) - -(ert-deftest test-update-text-file-insert-at-line-boundary-first-line () - "Boundary: inserting at line 1 prepends." - (should (equal (cj/update-text-file--insert-at-line "a\nb\n" 1 "X") - "X\na\nb\n"))) - -(ert-deftest test-update-text-file-insert-at-line-boundary-one-past-end () - "Boundary: inserting one past the last line appends." - (should (equal (cj/update-text-file--insert-at-line "a\nb\n" 3 "X") - "a\nb\nX\n"))) - -(ert-deftest test-update-text-file-insert-at-line-boundary-no-trailing-newline () - "Boundary: works on content without a trailing newline." - (should (equal (cj/update-text-file--insert-at-line "a\nb" 2 "X") - "a\nX\nb"))) - -(ert-deftest test-update-text-file-insert-at-line-boundary-text-with-trailing-newline () - "Boundary: inserted text that ends in newline is not double-terminated." - (should (equal (cj/update-text-file--insert-at-line "a\nb\n" 2 "X\n") - "a\nX\nb\n"))) - -(ert-deftest test-update-text-file-insert-at-line-boundary-multiline-text () - "Boundary: multi-line inserted text is inserted as a block." - (should (equal (cj/update-text-file--insert-at-line "a\nb\n" 2 "X\nY") - "a\nX\nY\nb\n"))) - -(ert-deftest test-update-text-file-insert-at-line-boundary-empty-file-line-1 () - "Boundary: inserting at line 1 in an empty file works." - (should (equal (cj/update-text-file--insert-at-line "" 1 "X") - "X\n"))) - -(ert-deftest test-update-text-file-insert-at-line-error-empty-file-line-2 () - "Error: line 2 is out of range for an empty file." - (should-error (cj/update-text-file--insert-at-line "" 2 "X"))) - -(ert-deftest test-update-text-file-insert-at-line-error-out-of-range () - "Error: line number beyond file length signals." - (should-error (cj/update-text-file--insert-at-line "a\nb\n" 5 "X"))) - -(ert-deftest test-update-text-file-insert-at-line-error-zero () - "Error: line number 0 signals." - (should-error (cj/update-text-file--insert-at-line "a\n" 0 "X"))) - -(ert-deftest test-update-text-file-insert-at-line-error-negative () - "Error: negative line number signals." - (should-error (cj/update-text-file--insert-at-line "a\n" -1 "X"))) - -(ert-deftest test-update-text-file-insert-at-line-error-empty-text () - "Error: empty text signals." - (should-error (cj/update-text-file--insert-at-line "a\n" 1 ""))) - -;; ----------------------------------------------------- delete-lines - -(ert-deftest test-update-text-file-delete-lines-normal () - "Normal: removes lines containing the literal pattern." - (should (equal (cj/update-text-file--delete-lines "keep\nkill me\nkeep\n" "kill") - "keep\nkeep\n"))) - -(ert-deftest test-update-text-file-delete-lines-boundary-no-match () - "Boundary: pattern matches nothing returns content unchanged." - (should (equal (cj/update-text-file--delete-lines "a\nb\nc\n" "z") - "a\nb\nc\n"))) - -(ert-deftest test-update-text-file-delete-lines-boundary-all-lines-match () - "Boundary: every line removed yields the empty string." - (should (equal (cj/update-text-file--delete-lines "x\nx\nx\n" "x") ""))) - -(ert-deftest test-update-text-file-delete-lines-boundary-special-chars-literal () - "Boundary: regex metacharacters in pattern are treated as literals." - (should (equal (cj/update-text-file--delete-lines "a.b\naxb\n" ".") - "axb\n"))) - -(ert-deftest test-update-text-file-delete-lines-boundary-no-trailing-newline () - "Boundary: content without trailing newline keeps that shape." - (should (equal (cj/update-text-file--delete-lines "keep\ndrop" "drop") - "keep"))) - -(ert-deftest test-update-text-file-delete-lines-boundary-empty-file () - "Boundary: deleting from an empty file returns the empty string." - (should (equal (cj/update-text-file--delete-lines "" "anything") ""))) - -(ert-deftest test-update-text-file-delete-lines-boundary-backslash-literal () - "Boundary: backslashes in the pattern are literal." - (should (equal (cj/update-text-file--delete-lines "keep\npath\\name\n" "\\") - "keep\n"))) - -(ert-deftest test-update-text-file-delete-lines-error-empty-pattern () - "Error: empty pattern signals." - (should-error (cj/update-text-file--delete-lines "a\nb\n" ""))) - -(ert-deftest test-update-text-file-delete-lines-error-nil-pattern () - "Error: nil pattern signals." - (should-error (cj/update-text-file--delete-lines "a\nb\n" nil))) - -;; ----------------------------------------------------- apply-operation - -(ert-deftest test-update-text-file-apply-operation-dispatch () - "Each operation name dispatches to its transform." - (should (equal (cj/update-text-file--apply-operation "abc" "replace" "b" "B" nil) - "aBc")) - (should (equal (cj/update-text-file--apply-operation "a" "append" "b" nil nil) - "a\nb\n")) - (should (equal (cj/update-text-file--apply-operation "a" "prepend" "b" nil nil) - "b\na")) - (should (equal (cj/update-text-file--apply-operation "a\nb\n" "insert-at-line" "X" nil 2) - "a\nX\nb\n")) - (should (equal (cj/update-text-file--apply-operation "a\nb\n" "delete-lines" "a" nil nil) - "b\n"))) - -(ert-deftest test-update-text-file-apply-operation-error-unknown () - "Unknown operation signals." - (should-error (cj/update-text-file--apply-operation "x" "frobnicate" nil nil nil))) - -;; ----------------------------------------------------- validate-path - -(ert-deftest test-update-text-file-validate-path-normal () - "Normal: an existing readable+writable file under HOME passes." - (let* ((file (make-temp-file "test-update-text-file-"))) - (unwind-protect - (progn - ;; make-temp-file may land in /tmp; rebase to HOME for the test. - (let* ((home-file (expand-file-name - (concat ".test-update-text-file-" (format-time-string "%s") ".tmp") - "~"))) - (unwind-protect - (progn - (copy-file file home-file t) - (should (equal (cj/update-text-file--validate-path home-file) - (file-truename home-file)))) - (when (file-exists-p home-file) (delete-file home-file))))) - (when (file-exists-p file) (delete-file file))))) - -(ert-deftest test-update-text-file-validate-path-error-missing () - "Error: a missing file under HOME signals." - (let ((path (expand-file-name - (concat ".test-update-text-file-missing-" - (format-time-string "%s") ".tmp") - "~"))) - (when (file-exists-p path) (delete-file path)) - (should-error (cj/update-text-file--validate-path path)))) - -(ert-deftest test-update-text-file-validate-path-error-outside-home () - "Error: a path outside HOME signals." - (should-error (cj/update-text-file--validate-path "/etc/hostname"))) - -(ert-deftest test-update-text-file-validate-path-error-directory () - "Error: a directory signals." - (should-error (cj/update-text-file--validate-path "~"))) - -(ert-deftest test-update-text-file-validate-path-error-unreadable () - "Error: an unreadable file signals." - (test-update-text-file--in-home - "unreadable" "secret\n" - (lambda (path) - (cl-letf (((symbol-function 'file-readable-p) (lambda (_) nil))) - (should-error (cj/update-text-file--validate-path path)))))) - -(ert-deftest test-update-text-file-validate-path-error-unwritable () - "Error: an unwritable file signals." - (test-update-text-file--in-home - "unwritable" "locked\n" - (lambda (path) - (cl-letf (((symbol-function 'file-writable-p) (lambda (_) nil))) - (should-error (cj/update-text-file--validate-path path)))))) - -(ert-deftest test-update-text-file-validate-path-boundary-relative-home-path () - "Boundary: a relative path resolves under HOME." - (test-update-text-file--in-home - "relative" "ok\n" - (lambda (path) - (let ((relative (file-relative-name path (expand-file-name "~")))) - (should (equal (cj/update-text-file--validate-path relative) - (file-truename path))))))) - -(ert-deftest test-update-text-file-validate-path-boundary-symlink-inside-home () - "Boundary: a symlink inside HOME resolving inside HOME is accepted." - (test-update-text-file--in-home - "symlink-target" "ok\n" - (lambda (target) - (let ((link (expand-file-name - (format ".test-update-text-file-link-%s.tmp" - (format-time-string "%s%N")) - "~"))) - (unwind-protect - (progn - (make-symbolic-link target link t) - (should (equal (cj/update-text-file--validate-path link) - (file-truename target)))) - (when (file-symlink-p link) (delete-file link))))))) - -(ert-deftest test-update-text-file-validate-path-error-symlink-outside-home () - "Error: a symlink inside HOME pointing outside HOME is rejected." - (let ((outside (make-temp-file "test-update-text-file-outside-")) - (link (expand-file-name - (format ".test-update-text-file-outside-link-%s.tmp" - (format-time-string "%s%N")) - "~"))) - (unwind-protect - (progn - (make-symbolic-link outside link t) - (should-error (cj/update-text-file--validate-path link))) - (when (file-exists-p outside) (delete-file outside)) - (when (file-symlink-p link) (delete-file link))))) - -;; ----------------------------------------------------- backup-name - -(ert-deftest test-update-text-file-backup-name-shape () - "Backup names append a timestamped .bak suffix." - (let ((name (cj/update-text-file--backup-name "/home/user/foo.txt"))) - (should (string-prefix-p "/home/user/foo.txt-" name)) - (should (string-suffix-p ".bak" name)) - ;; Format is YYYY-MM-DD-HHMMSS. - (should (string-match-p "-[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-[0-9]\\{6\\}\\.bak\\'" - name)))) - -;; ----------------------------------------------------- file-level wrapper - -(defun test-update-text-file--in-home (suffix content fn) - "Write CONTENT to a temp file under HOME with SUFFIX, call FN, then delete. -Backups (path-TS.bak) are cleaned up after FN returns." - (let* ((name (format ".test-update-text-file-%s-%s.tmp" - suffix (format-time-string "%s%N"))) - (path (expand-file-name name "~"))) - (unwind-protect - (progn - (with-temp-file path (insert content)) - (funcall fn path)) - (when (file-exists-p path) (delete-file path)) - (dolist (b (file-expand-wildcards (concat path "-*.bak"))) - (when (file-exists-p b) (delete-file b)))))) - -(ert-deftest test-update-text-file-run-replace-normal () - "Wrapper: replace operation rewrites the file and creates a backup." - (test-update-text-file--in-home - "replace" "alpha bravo alpha\n" - (lambda (path) - (let ((result (cj/update-text-file--run path "replace" "alpha" "GAMMA" nil))) - (should (string-match-p "Updated" result)) - (should (string-match-p "backup:" result)) - (with-temp-buffer - (insert-file-contents path) - (should (equal (buffer-string) "GAMMA bravo GAMMA\n"))) - (let ((backup (car (file-expand-wildcards (concat path "-*.bak"))))) - (should backup) - (with-temp-buffer - (insert-file-contents backup) - (should (equal (buffer-string) "alpha bravo alpha\n")))))))) - -(ert-deftest test-update-text-file-run-no-change-no-backup () - "Wrapper: no-op operation leaves the file untouched and creates no backup." - (test-update-text-file--in-home - "noop" "abc\n" - (lambda (path) - (let ((result (cj/update-text-file--run path "replace" "zzz" "QQ" nil))) - (should (string-match-p "No changes" result)) - (with-temp-buffer - (insert-file-contents path) - (should (equal (buffer-string) "abc\n"))) - (should-not (file-expand-wildcards (concat path "-*.bak"))))))) - -(ert-deftest test-update-text-file-run-append-normal () - "Wrapper: append operation adds a line to the file." - (test-update-text-file--in-home - "append" "first\n" - (lambda (path) - (cj/update-text-file--run path "append" "second" nil nil) - (with-temp-buffer - (insert-file-contents path) - (should (equal (buffer-string) "first\nsecond\n")))))) - -(ert-deftest test-update-text-file-run-insert-at-line-normal () - "Wrapper: insert-at-line inserts and rewrites the file." - (test-update-text-file--in-home - "insert" "a\nb\nc\n" - (lambda (path) - (cj/update-text-file--run path "insert-at-line" "X" nil 2) - (with-temp-buffer - (insert-file-contents path) - (should (equal (buffer-string) "a\nX\nb\nc\n")))))) - -(ert-deftest test-update-text-file-run-delete-lines-normal () - "Wrapper: delete-lines removes matching lines." - (test-update-text-file--in-home - "delete" "keep1\nkill\nkeep2\nkill\n" - (lambda (path) - (cj/update-text-file--run path "delete-lines" "kill" nil nil) - (with-temp-buffer - (insert-file-contents path) - (should (equal (buffer-string) "keep1\nkeep2\n")))))) - -(ert-deftest test-update-text-file-run-error-transform-leaves-file-unchanged () - "Wrapper: transform errors create no backup and leave the file unchanged." - (test-update-text-file--in-home - "transform-error" "abc\n" - (lambda (path) - (should-error (cj/update-text-file--run path "replace" "" "x" nil)) - (with-temp-buffer - (insert-file-contents path) - (should (equal (buffer-string) "abc\n"))) - (should-not (file-expand-wildcards (concat path "-*.bak")))))) - -(ert-deftest test-update-text-file-run-error-unknown-operation-leaves-file-unchanged () - "Wrapper: unknown operations create no backup and leave the file unchanged." - (test-update-text-file--in-home - "unknown-operation" "abc\n" - (lambda (path) - (should-error (cj/update-text-file--run path "frobnicate" "x" nil nil)) - (with-temp-buffer - (insert-file-contents path) - (should (equal (buffer-string) "abc\n"))) - (should-not (file-expand-wildcards (concat path "-*.bak")))))) - -(ert-deftest test-update-text-file-run-error-too-large-leaves-file-unchanged () - "Wrapper: the size guard errors before backup/write." - (test-update-text-file--in-home - "too-large" "abcdef\n" - (lambda (path) - (let ((cj/update-text-file--size-limit 3)) - (should-error (cj/update-text-file--run path "append" "x" nil nil))) - (with-temp-buffer - (insert-file-contents path) - (should (equal (buffer-string) "abcdef\n"))) - (should-not (file-expand-wildcards (concat path "-*.bak")))))) - -(ert-deftest test-update-text-file-run-error-missing-file () - "Wrapper: missing file signals." - (let ((path (expand-file-name - (concat ".test-update-text-file-absent-" - (format-time-string "%s") ".tmp") - "~"))) - (when (file-exists-p path) (delete-file path)) - (should-error (cj/update-text-file--run path "append" "x" nil nil)))) - -(ert-deftest test-update-text-file-run-error-outside-home () - "Wrapper: path outside home signals." - (should-error (cj/update-text-file--run "/etc/hostname" "append" "x" nil nil))) - -(provide 'test-update-text-file) -;;; test-update-text-file.el ends here diff --git a/tests/testutil-ai-config.el b/tests/testutil-ai-config.el deleted file mode 100644 index c74862226..000000000 --- a/tests/testutil-ai-config.el +++ /dev/null @@ -1,81 +0,0 @@ -;;; testutil-ai-config.el --- Test stubs for ai-config.el tests -*- lexical-binding: t; -*- - -;;; Commentary: -;; Provides gptel and dependency stubs so ai-config.el can be loaded in -;; batch mode without the real gptel package. Must be required BEFORE -;; ai-config so stubs are in place when use-package :config runs. - -;;; Code: - -(setq load-prefer-newer t) - -;; Keep ai-config tests isolated from personal optional GPTel tool files. -(defvar cj/gptel-tools-directory (make-temp-file "gptel-tools-empty-" t)) -(defvar cj/gptel-local-tool-features nil) - -;; Pre-cache API keys so auth-source is never consulted -(defvar cj/anthropic-api-key-cached "test-anthropic-key") -(defvar cj/openai-api-key-cached "test-openai-key") - -;; Stub gptel variables (must exist before use-package :custom runs) -(defvar gptel-backend nil) -(defvar gptel-model nil) -(defvar gptel-mode nil) -(defvar gptel-prompt-prefix-alist nil) -(defvar gptel--debug nil) -(defvar gptel-default-mode nil) -(defvar gptel-expert-commands nil) -(defvar gptel-track-media nil) -(defvar gptel-include-reasoning nil) -(defvar gptel-log-level nil) -(defvar gptel-confirm-tool-calls nil) -(defvar gptel-directives nil) -(defvar gptel--system-message nil) -(defvar gptel-context--alist nil) -(defvar gptel-mode-map (make-sparse-keymap)) -(defvar gptel-post-response-functions nil) - -;; Stub gptel functions -(defun gptel-make-anthropic (name &rest _args) - "Stub: return a vector mimicking a gptel backend struct." - (vector 'cl-struct-gptel-backend name)) - -(defun gptel-make-openai (name &rest _args) - "Stub: return a vector mimicking a gptel backend struct." - (vector 'cl-struct-gptel-backend name)) - -(defun gptel-send (&rest _) "Stub." nil) -(defun gptel-menu (&rest _) "Stub." nil) -(defun gptel (&rest _) "Stub." nil) -(defun gptel-system-prompt (&rest _) "Stub." nil) -(defun gptel-rewrite (&rest _) "Stub." nil) -(defun gptel-add-file (&rest _) "Stub." nil) -(defun gptel-add (&rest _) "Stub." nil) -(defun gptel-backend-models (_backend) "Stub." nil) - -(provide 'gptel) -(provide 'gptel-context) - -;; Stub custom keymap (defined in user's keybinding config) -(defvar cj/custom-keymap (make-sparse-keymap)) - -;; Stub which-key -(unless (fboundp 'which-key-add-key-based-replacements) - (defun which-key-add-key-based-replacements (&rest _) "Stub." nil)) -(provide 'which-key) - -;; Stub gptel-prompts -(defun gptel-prompts-update (&rest _) "Stub." nil) -(defun gptel-prompts-add-update-watchers (&rest _) "Stub." nil) -(provide 'gptel-prompts) - -;; NOTE: gptel-magit is NOT stubbed here. ai-config.el now uses -;; with-eval-after-load 'magit instead of use-package gptel-magit, -;; so the magit integration only activates when magit is provided. -;; See test-ai-config-gptel-magit-lazy-loading.el for magit stub tests. - -;; Stub ai-conversations -(provide 'ai-conversations) - -(provide 'testutil-ai-config) -;;; testutil-ai-config.el ends here diff --git a/tests/testutil-filesystem.el b/tests/testutil-filesystem.el deleted file mode 100644 index b1970b62d..000000000 --- a/tests/testutil-filesystem.el +++ /dev/null @@ -1,180 +0,0 @@ -;;; testutil-filesystem.el --- -*- coding: utf-8; lexical-binding: t; -*- -;; -;; Author: Craig Jennings -;; -;;; Commentary: -;; This library provides reusable helper functions for GPTel filesystem tools. -;; -;; It uses f.el and core Emacs libraries for path manipulation, directory listing, -;; file info retrieval, filtering, and recursive traversal. -;; -;; Designed to be used by multiple tools that operate on the filesystem. -;; -;;; Code: - -(require 'f) -(require 'cl-lib) -(require 'subr-x) - -;; Get directory entries in PATH. Returns list of absolute paths. -;; Default excludes hidden files and directories (name begins with dot). -;; Optional INCLUDE-HIDDEN to include hidden entries. -;; Optional FILTER-PREDICATE is a function called on each absolute path to filter. -(defun cj/get--directory-entries (path &optional include-hidden filter-predicate) - "Return a list of entries (absolute paths) in directory PATH. -Entries exclude '.' and '..'. -By default, hidden entries (starting with '.') are excluded unless -INCLUDE-HIDDEN is non-nil. FILTER-PREDICATE, if non-nil, is a predicate -function called on each entry's absolute path; only entries where it returns -non-nil are included." - ;; Convert 'path' to an absolute filename string - (let* ((expanded-path (expand-file-name path)) - ;; get absolute paths in expanded directory - (entries (directory-files expanded-path t nil t)) - ;; remove "." ".." entries - (filtered-entries - (cl-remove-if - (lambda (entry) - (or (member (f-filename entry) '("." "..")) - ;; and hidden files include-hidden is non-nil. - (and (not include-hidden) - (string-prefix-p "." (f-filename entry))))) - entries))) - ;; apply filtered predicate if provided - (if filter-predicate - (seq-filter filter-predicate filtered-entries) - ;; retun filtered-entries - filtered-entries))) - -(defun cj/get-file-info (path) - "Get file information for PATH. -Returned plist keys: -:success t or nil -:error string error message if :success is nil -:path absolute file path (string) -:size file size (integer) -:last-modified last modification time (time value) -:directory boolean: t if a directory -:permissions string with symbolic permissions, e.g. \"drwxr-xr-x\" -:executable boolean: t if executable file -:owner string: owner name or UID if name unavailable -:group string: group name or GID if name unavailable" - ;; handle errors during evaluation - (condition-case err - (let* ((expanded-path (expand-file-name path))) - (if (not (file-readable-p expanded-path)) - ;; Explicit permission denied check - (list :success nil :path expanded-path :error - (format "Permission denied: %s" expanded-path)) - (let* - ;; t = return string names for uid/gid - ((attrs (file-attributes expanded-path t)) - (size (file-attribute-size attrs)) - (mod (file-attribute-modification-time attrs)) - (dirp (eq t (file-attribute-type attrs))) - (modes (file-modes expanded-path)) - (perm (cj/-mode-to-permissions modes)) - (execp (file-executable-p expanded-path)) - (owner (file-attribute-user-id attrs)) ; Get owner - (group (file-attribute-group-id attrs))) ; Get group - (list :success t :path expanded-path :size size :last-modified mod - :directory dirp :permissions perm :executable execp - :owner (or owner "unknown") - :group (or group "unknown"))))) - ;; if error, return failure plist with error info - (error (list :success nil :path path :error (error-message-string err))))) - -(defun cj/format-file-info (file-info base-path) - "Format FILE-INFO plist relative to BASE-PATH as a string. -Handles missing keys gracefully by supplying default values." - (let ((permissions (or (plist-get file-info :permissions) "")) - (executable (if (plist-get file-info :executable) "*" " ")) - (size (file-size-human-readable (or (plist-get file-info :size) 0))) - (last-modified (or (plist-get file-info :last-modified) (current-time))) - (path (or (plist-get file-info :path) base-path))) - (format " %s%s %10s %s %s" - permissions - executable - size - (format-time-string "%Y-%m-%d %H:%M" last-modified) - (file-relative-name path base-path)))) - -;; Convert file mode bits integer to string like ls -l, e.g. drwxr-xr-x -(defun cj/-mode-to-permissions (mode) - "Convert file MODE (returned by `file-modes') to symbolic permission string." - (concat - (if (eq (logand #o40000 mode) #o40000) "d" "-") - (mapconcat - (lambda (bits) - (concat (if (/= 0 (logand bits 4)) "r" "-") - (if (/= 0 (logand bits 2)) "w" "-") - (if (/= 0 (logand bits 1)) "x" "-"))) - (list (logand (/ mode 64) 7) - (logand (/ mode 8) 7) - (logand mode 7)) - ""))) - -;; Filter a list of file info plists by extension (case insensitive). -;; Always includes directories. -(defun cj/filter-by-extension (file-info-list extension) - "Keep only directories and files with EXTENSION from FILE-INFO-LIST. -EXTENSION should not include leading dot, e.g. \"org\"." - ;; return full list if no extension - (if (not extension) - file-info-list - (cl-remove-if-not - (lambda (fi) - ;; always keep directories - (or (plist-get fi :directory) - ;; and successful file entries - (and (plist-get fi :success) - ;; and file extensions that match case-insensitively - (string-suffix-p (concat "." extension) - (f-filename (plist-get fi :path)) - t)))) - file-info-list))) - -(defun cj/list-directory-recursive (path &optional include-hidden filter-predicate max-depth) - "Recursively list files under PATH applying FILTER-PREDICATE. -PATH is the directory to list. -INCLUDE-HIDDEN if non-nil, includes hidden files (those starting with '.'). -FILTER-PREDICATE, if non-nil, is a function called on file info plist and -returns non-nil to include file. -MAX-DEPTH limits recursion depth (nil or 0 = unlimited)." - ;; set up cl-recursive function with path and current depth - (cl-labels ((recurse (path depth) - (let ((expanded-path (expand-file-name path)) - ;; empty list to accumulate file info plists - (file-info-list '())) - ;; ensure we're working with directories only - (when (not (file-directory-p expanded-path)) - (error "Not a directory: %s" expanded-path)) - - ;; loop over each file in the path - (dolist (file-entry - (cj/get--directory-entries expanded-path include-hidden)) - ;; get the metadata for the file - (let ((file-metadata (cj/get-file-info file-entry))) - ;; if retrieving metadata was successful - (when (and file-metadata (plist-get file-metadata :success)) - ;; if there's no custom filter or it matches, add it to the list - (when (or (not filter-predicate) - (funcall filter-predicate file-metadata)) - (push file-metadata file-info-list)) - ;; if it's a directory and we're not at the max-depth - (when (and (plist-get file-metadata :directory) - (or (not max-depth) (< depth (1- max-depth)))) - ;; gather all the files and recurse with that file - (setq file-info-list - (nconc file-info-list (recurse file-entry (1+ depth))))) - ;; warn if recursion returned received both a success and error - (when (and (plist-get file-metadata :success) - (plist-get file-metadata :error)) - (message "Warning: %s" (plist-get file-metadata :error)))))) - ;; restore the file order (as they were pushed into reverse order) - (nreverse file-info-list)))) - ;; start recursion at the top level - (recurse path 0))) - -(provide 'testutil-filesystem) -;;; testutil-filesystem.el ends here. -- cgit v1.2.3