aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/run-coverage-file.el1
-rw-r--r--tests/test-ai-config--apply-model-selection.el45
-rw-r--r--tests/test-ai-config-auth-source-secret.el27
-rw-r--r--tests/test-ai-config-backend-and-model.el78
-rw-r--r--tests/test-ai-config-build-model-list.el101
-rw-r--r--tests/test-ai-config-commands.el160
-rw-r--r--tests/test-ai-config-current-model-selection.el74
-rw-r--r--tests/test-ai-config-fresh-org-prefix.el65
-rw-r--r--tests/test-ai-config-gptel-backend-libs.el58
-rw-r--r--tests/test-ai-config-gptel-commands.el155
-rw-r--r--tests/test-ai-config-gptel-local-tools.el57
-rw-r--r--tests/test-ai-config-gptel-magit-lazy-loading.el151
-rw-r--r--tests/test-ai-config-helpers.el183
-rw-r--r--tests/test-ai-config-model-to-string.el60
-rw-r--r--tests/test-ai-config-model-to-symbol.el61
-rw-r--r--tests/test-ai-conversations-browser.el244
-rw-r--r--tests/test-ai-conversations.el564
-rw-r--r--tests/test-ai-mcp-helpers.el419
-rw-r--r--tests/test-ai-quick-ask.el149
-rw-r--r--tests/test-ai-rewrite.el159
-rw-r--r--tests/test-ai-term--active-agent-dirs.el50
-rw-r--r--tests/test-ai-term--collapse-split.el14
-rw-r--r--tests/test-ai-term--f9-in-term.el56
-rw-r--r--tests/test-ai-term--keybindings.el53
-rw-r--r--tests/test-ai-term--live-count.el60
-rw-r--r--tests/test-ai-term--next-agent-dir.el48
-rw-r--r--tests/test-ai-term--next-no-agents.el34
-rw-r--r--tests/test-ai-term--quit.el65
-rw-r--r--tests/test-ai-term--show-or-create.el98
-rw-r--r--tests/test-ai-term--shutdown-countdown.el73
-rw-r--r--tests/test-calendar-sync--apply-single-exception.el37
-rw-r--r--tests/test-calendar-sync--robustness.el70
-rw-r--r--tests/test-calendar-sync.el9
-rw-r--r--tests/test-calibredb-epub-config.el20
-rw-r--r--tests/test-config-utilities--compile-this-elisp-buffer.el8
-rw-r--r--tests/test-coverage-core--relativize-keys.el123
-rw-r--r--tests/test-custom-buffer-file-print-diff-eww.el14
-rw-r--r--tests/test-dashboard-config-launchers.el5
-rw-r--r--tests/test-dev-fkeys--f6-current-file-tests-impl.el2
-rw-r--r--tests/test-dev-fkeys--f6-current-file-tests.el2
-rw-r--r--tests/test-dev-fkeys--f6-test-runner-cmd-for.el4
-rw-r--r--tests/test-dev-fkeys--f6-test-runner.el2
-rw-r--r--tests/test-dev-fkeys--projectile-advice-install.el4
-rw-r--r--tests/test-dirvish-config-drill.el4
-rw-r--r--tests/test-dirvish-config-popup.el248
-rw-r--r--tests/test-dirvish-config-print.el6
-rw-r--r--tests/test-dirvish-config-public-wrappers.el4
-rw-r--r--tests/test-dirvish-config-wallpaper-program.el4
-rw-r--r--tests/test-dirvish-config-wrappers.el2
-rw-r--r--tests/test-elfeed-config-helpers.el8
-rw-r--r--tests/test-erc-config-connected-servers.el9
-rw-r--r--tests/test-eshell-config--prompt.el75
-rw-r--r--tests/test-external-open-commands.el85
-rw-r--r--tests/test-face-diagnostic.el25
-rw-r--r--tests/test-flyspell-and-abbrev.el4
-rw-r--r--tests/test-google-keep-config.el142
-rw-r--r--tests/test-gptel-tools-git-diff.el163
-rw-r--r--tests/test-gptel-tools-git-log.el183
-rw-r--r--tests/test-gptel-tools-git-status.el124
-rw-r--r--tests/test-gptel-tools-list-directory-files.el257
-rw-r--r--tests/test-gptel-tools-move-to-trash.el219
-rw-r--r--tests/test-gptel-tools-read-buffer.el74
-rw-r--r--tests/test-gptel-tools-read-text-file.el201
-rw-r--r--tests/test-gptel-tools-web-fetch.el230
-rw-r--r--tests/test-gptel-tools-write-text-file.el223
-rw-r--r--tests/test-host-environment--detect-system-timezone.el18
-rw-r--r--tests/test-host-environment--display-predicates.el2
-rw-r--r--tests/test-hugo-config-commands.el8
-rw-r--r--tests/test-hugo-config-open-blog-dir-external.el8
-rw-r--r--tests/test-init-defer-games.el46
-rw-r--r--tests/test-init-module-headers.el3
-rw-r--r--tests/test-jumper--register-hygiene.el179
-rw-r--r--tests/test-keybindings--jump-open-var.el2
-rw-r--r--tests/test-latex-config--latexmk-wiring.el62
-rw-r--r--tests/test-mail-config-transport.el2
-rw-r--r--tests/test-media-utils.el16
-rw-r--r--tests/test-meta-subr-mock-arity.el113
-rw-r--r--tests/test-music-config-commands.el4
-rw-r--r--tests/test-music-config-helpers-untested.el4
-rw-r--r--tests/test-music-config-more-commands.el4
-rw-r--r--tests/test-music-config-playlist-commands.el2
-rw-r--r--tests/test-nerd-icons-config--apply-tint.el63
-rw-r--r--tests/test-nerd-icons-config--color-dir.el15
-rw-r--r--tests/test-org-agenda-config--base-files.el57
-rw-r--r--tests/test-org-capture-config-popup-window.el4
-rw-r--r--tests/test-org-config-keymap-ownership.el6
-rw-r--r--tests/test-org-drill-config-commands.el10
-rw-r--r--tests/test-org-drill-config.el4
-rw-r--r--tests/test-org-noter-config-commands.el6
-rw-r--r--tests/test-org-refile-config-commands.el4
-rw-r--r--tests/test-org-refile-config-scan-targets.el11
-rw-r--r--tests/test-org-reveal-config-header-template.el4
-rw-r--r--tests/test-org-webclipper-commands.el6
-rw-r--r--tests/test-prog-c-mode-settings.el6
-rw-r--r--tests/test-prog-general--find-file-respecting-split.el12
-rw-r--r--tests/test-prog-general-open-project-daily-prep.el4
-rw-r--r--tests/test-prog-go-commands.el8
-rw-r--r--tests/test-prog-json--json-format-buffer.el6
-rw-r--r--tests/test-prog-lsp.el66
-rw-r--r--tests/test-prog-python-commands.el6
-rw-r--r--tests/test-prog-python-setup.el4
-rw-r--r--tests/test-prog-webdev-format.el12
-rw-r--r--tests/test-prog-webdev-setup.el4
-rw-r--r--tests/test-prog-yaml--yaml-format-buffer.el6
-rw-r--r--tests/test-slack-config-commands.el4
-rw-r--r--tests/test-system-commands-resolve-and-run.el8
-rw-r--r--tests/test-system-defaults-functions.el14
-rw-r--r--tests/test-system-defaults.el13
-rw-r--r--tests/test-term-config--f8-in-term.el42
-rw-r--r--tests/test-term-tmux-history.el293
-rw-r--r--tests/test-term-toggle--buffer-filter.el51
-rw-r--r--tests/test-term-toggle--dispatch.el2
-rw-r--r--tests/test-term-toggle--display.el2
-rw-r--r--tests/test-transcription-process-and-sentinel.el4
-rw-r--r--tests/test-transcription-status-and-commands.el2
-rw-r--r--tests/test-transcription-video.el22
-rw-r--r--tests/test-ui-config-transparency-and-cursor.el12
-rw-r--r--tests/test-ui-navigation-split-follow-undo-kill.el10
-rw-r--r--tests/test-update-text-file.el473
-rw-r--r--tests/test-video-audio-recording--build-video-command.el8
-rw-r--r--tests/test-video-audio-recording--test-device.el8
-rw-r--r--tests/test-video-audio-recording-check-ffmpeg.el6
-rw-r--r--tests/test-video-audio-recording-ffmpeg-functions.el8
-rw-r--r--tests/test-video-audio-recording-process-cleanup.el16
-rw-r--r--tests/test-video-audio-recording-test-mic.el12
-rw-r--r--tests/test-video-audio-recording-test-monitor.el12
-rw-r--r--tests/test-video-audio-recording-toggle-functions.el4
-rw-r--r--tests/testutil-ai-config.el81
-rw-r--r--tests/testutil-filesystem.el180
-rw-r--r--tests/testutil-ghostel-buffers.el21
130 files changed, 2159 insertions, 5898 deletions
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 8da2e4b01..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) (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) (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 371a75cc8..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 (_) 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) '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: <prompt> 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-ai-term--active-agent-dirs.el b/tests/test-ai-term--active-agent-dirs.el
new file mode 100644
index 000000000..86e557b42
--- /dev/null
+++ b/tests/test-ai-term--active-agent-dirs.el
@@ -0,0 +1,50 @@
+;;; test-ai-term--active-agent-dirs.el --- Tests for cj/--ai-term-active-agent-dirs -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The queue `cj/ai-term-next' steps through: project dirs with an active
+;; agent, which is either a live agent buffer (attached) or a live tmux session
+;; with no Emacs buffer (detached). Folding detached sessions in is what lets
+;; the step key reach and attach a session that isn't currently on screen.
+;; Candidates / buffers / sessions are mocked so the enumeration logic is
+;; exercised without a real tmux server.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(ert-deftest test-ai-term--active-agent-dirs-includes-attached-and-detached ()
+ "Normal: dirs with a live buffer OR a live session are active and sorted by
+name; dirs with neither are excluded."
+ (let ((buf (get-buffer-create (cj/--ai-term-buffer-name "/p/alpha"))))
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--ai-term-candidates)
+ (lambda (&rest _) '("/p/alpha" "/p/beta" "/p/gamma" "/p/delta")))
+ ((symbol-function 'cj/--ai-term-agent-buffers)
+ (lambda (&rest _) (list buf)))
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda (&rest _) (list (cj/--ai-term-tmux-session-name "/p/gamma")))))
+ ;; alpha attached (buffer), gamma detached (session); beta/delta neither.
+ (should (equal '("/p/alpha" "/p/gamma") (cj/--ai-term-active-agent-dirs))))
+ (kill-buffer buf))))
+
+(ert-deftest test-ai-term--active-agent-dirs-detached-only ()
+ "Normal: a dir with only a live session (no buffer) is included -- the detached case."
+ (cl-letf (((symbol-function 'cj/--ai-term-candidates) (lambda (&rest _) '("/p/solo")))
+ ((symbol-function 'cj/--ai-term-agent-buffers) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda (&rest _) (list (cj/--ai-term-tmux-session-name "/p/solo")))))
+ (should (equal '("/p/solo") (cj/--ai-term-active-agent-dirs)))))
+
+(ert-deftest test-ai-term--active-agent-dirs-empty-when-none-active ()
+ "Boundary: no live buffers and no sessions -> an empty queue."
+ (cl-letf (((symbol-function 'cj/--ai-term-candidates) (lambda (&rest _) '("/p/a" "/p/b")))
+ ((symbol-function 'cj/--ai-term-agent-buffers) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions) (lambda (&rest _) nil)))
+ (should (null (cj/--ai-term-active-agent-dirs)))))
+
+(provide 'test-ai-term--active-agent-dirs)
+;;; test-ai-term--active-agent-dirs.el ends here
diff --git a/tests/test-ai-term--collapse-split.el b/tests/test-ai-term--collapse-split.el
index d7b4ee17f..a09af5598 100644
--- a/tests/test-ai-term--collapse-split.el
+++ b/tests/test-ai-term--collapse-split.el
@@ -59,7 +59,12 @@ different agent (stale quit-restore after slot reuse)."
(agent-a (get-buffer-create "agent [collapse-a]"))
(agent-b (get-buffer-create "agent [collapse-b]"))
(agent-c (get-buffer-create "agent [collapse-c]"))
- (cj/--ai-term-last-was-bury nil))
+ (cj/--ai-term-last-was-bury nil)
+ ;; Isolate the layout-capture globals cj/ai-term writes on toggle-off,
+ ;; so this test doesn't leak last-direction/last-size into others -- the
+ ;; display-rule test splits via display-saved, which reads them.
+ (cj/--ai-term-last-direction nil)
+ (cj/--ai-term-last-size nil))
(unwind-protect
(save-window-excursion
(delete-other-windows)
@@ -89,7 +94,12 @@ to a NON-agent buffer (the working file), never another agent. Before the fix,
(let ((work (get-buffer-create "*test-collapse-sw-work*"))
(agent-a (get-buffer-create "agent [collapse-sw-a]"))
(agent-b (get-buffer-create "agent [collapse-sw-b]"))
- (cj/--ai-term-last-was-bury nil))
+ (cj/--ai-term-last-was-bury nil)
+ ;; Isolate the layout-capture globals cj/ai-term writes on toggle-off,
+ ;; so this test doesn't leak last-direction/last-size into others -- the
+ ;; display-rule test splits via display-saved, which reads them.
+ (cj/--ai-term-last-direction nil)
+ (cj/--ai-term-last-size nil))
(unwind-protect
(save-window-excursion
(delete-other-windows)
diff --git a/tests/test-ai-term--f9-in-term.el b/tests/test-ai-term--f9-in-term.el
deleted file mode 100644
index dad11ffc0..000000000
--- a/tests/test-ai-term--f9-in-term.el
+++ /dev/null
@@ -1,56 +0,0 @@
-;;; test-ai-term--f9-in-term.el --- F9 reaches Emacs from inside an agent buffer -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; ghostel's semi-char mode forwards keys not in `ghostel-keymap-exceptions' to
-;; the terminal program, so a plain <f9> typed while point is in an agent
-;; buffer would be sent to the program instead of toggling the agent -- exactly
-;; the case when the agent buffer fills the frame. `ai-term.el' re-binds the F9
-;; family in `ghostel-mode-map'. These tests require ghostel (which defines
-;; `ghostel-mode-map' and lets ai-term's `with-eval-after-load' fire) BEFORE
-;; ai-term, then confirm the bindings landed (and the global ones are intact).
-;; `(require 'ghostel)' does not load the native module, so this stays light.
-
-;;; Code:
-
-(require 'ert)
-(require 'package)
-
-(setq package-user-dir (expand-file-name "elpa" user-emacs-directory))
-(package-initialize)
-(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'ghostel)
-(require 'ai-term)
-
-(ert-deftest test-ai-term-f9-bound-in-ghostel-mode-map ()
- "Normal: <f9> in `ghostel-mode-map' runs the agent toggle."
- (should (eq (keymap-lookup ghostel-mode-map "<f9>") #'cj/ai-term)))
-
-(ert-deftest test-ai-term-f9-family-bound-in-ghostel-mode-map ()
- "Normal: the C-/M-/C-S- F9 variants are bound in `ghostel-mode-map' too.
-`M-<f9>' and `C-S-<f9>' both close an agent via `cj/ai-term-close'."
- (should (eq (keymap-lookup ghostel-mode-map "C-<f9>") #'cj/ai-term-pick-project))
- (should (eq (keymap-lookup ghostel-mode-map "M-<f9>") #'cj/ai-term-close))
- (should (eq (keymap-lookup ghostel-mode-map "C-S-<f9>") #'cj/ai-term-close)))
-
-(ert-deftest test-ai-term-f9-still-bound-globally ()
- "Normal: the global F9 family bindings are intact.
-`<f9>' toggles the ai-term agent window; `C-<f9>' picks a project
-agent; `M-<f9>' and `C-S-<f9>' close an agent via `cj/ai-term-close'."
- (should (eq (lookup-key (current-global-map) (kbd "<f9>")) #'cj/ai-term))
- (should (eq (lookup-key (current-global-map) (kbd "C-<f9>")) #'cj/ai-term-pick-project))
- (should (eq (lookup-key (current-global-map) (kbd "M-<f9>")) #'cj/ai-term-close))
- (should (eq (lookup-key (current-global-map) (kbd "C-S-<f9>")) #'cj/ai-term-close)))
-
-(ert-deftest test-ai-term-f9-family-in-keymap-exceptions ()
- "Regression: the F9 family is in `ghostel-keymap-exceptions' so semi-char
-mode lets it reach Emacs instead of forwarding it to the terminal program.
-Binding in `ghostel-mode-map' alone is not enough -- the semi-char map outranks
-it and forwards any key not in the exceptions to the pty."
- (dolist (key '("<f9>" "C-<f9>" "M-<f9>" "C-S-<f9>"))
- (should (member key ghostel-keymap-exceptions)))
- ;; The rebuilt semi-char map must no longer forward <f9> to the pty.
- (should-not (eq (keymap-lookup ghostel-semi-char-mode-map "<f9>")
- 'ghostel--send-event)))
-
-(provide 'test-ai-term--f9-in-term)
-;;; test-ai-term--f9-in-term.el ends here
diff --git a/tests/test-ai-term--keybindings.el b/tests/test-ai-term--keybindings.el
new file mode 100644
index 000000000..6f7f53a5e
--- /dev/null
+++ b/tests/test-ai-term--keybindings.el
@@ -0,0 +1,53 @@
+;;; test-ai-term--keybindings.el --- ai-term keybinding placement -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; ai-term lives under the C-; a prefix (vacated when gptel was archived), with
+;; the frequent "swap to the next agent" also on M-SPC for a fast chord. M-SPC
+;; must reach Emacs from inside an agent buffer, so it is bound in
+;; `eat-semi-char-mode-map' (EAT forwards unbound keys to the pty otherwise).
+;; C-; is already bound there via eat-config, so the C-; a family resolves
+;; through the global prefix. These tests require eat (so ai-term's
+;; `with-eval-after-load' fires) before ai-term, then confirm the bindings
+;; landed and the old F9 family is gone.
+
+;;; Code:
+
+(require 'ert)
+(require 'package)
+
+(setq package-user-dir (expand-file-name "elpa" user-emacs-directory))
+(package-initialize)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'eat)
+(require 'ai-term)
+
+(ert-deftest test-ai-term-keymap-leaf-bindings ()
+ "Normal: the ai-term keymap binds toggle/select/next/kill on a/s/n/k."
+ (should (eq (keymap-lookup cj/ai-term-keymap "a") #'cj/ai-term))
+ (should (eq (keymap-lookup cj/ai-term-keymap "s") #'cj/ai-term-pick-project))
+ (should (eq (keymap-lookup cj/ai-term-keymap "n") #'cj/ai-term-next))
+ (should (eq (keymap-lookup cj/ai-term-keymap "k") #'cj/ai-term-close)))
+
+(ert-deftest test-ai-term-keymap-registered-under-custom-prefix ()
+ "Normal: the ai-term keymap is registered under C-; a."
+ (should (eq (keymap-lookup cj/custom-keymap "a") cj/ai-term-keymap)))
+
+(ert-deftest test-ai-term-next-bound-to-meta-space-globally ()
+ "Normal: M-SPC runs `cj/ai-term-next' (the fast swap chord)."
+ (should (eq (lookup-key (current-global-map) (kbd "M-SPC")) #'cj/ai-term-next)))
+
+(ert-deftest test-ai-term-meta-space-bound-in-eat-semi-char-mode-map ()
+ "Normal: M-SPC is bound in `eat-semi-char-mode-map' so swap works inside an
+agent. EAT forwards unbound keys to the pty, so the bind is what lets it reach
+Emacs -- no ghostel-style exception list or rebuild is needed."
+ (should (eq (keymap-lookup eat-semi-char-mode-map "M-SPC") #'cj/ai-term-next)))
+
+(ert-deftest test-ai-term-f9-family-removed-globally ()
+ "Regression: the old F9 family no longer binds the ai-term commands globally."
+ (should-not (eq (lookup-key (current-global-map) (kbd "<f9>")) #'cj/ai-term))
+ (should-not (eq (lookup-key (current-global-map) (kbd "C-<f9>")) #'cj/ai-term-pick-project))
+ (should-not (eq (lookup-key (current-global-map) (kbd "s-<f9>")) #'cj/ai-term-next))
+ (should-not (eq (lookup-key (current-global-map) (kbd "M-<f9>")) #'cj/ai-term-close)))
+
+(provide 'test-ai-term--keybindings)
+;;; test-ai-term--keybindings.el ends here
diff --git a/tests/test-ai-term--live-count.el b/tests/test-ai-term--live-count.el
new file mode 100644
index 000000000..1432599cc
--- /dev/null
+++ b/tests/test-ai-term--live-count.el
@@ -0,0 +1,60 @@
+;;; test-ai-term--live-count.el --- Tests for cj/ai-term-live-count -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The shutdown safety gate: the integer count of live AI-term (aiv-*) tmux
+;; sessions, read by the rulesets wrap-it-up workflow via emacsclient -e. No
+;; server / no sessions is 0, not an error.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(defmacro test-ai-term-live-count--with-tmux (exit-code output &rest body)
+ "Run BODY with `process-file' mocked to a tmux list-sessions response.
+EXIT-CODE is returned (or the symbol `error' to signal); OUTPUT is written to
+the stdout destination buffer."
+ (declare (indent 2))
+ `(cl-letf (((symbol-function 'process-file)
+ (lambda (_program _infile destination _display &rest _args)
+ (when (eq ,exit-code 'error) (error "tmux: command not found"))
+ (let ((buffer (cond ((eq destination t) (current-buffer))
+ ((bufferp destination) destination)
+ ((consp destination)
+ (and (eq (car destination) t) (current-buffer))))))
+ (when (bufferp buffer)
+ (with-current-buffer buffer (insert ,output))))
+ ,exit-code)))
+ (let ((cj/ai-term-tmux-session-prefix "aiv-"))
+ ,@body)))
+
+(ert-deftest test-ai-term-live-count-counts-matching-sessions ()
+ "Normal: two aiv-* sessions among others count as 2."
+ (test-ai-term-live-count--with-tmux 0 "aiv-foo\nrandom\naiv-bar\n"
+ (should (= (cj/ai-term-live-count) 2))))
+
+(ert-deftest test-ai-term-live-count-single-session ()
+ "Boundary: a sole aiv-* session counts as 1."
+ (test-ai-term-live-count--with-tmux 0 "aiv-only\nother\n"
+ (should (= (cj/ai-term-live-count) 1))))
+
+(ert-deftest test-ai-term-live-count-no-matching-sessions ()
+ "Boundary: a running server with no aiv-* sessions is 0."
+ (test-ai-term-live-count--with-tmux 0 "other-a\nother-b\n"
+ (should (= (cj/ai-term-live-count) 0))))
+
+(ert-deftest test-ai-term-live-count-no-server ()
+ "Error: tmux exits non-zero (no server) -> 0, not a signal."
+ (test-ai-term-live-count--with-tmux 1 "no server running\n"
+ (should (= (cj/ai-term-live-count) 0))))
+
+(ert-deftest test-ai-term-live-count-tmux-missing ()
+ "Error: tmux not installed -> 0."
+ (test-ai-term-live-count--with-tmux 'error ""
+ (should (= (cj/ai-term-live-count) 0))))
+
+(provide 'test-ai-term--live-count)
+;;; test-ai-term--live-count.el ends here
diff --git a/tests/test-ai-term--next-agent-dir.el b/tests/test-ai-term--next-agent-dir.el
new file mode 100644
index 000000000..b5cf1cdf5
--- /dev/null
+++ b/tests/test-ai-term--next-agent-dir.el
@@ -0,0 +1,48 @@
+;;; test-ai-term--next-agent-dir.el --- Tests for cj/--ai-term-next-agent-dir -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The pure decision helper behind `cj/ai-term-next'. Given the current
+;; active-agent project dir and the ordered list of active-agent dirs, it
+;; returns the next dir in the queue, wrapping after the last. A nil or
+;; non-member CURRENT returns the first; an empty list returns nil. Dirs are
+;; matched with `member' (string equality). No side effects -- list logic only.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(defconst test-ai-term--dirs '("/p/a" "/p/b" "/p/c"))
+
+(ert-deftest test-ai-term--next-agent-dir-advances-from-first ()
+ "Normal: current is the first element -> returns the second."
+ (should (equal "/p/b" (cj/--ai-term-next-agent-dir "/p/a" test-ai-term--dirs))))
+
+(ert-deftest test-ai-term--next-agent-dir-advances-from-middle ()
+ "Normal: current in the middle -> returns the following element."
+ (should (equal "/p/c" (cj/--ai-term-next-agent-dir "/p/b" test-ai-term--dirs))))
+
+(ert-deftest test-ai-term--next-agent-dir-wraps-after-last ()
+ "Boundary: current is the last element -> wraps to the first."
+ (should (equal "/p/a" (cj/--ai-term-next-agent-dir "/p/c" test-ai-term--dirs))))
+
+(ert-deftest test-ai-term--next-agent-dir-single-element-returns-itself ()
+ "Boundary: a one-agent queue wraps current back to itself."
+ (should (equal "/p/a" (cj/--ai-term-next-agent-dir "/p/a" '("/p/a")))))
+
+(ert-deftest test-ai-term--next-agent-dir-nil-current-returns-first ()
+ "Boundary: nil current (no agent displayed) -> returns the first."
+ (should (equal "/p/a" (cj/--ai-term-next-agent-dir nil '("/p/a" "/p/b")))))
+
+(ert-deftest test-ai-term--next-agent-dir-non-member-current-returns-first ()
+ "Error: current not in the queue -> returns the first rather than nil."
+ (should (equal "/p/a" (cj/--ai-term-next-agent-dir "/p/stray" '("/p/a" "/p/b")))))
+
+(ert-deftest test-ai-term--next-agent-dir-empty-queue-returns-nil ()
+ "Boundary: an empty queue returns nil (nothing to switch to)."
+ (should (null (cj/--ai-term-next-agent-dir nil '()))))
+
+(provide 'test-ai-term--next-agent-dir)
+;;; test-ai-term--next-agent-dir.el ends here
diff --git a/tests/test-ai-term--next-no-agents.el b/tests/test-ai-term--next-no-agents.el
new file mode 100644
index 000000000..59132df8e
--- /dev/null
+++ b/tests/test-ai-term--next-no-agents.el
@@ -0,0 +1,34 @@
+;;; test-ai-term--next-no-agents.el --- cj/ai-term-next no-agents fallback -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; When no agent buffers are open, `cj/ai-term-next' (bound to M-SPC) launches
+;; the project picker (`cj/ai-term-pick-project') to start the first agent,
+;; instead of signalling a `user-error'. The swap key thus doubles as a
+;; "start an agent" key when there is nothing to swap to.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(ert-deftest test-ai-term-next-no-agents-launches-picker ()
+ "Error: no agents open -> launches the picker instead of erroring."
+ (let ((picked 0))
+ (cl-letf (((symbol-function 'cj/--ai-term-active-agent-dirs) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--ai-term-displayed-agent-window) (lambda (&rest _) nil))
+ ((symbol-function 'cj/ai-term-pick-project) (lambda (&rest _) (setq picked (1+ picked)))))
+ (cj/ai-term-next)
+ (should (= picked 1)))))
+
+(ert-deftest test-ai-term-next-no-agents-does-not-signal ()
+ "Error: no agents open -> returns normally, no user-error raised."
+ (cl-letf (((symbol-function 'cj/--ai-term-active-agent-dirs) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--ai-term-displayed-agent-window) (lambda (&rest _) nil))
+ ((symbol-function 'cj/ai-term-pick-project) (lambda (&rest _) nil)))
+ (should (progn (cj/ai-term-next) t))))
+
+(provide 'test-ai-term--next-no-agents)
+;;; test-ai-term--next-no-agents.el ends here
diff --git a/tests/test-ai-term--quit.el b/tests/test-ai-term--quit.el
new file mode 100644
index 000000000..55ace81db
--- /dev/null
+++ b/tests/test-ai-term--quit.el
@@ -0,0 +1,65 @@
+;;; test-ai-term--quit.el --- Tests for cj/ai-term-quit -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Headless teardown of a project's AI-term: kill the aiv-<name> tmux session,
+;; then the agent buffer. Driven by the rulesets Stop hook via emacsclient -e,
+;; keyed by project basename. Must be idempotent (a no-op when already gone).
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(defmacro test-ai-term-quit--with-tmux (calls-var &rest body)
+ "Run BODY with `process-file' mocked to record arg lists into CALLS-VAR (0 exit)."
+ (declare (indent 1))
+ `(cl-letf (((symbol-function 'process-file)
+ (lambda (_program _infile _destination _display &rest args)
+ (push args ,calls-var) 0)))
+ ,@body))
+
+(ert-deftest test-ai-term-quit-kills-session-and-buffer ()
+ "Normal: quit kills the project's aiv- session and its agent buffer."
+ (let ((buf (get-buffer-create "agent [myproj]"))
+ (calls nil))
+ (unwind-protect
+ (test-ai-term-quit--with-tmux calls
+ (cj/ai-term-quit "myproj")
+ (should (member '("kill-session" "-t" "aiv-myproj") calls))
+ (should-not (buffer-live-p buf)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest test-ai-term-quit-sanitizes-dotted-basename ()
+ "Boundary: a dotted basename maps to the sanitized session tmux really uses."
+ (let ((buf (get-buffer-create "agent [.emacs.d]"))
+ (calls nil))
+ (unwind-protect
+ (test-ai-term-quit--with-tmux calls
+ (cj/ai-term-quit ".emacs.d")
+ (should (member '("kill-session" "-t" "aiv-_emacs_d") calls))
+ (should-not (buffer-live-p buf)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest test-ai-term-quit-idempotent-when-gone ()
+ "Error/Boundary: a second quit (session + buffer already gone) does not error."
+ (let ((calls nil))
+ (test-ai-term-quit--with-tmux calls
+ ;; No buffer named "agent [ghost]" exists; session kill is a no-op in tmux.
+ (should (stringp (cj/ai-term-quit "ghost")))
+ (should (member '("kill-session" "-t" "aiv-ghost") calls)))))
+
+(ert-deftest test-ai-term-quit-leaves-non-agent-buffers ()
+ "Error: a same-named-but-non-agent buffer is never killed (prefix guard)."
+ (let ((buf (get-buffer-create "notes-myproj"))
+ (calls nil))
+ (unwind-protect
+ (test-ai-term-quit--with-tmux calls
+ (cj/ai-term-quit "myproj")
+ (should (buffer-live-p buf)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(provide 'test-ai-term--quit)
+;;; test-ai-term--quit.el ends here
diff --git a/tests/test-ai-term--show-or-create.el b/tests/test-ai-term--show-or-create.el
index c6653dcdd..4f5f1f67f 100644
--- a/tests/test-ai-term--show-or-create.el
+++ b/tests/test-ai-term--show-or-create.el
@@ -3,13 +3,13 @@
;;; Commentary:
;; Tests the show-or-create branching:
;;
-;; - buffer absent -> ghostel called, agent command + newline sent
-;; - buffer present, live -> ghostel not called, buffer displayed
-;; - buffer present, dead -> old buffer killed, ghostel recreates
+;; - buffer absent -> eat called, agent command + newline sent
+;; - buffer present, live -> eat not called, buffer displayed
+;; - buffer present, dead -> old buffer killed, eat recreates
;;
-;; ghostel functions are stubbed so the test does no process spawning and
-;; never loads the native module. Production calls (ghostel) with no name and
-;; relies on the dynamically bound `ghostel-buffer-name'; the mock honors that.
+;; eat + the send helper are stubbed so the test does no process spawning.
+;; Production calls (eat) and relies on the dynamically bound `eat-buffer-name';
+;; the mock honors that.
;;; Code:
@@ -19,19 +19,17 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'ai-term)
-;; ghostel isn't loaded in batch -- provide stubs so cl-letf has overrides.
-(unless (fboundp 'ghostel)
- (defun ghostel (&optional _arg) nil))
-(unless (fboundp 'ghostel-send-string)
- (defun ghostel-send-string (_s) nil))
+;; eat isn't loaded in batch -- provide a stub so cl-letf has an override.
+(unless (fboundp 'eat)
+ (defun eat (&optional _program _arg) nil))
-(defmacro test-ai-term--with-mock-ghostel (vars &rest body)
- "Run BODY with ghostel + ghostel-send-string mocked.
+(defmacro test-ai-term--with-mock-eat (vars &rest body)
+ "Run BODY with eat + `cj/--ai-term-send-string' mocked.
-VARS is a plist of capture variable names: :calls (buffer names ghostel
-was asked to create), :strings (sent strings), :default-dir. The mocked
-`ghostel' creates and returns a buffer named after the dynamically bound
-`ghostel-buffer-name', mirroring the real entry point."
+VARS is a plist of capture variable names: :calls (buffer names eat was asked
+to create), :strings (sent strings), :default-dir. The mocked `eat' creates
+and returns a buffer named after the dynamically bound `eat-buffer-name',
+mirroring the real entry point."
(declare (indent 1) (debug t))
(let ((calls (plist-get vars :calls))
(strings (plist-get vars :strings))
@@ -39,14 +37,14 @@ was asked to create), :strings (sent strings), :default-dir. The mocked
`(let ((,calls '())
(,strings '())
(,ddir nil))
- (cl-letf (((symbol-function 'ghostel)
- (lambda (&optional _arg)
+ (cl-letf (((symbol-function 'eat)
+ (lambda (&optional _program _arg)
(setq ,ddir default-directory)
- (let ((b (get-buffer-create ghostel-buffer-name)))
+ (let ((b (get-buffer-create eat-buffer-name)))
(push (buffer-name b) ,calls)
b)))
- ((symbol-function 'ghostel-send-string)
- (lambda (s) (push s ,strings))))
+ ((symbol-function 'cj/--ai-term-send-string)
+ (lambda (_buf s) (push s ,strings))))
,@body))))
(defun test-ai-term--cleanup (name)
@@ -55,33 +53,33 @@ was asked to create), :strings (sent strings), :default-dir. The mocked
(kill-buffer name)))
(ert-deftest test-ai-term--show-or-create-creates-when-buffer-missing ()
- "Normal: no existing buffer -> ghostel called once, launch cmd + newline
-sent, the project recorded at the front of the MRU list."
+ "Normal: no existing buffer -> eat called once, launch cmd + newline sent,
+the project recorded at the front of the MRU list."
(let ((name "agent [normal-create-test]")
(cj/--ai-term-mru nil))
(test-ai-term--cleanup name)
(unwind-protect
- (test-ai-term--with-mock-ghostel (:calls calls :strings strings
- :default-dir ddir)
+ (test-ai-term--with-mock-eat (:calls calls :strings strings
+ :default-dir ddir)
(cj/--ai-term-show-or-create "/tmp/some-project" name)
(should (equal calls (list name)))
- (should (equal (reverse strings)
- (list (cj/--ai-term-launch-command "/tmp/some-project")
- "\n")))
+ (should (equal strings
+ (list (concat (cj/--ai-term-launch-command "/tmp/some-project")
+ "\n"))))
(should (equal ddir "/tmp/some-project"))
(should (equal (car cj/--ai-term-mru) "/tmp/some-project")))
(test-ai-term--cleanup name))))
(ert-deftest test-ai-term--show-or-create-displays-existing-when-process-live ()
- "Normal: buffer exists with live process -> ghostel not called."
+ "Normal: buffer exists with live process -> eat not called."
(let ((name "agent [reuse-test]"))
(test-ai-term--cleanup name)
(unwind-protect
(let ((buf (get-buffer-create name)))
(cl-letf (((symbol-function 'cj/--ai-term-process-live-p)
(lambda (b) (and (eq b buf) t))))
- (test-ai-term--with-mock-ghostel (:calls calls :strings strings
- :default-dir _ddir)
+ (test-ai-term--with-mock-eat (:calls calls :strings strings
+ :default-dir _ddir)
(cj/--ai-term-show-or-create "/tmp/reuse" name)
(should (null calls))
(should (null strings)))))
@@ -95,27 +93,27 @@ sent, the project recorded at the front of the MRU list."
(let ((stale (get-buffer-create name)))
(cl-letf (((symbol-function 'cj/--ai-term-process-live-p)
(lambda (_b) nil)))
- (test-ai-term--with-mock-ghostel (:calls calls :strings strings
- :default-dir _ddir)
+ (test-ai-term--with-mock-eat (:calls calls :strings strings
+ :default-dir _ddir)
(cj/--ai-term-show-or-create "/tmp/dead" name)
(should (equal calls (list name)))
- (should (equal (reverse strings)
- (list (cj/--ai-term-launch-command "/tmp/dead")
- "\n")))
+ (should (equal strings
+ (list (concat (cj/--ai-term-launch-command "/tmp/dead")
+ "\n"))))
(should-not (buffer-live-p stale)))))
(test-ai-term--cleanup name))))
(ert-deftest test-ai-term--show-or-create-preserves-selected-window ()
- "Regression: ghostel's same-window switch must not bury the dashboard.
+ "Regression: eat's same-window switch must not bury the dashboard.
-Real `ghostel' switches the selected window to its buffer as a side-effect of
+Real `eat' switches the selected window to its buffer as a side-effect of
construction. On a fresh-boot frame (one window showing the dashboard), that
side-effect would otherwise leave the original window pointing at the new
-agent buffer. The wrapper runs `(ghostel)' inside `save-window-excursion' so
-the original window state is restored before `display-buffer' fires, leaving
-the dashboard put and letting the alist place agent into a fresh split.
+agent buffer. The wrapper runs `(eat)' inside `save-window-excursion' so the
+original window state is restored before `display-buffer' fires, leaving the
+dashboard put and letting the alist place agent into a fresh split.
-This test stubs `ghostel' to mimic the same-window side-effect and asserts the
+This test stubs `eat' to mimic the same-window side-effect and asserts the
originally-selected window still shows its original buffer afterward."
(let ((agent-name "agent [preserve-window-test]")
(orig-name "*test-original-buffer*"))
@@ -128,24 +126,24 @@ originally-selected window still shows its original buffer afterward."
(orig-win (selected-window)))
(set-window-buffer orig-win orig-buf)
(cl-letf
- (((symbol-function 'ghostel)
- (lambda (&optional _arg)
- (let ((buf (get-buffer-create ghostel-buffer-name)))
+ (((symbol-function 'eat)
+ (lambda (&optional _program _arg)
+ (let ((buf (get-buffer-create eat-buffer-name)))
(set-window-buffer (selected-window) buf)
buf)))
- ((symbol-function 'ghostel-send-string)
- (lambda (_s) nil)))
+ ((symbol-function 'cj/--ai-term-send-string)
+ (lambda (_buf _s) nil)))
(cj/--ai-term-show-or-create "/tmp/preserve" agent-name)
(should (eq (window-buffer orig-win) orig-buf)))))
(test-ai-term--cleanup agent-name)
(when (get-buffer orig-name) (kill-buffer orig-name)))))
(ert-deftest test-ai-term--show-or-create-returns-buffer ()
- "Normal: return value is the ghostel buffer named after the project."
+ "Normal: return value is the eat buffer named after the project."
(let ((name "agent [return-test]"))
(test-ai-term--cleanup name)
(unwind-protect
- (test-ai-term--with-mock-ghostel (:calls _c :strings _s :default-dir _d)
+ (test-ai-term--with-mock-eat (:calls _c :strings _s :default-dir _d)
(let ((result (cj/--ai-term-show-or-create "/tmp/return" name)))
(should (bufferp result))
(should (equal (buffer-name result) name))))
diff --git a/tests/test-ai-term--shutdown-countdown.el b/tests/test-ai-term--shutdown-countdown.el
new file mode 100644
index 000000000..6500e9634
--- /dev/null
+++ b/tests/test-ai-term--shutdown-countdown.el
@@ -0,0 +1,73 @@
+;;; test-ai-term--shutdown-countdown.el --- Tests for the shutdown countdown -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The "wrap it up and shutdown" countdown. The testable logic is the safety
+;; gate (abort when more than one aiv-* session is live) and the cancel/timer
+;; bookkeeping; the tick rendering and the actual shutdown side effect are
+;; manual (see the spec). shell-command is stubbed throughout so no test can
+;; power the machine off, and timers are cancelled rather than allowed to fire.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(defmacro test-ai-term-shutdown--with (live-count shell-var &rest body)
+ "Run BODY with `cj/ai-term-live-count' mocked to LIVE-COUNT and `shell-command'
+recording its argument into SHELL-VAR; the timer is cleared before and after."
+ (declare (indent 2))
+ `(progn
+ (cj/--ai-term-shutdown-clear-timer)
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/ai-term-live-count) (lambda () ,live-count))
+ ((symbol-function 'shell-command)
+ (lambda (cmd &rest _) (setq ,shell-var cmd) 0)))
+ ,@body)
+ (cj/--ai-term-shutdown-clear-timer))))
+
+(ert-deftest test-ai-term-shutdown-aborts-when-other-sessions-live ()
+ "Normal: more than one live session aborts -- no timer, no shutdown."
+ (let ((shell nil))
+ (test-ai-term-shutdown--with 2 shell
+ (should-not (cj/ai-term-shutdown-countdown 3))
+ (should-not cj/--ai-term-shutdown-timer)
+ (should-not shell))))
+
+(ert-deftest test-ai-term-shutdown-schedules-timer-when-sole-session ()
+ "Normal: the sole live session schedules the countdown timer (does not fire here)."
+ (let ((shell nil))
+ (test-ai-term-shutdown--with 1 shell
+ (cj/ai-term-shutdown-countdown 3)
+ (should (timerp cj/--ai-term-shutdown-timer))
+ ;; The timer has not ticked (no event loop in batch), so no shutdown yet.
+ (should-not shell))))
+
+(ert-deftest test-ai-term-shutdown-cancel-clears-the-timer ()
+ "Normal: cancel stops an in-progress countdown."
+ (let ((shell nil))
+ (test-ai-term-shutdown--with 1 shell
+ (cj/ai-term-shutdown-countdown 5)
+ (should (timerp cj/--ai-term-shutdown-timer))
+ (cj/ai-term-shutdown-cancel)
+ (should-not cj/--ai-term-shutdown-timer)
+ (should-not shell))))
+
+(ert-deftest test-ai-term-shutdown-tick-fires-shutdown-at-zero ()
+ "Boundary: invoking the timer function at zero remaining runs the shutdown
+command and clears the timer. Drives the tick directly rather than waiting."
+ (let ((shell nil))
+ (test-ai-term-shutdown--with 1 shell
+ (cj/ai-term-shutdown-countdown 1)
+ (let ((fn (timer--function cj/--ai-term-shutdown-timer)))
+ ;; remaining starts at 1: first call renders, second call hits zero.
+ (funcall fn)
+ (should-not shell)
+ (funcall fn)
+ (should (equal shell cj/ai-term-shutdown-command))
+ (should-not cj/--ai-term-shutdown-timer)))))
+
+(provide 'test-ai-term--shutdown-countdown)
+;;; test-ai-term--shutdown-countdown.el ends here
diff --git a/tests/test-calendar-sync--apply-single-exception.el b/tests/test-calendar-sync--apply-single-exception.el
index 3d2342708..f23104d98 100644
--- a/tests/test-calendar-sync--apply-single-exception.el
+++ b/tests/test-calendar-sync--apply-single-exception.el
@@ -105,5 +105,42 @@
(plist-get (calendar-sync--apply-single-exception occ exc)
:url)))))
+;;; Status re-derivation from overridden attendees (chime handoff 2026-06-24)
+
+(ert-deftest test-calendar-sync--apply-single-exception-declined-occurrence-rederives-status ()
+ "Normal: a declined single occurrence re-derives :status from the override attendees."
+ (let ((calendar-sync-user-emails '("craig@example.com"))
+ (occ (list :start '(2026 6 24 16 0) :status "accepted" :uid "abc"))
+ (exc (list :start '(2026 6 24 16 0)
+ :attendees (list (list :email "craig@example.com" :partstat "DECLINED")))))
+ (should (equal "declined"
+ (plist-get (calendar-sync--apply-single-exception occ exc) :status)))))
+
+(ert-deftest test-calendar-sync--apply-single-exception-no-attendee-override-keeps-status ()
+ "Boundary: an exception with no attendee block leaves the inherited :status intact."
+ (let ((calendar-sync-user-emails '("craig@example.com"))
+ (occ (list :start '(2026 6 24 16 0) :status "accepted" :uid "abc"))
+ (exc (list :start '(2026 6 24 16 0) :summary "Moved")))
+ (should (equal "accepted"
+ (plist-get (calendar-sync--apply-single-exception occ exc) :status)))))
+
+(ert-deftest test-calendar-sync--apply-single-exception-accepted-override-stays-accepted ()
+ "Normal: an accepted attendee override keeps :status accepted."
+ (let ((calendar-sync-user-emails '("craig@example.com"))
+ (occ (list :start '(2026 6 24 16 0) :status "accepted" :uid "abc"))
+ (exc (list :start '(2026 6 24 16 0)
+ :attendees (list (list :email "craig@example.com" :partstat "ACCEPTED")))))
+ (should (equal "accepted"
+ (plist-get (calendar-sync--apply-single-exception occ exc) :status)))))
+
+(ert-deftest test-calendar-sync--apply-single-exception-override-without-user-keeps-status ()
+ "Boundary: override attendees that don't include the user leave :status intact."
+ (let ((calendar-sync-user-emails '("craig@example.com"))
+ (occ (list :start '(2026 6 24 16 0) :status "accepted" :uid "abc"))
+ (exc (list :start '(2026 6 24 16 0)
+ :attendees (list (list :email "someone@else.com" :partstat "DECLINED")))))
+ (should (equal "accepted"
+ (plist-get (calendar-sync--apply-single-exception occ exc) :status)))))
+
(provide 'test-calendar-sync--apply-single-exception)
;;; test-calendar-sync--apply-single-exception.el ends here
diff --git a/tests/test-calendar-sync--robustness.el b/tests/test-calendar-sync--robustness.el
new file mode 100644
index 000000000..2c044b013
--- /dev/null
+++ b/tests/test-calendar-sync--robustness.el
@@ -0,0 +1,70 @@
+;;; test-calendar-sync--robustness.el --- Tests for sync robustness fixes -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for two robustness fixes:
+;; - calendar-sync--parse-ics distinguishes a healthy zero-event calendar
+;; (a real iCalendar with no in-window events -> non-nil header) from
+;; garbage (no BEGIN:VCALENDAR -> nil), so a near-empty calendar no longer
+;; reports "parse failed".
+;; - calendar-sync--write-file writes atomically (temp file + rename), so a
+;; reader never sees a half-written calendar and no temp file is left behind.
+;; (The curl --fail change is in the make-process command list and is exercised
+;; against the live feed, not unit-tested here.)
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+
+;;; calendar-sync--parse-ics: zero-event vs garbage
+
+(ert-deftest test-calendar-sync--parse-ics-valid-zero-events-non-nil ()
+ "Normal: a real iCalendar with no in-window events returns a non-nil empty
+calendar, not a parse failure."
+ (let ((result (calendar-sync--parse-ics "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR\n")))
+ (should result)
+ (should (string-match-p "Calendar Events" result))))
+
+(ert-deftest test-calendar-sync--parse-ics-garbage-nil ()
+ "Error: non-iCalendar content (no BEGIN:VCALENDAR, e.g. an HTML error page)
+returns nil -- a genuine failure."
+ (should-not (calendar-sync--parse-ics "HTTP 404 Not Found\n<html><body>error</body></html>")))
+
+;;; calendar-sync--write-file: atomic
+
+(ert-deftest test-calendar-sync--write-file-writes-content ()
+ "Normal: the content lands in the target file."
+ (let* ((dir (make-temp-file "cal-sync-test-" t))
+ (file (expand-file-name "agenda.org" dir)))
+ (unwind-protect
+ (progn
+ (calendar-sync--write-file "# Calendar Events\n\nhello\n" file)
+ (should (equal "# Calendar Events\n\nhello\n"
+ (with-temp-buffer (insert-file-contents file)
+ (buffer-string)))))
+ (delete-directory dir t))))
+
+(ert-deftest test-calendar-sync--write-file-leaves-no-temp ()
+ "Boundary: the temp file is renamed into place, not left in the directory."
+ (let* ((dir (make-temp-file "cal-sync-test-" t))
+ (file (expand-file-name "agenda.org" dir)))
+ (unwind-protect
+ (progn
+ (calendar-sync--write-file "x" file)
+ ;; only the target file remains -- no leftover .calendar-sync-* temp
+ (should (equal '("agenda.org")
+ (directory-files dir nil "\\`[^.]"))))
+ (delete-directory dir t))))
+
+(ert-deftest test-calendar-sync--write-file-creates-parent-dir ()
+ "Boundary: a missing parent directory is created."
+ (let* ((root (make-temp-file "cal-sync-test-" t))
+ (file (expand-file-name "sub/nested/agenda.org" root)))
+ (unwind-protect
+ (progn
+ (calendar-sync--write-file "y" file)
+ (should (file-exists-p file)))
+ (delete-directory root t))))
+
+(provide 'test-calendar-sync--robustness)
+;;; test-calendar-sync--robustness.el ends here
diff --git a/tests/test-calendar-sync.el b/tests/test-calendar-sync.el
index 62b00aba1..f562cfc61 100644
--- a/tests/test-calendar-sync.el
+++ b/tests/test-calendar-sync.el
@@ -471,11 +471,14 @@ Earlier events should appear first in the output."
(should (string-match-p "\\* Event 1" org-content))
(should (string-match-p "\\* Event 2" org-content))))
-(ert-deftest test-calendar-sync--parse-ics-boundary-empty-calendar-returns-nil ()
- "Test parsing empty calendar (no events)."
+(ert-deftest test-calendar-sync--parse-ics-boundary-empty-calendar-returns-header ()
+ "A valid but empty iCalendar (no events) is a healthy zero-event calendar:
+it returns a non-nil header so the sync reports success, not a parse failure.
+Garbage with no BEGIN:VCALENDAR still returns nil (covered elsewhere)."
(let* ((ics "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR")
(org-content (calendar-sync--parse-ics ics)))
- (should (null org-content))))
+ (should org-content)
+ (should (string-match-p "Calendar Events" org-content))))
(ert-deftest test-calendar-sync--parse-ics-error-malformed-ics-returns-nil ()
"Test that malformed .ics returns nil and sets error."
diff --git a/tests/test-calibredb-epub-config.el b/tests/test-calibredb-epub-config.el
index 48d638358..cb3a9ba74 100644
--- a/tests/test-calibredb-epub-config.el
+++ b/tests/test-calibredb-epub-config.el
@@ -29,8 +29,8 @@
`(with-temp-buffer
(setq-local major-mode 'nov-mode)
(cl-letf (((symbol-function 'get-buffer-window) (lambda (&rest _) 'win))
- ((symbol-function 'window-body-width) (lambda (_) 200))
- ((symbol-function 'window-margins) (lambda (_) '(nil . nil)))
+ ((symbol-function 'window-body-width) (lambda (&rest _) 200))
+ ((symbol-function 'window-margins) (lambda (&rest _) '(nil . nil)))
((symbol-function 'set-window-margins) (lambda (&rest _) nil))
((symbol-function 'set-window-fringes) (lambda (&rest _) nil)))
,@body)))
@@ -73,8 +73,8 @@ below 50% of the usable columns."
(let ((cj/nov-margin-percent 25)
(cj/nov-min-text-width 40))
(cl-letf (((symbol-function 'get-buffer-window) (lambda (&rest _) 'win))
- ((symbol-function 'window-body-width) (lambda (_) 120))
- ((symbol-function 'window-margins) (lambda (_) '(nil . nil))))
+ ((symbol-function 'window-body-width) (lambda (&rest _) 120))
+ ((symbol-function 'window-margins) (lambda (&rest _) '(nil . nil))))
(should (= 60 (cj/nov--text-width-for-window))))))
(ert-deftest test-calibredb-epub-nov-text-width-for-window-idempotent ()
@@ -85,8 +85,8 @@ this, every layout pass would shave the column by another margin fraction."
(let ((cj/nov-margin-percent 25)
(cj/nov-min-text-width 40))
(cl-letf (((symbol-function 'get-buffer-window) (lambda (&rest _) 'win))
- ((symbol-function 'window-body-width) (lambda (_) 60))
- ((symbol-function 'window-margins) (lambda (_) '(30 . 30))))
+ ((symbol-function 'window-body-width) (lambda (&rest _) 60))
+ ((symbol-function 'window-margins) (lambda (&rest _) '(30 . 30))))
(should (= 60 (cj/nov--text-width-for-window))))))
(ert-deftest test-calibredb-epub-nov-text-width-for-window-no-window ()
@@ -214,15 +214,15 @@ so nov's `shr' fills the text itself rather than relying on visual-fill-column."
(ert-deftest test-calibredb-epub-nov-natural-window-width-no-margins ()
"Normal: with no margins set, the natural width equals `window-body-width'."
(cl-letf (((symbol-function 'get-buffer-window) (lambda (&rest _) 'win))
- ((symbol-function 'window-body-width) (lambda (_) 100))
- ((symbol-function 'window-margins) (lambda (_) '(nil . nil))))
+ ((symbol-function 'window-body-width) (lambda (&rest _) 100))
+ ((symbol-function 'window-margins) (lambda (&rest _) '(nil . nil))))
(should (= 100 (cj/nov--natural-window-width)))))
(ert-deftest test-calibredb-epub-nov-natural-window-width-adds-margins ()
"Boundary: with margins set, the natural width adds them back to the body."
(cl-letf (((symbol-function 'get-buffer-window) (lambda (&rest _) 'win))
- ((symbol-function 'window-body-width) (lambda (_) 60))
- ((symbol-function 'window-margins) (lambda (_) '(20 . 20))))
+ ((symbol-function 'window-body-width) (lambda (&rest _) 60))
+ ((symbol-function 'window-margins) (lambda (&rest _) '(20 . 20))))
(should (= 100 (cj/nov--natural-window-width)))))
(ert-deftest test-calibredb-epub-nov-natural-window-width-no-window-fallback ()
diff --git a/tests/test-config-utilities--compile-this-elisp-buffer.el b/tests/test-config-utilities--compile-this-elisp-buffer.el
index fb5e288a1..a06440abb 100644
--- a/tests/test-config-utilities--compile-this-elisp-buffer.el
+++ b/tests/test-config-utilities--compile-this-elisp-buffer.el
@@ -21,7 +21,7 @@ effects."
(declare (indent 1) (debug t))
`(with-temp-buffer
(setq buffer-file-name ,path)
- (cl-letf (((symbol-function 'save-buffer) (lambda () nil)))
+ (cl-letf (((symbol-function 'save-buffer) (lambda (&rest _) nil)))
,@body)))
(ert-deftest test-config-utilities-compile-buffer-not-elisp-raises ()
@@ -47,7 +47,7 @@ effects."
((symbol-function 'native-compile)
(lambda (_) (error "should not call sync native-compile")))
((symbol-function 'byte-compile-file)
- (lambda (_) (error "should not call byte-compile-file"))))
+ (lambda (&rest _) (error "should not call byte-compile-file"))))
(cj/compile-this-elisp-buffer)
(should (equal called-with "/tmp/some.el"))))))
@@ -60,7 +60,7 @@ effects."
((symbol-function 'native-compile)
(lambda (file) (setq called-with file)))
((symbol-function 'byte-compile-file)
- (lambda (_) (error "should not call byte-compile-file"))))
+ (lambda (&rest _) (error "should not call byte-compile-file"))))
(cj/compile-this-elisp-buffer)
(should (equal called-with "/tmp/some.el"))))))
@@ -71,7 +71,7 @@ effects."
(cl-letf (((symbol-function 'fboundp)
(lambda (sym) (eq sym 'byte-compile-file)))
((symbol-function 'byte-compile-file)
- (lambda (file) (setq called-with file) "/tmp/some.elc")))
+ (lambda (file &rest _) (setq called-with file) "/tmp/some.elc")))
(cj/compile-this-elisp-buffer)
(should (equal called-with "/tmp/some.el"))))))
diff --git a/tests/test-coverage-core--relativize-keys.el b/tests/test-coverage-core--relativize-keys.el
new file mode 100644
index 000000000..82031cd15
--- /dev/null
+++ b/tests/test-coverage-core--relativize-keys.el
@@ -0,0 +1,123 @@
+;;; test-coverage-core--relativize-keys.el --- Tests for path-key normalization -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit + integration tests for `cj/--coverage-relativize-keys', the helper
+;; that normalizes a file-path-keyed coverage table to repo-relative paths.
+;;
+;; The bug it fixes: `cj/--coverage-parse-simplecov' returns ABSOLUTE path
+;; keys (simplecov/undercover emit absolute source paths), while
+;; `cj/--coverage-parse-diff-output' returns repo-RELATIVE keys (git's
+;; "+++ b/<path>"). `cj/--coverage-intersect' joins the two by exact string
+;; key, so for the diff-aware scopes every changed file was classified
+;; ":tracked nil" — zero matches ever. Normalizing both tables to
+;; repo-relative before the intersect makes the join work.
+;;
+;; The integration test drives the real parsers (a simplecov JSON fixture
+;; with an absolute key + a git-diff string with the relative key) through
+;; relativize + intersect, and asserts the file is tracked with the right
+;; covered/uncovered split — the end-to-end reproduction of the bug.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'coverage-core)
+
+(defun test-coverage-relativize--hash-of-lines (pairs)
+ "Build a file → line-set hash table from PAIRS.
+Each pair is (FILE . (LINES...)); LINES becomes a hash-table of line → t."
+ (let ((result (make-hash-table :test 'equal)))
+ (dolist (pair pairs)
+ (let ((lines (make-hash-table :test 'eql)))
+ (dolist (line (cdr pair))
+ (puthash line t lines))
+ (puthash (car pair) lines result)))
+ result))
+
+;;; Normal cases
+
+(ert-deftest test-coverage-relativize-absolute-key-made-relative ()
+ "Normal: an absolute key is relativized against ROOT."
+ (let* ((table (test-coverage-relativize--hash-of-lines
+ '(("/home/u/.emacs.d/modules/foo.el" 10 11))))
+ (out (cj/--coverage-relativize-keys table "/home/u/.emacs.d")))
+ (should (gethash "modules/foo.el" out))
+ (should (null (gethash "/home/u/.emacs.d/modules/foo.el" out)))))
+
+(ert-deftest test-coverage-relativize-preserves-line-set ()
+ "Normal: the line-set value travels unchanged to the new key."
+ (let* ((table (test-coverage-relativize--hash-of-lines
+ '(("/r/modules/foo.el" 4 8 15))))
+ (out (cj/--coverage-relativize-keys table "/r"))
+ (lines (gethash "modules/foo.el" out)))
+ (should (hash-table-p lines))
+ (should (gethash 4 lines))
+ (should (gethash 8 lines))
+ (should (gethash 15 lines))))
+
+;;; Boundary cases
+
+(ert-deftest test-coverage-relativize-already-relative-unchanged ()
+ "Boundary: an already-relative key is left as-is, not re-relativized."
+ (let* ((table (test-coverage-relativize--hash-of-lines
+ '(("modules/foo.el" 1 2))))
+ (out (cj/--coverage-relativize-keys table "/home/u/.emacs.d")))
+ (should (gethash "modules/foo.el" out))
+ (should (= 1 (hash-table-count out)))))
+
+(ert-deftest test-coverage-relativize-empty-table ()
+ "Boundary: an empty table yields an empty table."
+ (let ((out (cj/--coverage-relativize-keys (make-hash-table :test 'equal) "/r")))
+ (should (hash-table-p out))
+ (should (= 0 (hash-table-count out)))))
+
+;;; Error cases
+
+(ert-deftest test-coverage-relativize-nil-table-returns-empty ()
+ "Error: a nil table returns an empty table rather than erroring."
+ (let ((out (cj/--coverage-relativize-keys nil "/r")))
+ (should (hash-table-p out))
+ (should (= 0 (hash-table-count out)))))
+
+;;; Integration — the real bug reproduction
+
+(ert-deftest test-coverage-integration-absolute-report-relative-diff-tracks ()
+ "Integration: a simplecov report (absolute keys) and a git diff (relative
+keys) for the same file intersect as TRACKED once both are relativized.
+This is the diff-aware-scope bug: without normalization the file reads
+\":tracked nil\"."
+ (let* ((root "/tmp/cov-root")
+ (abs-path (concat root "/modules/foo.el"))
+ (report (make-temp-file "cov-report-" nil ".json"))
+ (diff (concat
+ "diff --git a/modules/foo.el b/modules/foo.el\n"
+ "index 1111111..2222222 100644\n"
+ "--- a/modules/foo.el\n"
+ "+++ b/modules/foo.el\n"
+ "@@ -2,0 +2,3 @@\n"
+ "+line two\n"
+ "+line three\n"
+ "+line four\n")))
+ (unwind-protect
+ (progn
+ ;; simplecov array: index1=null, 2=hit, 3=0-hits, 4=hit
+ ;; → covered lines {2, 4}
+ (with-temp-file report
+ (insert (format "{\"t\":{\"coverage\":{%S:[null,1,0,2]}}}" abs-path)))
+ (let* ((covered (cj/--coverage-relativize-keys
+ (cj/--coverage-parse-simplecov report) root))
+ (changed (cj/--coverage-relativize-keys
+ (cj/--coverage-parse-diff-output diff) root))
+ (records (cj/--coverage-intersect covered changed))
+ (record (car records)))
+ (should (= 1 (length records)))
+ (should (equal "modules/foo.el" (plist-get record :path)))
+ (should (eq t (plist-get record :tracked)))
+ (should (equal '(2 3 4) (plist-get record :changed-lines)))
+ (should (equal '(2 4) (plist-get record :covered-lines)))
+ (should (equal '(3) (plist-get record :uncovered-lines)))))
+ (delete-file report))))
+
+(provide 'test-coverage-core--relativize-keys)
+;;; test-coverage-core--relativize-keys.el ends here
diff --git a/tests/test-custom-buffer-file-print-diff-eww.el b/tests/test-custom-buffer-file-print-diff-eww.el
index 9aa73cbee..56cc917e0 100644
--- a/tests/test-custom-buffer-file-print-diff-eww.el
+++ b/tests/test-custom-buffer-file-print-diff-eww.el
@@ -30,14 +30,14 @@
(let ((cj/print-spooler-command "lpr")
(cj/print--spooler-cache nil))
(cl-letf (((symbol-function 'executable-find)
- (lambda (cmd) (when (equal cmd "lpr") "/usr/bin/lpr"))))
+ (lambda (cmd &rest _) (when (equal cmd "lpr") "/usr/bin/lpr"))))
(should (equal (cj/print--resolve-spooler) "lpr")))))
(ert-deftest test-cbf-resolve-spooler-explicit-string-missing-errors ()
"Error: explicit string spooler not on PATH signals user-error."
(let ((cj/print-spooler-command "notathing")
(cj/print--spooler-cache nil))
- (cl-letf (((symbol-function 'executable-find) (lambda (_) nil)))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(should-error (cj/print--resolve-spooler) :type 'user-error))))
(ert-deftest test-cbf-resolve-spooler-auto-detects-lpr-first ()
@@ -45,7 +45,7 @@
(let ((cj/print-spooler-command 'auto)
(cj/print--spooler-cache nil))
(cl-letf (((symbol-function 'executable-find)
- (lambda (cmd) (when (equal cmd "lpr") "/usr/bin/lpr"))))
+ (lambda (cmd &rest _) (when (equal cmd "lpr") "/usr/bin/lpr"))))
(should (equal (cj/print--resolve-spooler) "lpr"))
(should (equal cj/print--spooler-cache "lpr")))))
@@ -54,14 +54,14 @@
(let ((cj/print-spooler-command 'auto)
(cj/print--spooler-cache nil))
(cl-letf (((symbol-function 'executable-find)
- (lambda (cmd) (when (equal cmd "lp") "/usr/bin/lp"))))
+ (lambda (cmd &rest _) (when (equal cmd "lp") "/usr/bin/lp"))))
(should (equal (cj/print--resolve-spooler) "lp")))))
(ert-deftest test-cbf-resolve-spooler-auto-no-tool-errors ()
"Error: `auto' with neither lpr nor lp signals user-error."
(let ((cj/print-spooler-command 'auto)
(cj/print--spooler-cache nil))
- (cl-letf (((symbol-function 'executable-find) (lambda (_) nil)))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(should-error (cj/print--resolve-spooler) :type 'user-error))))
(ert-deftest test-cbf-resolve-spooler-auto-returns-cached-value ()
@@ -69,7 +69,7 @@
(let ((cj/print-spooler-command 'auto)
(cj/print--spooler-cache "cached-cmd"))
(cl-letf (((symbol-function 'executable-find)
- (lambda (_) (error "should not be called"))))
+ (lambda (_ &rest _) (error "should not be called"))))
(should (equal (cj/print--resolve-spooler) "cached-cmd")))))
(ert-deftest test-cbf-resolve-spooler-invalid-value-errors ()
@@ -87,7 +87,7 @@
(with-temp-buffer
(rename-buffer "*test-cbf-copy-name*" t)
(cl-letf (((symbol-function 'kill-new)
- (lambda (s) (setq killed s)))
+ (lambda (s &rest _) (setq killed s)))
((symbol-function 'message)
(lambda (fmt &rest args)
(setq msg (apply #'format fmt args)))))
diff --git a/tests/test-dashboard-config-launchers.el b/tests/test-dashboard-config-launchers.el
index e7e8d2f33..e7e5dcd53 100644
--- a/tests/test-dashboard-config-launchers.el
+++ b/tests/test-dashboard-config-launchers.el
@@ -56,7 +56,8 @@ Slack, Linear, and Signal sharing the last row."
(cl-letf (((symbol-function 'nerd-icons-faicon) (lambda (n &rest _) (concat "I:" n)))
((symbol-function 'nerd-icons-devicon) (lambda (n &rest _) (concat "I:" n)))
((symbol-function 'nerd-icons-mdicon) (lambda (n &rest _) (concat "I:" n)))
- ((symbol-function 'nerd-icons-octicon) (lambda (n &rest _) (concat "I:" n))))
+ ((symbol-function 'nerd-icons-octicon) (lambda (n &rest _) (concat "I:" n)))
+ ((symbol-function 'nerd-icons-codicon) (lambda (n &rest _) (concat "I:" n))))
(let ((rows (cj/dashboard--navigator-rows)))
(should (= 4 (length rows)))
(should (equal '(4 4 3 3) (mapcar #'length rows)))
@@ -85,7 +86,7 @@ Slack, Linear, and Signal sharing the last row."
(let ((map (make-sparse-keymap)) (calls nil))
(cl-letf (((symbol-function 'projectile-switch-project) (lambda (&rest _) (push 'code calls)))
((symbol-function 'dirvish) (lambda (&rest _) (push 'files calls)))
- ((symbol-function 'ghostel) (lambda (&rest _) (push 'term calls)))
+ ((symbol-function 'cj/term-toggle) (lambda (&rest _) (push 'term calls)))
((symbol-function 'cj/main-agenda-display) (lambda (&rest _) (push 'agenda calls)))
((symbol-function 'cj/elfeed-open) (lambda (&rest _) (push 'feeds calls)))
((symbol-function 'calibredb) (lambda (&rest _) (push 'books calls)))
diff --git a/tests/test-dev-fkeys--f6-current-file-tests-impl.el b/tests/test-dev-fkeys--f6-current-file-tests-impl.el
index 1cf222305..2d8e43858 100644
--- a/tests/test-dev-fkeys--f6-current-file-tests-impl.el
+++ b/tests/test-dev-fkeys--f6-current-file-tests-impl.el
@@ -111,7 +111,7 @@ runner instead of erroring as unsupported."
(let ((compile-called nil))
(cl-letf (((symbol-function 'compile)
(lambda (cmd) (setq compile-called cmd)))
- ((symbol-function 'executable-find) (lambda (_) nil)))
+ ((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(cj/--f6-current-file-tests-impl
"/home/u/proj/src/foo.test.ts" "/home/u/proj/")
(should (stringp compile-called))
diff --git a/tests/test-dev-fkeys--f6-current-file-tests.el b/tests/test-dev-fkeys--f6-current-file-tests.el
index 3f6adc255..97c1c7675 100644
--- a/tests/test-dev-fkeys--f6-current-file-tests.el
+++ b/tests/test-dev-fkeys--f6-current-file-tests.el
@@ -16,7 +16,7 @@
(ert-deftest test-dev-fkeys-f6-current-file-tests-routes-to-impl ()
"Normal: C-F6 invokes the orchestrator with buffer file and projectile root."
(let (seen-file seen-root)
- (cl-letf (((symbol-function 'buffer-file-name) (lambda () "/p/foo.el"))
+ (cl-letf (((symbol-function 'buffer-file-name) (lambda (&rest _) "/p/foo.el"))
((symbol-function 'cj/--f4-project-root) (lambda () "/p/"))
((symbol-function 'cj/--f6-current-file-tests-impl)
(lambda (file root)
diff --git a/tests/test-dev-fkeys--f6-test-runner-cmd-for.el b/tests/test-dev-fkeys--f6-test-runner-cmd-for.el
index 9a5526125..d7b6a0597 100644
--- a/tests/test-dev-fkeys--f6-test-runner-cmd-for.el
+++ b/tests/test-dev-fkeys--f6-test-runner-cmd-for.el
@@ -126,13 +126,13 @@ neither tool is present, the user gets a clear runner-not-found error
rather than a silent nil that F6's outer wrapper interprets as
\"language unsupported.\""
(cl-letf (((symbol-function 'executable-find)
- (lambda (_) nil)))
+ (lambda (_ &rest _) nil)))
(should (equal
(cj/--f6-test-runner-cmd-for
'typescript t "src/foo.test.ts" "foo" "src")
"npx --no-install jest src/foo.test.ts")))
(cl-letf (((symbol-function 'executable-find)
- (lambda (p) (when (equal p "vitest") "/usr/bin/vitest"))))
+ (lambda (p &rest _) (when (equal p "vitest") "/usr/bin/vitest"))))
(should (equal
(cj/--f6-test-runner-cmd-for
'typescript t "src/foo.test.ts" "foo" "src")
diff --git a/tests/test-dev-fkeys--f6-test-runner.el b/tests/test-dev-fkeys--f6-test-runner.el
index eb9cec5ef..d5f58a66d 100644
--- a/tests/test-dev-fkeys--f6-test-runner.el
+++ b/tests/test-dev-fkeys--f6-test-runner.el
@@ -79,7 +79,7 @@ Components integrated:
(lambda (&rest _) "Current file's tests"))
((symbol-function 'projectile-test-project) (lambda (_arg) nil))
((symbol-function 'cj/--f4-project-root) (lambda () "/p/"))
- ((symbol-function 'buffer-file-name) (lambda () "/p/foo.el"))
+ ((symbol-function 'buffer-file-name) (lambda (&rest _) "/p/foo.el"))
((symbol-function 'cj/--f6-current-file-tests-impl)
(lambda (file root)
(setq seen-file file seen-root root))))
diff --git a/tests/test-dev-fkeys--projectile-advice-install.el b/tests/test-dev-fkeys--projectile-advice-install.el
index bfa9b691f..d0a9a9cc0 100644
--- a/tests/test-dev-fkeys--projectile-advice-install.el
+++ b/tests/test-dev-fkeys--projectile-advice-install.el
@@ -16,7 +16,7 @@
"When Projectile is not loaded, registration should use `eval-after-load'."
(let (registered-feature registered-form install-called)
(cl-letf (((symbol-function 'featurep)
- (lambda (feature) (and (not (eq feature 'projectile))
+ (lambda (feature &rest _) (and (not (eq feature 'projectile))
(featurep feature))))
((symbol-function 'eval-after-load)
(lambda (feature form)
@@ -33,7 +33,7 @@
"When Projectile is already loaded, registration should install immediately."
(let (install-called eval-after-load-called)
(cl-letf (((symbol-function 'featurep)
- (lambda (feature) (eq feature 'projectile)))
+ (lambda (feature &rest _) (eq feature 'projectile)))
((symbol-function 'eval-after-load)
(lambda (&rest _args) (setq eval-after-load-called t)))
((symbol-function 'cj/--projectile-install-revert-advice)
diff --git a/tests/test-dirvish-config-drill.el b/tests/test-dirvish-config-drill.el
index f26de6d87..de0541a0c 100644
--- a/tests/test-dirvish-config-drill.el
+++ b/tests/test-dirvish-config-drill.el
@@ -34,7 +34,7 @@
"Normal: an `.org' file at point is opened and drilled."
(let (opened (drilled 0))
(cl-letf (((symbol-function 'dired-get-filename) (lambda (&rest _) "/tmp/decks/cards.org"))
- ((symbol-function 'find-file) (lambda (f) (setq opened f)))
+ ((symbol-function 'find-file) (lambda (f &rest _) (setq opened f)))
((symbol-function 'cj/drill-this-file) (lambda (&rest _) (cl-incf drilled))))
(cj/dirvish-drill-file))
(should (equal "/tmp/decks/cards.org" opened))
@@ -44,7 +44,7 @@
"Boundary: the `.org' check ignores case."
(let (opened)
(cl-letf (((symbol-function 'dired-get-filename) (lambda (&rest _) "/tmp/decks/CARDS.ORG"))
- ((symbol-function 'find-file) (lambda (f) (setq opened f)))
+ ((symbol-function 'find-file) (lambda (f &rest _) (setq opened f)))
((symbol-function 'cj/drill-this-file) #'ignore))
(cj/dirvish-drill-file))
(should (equal "/tmp/decks/CARDS.ORG" opened))))
diff --git a/tests/test-dirvish-config-popup.el b/tests/test-dirvish-config-popup.el
new file mode 100644
index 000000000..2bd3a192c
--- /dev/null
+++ b/tests/test-dirvish-config-popup.el
@@ -0,0 +1,248 @@
+;;; test-dirvish-config-popup.el --- Dirvish Hyprland popup tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the Hyprland Super+F dirvish popup. The launcher opens an
+;; emacsclient frame named "dirvish" (window rules float/size/center it by that
+;; name) and runs `cj/dirvish-popup', which opens Dirvish rooted at home. `q'
+;; runs `cj/dirvish-popup-quit': in the popup frame it quits Dirvish and deletes
+;; the frame; in any other frame it quits Dirvish normally. Covered here: frame
+;; discovery by name, the emacsclient focus race on open, and the quit dispatch
+;; on every frame condition.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'dirvish-config)
+
+;;; cj/--dirvish-popup-frame (find the popup frame by name)
+
+(ert-deftest test-dirvish-config-popup-frame-found ()
+ "Normal: returns the live frame whose name is \"dirvish\"."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fb fc)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter)
+ (lambda (f _p) (if (eq f 'fb) "dirvish" "other"))))
+ (should (eq (cj/--dirvish-popup-frame) 'fb))))
+
+(ert-deftest test-dirvish-config-popup-frame-none ()
+ "Boundary: no popup frame present yields nil."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fc)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter) (lambda (_f _p) "other")))
+ (should-not (cj/--dirvish-popup-frame))))
+
+(ert-deftest test-dirvish-config-popup-frame-skips-dead ()
+ "Boundary: a dead frame named \"dirvish\" is skipped."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fb)))
+ ((symbol-function 'frame-live-p) (lambda (f) (not (eq f 'fb))))
+ ((symbol-function 'frame-parameter) (lambda (_f _p) "dirvish")))
+ (should (eq (cj/--dirvish-popup-frame) 'fa))))
+
+;;; cj/dirvish-popup (open dirvish in the named frame)
+
+(ert-deftest test-dirvish-config-popup-selects-named-frame ()
+ "Integration: cj/dirvish-popup focuses the \"dirvish\" frame found by name,
+not whatever frame happens to be selected (the emacsclient -c focus race).
+
+Components integrated:
+- cj/dirvish-popup (real)
+- cj/--dirvish-popup-frame (MOCKED — returns a sentinel frame)
+- select-frame-set-input-focus (MOCKED — records the focused frame)
+- dirvish (MOCKED — records the path opened)"
+ (let ((focused nil) (opened nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup-frame))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f &rest _) (setq focused f)))
+ ((symbol-function 'dirvish) (lambda (&optional p) (setq opened (or p t)))))
+ (cj/dirvish-popup))
+ (should (eq focused 'popup-frame))
+ (should opened)))
+
+(ert-deftest test-dirvish-config-popup-no-frame-still-opens ()
+ "Integration: with no popup frame found, cj/dirvish-popup skips the focus call
+and still opens Dirvish (no error)."
+ (let ((focused 'unset) (opened nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () nil))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f &rest _) (setq focused f)))
+ ((symbol-function 'dirvish) (lambda (&optional _p) (setq opened t))))
+ (cj/dirvish-popup))
+ (should (eq focused 'unset))
+ (should opened)))
+
+;;; cj/dirvish-popup-quit (quit; delete the popup frame only when in it)
+
+(ert-deftest test-dirvish-config-popup-quit-in-popup-deletes-frame ()
+ "Normal: in the popup frame, q quits Dirvish and deletes the popup frame."
+ (let ((quit 0) (deleted nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'selected-frame) (lambda () 'popup))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'dirvish-quit) (lambda () (cl-incf quit)))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/dirvish-popup-quit))
+ (should (= quit 1))
+ (should (eq deleted 'popup))))
+
+(ert-deftest test-dirvish-config-popup-quit-normal-frame-keeps-frame ()
+ "Boundary: with no popup frame, q quits Dirvish and deletes nothing."
+ (let ((quit 0) (deleted 'unset))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () nil))
+ ((symbol-function 'selected-frame) (lambda () 'main))
+ ((symbol-function 'dirvish-quit) (lambda () (cl-incf quit)))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/dirvish-popup-quit))
+ (should (= quit 1))
+ (should (eq deleted 'unset))))
+
+(ert-deftest test-dirvish-config-popup-quit-popup-not-selected-keeps-frame ()
+ "Boundary: the popup exists but a different frame is selected — q quits Dirvish
+in that frame and does not delete the popup."
+ (let ((quit 0) (deleted 'unset))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'selected-frame) (lambda () 'main))
+ ((symbol-function 'dirvish-quit) (lambda () (cl-incf quit)))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/dirvish-popup-quit))
+ (should (= quit 1))
+ (should (eq deleted 'unset))))
+
+(ert-deftest test-dirvish-config-popup-quit-survives-dirvish-quit-error ()
+ "Error: a signal from dirvish-quit in the popup still deletes the frame."
+ (let ((deleted nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'selected-frame) (lambda () 'popup))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'dirvish-quit) (lambda () (error "boom")))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/dirvish-popup-quit))
+ (should (eq deleted 'popup))))
+
+;;; cj/dirvish-popup-focus-existing (second-launch re-use guard)
+
+(ert-deftest test-dirvish-config-popup-focus-existing-found ()
+ "Normal: an existing popup is focused and t is returned."
+ (let ((focused nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f &rest _) (setq focused f))))
+ (should (eq (cj/dirvish-popup-focus-existing) t))
+ (should (eq focused 'popup)))))
+
+(ert-deftest test-dirvish-config-popup-focus-existing-none ()
+ "Boundary: no popup present — returns nil and focuses nothing."
+ (let ((focused 'unset))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () nil))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f &rest _) (setq focused f))))
+ (should-not (cj/dirvish-popup-focus-existing))
+ (should (eq focused 'unset)))))
+
+;;; cj/--dirvish-popup-selected-p
+
+(ert-deftest test-dirvish-config-popup-selected-p-true ()
+ "Normal: true when the selected frame is the popup frame."
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'selected-frame) (lambda () 'popup)))
+ (should (cj/--dirvish-popup-selected-p))))
+
+(ert-deftest test-dirvish-config-popup-selected-p-false-other-frame ()
+ "Boundary: false when a different frame is selected."
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'selected-frame) (lambda () 'main)))
+ (should-not (cj/--dirvish-popup-selected-p))))
+
+(ert-deftest test-dirvish-config-popup-selected-p-false-no-popup ()
+ "Boundary: false when no popup frame exists."
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () nil))
+ ((symbol-function 'selected-frame) (lambda () 'main)))
+ (should-not (cj/--dirvish-popup-selected-p))))
+
+;;; cj/dirvish-popup-find-file (popup = launcher; outside = plain find-file)
+
+(ert-deftest test-dirvish-config-popup-find-file-in-popup-file-launches-external ()
+ "Normal: in the popup, a file at point opens via cj/xdg-open, not in-frame."
+ (let ((opened nil) (visited nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-selected-p) (lambda () t))
+ ((symbol-function 'dired-get-file-for-visit) (lambda () "/tmp/a.mp4"))
+ ((symbol-function 'file-directory-p) (lambda (_f) nil))
+ ((symbol-function 'cj/xdg-open) (lambda (f) (setq opened f)))
+ ((symbol-function 'dired-find-file) (lambda () (setq visited t))))
+ (cj/dirvish-popup-find-file))
+ (should (equal opened "/tmp/a.mp4"))
+ (should-not visited)))
+
+(ert-deftest test-dirvish-config-popup-find-file-in-popup-dir-navigates ()
+ "Boundary: in the popup, a directory at point is entered normally."
+ (let ((opened nil) (visited nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-selected-p) (lambda () t))
+ ((symbol-function 'dired-get-file-for-visit) (lambda () "/tmp/dir/"))
+ ((symbol-function 'file-directory-p) (lambda (_f) t))
+ ((symbol-function 'cj/xdg-open) (lambda (f) (setq opened f)))
+ ((symbol-function 'dired-find-file) (lambda () (setq visited t))))
+ (cj/dirvish-popup-find-file))
+ (should visited)
+ (should-not opened)))
+
+(ert-deftest test-dirvish-config-popup-find-file-outside-popup-is-plain-find-file ()
+ "Boundary: outside the popup, behaves exactly like dired-find-file."
+ (let ((opened nil) (visited nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-selected-p) (lambda () nil))
+ ((symbol-function 'cj/xdg-open) (lambda (f) (setq opened f)))
+ ((symbol-function 'dired-find-file) (lambda () (setq visited t))))
+ (cj/dirvish-popup-find-file))
+ (should visited)
+ (should-not opened)))
+
+;;; cj/--dirvish-popup-focus-watch (dismiss on focus loss, armed after focus)
+
+(ert-deftest test-dirvish-config-popup-focus-watch-focused-arms-flag ()
+ "Normal: while the popup is focused, the watch sets the had-focus flag and
+deletes nothing."
+ (let ((params '()) (deleted nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'frame-focus-state) (lambda (_f) t))
+ ((symbol-function 'frame-parameter) (lambda (_f p) (plist-get params p)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setq params (plist-put params p v))))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--dirvish-popup-focus-watch))
+ (should (plist-get params 'cj-dirvish-popup-had-focus))
+ (should-not deleted)))
+
+(ert-deftest test-dirvish-config-popup-focus-watch-unfocused-after-arming-deletes ()
+ "Normal: lost focus after having held it — the popup is deleted."
+ (let ((params (list 'cj-dirvish-popup-had-focus t)) (deleted nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'frame-focus-state) (lambda (_f) nil))
+ ((symbol-function 'frame-parameter) (lambda (_f p) (plist-get params p)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setq params (plist-put params p v))))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--dirvish-popup-focus-watch))
+ (should (eq deleted 'popup))))
+
+(ert-deftest test-dirvish-config-popup-focus-watch-unfocused-before-arming-keeps ()
+ "Boundary: not focused and never armed (the creation race) — NOT deleted."
+ (let ((params '()) (deleted nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'frame-focus-state) (lambda (_f) nil))
+ ((symbol-function 'frame-parameter) (lambda (_f p) (plist-get params p)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setq params (plist-put params p v))))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--dirvish-popup-focus-watch))
+ (should-not deleted)))
+
+(ert-deftest test-dirvish-config-popup-focus-watch-no-popup-is-noop ()
+ "Error: with no popup frame, the watch does nothing and doesn't raise."
+ (let ((deleted nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () nil))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--dirvish-popup-focus-watch))
+ (should-not deleted)))
+
+(provide 'test-dirvish-config-popup)
+;;; test-dirvish-config-popup.el ends here
diff --git a/tests/test-dirvish-config-print.el b/tests/test-dirvish-config-print.el
index ab6d073f0..308d00f68 100644
--- a/tests/test-dirvish-config-print.el
+++ b/tests/test-dirvish-config-print.el
@@ -50,18 +50,18 @@
(ert-deftest test-dirvish-print-program-prefers-lp ()
"Normal: `lp' is used when available."
(cl-letf (((symbol-function 'executable-find)
- (lambda (cmd) (when (equal cmd "lp") "/usr/bin/lp"))))
+ (lambda (cmd &rest _) (when (equal cmd "lp") "/usr/bin/lp"))))
(should (equal (cj/--print-program) "/usr/bin/lp"))))
(ert-deftest test-dirvish-print-program-falls-back-to-lpr ()
"Boundary: `lpr' is used when `lp' is missing."
(cl-letf (((symbol-function 'executable-find)
- (lambda (cmd) (when (equal cmd "lpr") "/usr/bin/lpr"))))
+ (lambda (cmd &rest _) (when (equal cmd "lpr") "/usr/bin/lpr"))))
(should (equal (cj/--print-program) "/usr/bin/lpr"))))
(ert-deftest test-dirvish-print-program-none-available ()
"Error: nil when neither `lp' nor `lpr' is on PATH."
- (cl-letf (((symbol-function 'executable-find) (lambda (_cmd) nil)))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_cmd &rest _) nil)))
(should-not (cj/--print-program))))
;;; ---------------------------- cj/dirvish-print-file -------------------------
diff --git a/tests/test-dirvish-config-public-wrappers.el b/tests/test-dirvish-config-public-wrappers.el
index cec979e4a..d1141d33a 100644
--- a/tests/test-dirvish-config-public-wrappers.el
+++ b/tests/test-dirvish-config-public-wrappers.el
@@ -124,7 +124,7 @@ confused when several built-ins are overridden in the same test."
((symbol-function 'cj/get-project-root)
(lambda () nil))
((symbol-function 'kill-new)
- (lambda (s) (setq killed s)))
+ (lambda (s &rest _) (setq killed s)))
((symbol-function 'message) #'ignore))
(cj/dired-copy-path-as-kill))
(should (stringp killed))
@@ -139,7 +139,7 @@ confused when several built-ins are overridden in the same test."
(lambda (&rest _) "/tmp/foo.txt"))
((symbol-function 'cj/get-project-root) (lambda () nil))
((symbol-function 'kill-new)
- (lambda (s) (setq killed s)))
+ (lambda (s &rest _) (setq killed s)))
((symbol-function 'message) #'ignore))
(cj/dired-copy-path-as-kill t))
(should (string-prefix-p "[[file:" killed))
diff --git a/tests/test-dirvish-config-wallpaper-program.el b/tests/test-dirvish-config-wallpaper-program.el
index 556c13100..41d2ad8b2 100644
--- a/tests/test-dirvish-config-wallpaper-program.el
+++ b/tests/test-dirvish-config-wallpaper-program.el
@@ -28,9 +28,9 @@
'("feh" "--bg-fill"))))
(ert-deftest test-cj--wallpaper-program-for-wayland ()
- "Normal: wayland dispatches to swww with the img subcommand."
+ "Normal: wayland dispatches to the set-wallpaper script (awww backend + waypaper persist)."
(should (equal (cj/--wallpaper-program-for 'wayland)
- '("swww" "img"))))
+ '("set-wallpaper"))))
(ert-deftest test-cj--wallpaper-program-for-unknown-returns-nil ()
"Boundary: an unknown environment returns nil so the wrapper can fall back."
diff --git a/tests/test-dirvish-config-wrappers.el b/tests/test-dirvish-config-wrappers.el
index bead45830..39f272474 100644
--- a/tests/test-dirvish-config-wrappers.el
+++ b/tests/test-dirvish-config-wrappers.el
@@ -40,7 +40,7 @@ puts the older one first)."
((symbol-function 'ediff-files)
(lambda (a b) (setq ediff-args (list a b))))
((symbol-function 'current-window-configuration)
- (lambda () nil))
+ (lambda (&rest _) nil))
((symbol-function 'add-hook) #'ignore))
(cj/dired-ediff-files)
;; Pair returns (older . newer) so ediff-files sees (older newer).
diff --git a/tests/test-elfeed-config-helpers.el b/tests/test-elfeed-config-helpers.el
index 59a0ed331..16cbb7443 100644
--- a/tests/test-elfeed-config-helpers.el
+++ b/tests/test-elfeed-config-helpers.el
@@ -39,7 +39,7 @@
(ert-deftest test-elfeed-extract-stream-url-normal-returns-url ()
"Normal: a successful yt-dlp run returns the trimmed https stream URL."
(cl-letf (((symbol-function 'executable-find)
- (lambda (p) (and (equal p "yt-dlp") "/usr/bin/yt-dlp")))
+ (lambda (p &rest _) (and (equal p "yt-dlp") "/usr/bin/yt-dlp")))
((symbol-function 'cj/log-silently) #'ignore)
((symbol-function 'call-process)
(lambda (_prog _infile _dest _disp &rest _args)
@@ -49,7 +49,7 @@
(ert-deftest test-elfeed-extract-stream-url-boundary-non-url-output-is-nil ()
"Boundary: output that is not an http(s) URL yields nil, not the raw text."
- (cl-letf (((symbol-function 'executable-find) (lambda (_) "/usr/bin/yt-dlp"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/yt-dlp"))
((symbol-function 'cj/log-silently) #'ignore)
((symbol-function 'call-process)
(lambda (_p _i _d _disp &rest _) (insert "ERROR: unavailable\n") 0)))
@@ -57,7 +57,7 @@
(ert-deftest test-elfeed-extract-stream-url-boundary-nonzero-exit-is-nil ()
"Boundary: a nonzero yt-dlp exit code yields nil."
- (cl-letf (((symbol-function 'executable-find) (lambda (_) "/usr/bin/yt-dlp"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/yt-dlp"))
((symbol-function 'cj/log-silently) #'ignore)
((symbol-function 'call-process)
(lambda (_p _i _d _disp &rest _) (insert "boom") 1)))
@@ -65,7 +65,7 @@
(ert-deftest test-elfeed-extract-stream-url-error-without-yt-dlp ()
"Error: a missing yt-dlp signals before attempting the call."
- (cl-letf (((symbol-function 'executable-find) (lambda (_) nil)))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(should-error (cj/extract-stream-url "u" "best") :type 'error)))
;;; cj/elfeed-process-entries
diff --git a/tests/test-erc-config-connected-servers.el b/tests/test-erc-config-connected-servers.el
index 7d4540d68..394367c3e 100644
--- a/tests/test-erc-config-connected-servers.el
+++ b/tests/test-erc-config-connected-servers.el
@@ -5,8 +5,9 @@
;; process. The original test compared a buffer's own erc-server-process to the
;; same buffer-local value inside `with-current-buffer', which is always true, so
;; it returned every ERC buffer (channels, queries, dead connections). These
-;; tests stub `erc-buffer-list' and the two ERC predicates so the classification
-;; is exercised without a real IRC connection.
+;; tests stub `erc-buffer-list' and the two ERC predicates
+;; (`erc-server-or-unjoined-channel-buffer-p' and `erc-server-process-alive')
+;; so the classification is exercised without a real IRC connection.
;;; Code:
@@ -25,7 +26,7 @@ returned; a channel buffer and a dead-connection server buffer are excluded."
(unwind-protect
(cl-letf (((symbol-function 'erc-buffer-list)
(lambda (&rest _) (list b-server b-channel b-dead)))
- ((symbol-function 'erc-server-buffer-p)
+ ((symbol-function 'erc-server-or-unjoined-channel-buffer-p)
(lambda (&rest _) (memq (current-buffer) (list b-server b-dead))))
((symbol-function 'erc-server-process-alive)
(lambda (&rest _) (eq (current-buffer) b-server))))
@@ -39,7 +40,7 @@ returned; a channel buffer and a dead-connection server buffer are excluded."
(unwind-protect
(cl-letf (((symbol-function 'erc-buffer-list)
(lambda (&rest _) (list b-channel)))
- ((symbol-function 'erc-server-buffer-p) (lambda (&rest _) nil))
+ ((symbol-function 'erc-server-or-unjoined-channel-buffer-p) (lambda (&rest _) nil))
((symbol-function 'erc-server-process-alive) (lambda (&rest _) nil)))
(should (null (cj/erc-connected-servers))))
(kill-buffer b-channel))))
diff --git a/tests/test-eshell-config--prompt.el b/tests/test-eshell-config--prompt.el
new file mode 100644
index 000000000..7073c7e0b
--- /dev/null
+++ b/tests/test-eshell-config--prompt.el
@@ -0,0 +1,75 @@
+;;; test-eshell-config--prompt.el --- Tests for eshell prompt helpers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the pure prompt-segment helpers added for zsh parity: the
+;; .git/HEAD branch reader and the exit-status segment.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'eshell-config)
+
+(defvar eshell-last-command-status) ; declared special for the status tests
+
+;;; cj/--eshell-git-branch
+
+(ert-deftest test-eshell-git-branch-reads-head ()
+ "Normal: a .git/HEAD pointing at a branch returns the branch name."
+ (let ((dir (make-temp-file "esh-git-" t)))
+ (unwind-protect
+ (progn
+ (make-directory (expand-file-name ".git" dir))
+ (with-temp-file (expand-file-name ".git/HEAD" dir)
+ (insert "ref: refs/heads/feature-x\n"))
+ (let ((default-directory (file-name-as-directory dir)))
+ (should (equal (cj/--eshell-git-branch) "feature-x"))))
+ (delete-directory dir t))))
+
+(ert-deftest test-eshell-git-branch-no-repo-nil ()
+ "Boundary: a directory with no .git returns nil."
+ (let ((dir (make-temp-file "esh-nogit-" t)))
+ (unwind-protect
+ (let ((default-directory (file-name-as-directory dir)))
+ (should-not (cj/--eshell-git-branch)))
+ (delete-directory dir t))))
+
+(ert-deftest test-eshell-git-branch-detached-nil ()
+ "Boundary: a detached HEAD (a raw SHA, no ref) returns nil."
+ (let ((dir (make-temp-file "esh-detached-" t)))
+ (unwind-protect
+ (progn
+ (make-directory (expand-file-name ".git" dir))
+ (with-temp-file (expand-file-name ".git/HEAD" dir)
+ (insert "a1b2c3d4e5f6\n"))
+ (let ((default-directory (file-name-as-directory dir)))
+ (should-not (cj/--eshell-git-branch))))
+ (delete-directory dir t))))
+
+(ert-deftest test-eshell-git-branch-remote-skipped ()
+ "Boundary: a remote default-directory is skipped (no TRAMP read)."
+ (let ((default-directory "/ssh:host:/some/path/"))
+ (should-not (cj/--eshell-git-branch))))
+
+;;; cj/--eshell-prompt-status-segment
+
+(ert-deftest test-eshell-prompt-status-zero-empty ()
+ "Normal: a zero exit status yields an empty segment."
+ (let ((eshell-last-command-status 0))
+ (should (equal (cj/--eshell-prompt-status-segment) ""))))
+
+(ert-deftest test-eshell-prompt-status-nonzero-bracketed ()
+ "Normal: a non-zero exit status is shown in brackets."
+ (let ((eshell-last-command-status 1))
+ (should (equal (cj/--eshell-prompt-status-segment) " [1]")))
+ (let ((eshell-last-command-status 130))
+ (should (equal (cj/--eshell-prompt-status-segment) " [130]"))))
+
+(ert-deftest test-eshell-prompt-status-unset-empty ()
+ "Boundary: an unset status yields an empty segment, no error."
+ (let ((eshell-last-command-status nil))
+ (should (equal (cj/--eshell-prompt-status-segment) ""))))
+
+(provide 'test-eshell-config--prompt)
+;;; test-eshell-config--prompt.el ends here
diff --git a/tests/test-external-open-commands.el b/tests/test-external-open-commands.el
index c0c83a340..3d8adc15e 100644
--- a/tests/test-external-open-commands.el
+++ b/tests/test-external-open-commands.el
@@ -81,8 +81,9 @@
;;; cj/find-file-auto
(ert-deftest test-external-open-find-file-auto-routes-media-externally ()
- "Normal: a `.mp4' filename (in `default-open-extensions') triggers
-`cj/xdg-open' instead of the original `find-file'."
+ "Normal: a non-video external extension (`.docx', in
+`default-open-extensions') triggers `cj/xdg-open' instead of the original
+`find-file'."
(let ((opened nil)
(orig-called nil))
(cl-letf (((symbol-function 'cj/xdg-open)
@@ -90,8 +91,23 @@
;; orig-fun replacement -- shouldn't run for a routed extension.
((symbol-function 'cj/find-file-auto--orig-stub)
(lambda (&rest _) (setq orig-called t))))
- (cj/find-file-auto #'cj/find-file-auto--orig-stub "/tmp/video.mp4"))
- (should (equal opened "/tmp/video.mp4"))
+ (cj/find-file-auto #'cj/find-file-auto--orig-stub "/tmp/report.docx"))
+ (should (equal opened "/tmp/report.docx"))
+ (should-not orig-called)))
+
+(ert-deftest test-external-open-find-file-auto-routes-video-to-looping-player ()
+ "Normal: a video filename triggers `cj/open-video-looping', not `cj/xdg-open'
+or the original `find-file'."
+ (let ((looped nil) (xdg nil) (orig-called nil))
+ (cl-letf (((symbol-function 'cj/open-video-looping)
+ (lambda (file) (setq looped file)))
+ ((symbol-function 'cj/xdg-open)
+ (lambda (_) (setq xdg t)))
+ ((symbol-function 'cj/find-file-auto--orig-stub)
+ (lambda (&rest _) (setq orig-called t))))
+ (cj/find-file-auto #'cj/find-file-auto--orig-stub "/tmp/clip.mp4"))
+ (should (equal looped "/tmp/clip.mp4"))
+ (should-not xdg)
(should-not orig-called)))
(ert-deftest test-external-open-find-file-auto-passes-through-text-files ()
@@ -116,5 +132,66 @@
(cj/find-file-auto #'cj/find-file-auto--orig-stub nil))
(should orig-called)))
+;;; cj/--video-file-p
+
+(ert-deftest test-external-open-video-file-p-matches-video ()
+ "Normal: common video extensions match, case-insensitively."
+ (should (cj/--video-file-p "/tmp/a.mp4"))
+ (should (cj/--video-file-p "/tmp/a.mkv"))
+ (should (cj/--video-file-p "/tmp/a.webm"))
+ (should (cj/--video-file-p "/tmp/A.MP4")))
+
+(ert-deftest test-external-open-video-file-p-rejects-non-video ()
+ "Boundary: audio, docs, and nil do not match."
+ (should-not (cj/--video-file-p "/tmp/a.mp3"))
+ (should-not (cj/--video-file-p "/tmp/a.txt"))
+ (should-not (cj/--video-file-p "/tmp/a.docx"))
+ (should-not (cj/--video-file-p nil)))
+
+;;; cj/--video-open-arglist
+
+(ert-deftest test-external-open-video-arglist-appends-file-after-args ()
+ "Normal: the player args precede the file in the argument list."
+ (let ((cj/video-open-args '("--loop-file=inf")))
+ (should (equal (cj/--video-open-arglist "/tmp/a.mp4")
+ '("--loop-file=inf" "/tmp/a.mp4")))))
+
+(ert-deftest test-external-open-video-arglist-respects-custom-args ()
+ "Boundary: custom args are honored; empty args yields just the file."
+ (let ((cj/video-open-args '("--loop=inf" "--mute=yes")))
+ (should (equal (cj/--video-open-arglist "/tmp/a.mkv")
+ '("--loop=inf" "--mute=yes" "/tmp/a.mkv"))))
+ (let ((cj/video-open-args nil))
+ (should (equal (cj/--video-open-arglist "/tmp/a.mkv") '("/tmp/a.mkv")))))
+
+;;; cj/open-video-looping
+
+(ert-deftest test-external-open-video-looping-calls-player-with-loop-args ()
+ "Normal: posix path calls the player with loop args + file, async (no wait)."
+ (let ((tmp (make-temp-file "test-ext-video-" nil ".mp4"))
+ (call nil))
+ (unwind-protect
+ (cl-letf (((symbol-function 'env-windows-p) (lambda () nil))
+ ((symbol-function 'call-process)
+ (lambda (prog _infile dest _disp &rest args)
+ (setq call (list prog dest args))
+ 0)))
+ (let ((cj/video-open-command "mpv")
+ (cj/video-open-args '("--loop-file=inf")))
+ (cj/open-video-looping tmp)))
+ (delete-file tmp))
+ (should (equal (nth 0 call) "mpv"))
+ (should (equal (nth 1 call) 0)) ; async destination: don't wait
+ (should (member "--loop-file=inf" (nth 2 call)))
+ (should (cl-find-if (lambda (a) (and (stringp a)
+ (string-match-p "\\.mp4\\'" a)))
+ (nth 2 call)))))
+
+(ert-deftest test-external-open-video-looping-errors-when-no-file ()
+ "Error: a buffer with no associated file signals user-error."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'cj/file-from-context) (lambda (_) nil)))
+ (should-error (cj/open-video-looping) :type 'user-error))))
+
(provide 'test-external-open-commands)
;;; test-external-open-commands.el ends here
diff --git a/tests/test-face-diagnostic.el b/tests/test-face-diagnostic.el
index 241425fc5..32595b464 100644
--- a/tests/test-face-diagnostic.el
+++ b/tests/test-face-diagnostic.el
@@ -286,6 +286,31 @@
(should (string-match-p "Real font" report))
(should (string-match-p "Provenance" report)))))
+(ert-deftest test-face-diag-face-button-real-face-is-button ()
+ "Normal: a real face renders as a `describe-face' button carrying the face.
+Visible label is unchanged; the button data is the face so RET/mouse opens it."
+ (let ((s (cj/--face-diag-face-button 'bold)))
+ (should (equal (substring-no-properties s) "bold"))
+ (should (get-text-property 0 'button s))
+ (should (eq (get-text-property 0 'button-data s) 'bold))))
+
+(ert-deftest test-face-diag-face-button-non-face-is-plain ()
+ "Boundary: a symbol that is not a face stays plain text, no button."
+ (let ((s (cj/--face-diag-face-button 'cj-not-a-real-face-xyz)))
+ (should (equal s "cj-not-a-real-face-xyz"))
+ (should-not (get-text-property 0 'button s))))
+
+(ert-deftest test-face-diag-face-button-anonymous-spec-is-plain ()
+ "Error: an anonymous (:attr val ...) spec is not a face, so no button."
+ (let ((s (cj/--face-diag-face-button '(:foreground "red"))))
+ (should-not (get-text-property 0 'button s))))
+
+(ert-deftest test-face-diag-render-faces-buttonizes-real-face ()
+ "Normal: a real face in the stack render carries a button property."
+ (let ((s (cj/--face-diag-render-faces '(bold))))
+ (should (string-match-p "bold" s))
+ (should (get-text-property 0 'button s))))
+
(ert-deftest test-face-diag-render-banner-out-of-scope ()
"Boundary: a terminal classification renders a banner naming the ANSI source."
(should (string-match-p "terminal" (cj/--face-diag-render-banner 'terminal-ansi)))
diff --git a/tests/test-flyspell-and-abbrev.el b/tests/test-flyspell-and-abbrev.el
index 793fdc0f4..ef8cc6375 100644
--- a/tests/test-flyspell-and-abbrev.el
+++ b/tests/test-flyspell-and-abbrev.el
@@ -32,12 +32,12 @@
(ert-deftest test-flyspell-require-spell-checker-present ()
"Normal: a checker on PATH means no error."
(cl-letf (((symbol-function 'executable-find)
- (lambda (cmd) (equal cmd (car cj/--spell-checker-executables)))))
+ (lambda (cmd &rest _) (equal cmd (car cj/--spell-checker-executables)))))
(should-not (cj/--require-spell-checker))))
(ert-deftest test-flyspell-require-spell-checker-missing ()
"Error: no checker on PATH signals user-error."
- (cl-letf (((symbol-function 'executable-find) (lambda (_) nil)))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(should-error (cj/--require-spell-checker) :type 'user-error)))
;; --------------------- cj/find-previous-flyspell-overlay ---------------------
diff --git a/tests/test-google-keep-config.el b/tests/test-google-keep-config.el
new file mode 100644
index 000000000..690355506
--- /dev/null
+++ b/tests/test-google-keep-config.el
@@ -0,0 +1,142 @@
+;;; test-google-keep-config.el --- Tests for google-keep-config -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the pure JSON-to-org core of google-keep-config.el (the part that
+;; later extracts to a package) plus the parse-render-write chain. The bridge
+;; subprocess + auth are the IO boundary, exercised live once the token is set.
+
+;;; Code:
+
+(require 'ert)
+(require 'google-keep-config)
+
+(defun test-google-keep--note (&rest overrides)
+ "Build a note alist (parse-shaped) with OVERRIDES merged in."
+ (let ((base (list (cons 'id "abc")
+ (cons 'title "Groceries")
+ (cons 'text "milk\neggs")
+ (cons 'labels '("shopping" "home"))
+ (cons 'pinned nil)
+ (cons 'archived nil)
+ (cons 'color "WHITE")
+ (cons 'updated "2026-06-25T04:00:00Z"))))
+ (dolist (pair overrides base)
+ (setf (alist-get (car pair) base) (cdr pair)))))
+
+;;; cj/keep--parse-json
+
+(ert-deftest test-google-keep-parse-json-array ()
+ "Normal: a JSON array parses to a list of note alists."
+ (let ((notes (cj/keep--parse-json
+ "[{\"id\":\"a\",\"title\":\"T\",\"labels\":[\"x\"],\"pinned\":true}]")))
+ (should (= 1 (length notes)))
+ (should (equal "a" (alist-get 'id (car notes))))
+ (should (equal '("x") (alist-get 'labels (car notes))))
+ (should (eq t (alist-get 'pinned (car notes))))))
+
+(ert-deftest test-google-keep-parse-json-empty ()
+ "Boundary: an empty Keep ([]) parses to an empty list."
+ (should (null (cj/keep--parse-json "[]"))))
+
+;;; cj/keep--label-to-tag
+
+(ert-deftest test-google-keep-label-to-tag-plain ()
+ "Normal: an alphanumeric label is unchanged."
+ (should (equal "shopping" (cj/keep--label-to-tag "shopping"))))
+
+(ert-deftest test-google-keep-label-to-tag-sanitizes ()
+ "Boundary: spaces and punctuation become underscores (valid org tag chars)."
+ (should (equal "to_do_list_" (cj/keep--label-to-tag "to do/list!"))))
+
+;;; cj/keep--note-tags
+
+(ert-deftest test-google-keep-note-tags-labels ()
+ "Normal: labels render as a trailing org-tag string."
+ (should (equal " :shopping:home:" (cj/keep--note-tags (test-google-keep--note)))))
+
+(ert-deftest test-google-keep-note-tags-archived ()
+ "Normal: an archived note gains the archived tag."
+ (should (equal " :shopping:home:archived:"
+ (cj/keep--note-tags (test-google-keep--note (cons 'archived t))))))
+
+(ert-deftest test-google-keep-note-tags-none ()
+ "Boundary: no labels and not archived yields an empty tag string."
+ (should (equal "" (cj/keep--note-tags
+ (test-google-keep--note (cons 'labels nil))))))
+
+;;; cj/keep--note-heading
+
+(ert-deftest test-google-keep-note-heading-full ()
+ "Normal: a full note renders heading, properties, link, and body."
+ (let ((s (cj/keep--note-heading (test-google-keep--note))))
+ (should (string-match-p "\\`\\* Groceries :shopping:home:\n" s))
+ (should (string-match-p ":KEEP_ID: abc\n" s))
+ (should (string-match-p ":UPDATED: 2026-06-25T04:00:00Z\n" s))
+ (should (string-match-p "\\[\\[https://keep.google.com/#NOTE/abc\\]\\[open in Keep\\]\\]" s))
+ (should (string-match-p "milk\neggs\n" s))))
+
+(ert-deftest test-google-keep-note-heading-untitled ()
+ "Boundary: an empty title falls back to (untitled)."
+ (let ((s (cj/keep--note-heading (test-google-keep--note (cons 'title "")))))
+ (should (string-match-p "\\`\\* (untitled)" s))))
+
+(ert-deftest test-google-keep-note-heading-empty-text ()
+ "Boundary: an empty body emits no trailing text block."
+ (let ((s (cj/keep--note-heading
+ (test-google-keep--note (cons 'text "") (cons 'labels nil)))))
+ (should-not (string-match-p "open in Keep\\]\\]\n.+[^\n]" s))))
+
+;;; cj/keep--sort-pinned-first
+
+(ert-deftest test-google-keep-sort-pinned-first ()
+ "Normal: pinned notes come first, order otherwise preserved."
+ (let* ((a (test-google-keep--note (cons 'id "a") (cons 'pinned nil)))
+ (b (test-google-keep--note (cons 'id "b") (cons 'pinned t)))
+ (c (test-google-keep--note (cons 'id "c") (cons 'pinned nil)))
+ (sorted (cj/keep--sort-pinned-first (list a b c))))
+ (should (equal '("b" "a" "c") (mapcar (lambda (n) (alist-get 'id n)) sorted)))))
+
+;;; cj/keep--render
+
+(ert-deftest test-google-keep-render-header-and-notes ()
+ "Normal: the page carries the read-only header and a heading per note."
+ (let ((s (cj/keep--render (list (test-google-keep--note)) "2026-06-25 04:00")))
+ (should (string-match-p "read-only view" s))
+ (should (string-match-p "Last refresh: 2026-06-25 04:00" s))
+ (should (string-match-p "^\\* Groceries" s))))
+
+(ert-deftest test-google-keep-render-empty ()
+ "Boundary: no notes still produces a valid header-only page."
+ (let ((s (cj/keep--render nil)))
+ (should (string-match-p "#\\+TITLE: Google Keep" s))
+ (should-not (string-match-p "^\\* " s))))
+
+;;; cj/keep--write-atomically + the parse-render-write chain
+
+(ert-deftest test-google-keep-write-atomically ()
+ "Normal: content lands in the target file via temp + rename."
+ (let* ((dir (make-temp-file "keep-test-" t))
+ (file (expand-file-name "keep.org" dir)))
+ (unwind-protect
+ (progn
+ (cj/keep--write-atomically "hello\n" file)
+ (should (equal "hello\n"
+ (with-temp-buffer (insert-file-contents file)
+ (buffer-string)))))
+ (delete-directory dir t))))
+
+(ert-deftest test-google-keep-write-notes-chain ()
+ "Normal: JSON in, a rendered org file out, with the note count returned."
+ (let* ((dir (make-temp-file "keep-test-" t))
+ (keep-file (expand-file-name "keep.org" dir)))
+ (unwind-protect
+ (let ((n (cj/keep--write-notes
+ "[{\"id\":\"a\",\"title\":\"One\",\"labels\":[],\"pinned\":false,\"archived\":false,\"color\":\"WHITE\",\"updated\":\"2026-06-25T04:00:00Z\"}]")))
+ (should (= 1 n))
+ (should (string-match-p "^\\* One"
+ (with-temp-buffer (insert-file-contents keep-file)
+ (buffer-string)))))
+ (delete-directory dir t))))
+
+(provide 'test-google-keep-config)
+;;; test-google-keep-config.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 b6dbefccb..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
- "<html><body><h1>Hello</h1><p>World</p></body></html>")))
- (should (string-match-p "Hello" out))
- (should (string-match-p "World" out))
- (should-not (string-match-p "<h1>" out))
- (should-not (string-match-p "<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 (_) nil)))
- (should-error (cj/gptel-web-fetch--html-to-text "<p>x</p>"))))
-
-(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) (and (equal program "pandoc") "/bin/pandoc")))
- ((symbol-function 'call-process-region)
- (lambda (&rest _args) 9)))
- (should-error (cj/gptel-web-fetch--html-to-text "<p>x</p>"))))
-
-(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) (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 "<p>x</p>")
- "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 "<html><body><p>fetched</p></body></html>"))))
- (let ((out (cj/gptel-web-fetch--run "https://example.com")))
- (should (string-match-p "fetched" out))
- (should-not (string-match-p "<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 "<html><body><p>raw</p></body></html>"))))
- (let ((out (cj/gptel-web-fetch--run "https://example.com" t)))
- (should (string-match-p "<p>raw</p>" 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 "<html><body>"
- (make-string 1000 ?x)
- "</body></html>")))
- (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-host-environment--detect-system-timezone.el b/tests/test-host-environment--detect-system-timezone.el
index 1b5e61081..209283d1e 100644
--- a/tests/test-host-environment--detect-system-timezone.el
+++ b/tests/test-host-environment--detect-system-timezone.el
@@ -22,7 +22,7 @@
(cl-letf (((symbol-function 'cj/match-localtime-to-zoneinfo)
(lambda () "America/Los_Angeles"))
((symbol-function 'getenv)
- (lambda (_) (error "TZ should not have been consulted"))))
+ (lambda (_ &rest _) (error "TZ should not have been consulted"))))
(should (equal (cj/detect-system-timezone) "America/Los_Angeles"))))
(ert-deftest test-host-environment-detect-tz-env-var-wins-when-match-nil ()
@@ -30,7 +30,7 @@
(cl-letf (((symbol-function 'cj/match-localtime-to-zoneinfo)
(lambda () nil))
((symbol-function 'getenv)
- (lambda (name) (when (string= name "TZ") "Europe/Berlin"))))
+ (lambda (name &rest _) (when (string= name "TZ") "Europe/Berlin"))))
(should (equal (cj/detect-system-timezone) "Europe/Berlin"))))
(ert-deftest test-host-environment-detect-tz-falls-through-to-etc-timezone ()
@@ -41,7 +41,7 @@ contents primitives."
(cl-letf (((symbol-function 'cj/match-localtime-to-zoneinfo)
(lambda () nil))
((symbol-function 'getenv)
- (lambda (_) nil))
+ (lambda (_ &rest _) nil))
((symbol-function 'file-exists-p)
(lambda (path) (string= path "/etc/timezone")))
((symbol-function 'insert-file-contents)
@@ -55,7 +55,7 @@ contents primitives."
(cl-letf (((symbol-function 'cj/match-localtime-to-zoneinfo)
(lambda () nil))
((symbol-function 'getenv)
- (lambda (_) nil))
+ (lambda (_ &rest _) nil))
((symbol-function 'file-exists-p)
(lambda (path) (string= path "/etc/timezone")))
((symbol-function 'insert-file-contents)
@@ -69,7 +69,7 @@ contents primitives."
(cl-letf (((symbol-function 'cj/match-localtime-to-zoneinfo)
(lambda () nil))
((symbol-function 'getenv)
- (lambda (_) nil))
+ (lambda (_ &rest _) nil))
((symbol-function 'file-exists-p) (lambda (_) nil))
((symbol-function 'file-symlink-p) (lambda (_) nil)))
(should-not (cj/detect-system-timezone))))
@@ -79,24 +79,24 @@ contents primitives."
yields the zone after the /zoneinfo/ segment."
(cl-letf (((symbol-function 'cj/match-localtime-to-zoneinfo)
(lambda () nil))
- ((symbol-function 'getenv) (lambda (_) nil))
+ ((symbol-function 'getenv) (lambda (_ &rest _) nil))
((symbol-function 'file-exists-p) (lambda (_) nil))
((symbol-function 'file-symlink-p)
(lambda (path) (string= path "/etc/localtime")))
((symbol-function 'file-truename)
- (lambda (_) "/usr/share/zoneinfo/America/Denver")))
+ (lambda (_ &rest _) "/usr/share/zoneinfo/America/Denver")))
(should (equal (cj/detect-system-timezone) "America/Denver"))))
(ert-deftest test-host-environment-detect-tz-symlink-without-zoneinfo-is-nil ()
"Error: a symlink target with no /zoneinfo/ segment yields nil."
(cl-letf (((symbol-function 'cj/match-localtime-to-zoneinfo)
(lambda () nil))
- ((symbol-function 'getenv) (lambda (_) nil))
+ ((symbol-function 'getenv) (lambda (_ &rest _) nil))
((symbol-function 'file-exists-p) (lambda (_) nil))
((symbol-function 'file-symlink-p)
(lambda (path) (string= path "/etc/localtime")))
((symbol-function 'file-truename)
- (lambda (_) "/var/lib/elsewhere/localtime")))
+ (lambda (_ &rest _) "/var/lib/elsewhere/localtime")))
(should-not (cj/detect-system-timezone))))
(provide 'test-host-environment--detect-system-timezone)
diff --git a/tests/test-host-environment--display-predicates.el b/tests/test-host-environment--display-predicates.el
index 15dff2ef8..5a87b5009 100644
--- a/tests/test-host-environment--display-predicates.el
+++ b/tests/test-host-environment--display-predicates.el
@@ -26,7 +26,7 @@ GRAPHIC-P becomes the return of `(display-graphic-p)'."
`(cl-letf (((symbol-function 'window-system)
(lambda (&optional _) ,window-system-value))
((symbol-function 'getenv)
- (lambda (name)
+ (lambda (name &rest _)
(when (string= name "WAYLAND_DISPLAY") ,wayland-display)))
((symbol-function 'display-graphic-p)
(lambda (&optional _) ,graphic-p)))
diff --git a/tests/test-hugo-config-commands.el b/tests/test-hugo-config-commands.el
index 01df5fc18..07bc27ca3 100644
--- a/tests/test-hugo-config-commands.el
+++ b/tests/test-hugo-config-commands.el
@@ -134,7 +134,7 @@ stubbed before the org-mode-derived guard runs."
((symbol-function 'completing-read)
(lambda (&rest _) "Foo Post"))
((symbol-function 'find-file)
- (lambda (f) (setq opened f))))
+ (lambda (f &rest _) (setq opened f))))
(cj/hugo-open-draft))
(should (equal opened "/tmp/foo.org"))))
@@ -196,7 +196,7 @@ stubbed before the org-mode-derived guard runs."
(msg nil))
(cl-letf (((symbol-function 'process-live-p) (lambda (_) t))
((symbol-function 'kill-process)
- (lambda (p) (setq killed p)))
+ (lambda (p &rest _) (setq killed p)))
((symbol-function 'message)
(lambda (fmt &rest args)
(setq msg (apply #'format fmt args)))))
@@ -210,7 +210,7 @@ stubbed before the org-mode-derived guard runs."
(let ((cj/hugo--preview-process nil)
(start-args nil))
(cl-letf (((symbol-function 'process-live-p) (lambda (_) nil))
- ((symbol-function 'executable-find) (lambda (_) "/usr/bin/hugo"))
+ ((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/hugo"))
((symbol-function 'start-process)
(lambda (&rest args)
(setq start-args args)
@@ -226,7 +226,7 @@ stubbed before the org-mode-derived guard runs."
"Error: a missing hugo binary signals user-error before start-process."
(let ((cj/hugo--preview-process nil))
(cl-letf (((symbol-function 'process-live-p) (lambda (_) nil))
- ((symbol-function 'executable-find) (lambda (_) nil))
+ ((symbol-function 'executable-find) (lambda (_ &rest _) nil))
((symbol-function 'start-process)
(lambda (&rest _) (error "start-process should not run")))
((symbol-function 'message) #'ignore))
diff --git a/tests/test-hugo-config-open-blog-dir-external.el b/tests/test-hugo-config-open-blog-dir-external.el
index 0bf689826..05f116e6d 100644
--- a/tests/test-hugo-config-open-blog-dir-external.el
+++ b/tests/test-hugo-config-open-blog-dir-external.el
@@ -44,7 +44,7 @@ filesystem checks."
(cl-letf (((symbol-function 'env-macos-p) (lambda () ,macos-p))
((symbol-function 'env-windows-p) (lambda () ,windows-p))
((symbol-function 'file-directory-p) (lambda (_d) t))
- ((symbol-function 'executable-find) (lambda (cmd) cmd))
+ ((symbol-function 'executable-find) (lambda (cmd &rest _) cmd))
((symbol-function 'start-process)
(lambda (_name _buf cmd &rest _args)
(setq test-hugo--captured-process-cmd cmd))))
@@ -86,7 +86,7 @@ filesystem checks."
((symbol-function 'file-directory-p) (lambda (_d) nil))
((symbol-function 'make-directory)
(lambda (_dir &rest _args) (setq mkdir-called t)))
- ((symbol-function 'executable-find) (lambda (cmd) cmd))
+ ((symbol-function 'executable-find) (lambda (cmd &rest _) cmd))
((symbol-function 'start-process) #'ignore))
(cj/hugo-open-blog-dir-external)
(should mkdir-called))))
@@ -99,7 +99,7 @@ filesystem checks."
((symbol-function 'file-directory-p) (lambda (_d) t))
((symbol-function 'make-directory)
(lambda (_dir &rest _args) (setq mkdir-called t)))
- ((symbol-function 'executable-find) (lambda (cmd) cmd))
+ ((symbol-function 'executable-find) (lambda (cmd &rest _) cmd))
((symbol-function 'start-process) #'ignore))
(cj/hugo-open-blog-dir-external)
(should-not mkdir-called))))
@@ -111,7 +111,7 @@ filesystem checks."
(cl-letf (((symbol-function 'env-macos-p) (lambda () nil))
((symbol-function 'env-windows-p) (lambda () nil))
((symbol-function 'file-directory-p) (lambda (_d) t))
- ((symbol-function 'executable-find) (lambda (_) nil))
+ ((symbol-function 'executable-find) (lambda (_ &rest _) nil))
((symbol-function 'start-process)
(lambda (&rest _) (error "start-process should not run"))))
(should-error (cj/hugo-open-blog-dir-external) :type 'user-error)))
diff --git a/tests/test-init-defer-games.el b/tests/test-init-defer-games.el
new file mode 100644
index 000000000..f3ec94de8
--- /dev/null
+++ b/tests/test-init-defer-games.el
@@ -0,0 +1,46 @@
+;;; test-init-defer-games.el --- games-config Phase 4 deferral -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; games-config is deferred (load-graph Phase 4): malyon and 2048-game autoload
+;; their own commands via package.el, and init.el loads games-config (which only
+;; supplies malyon's config) via `with-eval-after-load 'malyon'. These tests
+;; guard the command availability and exercise the real autoload-invocation path
+;; that M-x uses, which is where an earlier cut regressed ("Autoloading
+;; games-config.el failed to define function malyon").
+
+;;; Code:
+
+(require 'ert)
+(require 'package)
+
+(ert-deftest test-init-defer-games-commands-autoload-without-module ()
+ "Normal: the game commands resolve with games-config unloaded.
+Dropping the eager require keeps malyon and 2048-game reachable only because the
+packages autoload their own commands, so assert that holds."
+ (package-initialize)
+ (should-not (featurep 'games-config))
+ (should (commandp 'malyon))
+ (should (commandp '2048-game)))
+
+(ert-deftest test-init-defer-games-malyon-loads-and-configures ()
+ "Normal: resolving malyon's autoload yields a real command and applies config.
+Reproduces the M-x malyon path via `autoload-do-load': malyon autoloads from its
+own package, init.el's `with-eval-after-load 'malyon' loads games-config, and
+games-config sets the stories directory. This is the regression guard for the
+earlier cut that autoloaded malyon to games-config, where Emacs errored that the
+load failed to define malyon."
+ (package-initialize)
+ (add-to-list 'load-path (expand-file-name "modules" default-directory))
+ (require 'user-constants)
+ (unless (and (fboundp 'malyon) (autoloadp (symbol-function 'malyon)))
+ (ert-skip "malyon package not available as an autoload"))
+ (let ((org-dir "/tmp/games-defer-test/"))
+ (with-eval-after-load 'malyon (require 'games-config)) ; the init.el wiring
+ (should-not (featurep 'games-config))
+ (should (functionp (autoload-do-load (symbol-function 'malyon) 'malyon)))
+ (should (commandp 'malyon))
+ (should (featurep 'games-config))
+ (should (equal malyon-stories-directory "/tmp/games-defer-test/text.games/"))))
+
+(provide 'test-init-defer-games)
+;;; test-init-defer-games.el ends here
diff --git a/tests/test-init-module-headers.el b/tests/test-init-module-headers.el
index a5b331f4d..22dec1d5f 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"
@@ -106,6 +105,7 @@
"erc-config"
"eshell-config"
"eww-config"
+ "face-diagnostic"
"flyspell-and-abbrev"
"games-config"
"gloss-config"
@@ -129,7 +129,6 @@
"tramp-config"
"transcription-config"
"video-audio-recording"
- "term-config"
"weather-config"
"wrap-up")
"Modules annotated with the load-graph header contract.
diff --git a/tests/test-jumper--register-hygiene.el b/tests/test-jumper--register-hygiene.el
new file mode 100644
index 000000000..8fc430ac5
--- /dev/null
+++ b/tests/test-jumper--register-hygiene.el
@@ -0,0 +1,179 @@
+;;; test-jumper--register-hygiene.el --- Tests for jumper register hygiene -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for three related jumper.el defects from the 2026-06 config audit:
+;;
+;; 1. Register collisions on removal — removal shifted the vector but never
+;; freed the dropped register char, and a later store allocated by
+;; `jumper--next-index' (a char a surviving slot might still hold),
+;; silently overwriting that slot's marker. Store now allocates the first
+;; free char in the live slice; removal clears the freed register.
+;; 2. Dead-marker errors — `jumper--with-marker-at' guarded `markerp' but not
+;; buffer liveness, so after the buffer holding a location was killed,
+;; store/jump signaled wrong-type errors. Dead entries are now skipped.
+;; 3. Single-location toggle never toggled back — the `already-there' branch
+;; did nothing; it now jumps to the last-location register when set.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'jumper)
+
+(defvar test-jumper-hyg--orig-registers nil)
+(defvar test-jumper-hyg--orig-index nil)
+
+(defun test-jumper-hyg-setup ()
+ "Reset jumper state and the registers it uses to a clean slate."
+ (setq test-jumper-hyg--orig-registers jumper--registers)
+ (setq test-jumper-hyg--orig-index jumper--next-index)
+ (setq jumper--registers (make-vector jumper-max-locations nil))
+ (setq jumper--next-index 0)
+ (dotimes (i jumper-max-locations)
+ (set-register (+ ?0 i) nil))
+ (set-register jumper--last-location-register nil))
+
+(defun test-jumper-hyg-teardown ()
+ "Restore jumper state."
+ (setq jumper--registers test-jumper-hyg--orig-registers)
+ (setq jumper--next-index test-jumper-hyg--orig-index))
+
+;;; Defect 1 — register collisions on removal
+
+(ert-deftest test-jumper-hyg-store-after-remove-reuses-freed-register ()
+ "Normal: storing after a removal reuses the freed char, not next-index.
+Removing index 0 of [0 1 2] leaves the live slice holding chars 1 and 2;
+the next store must take the freed char 0, never 2 (which slot 1 still holds)."
+ (test-jumper-hyg-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "line 1\nline 2\nline 3\nline 4")
+ (goto-char (point-min))
+ (jumper--do-store-location) ; ?0 @ line 1
+ (forward-line 1) (jumper--do-store-location) ; ?1 @ line 2
+ (forward-line 1) (jumper--do-store-location) ; ?2 @ line 3
+ (jumper--do-remove-location 0) ; live slice now [?1 ?2]
+ (forward-line 1) ; line 4
+ (let ((reg (jumper--do-store-location)))
+ (should (= reg ?0)) ; freed char reused
+ (should (= (aref jumper--registers 2) ?0))
+ (should (= jumper--next-index 3))))
+ (test-jumper-hyg-teardown)))
+
+(ert-deftest test-jumper-hyg-store-after-remove-preserves-survivor ()
+ "Normal: the surviving slot's marker is not clobbered by the reused store.
+After removing index 0 and storing a new location, jumping to the slot that
+holds the old top register must still land on its original line."
+ (test-jumper-hyg-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "line 1\nline 2\nline 3\nline 4")
+ (goto-char (point-min))
+ (jumper--do-store-location) ; ?0 @ line 1
+ (forward-line 1) (jumper--do-store-location) ; ?1 @ line 2
+ (let ((line3 (progn (forward-line 1) (point))))
+ (jumper--do-store-location) ; ?2 @ line 3
+ (jumper--do-remove-location 0) ; slot1 now holds ?2 @ line3
+ (goto-char (point-max)) (jumper--do-store-location) ; reuse ?0
+ (goto-char (point-min))
+ (jumper--do-jump-to-location 1) ; slot1 = old line-3 marker
+ (should (= (point) line3))))
+ (test-jumper-hyg-teardown)))
+
+(ert-deftest test-jumper-hyg-remove-clears-freed-register ()
+ "Boundary: removing a location clears its register so the marker is freed."
+ (test-jumper-hyg-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "test")
+ (goto-char (point-min))
+ (jumper--do-store-location) ; ?0
+ (should (get-register ?0))
+ (jumper--do-remove-location 0)
+ (should (null (get-register ?0))))
+ (test-jumper-hyg-teardown)))
+
+;;; Defect 2 — dead-marker entries are skipped, not errored
+
+(ert-deftest test-jumper-hyg-with-marker-at-dead-buffer-returns-nil ()
+ "Error: a marker whose buffer was killed yields nil, not a wrong-type error."
+ (test-jumper-hyg-setup)
+ (let ((buf (generate-new-buffer "jumper-dead-test")))
+ (unwind-protect
+ (progn
+ (with-current-buffer buf
+ (insert "content")
+ (goto-char (point-min))
+ (jumper--do-store-location)) ; ?0 points into buf
+ (kill-buffer buf) ; marker now detached
+ (should (null (jumper--with-marker-at 0 (lambda () 'ran)))))
+ (when (buffer-live-p buf) (kill-buffer buf))
+ (test-jumper-hyg-teardown))))
+
+(ert-deftest test-jumper-hyg-location-exists-p-survives-dead-buffer ()
+ "Boundary: location-exists-p does not error when a stored buffer is dead."
+ (test-jumper-hyg-setup)
+ (let ((buf (generate-new-buffer "jumper-dead-test-2")))
+ (unwind-protect
+ (progn
+ (with-current-buffer buf
+ (insert "content")
+ (goto-char (point-min))
+ (jumper--do-store-location))
+ (kill-buffer buf)
+ (should (null (jumper--location-exists-p))))
+ (when (buffer-live-p buf) (kill-buffer buf))
+ (test-jumper-hyg-teardown))))
+
+(ert-deftest test-jumper-hyg-candidates-skip-dead-buffer ()
+ "Boundary: the candidate list omits a location whose buffer was killed."
+ (test-jumper-hyg-setup)
+ (let ((buf (generate-new-buffer "jumper-dead-test-3")))
+ (unwind-protect
+ (progn
+ (with-current-buffer buf
+ (insert "content")
+ (goto-char (point-min))
+ (jumper--do-store-location))
+ (kill-buffer buf)
+ (should (null (jumper--location-candidates))))
+ (when (buffer-live-p buf) (kill-buffer buf))
+ (test-jumper-hyg-teardown))))
+
+;;; Defect 3 — single-location toggle returns to the previous spot
+
+(ert-deftest test-jumper-hyg-toggle-back-when-last-set ()
+ "Normal: toggling at the only location jumps back to the last-location register.
+Jump to the location (which records the prior spot in 'z); toggling again while
+sitting on the location returns to that prior spot."
+ (test-jumper-hyg-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "line 1\nline 2\nline 3")
+ (goto-char (point-min))
+ (jumper--do-store-location) ; store @ line 1
+ (let ((away (point-max)))
+ (goto-char away)
+ (jumper--do-jump-to-location nil) ; jump to line 1, 'z := away
+ (should (= (point) (point-min)))
+ (let ((result (jumper--do-jump-to-location nil))) ; toggle back
+ (should (eq result 'jumped-back))
+ (should (= (point) away)))))
+ (test-jumper-hyg-teardown)))
+
+(ert-deftest test-jumper-hyg-toggle-at-location-no-last-stays ()
+ "Boundary: toggling at the location with no last-location set returns
+'already-there and does not move point."
+ (test-jumper-hyg-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "line 1\nline 2")
+ (goto-char (point-min))
+ (jumper--do-store-location)
+ (let ((result (jumper--do-jump-to-location nil)))
+ (should (eq result 'already-there))
+ (should (= (point) (point-min)))))
+ (test-jumper-hyg-teardown)))
+
+(provide 'test-jumper--register-hygiene)
+;;; test-jumper--register-hygiene.el ends here
diff --git a/tests/test-keybindings--jump-open-var.el b/tests/test-keybindings--jump-open-var.el
index bd04f4cf1..041f4a7d3 100644
--- a/tests/test-keybindings--jump-open-var.el
+++ b/tests/test-keybindings--jump-open-var.el
@@ -25,7 +25,7 @@ CAPTURE-VAR is set to the path passed to `find-file', or stays nil if
the mock is never called."
(declare (indent 1) (debug t))
`(cl-letf (((symbol-function 'find-file)
- (lambda (path) (setq ,capture-var path))))
+ (lambda (path &rest _) (setq ,capture-var path))))
,@body))
(defmacro test-keybindings--with-fixture (value &rest body)
diff --git a/tests/test-latex-config--latexmk-wiring.el b/tests/test-latex-config--latexmk-wiring.el
new file mode 100644
index 000000000..30b8f29de
--- /dev/null
+++ b/tests/test-latex-config--latexmk-wiring.el
@@ -0,0 +1,62 @@
+;;; test-latex-config--latexmk-wiring.el --- latexmk activation guards -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Guards the two breaks that kept the latexmk workflow from activating:
+;; 1. The :hook entry that sets `TeX-command-default' must target the real
+;; `TeX-mode-hook'. use-package appends "-hook" to any hook symbol not
+;; ending in "-mode", so the mode name `TeX-mode' is required; the literal
+;; `TeX-mode-hook' expands to the nonexistent `TeX-mode-hook-hook'.
+;; 2. `auctex-latexmk' must load so `auctex-latexmk-setup' runs. `:defer t'
+;; with no trigger never fires; `:after tex' loads it when AUCTeX loads.
+;;
+;; The forms are read from the source and macroexpanded, so the test fails the
+;; way the live config failed -- against the actual declaration.
+
+;;; Code:
+
+(require 'ert)
+(require 'seq)
+(require 'use-package)
+
+(defun test-latex-config--forms ()
+ "Return the top-level forms in latex-config.el."
+ (let ((file (expand-file-name "modules/latex-config.el" user-emacs-directory))
+ (forms '()))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (goto-char (point-min))
+ (condition-case nil
+ (while t (push (read (current-buffer)) forms))
+ (end-of-file nil)))
+ (nreverse forms)))
+
+(defun test-latex-config--use-package-form (package)
+ "Return the (use-package PACKAGE ...) top-level form from latex-config.el."
+ (seq-find (lambda (form)
+ (and (consp form)
+ (eq (car form) 'use-package)
+ (eq (cadr form) package)))
+ (test-latex-config--forms)))
+
+(ert-deftest test-latex-config-tex-hook-targets-real-hook ()
+ "Regression: the latexmk-default :hook expands to `TeX-mode-hook', not the
+unbound `TeX-mode-hook-hook' use-package builds from a non-mode hook symbol."
+ (let* ((form (test-latex-config--use-package-form 'tex))
+ (expansion (format "%S" (macroexpand-all form))))
+ (should form)
+ ;; The hook symbol is followed by whitespace before its lambda, so anchor
+ ;; on that to distinguish `TeX-mode-hook' from the broken `...-hook-hook'.
+ (should (string-match-p "TeX-mode-hook[ )]" expansion))
+ (should-not (string-match-p "TeX-mode-hook-hook" expansion))))
+
+(ert-deftest test-latex-config-auctex-latexmk-loads-after-tex ()
+ "Regression: auctex-latexmk uses `:after tex' so `auctex-latexmk-setup' runs;
+a bare `:defer t' with no trigger would never load it."
+ (let ((form (test-latex-config--use-package-form 'auctex-latexmk)))
+ (should form)
+ (should (member :after form))
+ (should (eq (cadr (member :after form)) 'tex))
+ (should-not (member :defer form))))
+
+(provide 'test-latex-config--latexmk-wiring)
+;;; test-latex-config--latexmk-wiring.el ends here
diff --git a/tests/test-mail-config-transport.el b/tests/test-mail-config-transport.el
index 2244b6dd2..0240102a2 100644
--- a/tests/test-mail-config-transport.el
+++ b/tests/test-mail-config-transport.el
@@ -18,7 +18,7 @@ EXECUTABLES is an alist of program name strings to executable paths."
(declare (indent 1))
`(let (test-mail-config--warnings)
(cl-letf (((symbol-function 'executable-find)
- (lambda (program)
+ (lambda (program &rest _)
(cdr (assoc program ,executables))))
((symbol-function 'display-warning)
(lambda (type message &rest _args)
diff --git a/tests/test-media-utils.el b/tests/test-media-utils.el
index 9384d568f..841b6faf9 100644
--- a/tests/test-media-utils.el
+++ b/tests/test-media-utils.el
@@ -24,7 +24,7 @@
(ert-deftest test-media-get-available-players-filters-by-executable ()
"Normal: only players whose :command is on PATH are reported."
(cl-letf (((symbol-function 'executable-find)
- (lambda (cmd) (and (member cmd '("mpv" "vlc")) cmd))))
+ (lambda (cmd &rest _) (and (member cmd '("mpv" "vlc")) cmd))))
(let ((result (cj/get-available-media-players)))
(should (memq 'mpv result))
(should (memq 'vlc result))
@@ -32,7 +32,7 @@
(ert-deftest test-media-get-available-players-none-installed ()
"Boundary: with nothing on PATH, the list is empty."
- (cl-letf (((symbol-function 'executable-find) (lambda (_) nil)))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(should-not (cj/get-available-media-players))))
;; ----------------------------- cj/media-play-it ------------------------------
@@ -41,7 +41,7 @@
"Normal: a player that needs no stream URL gets a plain command, no yt-dlp."
(let (captured cj/default-media-player)
(setq cj/default-media-player 'mpv)
- (cl-letf (((symbol-function 'executable-find) (lambda (_) "/usr/bin/mpv"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/mpv"))
((symbol-function 'start-process-shell-command)
(lambda (_n _b cmd) (setq captured cmd) 'proc))
((symbol-function 'set-process-sentinel) #'ignore)
@@ -56,7 +56,7 @@
"Normal: a player needing a stream URL wraps the URL in a yt-dlp -g call."
(let (captured cj/default-media-player)
(setq cj/default-media-player 'vlc)
- (cl-letf (((symbol-function 'executable-find) (lambda (_) "/usr/bin/vlc"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/vlc"))
((symbol-function 'start-process-shell-command)
(lambda (_n _b cmd) (setq captured cmd) 'proc))
((symbol-function 'set-process-sentinel) #'ignore)
@@ -71,7 +71,7 @@
"Error: an unavailable player command signals an error before launching."
(let (cj/default-media-player)
(setq cj/default-media-player 'mpv)
- (cl-letf (((symbol-function 'executable-find) (lambda (_) nil)))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(should-error (cj/media-play-it "https://example.com/v")))))
;; ------------------------------- cj/yt-dl-it ---------------------------------
@@ -79,19 +79,19 @@
(ert-deftest test-media-yt-dl-it-errors-without-yt-dlp ()
"Error: a missing yt-dlp aborts the download."
(cl-letf (((symbol-function 'executable-find)
- (lambda (cmd) (unless (equal cmd "yt-dlp") "/usr/bin/x"))))
+ (lambda (cmd &rest _) (unless (equal cmd "yt-dlp") "/usr/bin/x"))))
(should-error (cj/yt-dl-it "https://example.com/v"))))
(ert-deftest test-media-yt-dl-it-errors-without-tsp ()
"Error: yt-dlp present but tsp missing aborts the download."
(cl-letf (((symbol-function 'executable-find)
- (lambda (cmd) (unless (equal cmd "tsp") "/usr/bin/x"))))
+ (lambda (cmd &rest _) (unless (equal cmd "tsp") "/usr/bin/x"))))
(should-error (cj/yt-dl-it "https://example.com/v"))))
(ert-deftest test-media-yt-dl-it-builds-tsp-yt-dlp-process ()
"Normal: with both tools present, the URL is queued via tsp + yt-dlp."
(let (captured (videos-dir "/tmp/videos"))
- (cl-letf (((symbol-function 'executable-find) (lambda (_) "/usr/bin/x"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/x"))
((symbol-function 'start-process)
(lambda (&rest args) (setq captured args) 'proc))
((symbol-function 'set-process-sentinel) #'ignore)
diff --git a/tests/test-meta-subr-mock-arity.el b/tests/test-meta-subr-mock-arity.el
new file mode 100644
index 000000000..8ee2cb5e0
--- /dev/null
+++ b/tests/test-meta-subr-mock-arity.el
@@ -0,0 +1,113 @@
+;;; test-meta-subr-mock-arity.el --- Guard against arity-narrow subr mocks -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; A meta-test: it tests the other tests. Native compilation routes a
+;; redefined C primitive (subr) through a trampoline that calls the
+;; replacement with the primitive's FULL arity, filling optionals with nil.
+;; So a fixed-arity mock that is narrower than the primitive throws
+;; `wrong-number-of-arguments' the moment native-comp has compiled that
+;; trampoline -- a failure that appears intermittently as the eln-cache fills.
+;;
+;; The rule this enforces is NOT "never mock a subr" (the suite mocks subrs
+;; like `message' and `completing-read' hundreds of times, all fine). It is:
+;; a mock of a C primitive must be able to accept the primitive's maximum
+;; arity -- in practice, use (lambda (&rest _) ...). This test scans every
+;; file under tests/ for `cl-letf' / `setf' / `fset' redefinitions of a
+;; `symbol-function', and fails listing any whose replacement is too narrow.
+;;
+;; It is deterministic: a pure static read of the test sources plus
+;; `func-arity', with no dependence on whether native-comp happens to have
+;; built the trampoline yet.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'seq)
+
+(defconst test-meta-subr--test-dir
+ (expand-file-name "tests" (or (getenv "EMACS_CONFIG_ROOT") default-directory))
+ "Directory whose .el files are scanned for subr mocks.")
+
+(defun test-meta-subr--replacement-arglist (repl)
+ "Return the formal arglist of REPL, or the symbol `unknown'.
+Handles (lambda ARGS ...) and (function (lambda ARGS ...)); returns `variadic'
+for forms known to accept any arity (`ignore', `always'), and `unknown' for
+anything whose arity can't be read statically (a bare variable, a call)."
+ (pcase repl
+ (`(lambda ,args . ,_) args)
+ (`(function (lambda ,args . ,_)) args)
+ (`(quote ,(or 'ignore 'always)) 'variadic)
+ (`(function ,(or 'ignore 'always)) 'variadic)
+ (_ 'unknown)))
+
+(defun test-meta-subr--accepts-p (arglist subr-max)
+ "Non-nil if a lambda with ARGLIST can be called with SUBR-MAX positional args.
+ARGLIST may also be `variadic' or `unknown' (both treated as acceptable)."
+ (cond
+ ((memq arglist '(variadic unknown)) t)
+ ((memq '&rest arglist) t)
+ ((eq subr-max 'many) nil) ; only &rest accepts unbounded arity
+ ((integerp subr-max)
+ (>= (length (seq-remove (lambda (s) (memq s '(&optional &rest &key)))
+ arglist))
+ subr-max))
+ (t t)))
+
+(defun test-meta-subr--quoted-symbol (form)
+ "If FORM is 'SYM or #'SYM, return SYM, else nil."
+ (pcase form
+ (`(quote ,(and s (guard (symbolp s)))) s)
+ (`(function ,(and s (guard (symbolp s)))) s)))
+
+(defun test-meta-subr--collect (form acc)
+ "Walk FORM, pushing (SYM . REPLACEMENT) for each symbol-function redefinition.
+Covers `cl-letf'/`setf' binding shape ((symbol-function 'SYM) REPL) and
+\(fset 'SYM REPL)."
+ (when (consp form)
+ ;; (fset 'SYM REPL)
+ (when (eq (car-safe form) 'fset)
+ (let ((s (test-meta-subr--quoted-symbol (nth 1 form))))
+ (when s (push (cons s (nth 2 form)) acc))))
+ ;; binding element ((symbol-function 'SYM) REPL) -- cl-letf, cl-letf*, setf
+ (when (and (consp (car-safe form))
+ (eq (car-safe (car form)) 'symbol-function))
+ (let ((s (test-meta-subr--quoted-symbol (nth 1 (car form)))))
+ (when s (push (cons s (nth 1 form)) acc))))
+ (dolist (sub form) (setq acc (test-meta-subr--collect sub acc))))
+ acc)
+
+(defun test-meta-subr--violations ()
+ "Return a list of human-readable violation strings across the test files."
+ (let ((violations '()))
+ (dolist (file (directory-files-recursively test-meta-subr--test-dir "\\.el\\'"))
+ ;; Don't scan this meta-test itself (its examples would self-trip).
+ (unless (string-suffix-p "test-meta-subr-mock-arity.el" file)
+ (let ((mocks '()))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (goto-char (point-min))
+ (condition-case nil
+ (while t (setq mocks (test-meta-subr--collect (read (current-buffer)) mocks)))
+ (error nil)))
+ (pcase-dolist (`(,sym . ,repl) (nreverse mocks))
+ (when (and (fboundp sym)
+ (condition-case nil (subrp (symbol-function sym)) (error nil)))
+ (let ((subr-max (cdr (func-arity sym)))
+ (arglist (test-meta-subr--replacement-arglist repl)))
+ (unless (test-meta-subr--accepts-p arglist subr-max)
+ (push (format "%s: mock of subr `%s' (arity max %s) takes %S -- use (&rest _)"
+ (file-name-nondirectory file) sym subr-max arglist)
+ violations))))))))
+ (nreverse violations)))
+
+(ert-deftest test-meta-no-arity-narrow-subr-mocks ()
+ "No test mocks a C primitive with a lambda too narrow for its arity.
+Such a mock breaks under native-comp's subr trampoline (it calls the mock with
+the primitive's full arity). Fix by making the mock variadic: (lambda (&rest _)
+...). See this file's commentary."
+ (let ((violations (test-meta-subr--violations)))
+ (should (null violations))))
+
+(provide 'test-meta-subr-mock-arity)
+;;; test-meta-subr-mock-arity.el ends here
diff --git a/tests/test-music-config-commands.el b/tests/test-music-config-commands.el
index d57e339c4..3c585d0b7 100644
--- a/tests/test-music-config-commands.el
+++ b/tests/test-music-config-commands.el
@@ -176,9 +176,9 @@ last-played track and starts it."
(added-hooks nil)
(removed-hooks nil))
(cl-letf (((symbol-function 'add-hook)
- (lambda (hook _fn) (push hook added-hooks)))
+ (lambda (hook _fn &rest _) (push hook added-hooks)))
((symbol-function 'remove-hook)
- (lambda (hook _fn) (push hook removed-hooks)))
+ (lambda (hook _fn &rest _) (push hook removed-hooks)))
((symbol-function 'message) #'ignore))
(cj/music-toggle-consume)
(should cj/music-consume-mode)
diff --git a/tests/test-music-config-helpers-untested.el b/tests/test-music-config-helpers-untested.el
index 4ba0940a5..bfdb2634d 100644
--- a/tests/test-music-config-helpers-untested.el
+++ b/tests/test-music-config-helpers-untested.el
@@ -113,7 +113,7 @@ test prelude inserts filler with `inhibit-read-only' bound."
"Normal: when emms is already a feature, setup does not re-require."
(let ((called nil))
(cl-letf (((symbol-function 'featurep)
- (lambda (sym) (eq sym 'emms)))
+ (lambda (sym &rest _) (eq sym 'emms)))
((symbol-function 'require)
(lambda (&rest _) (setq called t) t)))
(cj/emms--setup))
@@ -123,7 +123,7 @@ test prelude inserts filler with `inhibit-read-only' bound."
"Boundary: when emms isn't yet loaded, setup requires it."
(let ((required nil))
(cl-letf (((symbol-function 'featurep)
- (lambda (sym) (not (eq sym 'emms))))
+ (lambda (sym &rest _) (not (eq sym 'emms))))
((symbol-function 'require)
(lambda (feat &rest _) (setq required feat) t)))
(cj/emms--setup))
diff --git a/tests/test-music-config-more-commands.el b/tests/test-music-config-more-commands.el
index a029a5a33..c351c1f15 100644
--- a/tests/test-music-config-more-commands.el
+++ b/tests/test-music-config-more-commands.el
@@ -94,7 +94,7 @@
((symbol-function 'cj/music--playlist-modified-p)
(lambda () nil))
((symbol-function 'find-file-other-window)
- (lambda (f) (setq opened f))))
+ (lambda (f &rest _) (setq opened f))))
(cj/music-playlist-edit))
(delete-file tmp))
(should (equal opened tmp))))
@@ -130,7 +130,7 @@
((symbol-function 'cj/music--ensure-playlist-buffer)
(lambda () buf))
((symbol-function 'switch-to-buffer)
- (lambda (b) (setq switched b)))
+ (lambda (b &rest _) (setq switched b)))
((symbol-function 'message)
(lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
(cj/music-playlist-show))
diff --git a/tests/test-music-config-playlist-commands.el b/tests/test-music-config-playlist-commands.el
index 3d6dfd8b9..891bc700c 100644
--- a/tests/test-music-config-playlist-commands.el
+++ b/tests/test-music-config-playlist-commands.el
@@ -132,7 +132,7 @@
(cl-letf (((symbol-function 'cj/music--playlist-modified-p)
(lambda () nil))
((symbol-function 'find-file-other-window)
- (lambda (p) (setq opened p))))
+ (lambda (p &rest _) (setq opened p))))
(cj/music-playlist-edit))
(should (equal opened tmp))
(delete-file tmp))
diff --git a/tests/test-nerd-icons-config--apply-tint.el b/tests/test-nerd-icons-config--apply-tint.el
deleted file mode 100644
index ef723352c..000000000
--- a/tests/test-nerd-icons-config--apply-tint.el
+++ /dev/null
@@ -1,63 +0,0 @@
-;;; test-nerd-icons-config--apply-tint.el --- Tests for cj/nerd-icons-apply-tint -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; Tests for the bulk-tint helper. Mocks `set-face-foreground' and `facep'
-;; at the framework boundary so the tests don't depend on nerd-icons being
-;; loaded — only on the symbol list and the dispatch logic.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-
-(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'nerd-icons-config)
-
-(defmacro test-nerd-icons-config--capture-set-face-foreground (calls-var &rest body)
- "Run BODY with `set-face-foreground' and `facep' stubbed.
-Each (face color) pair gets pushed onto CALLS-VAR. `facep' returns t
-for every symbol so all faces in the list count as defined."
- (declare (indent 1) (debug t))
- `(cl-letf (((symbol-function 'set-face-foreground)
- (lambda (face color &rest _) (push (cons face color) ,calls-var)))
- ((symbol-function 'facep)
- (lambda (_) t)))
- ,@body))
-
-(ert-deftest test-nerd-icons-config--apply-tint-covers-every-face ()
- "Normal: apply-tint calls set-face-foreground once per face in the list."
- (let ((calls nil))
- (test-nerd-icons-config--capture-set-face-foreground calls
- (cj/nerd-icons-apply-tint "test-color"))
- (should (= (length calls) (length cj/--nerd-icons-color-faces)))
- (dolist (face cj/--nerd-icons-color-faces)
- (should (assq face calls)))))
-
-(ert-deftest test-nerd-icons-config--apply-tint-passes-color-arg ()
- "Normal: apply-tint forwards COLOR to every set-face-foreground call."
- (let ((calls nil))
- (test-nerd-icons-config--capture-set-face-foreground calls
- (cj/nerd-icons-apply-tint "rebeccapurple"))
- (dolist (call calls)
- (should (equal (cdr call) "rebeccapurple")))))
-
-(ert-deftest test-nerd-icons-config--apply-tint-defaults-to-customvar ()
- "Normal: with no COLOR arg, uses `cj/nerd-icons-tint-color'."
- (let ((calls nil))
- (test-nerd-icons-config--capture-set-face-foreground calls
- (let ((cj/nerd-icons-tint-color "default-test-color"))
- (cj/nerd-icons-apply-tint)))
- (should (cl-every (lambda (call) (equal (cdr call) "default-test-color")) calls))))
-
-(ert-deftest test-nerd-icons-config--apply-tint-skips-undefined-faces ()
- "Boundary: faces that fail `facep' are silently skipped, not errored."
- (let ((calls nil))
- (cl-letf (((symbol-function 'set-face-foreground)
- (lambda (face color &rest _) (push (cons face color) calls)))
- ((symbol-function 'facep)
- (lambda (_) nil)))
- (cj/nerd-icons-apply-tint "any"))
- (should (null calls))))
-
-(provide 'test-nerd-icons-config--apply-tint)
-;;; test-nerd-icons-config--apply-tint.el ends here
diff --git a/tests/test-nerd-icons-config--color-dir.el b/tests/test-nerd-icons-config--color-dir.el
index 808c0dc34..2ae64a810 100644
--- a/tests/test-nerd-icons-config--color-dir.el
+++ b/tests/test-nerd-icons-config--color-dir.el
@@ -53,5 +53,20 @@ renders would stack `nerd-icons-yellow' over and over on the cached string."
(yellows (cl-count 'nerd-icons-yellow specs)))
(should (= yellows 1)))))
+(ert-deftest test-nerd-icons-config--color-dir-precedence-over-completion-face ()
+ "Normal: when the dir icon already carries nerd-icons-completion-dir-face
+\(what `nerd-icons-completion-get-icon' passes), the advice prepends
+nerd-icons-yellow so it is first in the face list and wins the merge. Locks
+the dir-precedence decision: the prepended advice face outranks the package's
+:face, even though that face lives in a different package."
+ (let* ((icon (propertize "X" 'face 'nerd-icons-completion-dir-face))
+ (result (cj/--nerd-icons-color-dir icon))
+ (faces (ensure-list (get-text-property 0 'face result))))
+ (should (memq 'nerd-icons-yellow faces))
+ (should (memq 'nerd-icons-completion-dir-face faces))
+ (should (= 0 (cl-position 'nerd-icons-yellow faces)))
+ (should (< (cl-position 'nerd-icons-yellow faces)
+ (cl-position 'nerd-icons-completion-dir-face faces)))))
+
(provide 'test-nerd-icons-config--color-dir)
;;; test-nerd-icons-config--color-dir.el ends here
diff --git a/tests/test-org-agenda-config--base-files.el b/tests/test-org-agenda-config--base-files.el
index c6939b4d7..bd202a195 100644
--- a/tests/test-org-agenda-config--base-files.el
+++ b/tests/test-org-agenda-config--base-files.el
@@ -3,8 +3,11 @@
;;; Commentary:
;; cj/--org-agenda-base-files is the single source of the fixed agenda base list
;; (inbox, schedule, and the three calendars) that was previously spelled out as
-;; a literal in three places. The path vars are special (defvar'd in
-;; user-constants), so they can be dynamically bound here.
+;; a literal in three places. It now drops files that do not exist so org-agenda
+;; never prompts to create a missing path (the hang class). The path vars are
+;; special (defvar'd in user-constants), so they can be dynamically bound; tests
+;; use real temp files for "exists" rather than mocking the `file-exists-p'
+;; primitive.
;;; Code:
@@ -13,24 +16,44 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'org-agenda-config)
-(ert-deftest test-org-agenda-base-files-returns-fixed-list-in-order ()
- "Normal: returns inbox, schedule, gcal, pcal, dcal in that order."
- (let ((inbox-file "/i")
- (schedule-file "/s")
- (gcal-file "/g")
- (pcal-file "/p")
- (dcal-file "/d"))
- (should (equal (cj/--org-agenda-base-files)
- '("/i" "/s" "/g" "/p" "/d")))))
+(defun test-oa-base--tmp ()
+ "Return a fresh existing temp file path."
+ (make-temp-file "oa-base-"))
+
+(ert-deftest test-org-agenda-base-files-returns-existing-in-order ()
+ "Normal: returns inbox, schedule, gcal, pcal, dcal (all existing) in order."
+ (let* ((i (test-oa-base--tmp)) (s (test-oa-base--tmp)) (g (test-oa-base--tmp))
+ (p (test-oa-base--tmp)) (d (test-oa-base--tmp))
+ (inbox-file i) (schedule-file s) (gcal-file g) (pcal-file p) (dcal-file d))
+ (unwind-protect
+ (should (equal (cj/--org-agenda-base-files) (list i s g p d)))
+ (dolist (f (list i s g p d)) (ignore-errors (delete-file f))))))
(ert-deftest test-org-agenda-base-files-reflects-current-values ()
"Boundary: the helper reads the vars at call time (not a captured snapshot)."
- (let ((inbox-file "first")
- (schedule-file "x") (gcal-file "x") (pcal-file "x") (dcal-file "x"))
- (should (equal (car (cj/--org-agenda-base-files)) "first"))
- (setq inbox-file "second")
- (should (equal (car (cj/--org-agenda-base-files)) "second"))
- (should (= (length (cj/--org-agenda-base-files)) 5))))
+ (let* ((a (test-oa-base--tmp)) (b (test-oa-base--tmp))
+ (inbox-file a) (schedule-file b) (gcal-file b) (pcal-file b) (dcal-file b))
+ (unwind-protect
+ (progn
+ (should (equal (car (cj/--org-agenda-base-files)) a))
+ (setq inbox-file b)
+ (should (equal (car (cj/--org-agenda-base-files)) b))
+ (should (= (length (cj/--org-agenda-base-files)) 5)))
+ (ignore-errors (delete-file a))
+ (ignore-errors (delete-file b)))))
+
+(ert-deftest test-org-agenda-base-files-drops-missing-files ()
+ "Boundary/Error: files that do not exist are dropped, so a fresh machine
+without synced calendars never hands org-agenda a path it would prompt to create."
+ (let* ((i (test-oa-base--tmp)) (s (test-oa-base--tmp))
+ (inbox-file i) (schedule-file s)
+ (gcal-file "/no/such/gcal.org")
+ (pcal-file "/no/such/pcal.org")
+ (dcal-file "/no/such/dcal.org"))
+ (unwind-protect
+ (should (equal (cj/--org-agenda-base-files) (list i s)))
+ (ignore-errors (delete-file i))
+ (ignore-errors (delete-file s)))))
(provide 'test-org-agenda-config--base-files)
;;; test-org-agenda-config--base-files.el ends here
diff --git a/tests/test-org-capture-config-popup-window.el b/tests/test-org-capture-config-popup-window.el
index d308fc2b7..671d55ab9 100644
--- a/tests/test-org-capture-config-popup-window.el
+++ b/tests/test-org-capture-config-popup-window.el
@@ -173,7 +173,7 @@ not whatever frame happens to be selected (the emacsclient -c focus race)."
(let ((focused nil))
(cl-letf (((symbol-function 'cj/org-capture--popup-frame) (lambda () 'popup-frame))
((symbol-function 'select-frame-set-input-focus)
- (lambda (f) (setq focused f)))
+ (lambda (f &rest _) (setq focused f)))
((symbol-function 'org-capture) (lambda (&rest _) nil)))
(cj/quick-capture))
(should (eq focused 'popup-frame))))
@@ -185,7 +185,7 @@ call and still runs the capture (no error)."
(captured nil))
(cl-letf (((symbol-function 'cj/org-capture--popup-frame) (lambda () nil))
((symbol-function 'select-frame-set-input-focus)
- (lambda (f) (setq focused f)))
+ (lambda (f &rest _) (setq focused f)))
((symbol-function 'org-capture) (lambda (&rest _) (setq captured t))))
(cj/quick-capture))
(should (eq focused 'unset))
diff --git a/tests/test-org-config-keymap-ownership.el b/tests/test-org-config-keymap-ownership.el
index 729d497cb..81f1ccd46 100644
--- a/tests/test-org-config-keymap-ownership.el
+++ b/tests/test-org-config-keymap-ownership.el
@@ -60,14 +60,14 @@ at the top level."
"Sparse-tree commands sit directly under `C-; O' (flat).
Lowercase creates, capital of the same letter cancels: `s' /
`S' for match-sparse-tree, `t' / `T' for show-todo-tree. Both
-capitals resolve to `org-show-all' -- the user's mental model is
+capitals resolve to `org-fold-show-all' -- the user's mental model is
\"capital cancels the lowercase I just ran\" without having to
remember which letter the cancel actually lives on. `R' is
`org-reveal' (no lowercase pair -- `r' is the table-row sub-prefix)."
(should (eq (keymap-lookup cj/org-map "s") #'org-match-sparse-tree))
- (should (eq (keymap-lookup cj/org-map "S") #'org-show-all))
+ (should (eq (keymap-lookup cj/org-map "S") #'org-fold-show-all))
(should (eq (keymap-lookup cj/org-map "t") #'org-show-todo-tree))
- (should (eq (keymap-lookup cj/org-map "T") #'org-show-all))
+ (should (eq (keymap-lookup cj/org-map "T") #'org-fold-show-all))
(should (eq (keymap-lookup cj/org-map "R") #'org-reveal)))
(ert-deftest test-org-config-keymap-ownership-regression-no-duplicate-org-keymap ()
diff --git a/tests/test-org-drill-config-commands.el b/tests/test-org-drill-config-commands.el
index c35bd6cd4..38f6b66e3 100644
--- a/tests/test-org-drill-config-commands.el
+++ b/tests/test-org-drill-config-commands.el
@@ -38,7 +38,7 @@
(let (opened (drilled 0))
(cl-letf (((symbol-function 'cj/--drill-pick-file)
(lambda (_dir) "/decks/german.org"))
- ((symbol-function 'find-file) (lambda (f) (setq opened f)))
+ ((symbol-function 'find-file) (lambda (f &rest _) (setq opened f)))
((symbol-function 'org-drill)
(lambda (&rest _) (cl-incf drilled))))
(cj/drill-edit))
@@ -54,7 +54,7 @@
(with-temp-file (expand-file-name "latin.org" tmp))
(cl-letf (((symbol-function 'read-directory-name) (lambda (&rest _) tmp))
((symbol-function 'completing-read) (lambda (&rest _) "latin.org"))
- ((symbol-function 'find-file) (lambda (f) (setq opened f))))
+ ((symbol-function 'find-file) (lambda (f &rest _) (setq opened f))))
(cj/drill-edit t))
(should (equal (expand-file-name "latin.org" tmp) opened)))
(delete-directory tmp t))))
@@ -85,7 +85,7 @@ and validation)."
((symbol-function 'directory-files)
(lambda (&rest _) '("/WRONG/raw.org")))
((symbol-function 'call-interactively)
- (lambda (fn)
+ (lambda (fn &rest _)
(setq called-fn fn
seen-targets org-refile-targets))))
(cj/drill-refile))
@@ -101,7 +101,7 @@ survives the call instead of being permanently replaced."
(let ((drill-dir "/tmp/cj-drill/")
(org-refile-targets '((sentinel :maxlevel . 9))))
(cl-letf (((symbol-function 'cj/--drill-files-or-error) (lambda (_dir) '("a.org")))
- ((symbol-function 'call-interactively) (lambda (_fn) nil)))
+ ((symbol-function 'call-interactively) (lambda (_fn &rest _) nil)))
(cj/drill-refile))
(should (equal org-refile-targets '((sentinel :maxlevel . 9))))))
@@ -112,7 +112,7 @@ the shared validated helper, instead of a low-level error, and never reaches
(let ((drill-dir (expand-file-name "cj-drill-nonexistent-XYZ/"
temporary-file-directory))
(called nil))
- (cl-letf (((symbol-function 'call-interactively) (lambda (_fn) (setq called t))))
+ (cl-letf (((symbol-function 'call-interactively) (lambda (_fn &rest _) (setq called t))))
(should-error (cj/drill-refile) :type 'user-error))
(should-not called)))
diff --git a/tests/test-org-drill-config.el b/tests/test-org-drill-config.el
index d3057de2a..9dffa0bca 100644
--- a/tests/test-org-drill-config.el
+++ b/tests/test-org-drill-config.el
@@ -118,7 +118,7 @@
(let (opened (drilled 0))
(cl-letf (((symbol-function 'cj/--drill-pick-file)
(lambda (_dir) "/decks/french.org"))
- ((symbol-function 'find-file) (lambda (f) (setq opened f)))
+ ((symbol-function 'find-file) (lambda (f &rest _) (setq opened f)))
((symbol-function 'org-drill) (lambda (&rest _) (cl-incf drilled))))
(cj/drill-start))
(should (equal "/decks/french.org" opened))
@@ -131,7 +131,7 @@
(let (opened)
(cl-letf (((symbol-function 'read-directory-name) (lambda (&rest _) dir))
((symbol-function 'completing-read) (lambda (&rest _) "latin.org"))
- ((symbol-function 'find-file) (lambda (f) (setq opened f)))
+ ((symbol-function 'find-file) (lambda (f &rest _) (setq opened f)))
((symbol-function 'org-drill) #'ignore))
(cj/drill-start t))
(should (equal (expand-file-name "latin.org" dir) opened)))))
diff --git a/tests/test-org-noter-config-commands.el b/tests/test-org-noter-config-commands.el
index 8860af06e..70c78645c 100644
--- a/tests/test-org-noter-config-commands.el
+++ b/tests/test-org-noter-config-commands.el
@@ -115,7 +115,7 @@
((symbol-function 'org-id-uuid)
(lambda () "00000000-0000-0000-0000-000000000000"))
((symbol-function 'find-file-noselect)
- (lambda (f) (get-buffer-create (concat "*test-" f "*")))))
+ (lambda (f &rest _) (get-buffer-create (concat "*test-" f "*")))))
(let ((path (cj/org-noter--create-notes-file)))
(should (file-exists-p path))
(with-temp-buffer
@@ -186,7 +186,7 @@
((symbol-function 'org-noter--get-doc-window)
(lambda () 'doc-win))
((symbol-function 'select-window)
- (lambda (w) (setq selected w))))
+ (lambda (w &rest _) (setq selected w))))
(cj/org-noter-start))
(should (eq selected 'doc-win))))
@@ -232,7 +232,7 @@
((symbol-function 'org-noter--get-doc-window)
(lambda () 'doc-win))
((symbol-function 'select-window)
- (lambda (w) (setq selected w)))
+ (lambda (w &rest _) (setq selected w)))
((symbol-function 'org-noter-insert-note)
(lambda () (setq inserted t))))
(cj/org-noter-insert-note-dwim))
diff --git a/tests/test-org-refile-config-commands.el b/tests/test-org-refile-config-commands.el
index 9bdd33647..2e99e9152 100644
--- a/tests/test-org-refile-config-commands.el
+++ b/tests/test-org-refile-config-commands.el
@@ -54,7 +54,7 @@
(with-temp-buffer
(setq buffer-file-name "/tmp/notes.org")
(cl-letf (((symbol-function 'call-interactively)
- (lambda (_fn)
+ (lambda (_fn &rest _)
(setq seen-targets org-refile-targets)))
((symbol-function 'save-buffer) #'ignore))
(cj/org-refile-in-file))
@@ -73,7 +73,7 @@
(setq buffer-file-name "/tmp/notes.org")
(cl-letf (((symbol-function 'call-interactively) #'ignore)
((symbol-function 'save-buffer)
- (lambda () (setq saved t))))
+ (lambda (&rest _) (setq saved t))))
(cj/org-refile-in-file))
(setq buffer-file-name nil))
(should saved)))
diff --git a/tests/test-org-refile-config-scan-targets.el b/tests/test-org-refile-config-scan-targets.el
index 71451a29a..6123d3262 100644
--- a/tests/test-org-refile-config-scan-targets.el
+++ b/tests/test-org-refile-config-scan-targets.el
@@ -101,9 +101,10 @@ maxlevel rules when no roam tags and no code/projects todo files exist."
(should (= 1 hits)))
(delete-directory tmp t))))
-(ert-deftest test-org-refile-scan-targets-includes-roam-project-and-topic-files ()
- "Normal: when the roam helpers are available, Project and Topic files
-become additional refile targets."
+(ert-deftest test-org-refile-scan-targets-includes-roam-topic-not-project ()
+ "Normal: roam Topic files become refile targets; Project files do NOT.
+Project notes were dropped as refile targets (2026-06-24) -- roam Projects are
+no longer scanned for refile."
(let* ((tmp (file-name-as-directory (make-temp-file "cj-refile-roam-" t)))
(inbox-file "/tmp/test-inbox.org")
(reference-file "/tmp/test-reference.org")
@@ -121,8 +122,8 @@ become additional refile targets."
(lambda () nil)))
(let* ((result (cj/--org-refile-scan-targets))
(paths (mapcar #'car result)))
- (should (member "/notes/alpha.org" paths))
- (should (member "/notes/topic.org" paths))))
+ (should (member "/notes/topic.org" paths))
+ (should-not (member "/notes/alpha.org" paths))))
(delete-directory tmp t))))
(ert-deftest test-org-refile-scan-targets-survives-permission-denied ()
diff --git a/tests/test-org-reveal-config-header-template.el b/tests/test-org-reveal-config-header-template.el
index df1db9e77..9bda10db7 100644
--- a/tests/test-org-reveal-config-header-template.el
+++ b/tests/test-org-reveal-config-header-template.el
@@ -24,9 +24,9 @@
;; Helper to call template with deterministic date and author
(defun test-reveal--header (title)
"Call cj/--reveal-header-template with TITLE, mocking time and user."
- (cl-letf (((symbol-function 'user-full-name) (lambda () "Test Author"))
+ (cl-letf (((symbol-function 'user-full-name) (lambda (&rest _) "Test Author"))
((symbol-function 'format-time-string)
- (lambda (_fmt) "2026-02-14")))
+ (lambda (_fmt &rest _) "2026-02-14")))
(cj/--reveal-header-template title)))
;;; Normal Cases
diff --git a/tests/test-org-webclipper-commands.el b/tests/test-org-webclipper-commands.el
index be7fc38cf..fb693192f 100644
--- a/tests/test-org-webclipper-commands.el
+++ b/tests/test-org-webclipper-commands.el
@@ -120,7 +120,7 @@ that registers the webclip entry. Providing `'org-protocol' fires the block."
(let ((cj/--webclip-url "https://example.com")
(cj/--webclip-title "Title"))
(cl-letf (((symbol-function 'require) (lambda (&rest _) t))
- ((symbol-function 'executable-find) (lambda (_) nil)))
+ ((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(let ((err (should-error (cj/org-protocol-webclip-handler)
:type 'user-error)))
(should (string-match-p "pandoc" (cadr err)))))))
@@ -130,7 +130,7 @@ that registers the webclip entry. Providing `'org-protocol' fires the block."
(let ((cj/--webclip-url "https://example.com")
(cj/--webclip-title "Title"))
(cl-letf (((symbol-function 'require) (lambda (&rest _) t))
- ((symbol-function 'executable-find) (lambda (_) "/usr/bin/pandoc"))
+ ((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/pandoc"))
((symbol-function 'org-web-tools--url-as-readable-org)
(lambda (_) "* Page Title\n** Sub heading\nBody.\n"))
((symbol-function 'message) #'ignore))
@@ -142,7 +142,7 @@ that registers the webclip entry. Providing `'org-protocol' fires the block."
(let ((cj/--webclip-url "https://example.com")
(cj/--webclip-title "Title"))
(cl-letf (((symbol-function 'require) (lambda (&rest _) t))
- ((symbol-function 'executable-find) (lambda (_) "/usr/bin/pandoc"))
+ ((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/pandoc"))
((symbol-function 'org-web-tools--url-as-readable-org)
(lambda (_) "* Page Title\n** Sub heading\nBody.\n"))
((symbol-function 'message) #'ignore))
diff --git a/tests/test-prog-c-mode-settings.el b/tests/test-prog-c-mode-settings.el
index 37a77a213..33c503377 100644
--- a/tests/test-prog-c-mode-settings.el
+++ b/tests/test-prog-c-mode-settings.el
@@ -18,7 +18,7 @@
(cl-letf (((symbol-function 'auto-fill-mode) (lambda (&rest _) nil))
((symbol-function 'electric-pair-local-mode) (lambda (&rest _) nil))
((symbol-function 'lsp-deferred) (lambda (&rest _) nil))
- ((symbol-function 'executable-find) (lambda (_) nil)))
+ ((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(cj/c-mode-settings))
(should (eq indent-tabs-mode nil))
(should (= c-basic-offset 4))
@@ -33,7 +33,7 @@
(cl-letf (((symbol-function 'auto-fill-mode) (lambda (&rest _) nil))
((symbol-function 'electric-pair-local-mode) (lambda (&rest _) nil))
((symbol-function 'lsp-deferred) (lambda () (cl-incf lsp-calls)))
- ((symbol-function 'executable-find) (lambda (_) "/usr/bin/clangd")))
+ ((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/clangd")))
(cj/c-mode-settings)))
(should (= lsp-calls 1))))
@@ -44,7 +44,7 @@
(cl-letf (((symbol-function 'auto-fill-mode) (lambda (&rest _) nil))
((symbol-function 'electric-pair-local-mode) (lambda (&rest _) nil))
((symbol-function 'lsp-deferred) (lambda () (cl-incf lsp-calls)))
- ((symbol-function 'executable-find) (lambda (_) nil)))
+ ((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(cj/c-mode-settings)))
(should (zerop lsp-calls))))
diff --git a/tests/test-prog-general--find-file-respecting-split.el b/tests/test-prog-general--find-file-respecting-split.el
index 6d45c51c0..821cc79d6 100644
--- a/tests/test-prog-general--find-file-respecting-split.el
+++ b/tests/test-prog-general--find-file-respecting-split.el
@@ -23,9 +23,9 @@
(delete-other-windows)
(let (current-arg other-called)
(cl-letf (((symbol-function 'find-file)
- (lambda (f) (setq current-arg f)))
+ (lambda (f &rest _) (setq current-arg f)))
((symbol-function 'find-file-other-window)
- (lambda (_f) (setq other-called t))))
+ (lambda (_f &rest _) (setq other-called t))))
(cj/--find-file-respecting-split "/tmp/proj/todo.org"))
(should (equal current-arg "/tmp/proj/todo.org"))
(should-not other-called))))
@@ -37,9 +37,9 @@
(split-window-right)
(let (other-arg current-called)
(cl-letf (((symbol-function 'find-file-other-window)
- (lambda (f) (setq other-arg f)))
+ (lambda (f &rest _) (setq other-arg f)))
((symbol-function 'find-file)
- (lambda (_f) (setq current-called t))))
+ (lambda (_f &rest _) (setq current-called t))))
(cj/--find-file-respecting-split "/tmp/proj/todo.org"))
(should (equal other-arg "/tmp/proj/todo.org"))
(should-not current-called))))
@@ -52,9 +52,9 @@
(split-window-below)
(let (other-called current-called)
(cl-letf (((symbol-function 'find-file-other-window)
- (lambda (_f) (setq other-called t)))
+ (lambda (_f &rest _) (setq other-called t)))
((symbol-function 'find-file)
- (lambda (_f) (setq current-called t))))
+ (lambda (_f &rest _) (setq current-called t))))
(cj/--find-file-respecting-split "/tmp/proj/todo.org"))
(should other-called)
(should-not current-called))))
diff --git a/tests/test-prog-general-open-project-daily-prep.el b/tests/test-prog-general-open-project-daily-prep.el
index d9c78ff0e..5bc4d7d27 100644
--- a/tests/test-prog-general-open-project-daily-prep.el
+++ b/tests/test-prog-general-open-project-daily-prep.el
@@ -40,7 +40,7 @@
(unwind-protect
(progn
(cl-letf (((symbol-function 'projectile-project-root) (lambda () root))
- ((symbol-function 'find-file-other-window) (lambda (f) (setq opened f))))
+ ((symbol-function 'find-file-other-window) (lambda (f &rest _) (setq opened f))))
(setq result (cj/open-project-daily-prep)))
(should-not opened)
(should (string-match-p "No daily-prep.org" result)))
@@ -50,7 +50,7 @@
"Error: outside a Projectile project, do not open; report it."
(let (opened result)
(cl-letf (((symbol-function 'projectile-project-root) (lambda () nil))
- ((symbol-function 'find-file-other-window) (lambda (f) (setq opened f))))
+ ((symbol-function 'find-file-other-window) (lambda (f &rest _) (setq opened f))))
(setq result (cj/open-project-daily-prep)))
(should-not opened)
(should (string-match-p "Not in a Projectile project" result))))
diff --git a/tests/test-prog-go-commands.el b/tests/test-prog-go-commands.el
index a2fc0625f..6e6998348 100644
--- a/tests/test-prog-go-commands.el
+++ b/tests/test-prog-go-commands.el
@@ -54,7 +54,7 @@
((symbol-function 'lsp-deferred)
(lambda (&rest _) (setq started t)))
((symbol-function 'executable-find)
- (lambda (path) (when (equal path gopls-path) "/usr/bin/gopls"))))
+ (lambda (path &rest _) (when (equal path gopls-path) "/usr/bin/gopls"))))
(cj/go-setup))
(should started))))
@@ -66,7 +66,7 @@
((symbol-function 'electric-pair-local-mode) #'ignore)
((symbol-function 'lsp-deferred)
(lambda (&rest _) (setq started t)))
- ((symbol-function 'executable-find) (lambda (_) nil)))
+ ((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(cj/go-setup))
(should-not started))))
@@ -104,7 +104,7 @@
"Normal: with delve on PATH, `gud-gdb' is called with `dlv debug'."
(let (started)
(cl-letf (((symbol-function 'executable-find)
- (lambda (path) (when (equal path dlv-path) "/usr/bin/dlv")))
+ (lambda (path &rest _) (when (equal path dlv-path) "/usr/bin/dlv")))
((symbol-function 'file-executable-p) (lambda (_) nil))
((symbol-function 'gud-gdb)
(lambda (cmd &rest _) (setq started cmd))))
@@ -117,7 +117,7 @@
"Error: delve missing -> message + no gud-gdb call."
(let ((started nil)
(msg nil))
- (cl-letf (((symbol-function 'executable-find) (lambda (_) nil))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) nil))
((symbol-function 'file-executable-p) (lambda (_) nil))
((symbol-function 'gud-gdb)
(lambda (&rest _) (setq started t)))
diff --git a/tests/test-prog-json--json-format-buffer.el b/tests/test-prog-json--json-format-buffer.el
index 70d7e98bb..c6297a404 100644
--- a/tests/test-prog-json--json-format-buffer.el
+++ b/tests/test-prog-json--json-format-buffer.el
@@ -16,7 +16,7 @@
(ert-deftest test-prog-json--json-format-buffer-invokes-jq-argv ()
"Normal: with jq present, the formatter calls jq via argv, no shell."
(let (program args)
- (cl-letf (((symbol-function 'executable-find) (lambda (_p) "/usr/bin/jq"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_p &rest _) "/usr/bin/jq"))
((symbol-function 'call-process-region)
(lambda (_start _end prog &rest rest)
(setq program prog
@@ -31,7 +31,7 @@
(ert-deftest test-prog-json--json-format-buffer-no-clobber-on-failure ()
"Error: a non-zero jq exit leaves the buffer untouched and signals an error."
- (cl-letf (((symbol-function 'executable-find) (lambda (_p) "/usr/bin/jq"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_p &rest _) "/usr/bin/jq"))
((symbol-function 'call-process-region)
(lambda (_start _end _prog _delete buffer &rest _)
(with-current-buffer buffer (insert "jq: parse error"))
@@ -112,7 +112,7 @@
(ert-deftest test-prog-json--json-format-buffer-fallback-formats-without-jq ()
"Falls back to built-in formatter when jq is not found."
- (cl-letf (((symbol-function 'executable-find) (lambda (_) nil)))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(with-temp-buffer
(insert "{\"b\":1,\"a\":2}")
(cj/json-format-buffer)
diff --git a/tests/test-prog-lsp.el b/tests/test-prog-lsp.el
new file mode 100644
index 000000000..7e38111d0
--- /dev/null
+++ b/tests/test-prog-lsp.el
@@ -0,0 +1,66 @@
+;;; test-prog-lsp.el --- Startup smoke test for LSP config resolution -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; A narrow smoke test of prog-lsp.el, the central LSP module. It pins the
+;; invariants that should hold the moment the config loads, before any server
+;; starts: lsp-enable-remote stays nil (so TRAMP files don't auto-start a slow
+;; LSP), the file-watch-ignore defaults live in one idempotent place, the eldoc
+;; provider is stripped from the global hook, and a mode never accrues a
+;; duplicate lsp-deferred entry. The generic :config defaults are deferred to
+;; lsp-mode's own load (see the make-test no-package-initialize note in
+;; CLAUDE.md), so this tests the top-level :init and helper surface, which runs.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'use-package)
+(require 'prog-lsp)
+
+;; lsp-mode's defcustom isn't loaded under make test, and prog-lsp's bare
+;; `(defvar lsp-file-watch-ignored-directories)' only marks it special within
+;; that file's unit. Declare it special here too so the `let' bindings below
+;; bind dynamically (the helper reads it through the symbol via add-to-list).
+(defvar lsp-file-watch-ignored-directories nil)
+
+(ert-deftest test-prog-lsp-enable-remote-nil ()
+ "Normal: lsp-enable-remote is nil so LSP never auto-starts on TRAMP files."
+ (should (boundp 'lsp-enable-remote))
+ (should (null lsp-enable-remote)))
+
+(ert-deftest test-prog-lsp-file-watch-adds-extras ()
+ "Normal: the build/cache ignore patterns get appended to lsp's watch-ignore list."
+ (let ((lsp-file-watch-ignored-directories '("[/\\\\]\\.git\\'")))
+ (cj/lsp--add-file-watch-ignored-extras)
+ (dolist (pattern cj/lsp-file-watch-ignored-extras)
+ (should (member pattern lsp-file-watch-ignored-directories)))
+ (should (member "[/\\\\]\\.git\\'" lsp-file-watch-ignored-directories))))
+
+(ert-deftest test-prog-lsp-file-watch-idempotent ()
+ "Boundary: adding the extras twice leaves each pattern present exactly once."
+ (let ((lsp-file-watch-ignored-directories '()))
+ (cj/lsp--add-file-watch-ignored-extras)
+ (cj/lsp--add-file-watch-ignored-extras)
+ (dolist (pattern cj/lsp-file-watch-ignored-extras)
+ (should (= 1 (cl-count pattern lsp-file-watch-ignored-directories
+ :test #'equal))))))
+
+(ert-deftest test-prog-lsp-eldoc-provider-removed-globally ()
+ "Normal: the global eldoc provider is stripped so lsp can't reattach it."
+ (let ((eldoc-documentation-functions
+ (list #'lsp-eldoc-function #'ignore)))
+ (cj/lsp--remove-eldoc-provider-global)
+ (should-not (memq 'lsp-eldoc-function eldoc-documentation-functions))
+ (should (memq 'ignore eldoc-documentation-functions))))
+
+(ert-deftest test-prog-lsp-no-duplicate-mode-hook ()
+ "Boundary: a mode prog-lsp wires never holds more than one lsp-deferred entry.
+prog-lsp and the per-language modules both add lsp-deferred for some modes;
+add-hook dedups identical symbols, and this pins that invariant so a future
+non-symbol (lambda) addition that breaks it gets caught."
+ (dolist (hook '(c-mode-hook python-mode-hook go-ts-mode-hook))
+ (when (boundp hook)
+ (should (>= 1 (cl-count 'lsp-deferred (symbol-value hook)))))))
+
+(provide 'test-prog-lsp)
+;;; test-prog-lsp.el ends here
diff --git a/tests/test-prog-python-commands.el b/tests/test-prog-python-commands.el
index 443e7d175..55aa502f7 100644
--- a/tests/test-prog-python-commands.el
+++ b/tests/test-prog-python-commands.el
@@ -64,7 +64,7 @@
"Normal: with mypy on PATH, `compile' gets the builder's command."
(let ((mypy-path "mypy")
compiled)
- (cl-letf (((symbol-function 'executable-find) (lambda (_p) "/usr/bin/mypy"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_p &rest _) "/usr/bin/mypy"))
((symbol-function 'compile) (lambda (cmd &rest _) (setq compiled cmd))))
(with-temp-buffer
(setq buffer-file-name "/home/me/foo.py")
@@ -76,7 +76,7 @@
"Boundary: no file -> the command targets `default-directory'."
(let ((mypy-path "mypy")
compiled)
- (cl-letf (((symbol-function 'executable-find) (lambda (_p) "/usr/bin/mypy"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_p &rest _) "/usr/bin/mypy"))
((symbol-function 'compile) (lambda (cmd &rest _) (setq compiled cmd))))
(with-temp-buffer
(setq-local default-directory "/home/me/proj/")
@@ -88,7 +88,7 @@
(let ((mypy-path "mypy")
(compiled nil)
(messaged nil))
- (cl-letf (((symbol-function 'executable-find) (lambda (_p) nil))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_p &rest _) nil))
((symbol-function 'compile) (lambda (&rest _) (setq compiled t)))
((symbol-function 'message) (lambda (fmt &rest args)
(setq messaged (apply #'format fmt args)))))
diff --git a/tests/test-prog-python-setup.el b/tests/test-prog-python-setup.el
index 0b56f8cc9..368097c9e 100644
--- a/tests/test-prog-python-setup.el
+++ b/tests/test-prog-python-setup.el
@@ -71,7 +71,7 @@ electric-pair-local-mode all get called once."
((symbol-function 'lsp-deferred)
(lambda (&rest _) (setq started t)))
((symbol-function 'executable-find)
- (lambda (path) (when (equal path pyright-path)
+ (lambda (path &rest _) (when (equal path pyright-path)
"/usr/bin/pyright"))))
(cj/python-setup))
(should started))))
@@ -86,7 +86,7 @@ electric-pair-local-mode all get called once."
((symbol-function 'electric-pair-local-mode) #'ignore)
((symbol-function 'lsp-deferred)
(lambda (&rest _) (setq started t)))
- ((symbol-function 'executable-find) (lambda (_) nil)))
+ ((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(cj/python-setup))
(should-not started))))
diff --git a/tests/test-prog-webdev-format.el b/tests/test-prog-webdev-format.el
index 694f9e968..cb5da406c 100644
--- a/tests/test-prog-webdev-format.el
+++ b/tests/test-prog-webdev-format.el
@@ -46,7 +46,7 @@
(ert-deftest test-prog-webdev-format-buffer-runs-prettier-on-the-file ()
"Normal: with prettier on PATH, the argv targets `buffer-file-name'."
(let (program args)
- (cl-letf (((symbol-function 'executable-find) (lambda (_p) "/usr/bin/prettier"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_p &rest _) "/usr/bin/prettier"))
((symbol-function 'call-process-region)
(lambda (_start _end prog &rest rest)
;; rest = (DELETE BUFFER DISPLAY &rest ARGS)
@@ -64,7 +64,7 @@
(ert-deftest test-prog-webdev-format-buffer-falls-back-to-file-ts ()
"Boundary: a buffer with no file uses the \"file.ts\" filename hint."
(let (args)
- (cl-letf (((symbol-function 'executable-find) (lambda (_p) "/usr/bin/prettier"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_p &rest _) "/usr/bin/prettier"))
((symbol-function 'call-process-region)
(lambda (_start _end _prog &rest rest)
(setq args (nthcdr 3 rest))
@@ -77,7 +77,7 @@
(ert-deftest test-prog-webdev-format-buffer-clamps-point-to-point-max ()
"Boundary: after a format that shrinks the buffer, point clamps to point-max."
- (cl-letf (((symbol-function 'executable-find) (lambda (_p) "/usr/bin/prettier"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_p &rest _) "/usr/bin/prettier"))
((symbol-function 'call-process-region)
(lambda (_start _end _prog _delete buffer &rest _)
;; Simulate prettier writing a shorter result to the output buffer.
@@ -91,7 +91,7 @@
(ert-deftest test-prog-webdev-format-buffer-replaces-on-success ()
"Normal: a zero exit replaces the buffer with the formatter's output."
- (cl-letf (((symbol-function 'executable-find) (lambda (_p) "/usr/bin/prettier"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_p &rest _) "/usr/bin/prettier"))
((symbol-function 'call-process-region)
(lambda (_start _end _prog _delete buffer &rest _)
(with-current-buffer buffer (insert "const x = 1;\n"))
@@ -103,7 +103,7 @@
(ert-deftest test-prog-webdev-format-buffer-no-clobber-on-failure ()
"Error: a non-zero exit leaves the buffer untouched and signals an error."
- (cl-letf (((symbol-function 'executable-find) (lambda (_p) "/usr/bin/prettier"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_p &rest _) "/usr/bin/prettier"))
((symbol-function 'call-process-region)
(lambda (_start _end _prog _delete buffer &rest _)
(with-current-buffer buffer (insert "[error] syntax error"))
@@ -117,7 +117,7 @@
(ert-deftest test-prog-webdev-format-buffer-errors-without-prettier ()
"Error: prettier missing -> `user-error', nothing shells out."
(let ((ran nil))
- (cl-letf (((symbol-function 'executable-find) (lambda (_p) nil))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_p &rest _) nil))
((symbol-function 'call-process-region)
(lambda (&rest _) (setq ran t) 0)))
(with-temp-buffer
diff --git a/tests/test-prog-webdev-setup.el b/tests/test-prog-webdev-setup.el
index 45310f237..906a54151 100644
--- a/tests/test-prog-webdev-setup.el
+++ b/tests/test-prog-webdev-setup.el
@@ -67,7 +67,7 @@ electric-pair-local-mode all get called."
((symbol-function 'lsp-deferred)
(lambda (&rest _) (setq started t)))
((symbol-function 'executable-find)
- (lambda (path) (when (equal path ts-language-server-path)
+ (lambda (path &rest _) (when (equal path ts-language-server-path)
"/usr/bin/typescript-language-server"))))
(cj/webdev-setup))
(should started))))
@@ -82,7 +82,7 @@ electric-pair-local-mode all get called."
((symbol-function 'electric-pair-local-mode) #'ignore)
((symbol-function 'lsp-deferred)
(lambda (&rest _) (setq started t)))
- ((symbol-function 'executable-find) (lambda (_) nil)))
+ ((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(cj/webdev-setup))
(should-not started))))
diff --git a/tests/test-prog-yaml--yaml-format-buffer.el b/tests/test-prog-yaml--yaml-format-buffer.el
index 28ad351f9..aae3199ce 100644
--- a/tests/test-prog-yaml--yaml-format-buffer.el
+++ b/tests/test-prog-yaml--yaml-format-buffer.el
@@ -14,7 +14,7 @@
(ert-deftest test-prog-yaml--yaml-format-buffer-invokes-prettier-argv ()
"Normal: with prettier present, the formatter calls it via argv, no shell."
(let (program args)
- (cl-letf (((symbol-function 'executable-find) (lambda (_p) "/usr/bin/prettier"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_p &rest _) "/usr/bin/prettier"))
((symbol-function 'call-process-region)
(lambda (_start _end prog &rest rest)
(setq program prog
@@ -29,7 +29,7 @@
(ert-deftest test-prog-yaml--yaml-format-buffer-no-clobber-on-failure ()
"Error: a non-zero prettier exit leaves the buffer untouched and errors."
- (cl-letf (((symbol-function 'executable-find) (lambda (_p) "/usr/bin/prettier"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_p &rest _) "/usr/bin/prettier"))
((symbol-function 'call-process-region)
(lambda (_start _end _prog _delete buffer &rest _)
(with-current-buffer buffer (insert "[error] bad yaml"))
@@ -98,7 +98,7 @@
(ert-deftest test-prog-yaml--yaml-format-buffer-error-no-prettier ()
"Signals user-error when prettier is not found."
- (cl-letf (((symbol-function 'executable-find) (lambda (_) nil)))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(with-temp-buffer
(insert "key: value\n")
(should-error (cj/yaml-format-buffer) :type 'user-error))))
diff --git a/tests/test-slack-config-commands.el b/tests/test-slack-config-commands.el
index 8944662ef..21cbb3e5a 100644
--- a/tests/test-slack-config-commands.el
+++ b/tests/test-slack-config-commands.el
@@ -194,7 +194,7 @@
((symbol-function 'slack-buffer-update-mark-request)
(lambda (_buf ts) (setq marked ts)))
((symbol-function 'bury-buffer)
- (lambda () (setq buried t))))
+ (lambda (&rest _) (setq buried t))))
(cj/slack-mark-read-and-bury))
(should (equal marked "1234.5678"))
(should buried)))
@@ -207,7 +207,7 @@
(cl-letf (((symbol-function 'slack-buffer-update-mark-request)
(lambda (&rest _) (setq marked t)))
((symbol-function 'bury-buffer)
- (lambda () (setq buried t))))
+ (lambda (&rest _) (setq buried t))))
(cj/slack-mark-read-and-bury))
(should-not marked)
(should buried)))
diff --git a/tests/test-system-commands-resolve-and-run.el b/tests/test-system-commands-resolve-and-run.el
index 2c9d98d0c..af2288fd9 100644
--- a/tests/test-system-commands-resolve-and-run.el
+++ b/tests/test-system-commands-resolve-and-run.el
@@ -118,19 +118,19 @@ does not run the command."
(ert-deftest test-system-cmd-service-available-true-on-zero-exit ()
"Normal: service is available when systemctl exists and `cat' exits 0."
- (cl-letf (((symbol-function 'executable-find) (lambda (_p) "/usr/bin/systemctl"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_p &rest _) "/usr/bin/systemctl"))
((symbol-function 'call-process) (lambda (&rest _) 0)))
(should (cj/system-cmd--emacs-service-available-p))))
(ert-deftest test-system-cmd-service-available-false-on-nonzero-exit ()
"Boundary: a nonzero exit (no such unit) means not available."
- (cl-letf (((symbol-function 'executable-find) (lambda (_p) "/usr/bin/systemctl"))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_p &rest _) "/usr/bin/systemctl"))
((symbol-function 'call-process) (lambda (&rest _) 1)))
(should-not (cj/system-cmd--emacs-service-available-p))))
(ert-deftest test-system-cmd-service-available-false-when-systemctl-absent ()
"Error: with no systemctl on PATH the service can't be available."
- (cl-letf (((symbol-function 'executable-find) (lambda (_p) nil))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_p &rest _) nil))
((symbol-function 'call-process)
(lambda (&rest _) (error "must not shell out without systemctl"))))
(should-not (cj/system-cmd--emacs-service-available-p))))
@@ -220,7 +220,7 @@ kill-emacs directly (the service owns the daemon lifecycle)."
(cl-letf (((symbol-function 'completing-read)
(lambda (&rest _) "Lock Screen"))
((symbol-function 'call-interactively)
- (lambda (cmd) (setq called cmd))))
+ (lambda (cmd &rest _) (setq called cmd))))
(cj/system-command-menu))
(should (eq called 'cj/system-cmd-lock))))
diff --git a/tests/test-system-defaults-functions.el b/tests/test-system-defaults-functions.el
index a5210be01..2562ff6aa 100644
--- a/tests/test-system-defaults-functions.el
+++ b/tests/test-system-defaults-functions.el
@@ -79,20 +79,6 @@
(should (eq (cj/disabled) nil))
(should (commandp #'cj/disabled)))
-;;; cj/minibuffer-setup-hook / cj/minibuffer-exit-hook
-
-(ert-deftest test-system-defaults-minibuffer-setup-inflates-gc-threshold ()
- "Normal: entering the minibuffer raises `gc-cons-threshold' to most-positive-fixnum."
- (let ((gc-cons-threshold 800000))
- (cj/minibuffer-setup-hook)
- (should (= gc-cons-threshold most-positive-fixnum))))
-
-(ert-deftest test-system-defaults-minibuffer-exit-restores-gc-threshold ()
- "Normal: leaving the minibuffer restores `gc-cons-threshold' to 800000."
- (let ((gc-cons-threshold most-positive-fixnum))
- (cj/minibuffer-exit-hook)
- (should (= gc-cons-threshold 800000))))
-
;;; unpropertize-kill-ring
(ert-deftest test-system-defaults-unpropertize-kill-ring-strips-properties ()
diff --git a/tests/test-system-defaults.el b/tests/test-system-defaults.el
index 928124f56..f653e1fbb 100644
--- a/tests/test-system-defaults.el
+++ b/tests/test-system-defaults.el
@@ -63,19 +63,6 @@ test clears it first to capture the path derived from the sandbox."
(expand-file-name dir)))
(should (string-suffix-p "backups" (directory-file-name dir)))))))
-;;; minibuffer GC hooks
-
-(ert-deftest test-system-defaults-minibuffer-gc-hooks-registered ()
- "Normal: the minibuffer GC raise/restore hooks are installed.
-Their bodies are tested in test-system-defaults-functions.el; this asserts
-they are actually wired onto the minibuffer hooks."
- (test-system-defaults--with-load-environment
- (let ((minibuffer-setup-hook nil)
- (minibuffer-exit-hook nil))
- (test-system-defaults--load)
- (should (memq 'cj/minibuffer-setup-hook minibuffer-setup-hook))
- (should (memq 'cj/minibuffer-exit-hook minibuffer-exit-hook)))))
-
;;; Customize-save warning
(ert-deftest test-system-defaults-customize-save-warns-once ()
diff --git a/tests/test-term-config--f8-in-term.el b/tests/test-term-config--f8-in-term.el
deleted file mode 100644
index 6cee4ff46..000000000
--- a/tests/test-term-config--f8-in-term.el
+++ /dev/null
@@ -1,42 +0,0 @@
-;;; test-term-config--f8-in-term.el --- F8 reaches Emacs from inside a ghostel buffer -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; <f8> is a global binding (`cj/main-agenda-display', set in org-agenda-config).
-;; ghostel's semi-char mode forwards every key NOT in `ghostel-keymap-exceptions'
-;; to the terminal program, so a plain <f8> typed while point is in a ghostel
-;; buffer would be sent to the program instead of opening the agenda. Unlike the
-;; F9 family, F8 is NOT re-bound in `ghostel-mode-map' -- it simply falls through
-;; to the global map once the semi-char map stops forwarding it, so the only
-;; wiring term-config.el adds is the keymap-exceptions entry plus the rebuild.
-;; These tests require ghostel (so term-config's `with-eval-after-load' fires)
-;; BEFORE term-config, then confirm the exception landed and the rebuilt
-;; semi-char map no longer forwards <f8>. `(require 'ghostel)' does not load the
-;; native module, so this stays light.
-
-;;; Code:
-
-(require 'ert)
-(require 'package)
-
-(setq package-user-dir (expand-file-name "elpa" user-emacs-directory))
-(package-initialize)
-(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'ghostel)
-(require 'term-config)
-
-(ert-deftest test-term-config-f8-in-keymap-exceptions ()
- "Regression: <f8> is in `ghostel-keymap-exceptions' so semi-char mode lets it
-reach Emacs instead of forwarding it to the terminal program. This is what lets
-the global agenda binding work from inside a ghostel buffer."
- (should (member "<f8>" ghostel-keymap-exceptions)))
-
-(ert-deftest test-term-config-f8-not-forwarded-by-semi-char-map ()
- "Regression: the rebuilt semi-char map must no longer forward <f8> to the pty.
-`add-to-list' updates the exceptions list but not the already-built map -- only
-`ghostel--rebuild-semi-char-keymap' (run in term-config's :init) drops the
-forwarding binding so <f8> falls through to the global agenda command."
- (should-not (eq (keymap-lookup ghostel-semi-char-mode-map "<f8>")
- 'ghostel--send-event)))
-
-(provide 'test-term-config--f8-in-term)
-;;; test-term-config--f8-in-term.el ends here
diff --git a/tests/test-term-tmux-history.el b/tests/test-term-tmux-history.el
index 51e9725c4..08d39e5bf 100644
--- a/tests/test-term-tmux-history.el
+++ b/tests/test-term-tmux-history.el
@@ -1,14 +1,13 @@
-;;; test-term-tmux-history.el --- Tests for term-config tmux history + menu UX -*- lexical-binding: t; -*-
+;;; test-term-tmux-history.el --- Tests for the EAT terminal copy-mode + tmux history -*- lexical-binding: t; -*-
;;; Commentary:
-;; Exercises the term-config (ghostel) terminal UX: the Emacs-owned tmux
-;; history buffer, the copy-mode-dwim engine pick, the tmux pane-id /
-;; attached-client predicates, and the C-; x menu bindings.
+;; Exercises the terminal UX carried into eat-config for the EAT agent
+;; terminals: the Emacs-owned tmux history buffer, the copy-mode-dwim engine
+;; pick, the tmux pane-id / attached-client predicates, and the C-; x menu
+;; bindings. Agents run EAT over tmux, so copy-mode is tmux's own copy-mode.
;;
-;; ghostel is required (which defines `ghostel-mode-map' /
-;; `ghostel-keymap-exceptions' and lets term-config's `with-eval-after-load'
-;; fire) before term-config. `(require 'ghostel)' does not load the native
-;; module; tmux is mocked via `process-file', so nothing spawns.
+;; eat is required (so eat-config's `with-eval-after-load' fires for the C-<up>
+;; bind) before eat-config; tmux is mocked via `process-file', so nothing spawns.
;;; Code:
@@ -21,8 +20,8 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
(setq load-prefer-newer t)
-(require 'ghostel)
-(require 'term-config)
+(require 'eat)
+(require 'eat-config)
(require 'testutil-ghostel-buffers)
(defmacro test-term-tmux-history--with-tmux-mock (responses &rest body)
@@ -51,6 +50,8 @@ RESPONSES is an alist of (ARGS EXIT-CODE OUTPUT)."
exit-code))))
,@body)))
+;;; tmux helpers
+
(ert-deftest test-term-tmux-history--pane-id-for-tty-matches-client ()
"Normal: current terminal pty maps to the active pane for that tmux client."
(test-term-tmux-history--with-tmux-mock
@@ -66,16 +67,39 @@ RESPONSES is an alist of (ARGS EXIT-CODE OUTPUT)."
(should (equal (cj/term--tmux-capture-pane "%8")
"first line\nsecond line\n"))))
+(ert-deftest test-term-current-tmux-pane-id-rejects-non-eat-buffer ()
+ "Error: pane-id lookup refuses a buffer that is not in `eat-mode'."
+ (with-temp-buffer
+ (should-error (cj/term--current-tmux-pane-id) :type 'user-error)))
+
+(ert-deftest test-term-current-tmux-pane-id-accepts-agent-named-buffer ()
+ "Normal: an agent-named eat buffer resolves by process TTY, not buffer name."
+ (let ((agent (cj/test--make-fake-eat-buffer "agent [emacs.d]")))
+ (unwind-protect
+ (with-current-buffer agent
+ (cl-letf (((symbol-function 'get-buffer-process)
+ (lambda (_buffer) 'fake-process))
+ ((symbol-function 'process-tty-name)
+ (lambda (_process &rest _) "/dev/pts/8")))
+ (test-term-tmux-history--with-tmux-mock
+ '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0
+ "/dev/pts/1\t%1\n/dev/pts/8\t%8\n"))
+ (should (equal (cj/term--current-tmux-pane-id) "%8")))))
+ (when (buffer-live-p agent)
+ (kill-buffer agent)))))
+
+;;; tmux history buffer
+
(ert-deftest test-term-tmux-history-open-renders-read-only-history-buffer ()
- "Normal: command renders tmux history in a normal Emacs buffer."
- (let ((origin (cj/test--make-fake-ghostel-buffer "*test-term-history-origin*")))
+ "Normal: the command renders tmux history in a normal Emacs buffer."
+ (let ((origin (cj/test--make-fake-eat-buffer "*test-term-history-origin*")))
(unwind-protect
(save-window-excursion
(switch-to-buffer origin)
(cl-letf (((symbol-function 'get-buffer-process)
(lambda (_buffer) 'fake-process))
((symbol-function 'process-tty-name)
- (lambda (_process) "/dev/pts/8")))
+ (lambda (_process &rest _) "/dev/pts/8")))
(test-term-tmux-history--with-tmux-mock
'((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0
"/dev/pts/8\t%8\n")
@@ -90,41 +114,8 @@ RESPONSES is an alist of (ARGS EXIT-CODE OUTPUT)."
(when (buffer-live-p origin)
(kill-buffer origin)))))
-(ert-deftest test-term-tmux-history-replaces-origin-buffer-in-same-window ()
- "Normal: the history view replaces the origin in the selected window.
-
-`cj/term-tmux-history' uses `switch-to-buffer' so reading scrollback keeps
-the terminal's frame slot rather than splitting or popping a new window."
- (let ((origin (cj/test--make-fake-ghostel-buffer "*test-term-history-inplace*")))
- (unwind-protect
- (save-window-excursion
- (delete-other-windows)
- (switch-to-buffer origin)
- (let ((win (selected-window)))
- (should (eq (window-buffer win) origin))
- (should (one-window-p))
- (cl-letf (((symbol-function 'get-buffer-process)
- (lambda (_buffer) 'fake-process))
- ((symbol-function 'process-tty-name)
- (lambda (_process) "/dev/pts/8")))
- (test-term-tmux-history--with-tmux-mock
- '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0
- "/dev/pts/8\t%8\n")
- (("capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" "%8") 0
- "scrollback line\n"))
- (cj/term-tmux-history)))
- (should (one-window-p))
- (should (eq (selected-window) win))
- (should (string-prefix-p
- "*terminal tmux history:"
- (buffer-name (window-buffer win))))))
- (cj/test--kill-buffers-matching-prefix "*terminal tmux history")
- (when (buffer-live-p origin)
- (kill-buffer origin)))))
-
(ert-deftest test-term-tmux-history-quit-returns-to-origin ()
- "Normal: q / <escape> / C-g (cj/term-tmux-history-quit) kills the history
-buffer and restores the origin buffer, window, and point."
+ "Normal: quit kills the history buffer and restores origin buffer/window/point."
(let ((origin (get-buffer-create "*test-term-history-return*")))
(unwind-protect
(let ((history (get-buffer-create "*terminal tmux history: test*")))
@@ -149,10 +140,8 @@ buffer and restores the origin buffer, window, and point."
(kill-buffer origin)))))
(ert-deftest test-term-tmux-history-mode-keymap ()
- "Normal: in the history buffer M-w copies without quitting; q, <escape>,
-and C-g quit back to the terminal; RET is left unbound (no special exit)."
- (should (eq (keymap-lookup cj/term-tmux-history-mode-map "M-w")
- #'kill-ring-save))
+ "Normal: M-w copies; q/<escape>/C-g quit; RET is left unbound."
+ (should (eq (keymap-lookup cj/term-tmux-history-mode-map "M-w") #'kill-ring-save))
(should (eq (keymap-lookup cj/term-tmux-history-mode-map "q")
#'cj/term-tmux-history-quit))
(should (eq (keymap-lookup cj/term-tmux-history-mode-map "<escape>")
@@ -161,56 +150,17 @@ and C-g quit back to the terminal; RET is left unbound (no special exit)."
#'cj/term-tmux-history-quit))
(should-not (keymap-lookup cj/term-tmux-history-mode-map "RET")))
-(ert-deftest test-term-keymap-includes-history-and-copy-bindings ()
- "Normal: the personal terminal map owns the high-level UX commands, and C-;
-reaches Emacs inside ghostel buffers so the prefix works there."
- (should (member "C-;" ghostel-keymap-exceptions))
- (should (eq (keymap-lookup cj/custom-keymap "x h") #'cj/term-tmux-history))
- (should (eq (keymap-lookup cj/custom-keymap "x c") #'cj/term-copy-mode-dwim))
- (should (equal (keymap-lookup ghostel-mode-map "C-;") cj/custom-keymap))
- (should (eq (keymap-lookup ghostel-mode-map "C-; x h") #'cj/term-tmux-history))
- (should (eq (keymap-lookup ghostel-mode-map "C-; x c") #'cj/term-copy-mode-dwim)))
-
-(ert-deftest test-term-keymap-prompt-navigation ()
- "Normal: n/p navigate prompts, capital N creates a new terminal buffer."
- (should (eq (keymap-lookup cj/custom-keymap "x n") #'ghostel-next-prompt))
- (should (eq (keymap-lookup cj/custom-keymap "x p") #'ghostel-previous-prompt))
- (should (eq (keymap-lookup cj/custom-keymap "x N") #'ghostel)))
-
-(ert-deftest test-term-current-tmux-pane-id-rejects-non-ghostel-buffer ()
- "Error: pane-id lookup refuses a buffer that is not in `ghostel-mode'."
- (with-temp-buffer
- (should-error (cj/term--current-tmux-pane-id) :type 'user-error)))
-
-(ert-deftest test-term-current-tmux-pane-id-accepts-agent-named-buffer ()
- "Normal: an agent-named ghostel buffer resolves by process TTY.
-
-The pane lookup keys off the live process TTY, never the buffer name, so a
-buffer named `agent [repo]' (ai-term.el's naming) resolves like any other
-ghostel-mode terminal."
- (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]")))
- (unwind-protect
- (with-current-buffer agent
- (cl-letf (((symbol-function 'get-buffer-process)
- (lambda (_buffer) 'fake-process))
- ((symbol-function 'process-tty-name)
- (lambda (_process) "/dev/pts/8")))
- (test-term-tmux-history--with-tmux-mock
- '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0
- "/dev/pts/1\t%1\n/dev/pts/8\t%8\n"))
- (should (equal (cj/term--current-tmux-pane-id) "%8")))))
- (when (buffer-live-p agent)
- (kill-buffer agent)))))
+;;; in-tmux-p predicate
(ert-deftest test-term-in-tmux-p-true-when-client-attached ()
"Normal: predicate returns t when tmux reports a client for our tty."
- (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]")))
+ (let ((agent (cj/test--make-fake-eat-buffer "agent [emacs.d]")))
(unwind-protect
(with-current-buffer agent
(cl-letf (((symbol-function 'get-buffer-process)
(lambda (_buffer) 'fake-process))
((symbol-function 'process-tty-name)
- (lambda (_process) "/dev/pts/8")))
+ (lambda (_process &rest _) "/dev/pts/8")))
(test-term-tmux-history--with-tmux-mock
'((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0
"/dev/pts/8\t%8\n"))
@@ -218,31 +168,24 @@ ghostel-mode terminal."
(when (buffer-live-p agent)
(kill-buffer agent)))))
-(ert-deftest test-term-in-tmux-p-nil-when-no-matching-client ()
- "Boundary: predicate returns nil when tmux runs but our tty has no client."
- (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]")))
- (unwind-protect
- (with-current-buffer agent
- (cl-letf (((symbol-function 'get-buffer-process)
- (lambda (_buffer) 'fake-process))
- ((symbol-function 'process-tty-name)
- (lambda (_process) "/dev/pts/8")))
- (test-term-tmux-history--with-tmux-mock
- '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0
- "/dev/pts/1\t%1\n"))
- (should-not (cj/term--in-tmux-p)))))
- (when (buffer-live-p agent)
- (kill-buffer agent)))))
+(ert-deftest test-term-in-tmux-p-nil-when-not-eat-mode ()
+ "Boundary: predicate refuses non-eat buffers without calling tmux."
+ (with-temp-buffer
+ (let ((tmux-called nil))
+ (cl-letf (((symbol-function 'process-file)
+ (lambda (&rest _) (setq tmux-called t) 0)))
+ (should-not (cj/term--in-tmux-p))
+ (should-not tmux-called)))))
(ert-deftest test-term-in-tmux-p-nil-when-tmux-fails ()
"Error: predicate swallows tmux failures and returns nil."
- (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]")))
+ (let ((agent (cj/test--make-fake-eat-buffer "agent [emacs.d]")))
(unwind-protect
(with-current-buffer agent
(cl-letf (((symbol-function 'get-buffer-process)
(lambda (_buffer) 'fake-process))
((symbol-function 'process-tty-name)
- (lambda (_process) "/dev/pts/8")))
+ (lambda (_process &rest _) "/dev/pts/8")))
(test-term-tmux-history--with-tmux-mock
'((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 1
"no server running"))
@@ -250,109 +193,85 @@ ghostel-mode terminal."
(when (buffer-live-p agent)
(kill-buffer agent)))))
-(ert-deftest test-term-in-tmux-p-nil-when-not-ghostel-mode ()
- "Boundary: predicate refuses non-ghostel buffers without calling tmux."
- (with-temp-buffer
- (let ((tmux-called nil))
- (cl-letf (((symbol-function 'process-file)
- (lambda (&rest _) (setq tmux-called t) 0)))
- (should-not (cj/term--in-tmux-p))
- (should-not tmux-called)))))
+;;; copy-mode (tmux path -- the agent terminal case)
(ert-deftest test-term-copy-mode-dwim-sends-tmux-prefix-when-attached ()
- "Normal: with tmux attached, dwim writes C-b [ then C-a into the pty so
-tmux enters its own copy-mode and lands the cursor at the start of the
-line. Without the trailing C-a the cursor inherits the live column (far
-right after a prompt) and scrolling up runs up the right edge; start-of-line
-puts it at column 0 so it runs up the left."
- (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]"))
- (sent nil)
- (copy-mode-called nil))
+ "Normal: with tmux attached, dwim writes C-b [ then C-a into the pty so tmux
+enters copy-mode with the cursor at column 0."
+ (let ((agent (cj/test--make-fake-eat-buffer "agent [emacs.d]"))
+ (sent nil))
(unwind-protect
(with-current-buffer agent
(cl-letf (((symbol-function 'get-buffer-process)
(lambda (_buffer) 'fake-process))
((symbol-function 'process-tty-name)
- (lambda (_process) "/dev/pts/8"))
- ((symbol-function 'ghostel-send-string)
- (lambda (s) (push s sent)))
- ((symbol-function 'ghostel-copy-mode)
- (lambda () (setq copy-mode-called t))))
+ (lambda (_process &rest _) "/dev/pts/8"))
+ ((symbol-function 'cj/--term-send-string)
+ (lambda (s) (push s sent))))
(test-term-tmux-history--with-tmux-mock
'((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0
"/dev/pts/8\t%8\n"))
(cj/term-copy-mode-dwim)
- (should (equal sent '("\C-b[\C-a")))
- (should-not copy-mode-called))))
+ (should (equal sent '("\C-b[\C-a"))))))
(when (buffer-live-p agent)
(kill-buffer agent)))))
-(ert-deftest test-term-copy-mode-dwim-falls-back-without-tmux ()
- "Boundary: without tmux, dwim calls `ghostel-copy-mode' then moves point
-to the start of the line and sends nothing to the pty. The
-`beginning-of-line' must run after `ghostel-copy-mode' so it repositions
-inside the copy view; column 0 keeps the cursor on the left edge while
-scrolling, parity with the tmux branch's trailing C-a."
- (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]"))
- (sent nil)
- (dwim-order nil))
+(ert-deftest test-term-copy-mode-up-tmux-enters-then-scrolls-up ()
+ "Normal: from a live (non-copy) tmux pane, C-<up> enters copy-mode then sends
+the up-arrow, so one stroke both enters copy-mode and scrolls up."
+ (let ((agent (cj/test--make-fake-eat-buffer "agent [emacs.d]"))
+ (sent nil))
(unwind-protect
(with-current-buffer agent
(cl-letf (((symbol-function 'get-buffer-process)
(lambda (_buffer) 'fake-process))
((symbol-function 'process-tty-name)
- (lambda (_process) "/dev/pts/8"))
- ((symbol-function 'ghostel-send-string)
- (lambda (s) (push s sent)))
- ((symbol-function 'ghostel-copy-mode)
- (lambda () (push 'copy-mode dwim-order)))
- ((symbol-function 'beginning-of-line)
- (lambda (&optional _n) (push 'beginning-of-line dwim-order))))
+ (lambda (_process &rest _) "/dev/pts/8"))
+ ((symbol-function 'cj/--term-send-string)
+ (lambda (s) (push s sent))))
(test-term-tmux-history--with-tmux-mock
- '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 1
- "no server running"))
- (cj/term-copy-mode-dwim)
- (should-not sent)
- (should (equal (reverse dwim-order) '(copy-mode beginning-of-line))))))
+ '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0
+ "/dev/pts/8\t%8\n")
+ (("display-message" "-p" "-t" "%8" "#{pane_in_mode}") 0 "0\n"))
+ (cj/term-copy-mode-up)
+ (should (equal (reverse sent) '("\C-b[\C-a" "\e[A"))))))
(when (buffer-live-p agent)
(kill-buffer agent)))))
-(ert-deftest test-term-prefix-and-f12-in-keymap-exceptions ()
- "Regression: C-; and F12 are in `ghostel-keymap-exceptions' and the rebuilt
-semi-char map no longer forwards them to the pty, so the prefix keymap and the
-F12 toggle reach Emacs inside ghostel buffers."
- (dolist (key '("C-;" "<f12>"))
- (should (member key ghostel-keymap-exceptions)))
- (should-not (eq (keymap-lookup ghostel-semi-char-mode-map "<f12>")
- 'ghostel--send-event)))
+(ert-deftest test-term-copy-mode-up-tmux-already-in-mode-just-scrolls ()
+ "Normal: when the tmux pane is already in copy-mode, C-<up> only sends the
+up-arrow -- it does not re-enter and reset the cursor."
+ (let ((agent (cj/test--make-fake-eat-buffer "agent [emacs.d]"))
+ (sent nil))
+ (unwind-protect
+ (with-current-buffer agent
+ (cl-letf (((symbol-function 'get-buffer-process)
+ (lambda (_buffer) 'fake-process))
+ ((symbol-function 'process-tty-name)
+ (lambda (_process &rest _) "/dev/pts/8"))
+ ((symbol-function 'cj/--term-send-string)
+ (lambda (s) (push s sent))))
+ (test-term-tmux-history--with-tmux-mock
+ '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0
+ "/dev/pts/8\t%8\n")
+ (("display-message" "-p" "-t" "%8" "#{pane_in_mode}") 0 "1\n"))
+ (cj/term-copy-mode-up)
+ (should (equal (reverse sent) '("\e[A"))))))
+ (when (buffer-live-p agent)
+ (kill-buffer agent)))))
-(ert-deftest test-term-window-nav-keys-in-keymap-exceptions ()
- "Regression: windmove (S-arrows) and buffer-move (C-M-arrows) are in
-`ghostel-keymap-exceptions' so they reach Emacs from inside a ghostel buffer
-instead of being forwarded to the terminal program."
- (dolist (key '("S-<up>" "S-<down>" "S-<left>" "S-<right>"
- "C-M-<up>" "C-M-<down>" "C-M-<left>" "C-M-<right>"))
- (should (member key ghostel-keymap-exceptions)))
- (should-not (eq (keymap-lookup ghostel-semi-char-mode-map "C-M-<left>")
- 'ghostel--send-event)))
+;;; bindings
-(ert-deftest test-term-f10-music-and-shutdown-in-keymap-exceptions ()
- "Regression: F10 (music playlist toggle) and C-F10 (server shutdown) are in
-`ghostel-keymap-exceptions' so they reach Emacs from inside a ghostel buffer
-instead of being forwarded to the terminal program. Both are global bindings,
-so dropping them from the semi-char map lets the lookup fall through to the
-global map."
- (dolist (key '("<f10>" "C-<f10>"))
- (should (member key ghostel-keymap-exceptions)))
- (should-not (eq (keymap-lookup ghostel-semi-char-mode-map "<f10>")
- 'ghostel--send-event)))
+(ert-deftest test-term-keymap-history-and-copy-bindings ()
+ "Normal: the C-; x terminal map owns the tmux-history and copy-mode commands."
+ (should (eq (keymap-lookup cj/custom-keymap "x h") #'cj/term-tmux-history))
+ (should (eq (keymap-lookup cj/custom-keymap "x c") #'cj/term-copy-mode-dwim))
+ (should (eq (keymap-lookup cj/custom-keymap "x t") #'cj/term-toggle)))
-(ert-deftest test-term-c-spc-forwarded-not-set-mark ()
- "Regression: C-SPC is forwarded to the terminal, not bound to the global
-`set-mark-command'. ghostel only forwards the `C-@' event, so without this an
-Emacs region gets stuck in the ghostel buffer and tmux copy-mode's
-begin-selection never starts."
- (should (eq (keymap-lookup ghostel-mode-map "C-SPC") #'cj/term-send-C-SPC)))
+(ert-deftest test-term-copy-mode-up-bound-in-eat-semi-char-map ()
+ "Normal: C-<up> enters copy-mode + scrolls up from inside an EAT terminal."
+ (should (eq (keymap-lookup eat-semi-char-mode-map "C-<up>")
+ #'cj/term-copy-mode-up)))
(provide 'test-term-tmux-history)
;;; test-term-tmux-history.el ends here
diff --git a/tests/test-term-toggle--buffer-filter.el b/tests/test-term-toggle--buffer-filter.el
index 2c96ecb38..6db2ec65c 100644
--- a/tests/test-term-toggle--buffer-filter.el
+++ b/tests/test-term-toggle--buffer-filter.el
@@ -1,11 +1,12 @@
;;; test-term-toggle--buffer-filter.el --- Tests for F12's buffer filter -*- lexical-binding: t; -*-
;;; Commentary:
-;; Three closely-related helpers determine which terminal buffers F12
-;; manages: the predicate `cj/--term-toggle-buffer-p', the MRU list
+;; Three closely-related helpers determine which terminal buffer F12
+;; manages: the predicate `cj/--term-toggle-buffer-p', the list
;; `cj/--term-toggle-buffers', and the per-frame window finder
-;; `cj/--term-toggle-displayed-window'. All three exclude agent-
-;; prefixed buffers so agent has its own F9 surface.
+;; `cj/--term-toggle-displayed-window'. F12 opens eshell (run through EAT via
+;; eat-eshell-mode), so it manages eshell-mode buffers. Standalone eat buffers,
+;; ghostel buffers, and ai-term's agent buffers are NOT F12-managed.
;;; Code:
@@ -13,7 +14,7 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
-(require 'term-config)
+(require 'eat-config)
(require 'testutil-ghostel-buffers)
(defun test-term-toggle--cleanup ()
@@ -21,16 +22,24 @@
(cj/test--kill-agent-buffers)
(cj/test--kill-test-term-buffers))
-(ert-deftest test-term-toggle--buffer-p-accepts-ghostel-mode ()
- "Normal: a ghostel-mode buffer with non-agent name qualifies."
+(ert-deftest test-term-toggle--buffer-p-accepts-eshell-mode ()
+ "Normal: an eshell-mode buffer qualifies as the F12 terminal."
(test-term-toggle--cleanup)
- (let ((buf (cj/test--make-fake-ghostel-buffer "*test-term-1*")))
+ (let ((buf (cj/test--make-fake-eshell-buffer "*test-term-1*")))
(unwind-protect
(should (cj/--term-toggle-buffer-p buf))
(kill-buffer buf))))
+(ert-deftest test-term-toggle--buffer-p-rejects-eat ()
+ "Boundary: a standalone eat buffer is NOT F12-managed (F12 opens eshell)."
+ (test-term-toggle--cleanup)
+ (let ((buf (cj/test--make-fake-eat-buffer "*test-term-eat*")))
+ (unwind-protect
+ (should-not (cj/--term-toggle-buffer-p buf))
+ (kill-buffer buf))))
+
(ert-deftest test-term-toggle--buffer-p-rejects-agent ()
- "Boundary: agent-prefixed terminal buffers are excluded from F12's set."
+ "Boundary: ai-term agent buffers are excluded from F12's set."
(test-term-toggle--cleanup)
(let ((buf (cj/test--make-fake-ghostel-buffer "agent [project-a]")))
(unwind-protect
@@ -38,7 +47,7 @@
(kill-buffer buf))))
(ert-deftest test-term-toggle--buffer-p-rejects-non-terminal ()
- "Boundary: a regular buffer (not ghostel-mode, no terminal name prefix) -> nil."
+ "Boundary: a regular buffer (not eshell-mode) -> nil."
(test-term-toggle--cleanup)
(let ((buf (get-buffer-create "*test-term-regular*")))
(unwind-protect
@@ -48,35 +57,35 @@
(ert-deftest test-term-toggle--buffer-p-rejects-dead-buffer ()
"Boundary: nil and dead buffers -> nil."
(should-not (cj/--term-toggle-buffer-p nil))
- (let ((buf (cj/test--make-fake-ghostel-buffer "*test-term-dead*")))
+ (let ((buf (cj/test--make-fake-eshell-buffer "*test-term-dead*")))
(kill-buffer buf)
(should-not (cj/--term-toggle-buffer-p buf))))
-(ert-deftest test-term-toggle--buffers-filters-agent ()
- "Normal: returns terminal buffers but excludes agent-prefixed ones."
+(ert-deftest test-term-toggle--buffers-returns-eshell-excludes-others ()
+ "Normal: returns the eshell terminal but not eat/agent buffers."
(test-term-toggle--cleanup)
- (let ((normal (cj/test--make-fake-ghostel-buffer "*test-term-normal*"))
+ (let ((esh (cj/test--make-fake-eshell-buffer "*test-term-esh*"))
(agent (cj/test--make-fake-ghostel-buffer "agent [for-test]")))
(unwind-protect
(let ((result (cj/--term-toggle-buffers)))
- (should (memq normal result))
+ (should (memq esh result))
(should-not (memq agent result)))
- (kill-buffer normal)
+ (kill-buffer esh)
(kill-buffer agent))))
(ert-deftest test-term-toggle--displayed-window-finds-terminal ()
- "Normal: terminal in a window -> returns that window."
+ "Normal: the eshell terminal in a window -> returns that window."
(test-term-toggle--cleanup)
- (let ((vt (cj/test--make-fake-ghostel-buffer "*test-term-shown*")))
+ (let ((esh (cj/test--make-fake-eshell-buffer "*test-term-shown*")))
(unwind-protect
(save-window-excursion
(delete-other-windows)
(let ((win (split-window-right)))
- (set-window-buffer win vt)
+ (set-window-buffer win esh)
(let ((result (cj/--term-toggle-displayed-window)))
(should (windowp result))
- (should (eq (window-buffer result) vt)))))
- (kill-buffer vt))))
+ (should (eq (window-buffer result) esh)))))
+ (kill-buffer esh))))
(ert-deftest test-term-toggle--displayed-window-skips-agent ()
"Boundary: only an agent terminal is displayed -> nil (agent not F12-managed)."
diff --git a/tests/test-term-toggle--dispatch.el b/tests/test-term-toggle--dispatch.el
index f13c2840b..0d17395cc 100644
--- a/tests/test-term-toggle--dispatch.el
+++ b/tests/test-term-toggle--dispatch.el
@@ -14,7 +14,7 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
-(require 'term-config)
+(require 'eat-config)
(require 'testutil-ghostel-buffers)
(ert-deftest test-term-toggle--dispatch-window-displayed-returns-toggle-off ()
diff --git a/tests/test-term-toggle--display.el b/tests/test-term-toggle--display.el
index d6dd33da2..d59d23b15 100644
--- a/tests/test-term-toggle--display.el
+++ b/tests/test-term-toggle--display.el
@@ -14,7 +14,7 @@
(require 'cl-lib)
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'term-config)
+(require 'eat-config)
(ert-deftest test-term-toggle--capture-state-records-direction-and-size ()
"Normal: capture-state writes direction and integer size.
diff --git a/tests/test-transcription-process-and-sentinel.el b/tests/test-transcription-process-and-sentinel.el
index 330a0260b..90b56f0a5 100644
--- a/tests/test-transcription-process-and-sentinel.el
+++ b/tests/test-transcription-process-and-sentinel.el
@@ -26,7 +26,7 @@
(let (msg)
(cl-letf (((symbol-function 'message)
(lambda (fmt &rest args) (setq msg (apply #'format fmt args))))
- ((symbol-function 'getenv) (lambda (_) nil)))
+ ((symbol-function 'getenv) (lambda (_ &rest _) nil)))
(cj/--notify "Transcription" "started"))
(should (equal msg "Transcription: started"))))
@@ -36,7 +36,7 @@ the title, body, and urgency."
(let (notify-kwargs)
(cl-letf (((symbol-function 'message) #'ignore)
((symbol-function 'getenv)
- (lambda (var) (and (equal var "DISPLAY") ":0")))
+ (lambda (var &rest _) (and (equal var "DISPLAY") ":0")))
((symbol-function 'notifications-notify)
(lambda (&rest kwargs) (setq notify-kwargs kwargs))))
(cj/--notify "Transcription" "done" 'critical))
diff --git a/tests/test-transcription-status-and-commands.el b/tests/test-transcription-status-and-commands.el
index 7c796de0e..af7255cdc 100644
--- a/tests/test-transcription-status-and-commands.el
+++ b/tests/test-transcription-status-and-commands.el
@@ -138,7 +138,7 @@
(cl-letf (((symbol-function 'process-live-p)
(lambda (_) t))
((symbol-function 'kill-process)
- (lambda (p) (setq killed p)))
+ (lambda (p &rest _) (setq killed p)))
((symbol-function 'message)
(lambda (fmt &rest args)
(setq msg (apply #'format fmt args)))))
diff --git a/tests/test-transcription-video.el b/tests/test-transcription-video.el
index 8327fa326..aa8383d12 100644
--- a/tests/test-transcription-video.el
+++ b/tests/test-transcription-video.el
@@ -128,6 +128,28 @@ goes through `cj/--start-transcription-process' with a cleanup hint."
;; deleted after transcription completes).
(should (equal (nth 1 extract-args) (cadr worker-call)))))
+(ert-deftest test-tx-transcribe-media-video-output-base-is-the-source ()
+ "Regression: a video's transcript derives from the VIDEO path (alongside the
+source), not the temp /tmp audio. The worker gets the video as its output base
+\(third arg), so cj/--transcription-output-files lands talk.mp4 -> talk.txt
+beside the video instead of in /tmp."
+ (let* ((tmp (make-temp-file "cj-tx-vid-" nil ".mp4"))
+ worker-call)
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--extract-audio-from-video)
+ (lambda (_vid _out cb) (funcall cb)))
+ ((symbol-function 'cj/--start-transcription-process)
+ (lambda (file &rest rest)
+ (setq worker-call (cons file rest))
+ 'fake-proc)))
+ (cj/transcribe-media tmp))
+ (delete-file tmp))
+ ;; the output base (third arg) is the source video, not the temp audio
+ (should (equal (nth 2 worker-call) tmp))
+ ;; so the derived transcript sits beside the video, not in /tmp
+ (should (equal (car (cj/--transcription-output-files (nth 2 worker-call)))
+ (concat (file-name-sans-extension tmp) ".txt")))))
+
(ert-deftest test-tx-transcribe-media-rejects-non-media ()
"Error: non-media paths get rejected up front."
(should-error (cj/transcribe-media "/notes/readme.txt") :type 'user-error))
diff --git a/tests/test-ui-config-transparency-and-cursor.el b/tests/test-ui-config-transparency-and-cursor.el
index b01fa2b71..13906773b 100644
--- a/tests/test-ui-config-transparency-and-cursor.el
+++ b/tests/test-ui-config-transparency-and-cursor.el
@@ -23,7 +23,7 @@
(cj/transparency-level 70)
(default-frame-alist nil)
(applied nil))
- (cl-letf (((symbol-function 'display-graphic-p) (lambda () t))
+ (cl-letf (((symbol-function 'display-graphic-p) (lambda (&rest _) t))
((symbol-function 'set-frame-parameter)
(lambda (_frame param value)
(when (eq param 'alpha) (setq applied value)))))
@@ -37,7 +37,7 @@
(cj/transparency-level 50)
(default-frame-alist '((alpha . (50 . 50))))
(applied nil))
- (cl-letf (((symbol-function 'display-graphic-p) (lambda () t))
+ (cl-letf (((symbol-function 'display-graphic-p) (lambda (&rest _) t))
((symbol-function 'set-frame-parameter)
(lambda (_frame param value)
(when (eq param 'alpha) (setq applied value)))))
@@ -52,7 +52,7 @@ the default-frame-alist so a future graphical frame would pick it up."
(cj/transparency-level 60)
(default-frame-alist nil)
(set-called nil))
- (cl-letf (((symbol-function 'display-graphic-p) (lambda () nil))
+ (cl-letf (((symbol-function 'display-graphic-p) (lambda (&rest _) nil))
((symbol-function 'set-frame-parameter)
(lambda (&rest _) (setq set-called t))))
(cj/apply-transparency))
@@ -66,7 +66,7 @@ surfaced via `message'; the default-alist update still happens."
(cj/transparency-level 60)
(default-frame-alist nil)
(msg nil))
- (cl-letf (((symbol-function 'display-graphic-p) (lambda () t))
+ (cl-letf (((symbol-function 'display-graphic-p) (lambda (&rest _) t))
((symbol-function 'set-frame-parameter)
(lambda (&rest _) (error "boom")))
((symbol-function 'message)
@@ -83,7 +83,7 @@ surfaced via `message'; the default-alist update still happens."
(cj/transparency-level 80)
(default-frame-alist nil)
(applied nil))
- (cl-letf (((symbol-function 'display-graphic-p) (lambda () t))
+ (cl-letf (((symbol-function 'display-graphic-p) (lambda (&rest _) t))
((symbol-function 'set-frame-parameter)
(lambda (_frame param value)
(when (eq param 'alpha) (setq applied value))))
@@ -97,7 +97,7 @@ surfaced via `message'; the default-alist update still happens."
(let ((cj/enable-transparency t)
(cj/transparency-level 90)
(default-frame-alist nil))
- (cl-letf (((symbol-function 'display-graphic-p) (lambda () t))
+ (cl-letf (((symbol-function 'display-graphic-p) (lambda (&rest _) t))
((symbol-function 'set-frame-parameter) #'ignore)
((symbol-function 'message) #'ignore))
(cj/toggle-transparency)
diff --git a/tests/test-ui-navigation-split-follow-undo-kill.el b/tests/test-ui-navigation-split-follow-undo-kill.el
index f6981a36a..35ed7a020 100644
--- a/tests/test-ui-navigation-split-follow-undo-kill.el
+++ b/tests/test-ui-navigation-split-follow-undo-kill.el
@@ -70,7 +70,7 @@ non-visited entry, not the second."
(setq buffer-file-name "/tmp/alive.txt"))
b))))
((symbol-function 'find-file)
- (lambda (f) (setq opened f))))
+ (lambda (f &rest _) (setq opened f))))
(unwind-protect
(cj/undo-kill-buffer 1)
(when (get-buffer "*test-alive*") (kill-buffer "*test-alive*"))))
@@ -93,7 +93,7 @@ currently-open most-recent file was never skipped."
(setq buffer-file-name "/tmp/alive.txt"))
b))))
((symbol-function 'find-file)
- (lambda (f) (setq opened f))))
+ (lambda (f &rest _) (setq opened f))))
(unwind-protect
(cj/undo-kill-buffer 1)
(when (get-buffer "*test-alive*") (kill-buffer "*test-alive*"))))
@@ -108,7 +108,7 @@ currently-open most-recent file was never skipped."
((symbol-function 'recentf-mode) (lambda (&rest _) t))
((symbol-function 'buffer-list) (lambda (&rest _) nil))
((symbol-function 'find-file)
- (lambda (f) (setq opened f))))
+ (lambda (f &rest _) (setq opened f))))
(cj/undo-kill-buffer 2))
(should (equal opened "/tmp/b.org"))))
@@ -121,7 +121,7 @@ currently-open most-recent file was never skipped."
((symbol-function 'recentf-mode) (lambda (&rest _) t))
((symbol-function 'buffer-list) (lambda (&rest _) nil))
((symbol-function 'find-file)
- (lambda (f) (setq opened f))))
+ (lambda (f &rest _) (setq opened f))))
(cj/undo-kill-buffer 0))
(should-not opened)))
@@ -134,7 +134,7 @@ not a wrong-type-argument from find-file on nil."
(cl-letf (((symbol-function 'require) (lambda (&rest _) t))
((symbol-function 'recentf-mode) (lambda (&rest _) t))
((symbol-function 'buffer-list) (lambda (&rest _) nil))
- ((symbol-function 'find-file) (lambda (f) (setq opened f))))
+ ((symbol-function 'find-file) (lambda (f &rest _) (setq opened f))))
(should-error (cj/undo-kill-buffer 5) :type 'user-error))
(should-not opened)))
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/test-video-audio-recording--build-video-command.el b/tests/test-video-audio-recording--build-video-command.el
index 3b79c9ecb..4f2909784 100644
--- a/tests/test-video-audio-recording--build-video-command.el
+++ b/tests/test-video-audio-recording--build-video-command.el
@@ -21,7 +21,7 @@
"Wayland command pipes wf-recorder to ffmpeg."
(let ((cj/recording-mic-boost 2.0)
(cj/recording-system-volume 1.0))
- (cl-letf (((symbol-function 'executable-find) (lambda (_prog) t)))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_prog &rest _) t)))
(let ((cmd (cj/recording--build-video-command "mic" "sys" "/tmp/out.mkv" t)))
(should (string-match-p "wf-recorder.*|.*ffmpeg" cmd))
(should (string-match-p "-i pipe:0" cmd))
@@ -60,7 +60,7 @@
"Device names with special characters are shell-quoted in Wayland mode."
(let ((cj/recording-mic-boost 1.0)
(cj/recording-system-volume 1.0))
- (cl-letf (((symbol-function 'executable-find) (lambda (_prog) t)))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_prog &rest _) t)))
(let ((cmd (cj/recording--build-video-command
"device with spaces" "sys" "/tmp/out.mkv" t)))
;; shell-quote-argument escapes spaces with backslashes
@@ -70,7 +70,7 @@
"Output filename with spaces is shell-quoted in Wayland mode."
(let ((cj/recording-mic-boost 1.0)
(cj/recording-system-volume 1.0))
- (cl-letf (((symbol-function 'executable-find) (lambda (_prog) t)))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_prog &rest _) t)))
(let ((cmd (cj/recording--build-video-command
"mic" "sys" "/tmp/my recording.mkv" t)))
;; Filename should be quoted/escaped
@@ -103,7 +103,7 @@
(ert-deftest test-video-audio-recording--build-video-command-error-wayland-no-wf-recorder ()
"Wayland mode signals error when wf-recorder is not installed."
- (cl-letf (((symbol-function 'executable-find) (lambda (_prog) nil)))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_prog &rest _) nil)))
(should-error (cj/recording--build-video-command "mic" "sys" "/tmp/out.mkv" t)
:type 'user-error)))
diff --git a/tests/test-video-audio-recording--test-device.el b/tests/test-video-audio-recording--test-device.el
index e701b69fd..aa85b4388 100644
--- a/tests/test-video-audio-recording--test-device.el
+++ b/tests/test-video-audio-recording--test-device.el
@@ -20,7 +20,7 @@
"Runs exactly 2 shell commands: ffmpeg to record, ffplay to playback."
(let ((commands nil))
(cl-letf (((symbol-function 'shell-command)
- (lambda (cmd) (push cmd commands) 0)))
+ (lambda (cmd &rest _) (push cmd commands) 0)))
(cj/recording--test-device "test-device" "test-" "GO!")
(should (= 2 (length commands)))
;; ffmpeg runs first (pushed last due to stack order)
@@ -31,7 +31,7 @@
"The provided device name appears in the ffmpeg command."
(let ((commands nil))
(cl-letf (((symbol-function 'shell-command)
- (lambda (cmd) (push cmd commands) 0)))
+ (lambda (cmd &rest _) (push cmd commands) 0)))
(cj/recording--test-device "alsa_input.usb-Jabra.mono" "mic-" "SPEAK!")
(let ((ffmpeg-cmd (cadr commands)))
(should (string-match-p "alsa_input.usb-Jabra.mono" ffmpeg-cmd))
@@ -43,7 +43,7 @@
"Device names with special characters are shell-quoted."
(let ((commands nil))
(cl-letf (((symbol-function 'shell-command)
- (lambda (cmd) (push cmd commands) 0)))
+ (lambda (cmd &rest _) (push cmd commands) 0)))
(cj/recording--test-device "device with spaces" "test-" "GO!")
(let ((ffmpeg-cmd (cadr commands)))
;; shell-quote-argument should have escaped the spaces
@@ -54,7 +54,7 @@
(ert-deftest test-video-audio-recording--test-device-error-ffmpeg-failure-no-crash ()
"Function completes without error even when ffmpeg returns non-zero."
(cl-letf (((symbol-function 'shell-command)
- (lambda (_cmd) 1)))
+ (lambda (_cmd &rest _) 1)))
;; Should not signal any error
(cj/recording--test-device "dev" "test-" "GO!")
(should t)))
diff --git a/tests/test-video-audio-recording-check-ffmpeg.el b/tests/test-video-audio-recording-check-ffmpeg.el
index 5c264b640..1d8f13247 100644
--- a/tests/test-video-audio-recording-check-ffmpeg.el
+++ b/tests/test-video-audio-recording-check-ffmpeg.el
@@ -20,7 +20,7 @@
(ert-deftest test-video-audio-recording-check-ffmpeg-normal-ffmpeg-found-returns-t ()
"Test that function returns t when ffmpeg is found."
(cl-letf (((symbol-function 'executable-find)
- (lambda (cmd)
+ (lambda (cmd &rest _)
(when (equal cmd "ffmpeg") "/usr/bin/ffmpeg"))))
(let ((result (cj/recording-check-ffmpeg)))
(should (eq t result)))))
@@ -30,13 +30,13 @@
(ert-deftest test-video-audio-recording-check-ffmpeg-error-ffmpeg-not-found-signals-error ()
"Test that function signals user-error when ffmpeg is not found."
(cl-letf (((symbol-function 'executable-find)
- (lambda (_cmd) nil)))
+ (lambda (_cmd &rest _) nil)))
(should-error (cj/recording-check-ffmpeg) :type 'user-error)))
(ert-deftest test-video-audio-recording-check-ffmpeg-error-message-mentions-pacman ()
"Test that error message includes installation command."
(cl-letf (((symbol-function 'executable-find)
- (lambda (_cmd) nil)))
+ (lambda (_cmd &rest _) nil)))
(condition-case err
(cj/recording-check-ffmpeg)
(user-error
diff --git a/tests/test-video-audio-recording-ffmpeg-functions.el b/tests/test-video-audio-recording-ffmpeg-functions.el
index 549aa317f..4b3570a26 100644
--- a/tests/test-video-audio-recording-ffmpeg-functions.el
+++ b/tests/test-video-audio-recording-ffmpeg-functions.el
@@ -190,7 +190,7 @@
(setq cj/video-recording-ffmpeg-process fake-process)
(cl-letf (((symbol-function 'cj/recording--wayland-p) (lambda () nil))
((symbol-function 'signal-process)
- (lambda (_pid _sig) (setq signal-called t) 0))
+ (lambda (_pid _sig &rest _) (setq signal-called t) 0))
((symbol-function 'cj/recording--wait-for-exit)
(lambda (_proc _timeout) t)))
(cj/video-recording-stop)
@@ -231,7 +231,7 @@
(signal-called nil))
(setq cj/audio-recording-ffmpeg-process fake-process)
(cl-letf (((symbol-function 'signal-process)
- (lambda (_pid _sig) (setq signal-called t) 0))
+ (lambda (_pid _sig &rest _) (setq signal-called t) 0))
((symbol-function 'cj/recording--wait-for-exit)
(lambda (_proc _timeout) t)))
(cj/audio-recording-stop)
@@ -287,7 +287,7 @@
(setq cj/video-recording-ffmpeg-process fake-process)
(cl-letf (((symbol-function 'cj/recording--wayland-p) (lambda () nil))
((symbol-function 'signal-process)
- (lambda (_pid _sig) (error "Signal failed"))))
+ (lambda (_pid _sig &rest _) (error "Signal failed"))))
(condition-case _err
(cj/video-recording-stop)
(error (setq error-raised t)))
@@ -303,7 +303,7 @@
(error-raised nil))
(setq cj/audio-recording-ffmpeg-process fake-process)
(cl-letf (((symbol-function 'signal-process)
- (lambda (_pid _sig) (error "Signal failed"))))
+ (lambda (_pid _sig &rest _) (error "Signal failed"))))
(condition-case _err
(cj/audio-recording-stop)
(error (setq error-raised t)))
diff --git a/tests/test-video-audio-recording-process-cleanup.el b/tests/test-video-audio-recording-process-cleanup.el
index 52177a17c..7cb261c16 100644
--- a/tests/test-video-audio-recording-process-cleanup.el
+++ b/tests/test-video-audio-recording-process-cleanup.el
@@ -53,7 +53,7 @@
(setq cj/video-recording-ffmpeg-process fake-process)
(cl-letf (((symbol-function 'cj/recording--wayland-p) (lambda () nil))
((symbol-function 'signal-process)
- (lambda (pid sig)
+ (lambda (pid sig &rest _)
(setq signaled-pid pid)
(setq signaled-sig sig)
0))
@@ -85,7 +85,7 @@ so ffmpeg sees EOF on its video input pipe and starts finalizing the file."
(push (cons 'pkill args) call-order))
0))
((symbol-function 'signal-process)
- (lambda (_pid _sig)
+ (lambda (_pid _sig &rest _)
(push 'signal call-order)
0))
((symbol-function 'cj/recording--wait-for-exit)
@@ -114,7 +114,7 @@ so ffmpeg sees EOF on its video input pipe and starts finalizing the file."
(when (equal program "pkill")
(push args pkill-args-list))
0))
- ((symbol-function 'signal-process) (lambda (_pid _sig) 0))
+ ((symbol-function 'signal-process) (lambda (_pid _sig &rest _) 0))
((symbol-function 'cj/recording--wait-for-exit)
(lambda (_proc _timeout) t)))
(cj/video-recording-stop)
@@ -140,7 +140,7 @@ so ffmpeg sees EOF on its video input pipe and starts finalizing the file."
(when (equal program "pkill")
(setq pkill-called t))
0))
- ((symbol-function 'signal-process) (lambda (_pid _sig) 0))
+ ((symbol-function 'signal-process) (lambda (_pid _sig &rest _) 0))
((symbol-function 'cj/recording--wait-for-exit)
(lambda (_proc _timeout) t)))
(cj/video-recording-stop)
@@ -206,7 +206,7 @@ so ffmpeg sees EOF on its video input pipe and starts finalizing the file."
(wait-timeout nil))
(setq cj/video-recording-ffmpeg-process fake-process)
(cl-letf (((symbol-function 'cj/recording--wayland-p) (lambda () nil))
- ((symbol-function 'signal-process) (lambda (_pid _sig) 0))
+ ((symbol-function 'signal-process) (lambda (_pid _sig &rest _) 0))
((symbol-function 'cj/recording--wait-for-exit)
(lambda (_proc timeout)
(setq wait-called t)
@@ -227,7 +227,7 @@ so ffmpeg sees EOF on its video input pipe and starts finalizing the file."
(warning-shown nil))
(setq cj/video-recording-ffmpeg-process fake-process)
(cl-letf (((symbol-function 'cj/recording--wayland-p) (lambda () nil))
- ((symbol-function 'signal-process) (lambda (_pid _sig) 0))
+ ((symbol-function 'signal-process) (lambda (_pid _sig &rest _) 0))
((symbol-function 'cj/recording--wait-for-exit)
(lambda (_proc _timeout) nil)) ; Simulate timeout
((symbol-function 'message)
@@ -247,7 +247,7 @@ so ffmpeg sees EOF on its video input pipe and starts finalizing the file."
(let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000")))
(warning-shown nil))
(setq cj/audio-recording-ffmpeg-process fake-process)
- (cl-letf (((symbol-function 'signal-process) (lambda (_pid _sig) 0))
+ (cl-letf (((symbol-function 'signal-process) (lambda (_pid _sig &rest _) 0))
((symbol-function 'cj/recording--wait-for-exit)
(lambda (_proc _timeout) nil)) ; Simulate timeout
((symbol-function 'message)
@@ -268,7 +268,7 @@ so ffmpeg sees EOF on its video input pipe and starts finalizing the file."
(wait-called nil)
(wait-timeout nil))
(setq cj/audio-recording-ffmpeg-process fake-process)
- (cl-letf (((symbol-function 'signal-process) (lambda (_pid _sig) 0))
+ (cl-letf (((symbol-function 'signal-process) (lambda (_pid _sig &rest _) 0))
((symbol-function 'cj/recording--wait-for-exit)
(lambda (_proc timeout)
(setq wait-called t)
diff --git a/tests/test-video-audio-recording-test-mic.el b/tests/test-video-audio-recording-test-mic.el
index 60b9eb0b7..64ef0eaab 100644
--- a/tests/test-video-audio-recording-test-mic.el
+++ b/tests/test-video-audio-recording-test-mic.el
@@ -36,11 +36,11 @@
(let ((temp-file nil))
;; Mock make-temp-file to capture filename
(cl-letf (((symbol-function 'make-temp-file)
- (lambda (prefix _dir-flag suffix)
+ (lambda (prefix _dir-flag suffix &rest _)
(setq temp-file (concat prefix "12345" suffix))
temp-file))
((symbol-function 'shell-command)
- (lambda (_cmd) 0)))
+ (lambda (_cmd &rest _) 0)))
(cj/recording-test-mic)
(should (string-match-p "\\.wav$" temp-file)))))
(test-mic-teardown)))
@@ -54,7 +54,7 @@
(let ((commands nil))
;; Mock shell-command to capture all commands
(cl-letf (((symbol-function 'shell-command)
- (lambda (cmd) (push cmd commands) 0)))
+ (lambda (cmd &rest _) (push cmd commands) 0)))
(cj/recording-test-mic)
(should (= 2 (length commands)))
;; First command should be ffmpeg (stored last in list due to push)
@@ -74,7 +74,7 @@
(let ((commands nil))
;; Capture all shell commands
(cl-letf (((symbol-function 'shell-command)
- (lambda (cmd) (push cmd commands) 0)))
+ (lambda (cmd &rest _) (push cmd commands) 0)))
(cj/recording-test-mic)
(should (= 2 (length commands)))
;; Second command should be ffplay
@@ -93,7 +93,7 @@
(cl-letf (((symbol-function 'message)
(lambda (fmt &rest args) (push (apply #'format fmt args) messages)))
((symbol-function 'shell-command)
- (lambda (_cmd) 0)))
+ (lambda (_cmd &rest _) 0)))
(cj/recording-test-mic)
(should (>= (length messages) 3))
;; Check for recording message
@@ -135,7 +135,7 @@
(setq cj/recording-mic-device "test-mic-device")
;; Mock shell-command to fail
(cl-letf (((symbol-function 'shell-command)
- (lambda (_cmd) 1))) ;; Non-zero exit code
+ (lambda (_cmd &rest _) 1))) ;; Non-zero exit code
;; Should complete without crashing (ffmpeg errors are ignored)
;; No error is raised - function just completes
(cj/recording-test-mic)
diff --git a/tests/test-video-audio-recording-test-monitor.el b/tests/test-video-audio-recording-test-monitor.el
index d821600f0..168e4f072 100644
--- a/tests/test-video-audio-recording-test-monitor.el
+++ b/tests/test-video-audio-recording-test-monitor.el
@@ -36,11 +36,11 @@
(let ((temp-file nil))
;; Mock make-temp-file to capture filename
(cl-letf (((symbol-function 'make-temp-file)
- (lambda (prefix _dir-flag suffix)
+ (lambda (prefix _dir-flag suffix &rest _)
(setq temp-file (concat prefix "12345" suffix))
temp-file))
((symbol-function 'shell-command)
- (lambda (_cmd) 0)))
+ (lambda (_cmd &rest _) 0)))
(cj/recording-test-monitor)
(should (string-match-p "monitor-test-" temp-file))
(should (string-match-p "\\.wav$" temp-file)))))
@@ -55,7 +55,7 @@
(let ((commands nil))
;; Mock shell-command to capture all commands
(cl-letf (((symbol-function 'shell-command)
- (lambda (cmd) (push cmd commands) 0)))
+ (lambda (cmd &rest _) (push cmd commands) 0)))
(cj/recording-test-monitor)
(should (= 2 (length commands)))
;; First command should be ffmpeg (stored last in list due to push)
@@ -75,7 +75,7 @@
(let ((commands nil))
;; Capture all shell commands
(cl-letf (((symbol-function 'shell-command)
- (lambda (cmd) (push cmd commands) 0)))
+ (lambda (cmd &rest _) (push cmd commands) 0)))
(cj/recording-test-monitor)
(should (= 2 (length commands)))
;; Second command should be ffplay
@@ -94,7 +94,7 @@
(cl-letf (((symbol-function 'message)
(lambda (fmt &rest args) (push (apply #'format fmt args) messages)))
((symbol-function 'shell-command)
- (lambda (_cmd) 0)))
+ (lambda (_cmd &rest _) 0)))
(cj/recording-test-monitor)
(should (>= (length messages) 3))
;; Check for recording message
@@ -136,7 +136,7 @@
(setq cj/recording-system-device "test-monitor-device")
;; Mock shell-command to fail
(cl-letf (((symbol-function 'shell-command)
- (lambda (_cmd) 1))) ;; Non-zero exit code
+ (lambda (_cmd &rest _) 1))) ;; Non-zero exit code
;; Should complete without crashing (ffmpeg errors are ignored)
;; No error is raised - function just completes
(cj/recording-test-monitor)
diff --git a/tests/test-video-audio-recording-toggle-functions.el b/tests/test-video-audio-recording-toggle-functions.el
index 2355ab4f6..cdd3096ac 100644
--- a/tests/test-video-audio-recording-toggle-functions.el
+++ b/tests/test-video-audio-recording-toggle-functions.el
@@ -84,7 +84,7 @@
(let ((prompt-called nil)
(recorded-dir nil))
(cl-letf (((symbol-function 'read-directory-name)
- (lambda (_prompt) (setq prompt-called t) "/custom/path/"))
+ (lambda (_prompt &rest _) (setq prompt-called t) "/custom/path/"))
((symbol-function 'file-directory-p)
(lambda (_dir) t)) ; Directory exists
((symbol-function 'cj/ffmpeg-record-video)
@@ -139,7 +139,7 @@
(let ((prompt-called nil)
(recorded-dir nil))
(cl-letf (((symbol-function 'read-directory-name)
- (lambda (_prompt) (setq prompt-called t) "/custom/path/"))
+ (lambda (_prompt &rest _) (setq prompt-called t) "/custom/path/"))
((symbol-function 'file-directory-p)
(lambda (_dir) t)) ; Directory exists
((symbol-function 'cj/ffmpeg-record-audio)
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 <c@cjennings.net>
-;;
-;;; 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.
diff --git a/tests/testutil-ghostel-buffers.el b/tests/testutil-ghostel-buffers.el
index 52fb27e00..8e26efec4 100644
--- a/tests/testutil-ghostel-buffers.el
+++ b/tests/testutil-ghostel-buffers.el
@@ -45,5 +45,26 @@ ghostel-mode predicate without the side-effects of `(ghostel)'."
(setq-local major-mode 'ghostel-mode))
buf))
+(defun cj/test--make-fake-eat-buffer (name)
+ "Return a buffer named NAME with `major-mode' set to `eat-mode'.
+
+Avoids actually launching an EAT process by setting the mode buffer-locally.
+Used by the F12 toggle tests that need a buffer satisfying the eat-mode
+predicate without the side-effects of `(eat)'."
+ (let ((buf (get-buffer-create name)))
+ (with-current-buffer buf
+ (setq-local major-mode 'eat-mode))
+ buf))
+
+(defun cj/test--make-fake-eshell-buffer (name)
+ "Return a buffer named NAME with `major-mode' set to `eshell-mode'.
+
+Avoids starting a real eshell by setting the mode buffer-locally. Used by the
+F12 toggle tests that need a buffer satisfying the eshell-mode predicate."
+ (let ((buf (get-buffer-create name)))
+ (with-current-buffer buf
+ (setq-local major-mode 'eshell-mode))
+ buf))
+
(provide 'testutil-ghostel-buffers)
;;; testutil-ghostel-buffers.el ends here