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-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--capture-state.el6
-rw-r--r--tests/test-ai-term--collapse-split.el14
-rw-r--r--tests/test-ai-term--default-geometry.el53
-rw-r--r--tests/test-ai-term--f9-in-term.el56
-rw-r--r--tests/test-ai-term--keybindings.el59
-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--reuse-edge-window.el41
-rw-r--r--tests/test-ai-term--shutdown-countdown.el73
-rw-r--r--tests/test-auth-config--plstore-read-fixed.el101
-rw-r--r--tests/test-browser-config.el23
-rw-r--r--tests/test-build-theme.el229
-rw-r--r--tests/test-calendar-sync--apply-single-exception.el79
-rw-r--r--tests/test-calendar-sync--expand-recurring-event.el106
-rw-r--r--tests/test-calendar-sync--get-all-property-lines.el18
-rw-r--r--tests/test-calendar-sync--parse-exception-event.el64
-rw-r--r--tests/test-calendar-sync--parse-timestamp.el23
-rw-r--r--tests/test-calendar-sync--robustness.el70
-rw-r--r--tests/test-calendar-sync.el26
-rw-r--r--tests/test-calibredb-epub-config.el20
-rw-r--r--tests/test-chrono-tools--sound-helpers.el54
-rw-r--r--tests/test-cj-window-geometry-lib.el67
-rw-r--r--tests/test-cj-window-toggle-lib.el13
-rw-r--r--tests/test-config-utilities--compile-this-elisp-buffer.el8
-rw-r--r--tests/test-coverage-core--changed-lines.el101
-rw-r--r--tests/test-coverage-core--project-root.el37
-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-custom-datetime-all-methods.el14
-rw-r--r--tests/test-custom-line-paragraph-duplicate-line-or-region.el14
-rw-r--r--tests/test-custom-ordering--region-helpers.el52
-rw-r--r--tests/test-custom-text-enclose--enclose-region-or-word.el62
-rw-r--r--tests/test-dashboard-config-launchers.el3
-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-hard-delete-command.el47
-rw-r--r--tests/test-dirvish-config-playlist.el55
-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-dwim-shell-config-command-fixes.el55
-rw-r--r--tests/test-elfeed-config--decode-html-entities.el31
-rw-r--r--tests/test-elfeed-config-helpers.el8
-rw-r--r--tests/test-elfeed-config-youtube-feed-format.el44
-rw-r--r--tests/test-erc-config--generate-buffer-name.el31
-rw-r--r--tests/test-erc-config-connected-servers.el9
-rw-r--r--tests/test-face-diagnostic.el25
-rw-r--r--tests/test-flyspell-and-abbrev.el4
-rw-r--r--tests/test-font-config--frame-lifecycle.el75
-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.el35
-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.el2
-rw-r--r--tests/test-jumper--location-candidates.el52
-rw-r--r--tests/test-jumper--register-hygiene.el179
-rw-r--r--tests/test-keybindings--jump-open-var.el2
-rw-r--r--tests/test-keybindings-tty-mirror.el33
-rw-r--r--tests/test-latex-config--latexmk-wiring.el62
-rw-r--r--tests/test-local-repository--car-member.el58
-rw-r--r--tests/test-mail-config--account-search-queries.el53
-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-modeline-config--click-map.el29
-rw-r--r--tests/test-modeline-config-string-cut-middle.el8
-rw-r--r--tests/test-modeline-config-string-truncate-p.el8
-rw-r--r--tests/test-mousetrap-mode--bind-events.el41
-rw-r--r--tests/test-music-config--playlist-side.el45
-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.el59
-rw-r--r--tests/test-org-capture-config--find-or-create-top-heading.el45
-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--deadgrep.el44
-rw-r--r--tests/test-prog-general--find-file-respecting-split.el12
-rw-r--r--tests/test-prog-general--find-project-root-file.el49
-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-reconcile--dirty-p.el49
-rw-r--r--tests/test-show-kill-ring--insert-item.el73
-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-system-lib--format-region-with-program.el68
-rw-r--r--tests/test-system-lib-font-lock-global-modes.el46
-rw-r--r--tests/test-system-utils-scratch-background.el30
-rw-r--r--tests/test-term-tmux-history.el128
-rw-r--r--tests/test-term-toggle--display.el37
-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-buffer-status-colors.el98
-rw-r--r--tests/test-ui-config--buffer-cursor-state.el74
-rw-r--r--tests/test-ui-config-transparency-and-cursor.el12
-rw-r--r--tests/test-ui-navigation--split-dashboard.el21
-rw-r--r--tests/test-ui-navigation--window-resize.el41
-rw-r--r--tests/test-ui-navigation-split-follow-undo-kill.el10
-rw-r--r--tests/test-ui-theme-commands.el18
-rw-r--r--tests/test-update-text-file.el473
-rw-r--r--tests/test-user-constants.el43
-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
171 files changed, 4159 insertions, 5872 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-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--capture-state.el b/tests/test-ai-term--capture-state.el
index 543f83ad7..aa7421350 100644
--- a/tests/test-ai-term--capture-state.el
+++ b/tests/test-ai-term--capture-state.el
@@ -27,7 +27,9 @@
(should (= cj/--ai-term-last-size (window-body-width right))))))
(ert-deftest test-ai-term--capture-state-below-split-sets-direction ()
- "Normal: below-split window -> direction=below, integer body-lines matching window."
+ "Normal: below-split window -> direction=below, integer total-lines matching window.
+The vertical axis captures total-height (not body-height) so the toggle
+round-trip is immune to the mode line's pixel height."
(save-window-excursion
(delete-other-windows)
(let ((below (split-window (selected-window) nil 'below))
@@ -36,7 +38,7 @@
(cj/--ai-term-capture-state below)
(should (eq cj/--ai-term-last-direction 'below))
(should (integerp cj/--ai-term-last-size))
- (should (= cj/--ai-term-last-size (window-body-height below))))))
+ (should (= cj/--ai-term-last-size (window-total-height below))))))
(ert-deftest test-ai-term--capture-state-noop-on-dead-window ()
"Boundary: nil window -> state remains unchanged."
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--default-geometry.el b/tests/test-ai-term--default-geometry.el
index 91013862d..1180c1979 100644
--- a/tests/test-ai-term--default-geometry.el
+++ b/tests/test-ai-term--default-geometry.el
@@ -1,18 +1,20 @@
;;; test-ai-term--default-geometry.el --- Tests for host-aware display defaults -*- lexical-binding: t; -*-
;;; Commentary:
-;; ai-term's default display geometry is chosen from the frame's pixel aspect
-;; ratio: a landscape frame docks the agent from the right (a width fraction), a
-;; square or portrait frame docks it from the bottom (a height fraction).
-;; `cj/--ai-term-direction-for-aspect' is the pure decision;
-;; `cj/--ai-term-default-direction' reads the frame and delegates to it;
-;; `cj/--ai-term-default-size' pairs the size fraction with that direction.
-;; They feed the default fallbacks in `cj/--ai-term-capture-state' and
-;; `cj/--ai-term-display-saved'.
+;; ai-term's default display geometry is chosen from the frame's column
+;; width: the agent docks from the right (a width fraction) only when a
+;; side-by-side split would leave both panes at least
+;; `cj/window-dock-min-columns' wide, otherwise from the bottom (a height
+;; fraction). `cj/--ai-term-default-direction' reads the frame width and
+;; delegates the decision to `cj/preferred-dock-direction' (tested in
+;; test-cj-window-geometry-lib.el); `cj/--ai-term-default-size' pairs the
+;; size fraction with that direction. They feed the default fallbacks in
+;; `cj/--ai-term-capture-state' and `cj/--ai-term-display-saved'.
;;
-;; The direction is tested on the pure helper (no frame mocking, which would
-;; trip the native-comp trampoline trap on the frame-pixel-* subrs); the size
-;; helper is tested by stubbing the direction defun.
+;; The direction is tested by stubbing `cj/preferred-dock-direction' (an
+;; ordinary defun -- safe to `cl-letf', unlike the frame-* subrs, which
+;; would trip the native-comp trampoline trap); the size helper is tested
+;; by stubbing the direction defun.
;;; Code:
@@ -22,17 +24,26 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'ai-term)
-(ert-deftest test-ai-term--direction-for-aspect-landscape-is-right ()
- "Normal: a wider-than-tall frame docks from the right."
- (should (eq (cj/--ai-term-direction-for-aspect 1920 1080) 'right)))
+(ert-deftest test-ai-term--default-direction-delegates-to-dock-rule ()
+ "Normal: default-direction passes the desktop-width fraction to the dock rule
+and returns its verdict."
+ (let ((cj/ai-term-desktop-width 0.5)
+ captured)
+ (cl-letf (((symbol-function 'cj/preferred-dock-direction)
+ (lambda (cols frac &rest _)
+ (setq captured (list cols frac))
+ 'below)))
+ (should (eq (cj/--ai-term-default-direction) 'below))
+ ;; the fraction passed is the agent's desktop-width
+ (should (= (nth 1 captured) 0.5))
+ ;; the first argument is a column count (the frame width)
+ (should (integerp (nth 0 captured))))))
-(ert-deftest test-ai-term--direction-for-aspect-portrait-is-below ()
- "Normal: a taller-than-wide frame docks from the bottom."
- (should (eq (cj/--ai-term-direction-for-aspect 1080 1920) 'below)))
-
-(ert-deftest test-ai-term--direction-for-aspect-square-is-below ()
- "Boundary: a square frame docks from the bottom (the conserving tie-break)."
- (should (eq (cj/--ai-term-direction-for-aspect 1000 1000) 'below)))
+(ert-deftest test-ai-term--default-direction-returns-right-when-rule-says ()
+ "Normal: when the dock rule returns `right', so does default-direction."
+ (cl-letf (((symbol-function 'cj/preferred-dock-direction)
+ (lambda (&rest _) 'right)))
+ (should (eq (cj/--ai-term-default-direction) 'right))))
(ert-deftest test-ai-term--default-size-pairs-width-with-right ()
"Normal: when the direction is `right' the size is the width fraction."
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..a8b92ffa8
--- /dev/null
+++ b/tests/test-ai-term--keybindings.el
@@ -0,0 +1,59 @@
+;;; 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
+;; `ghostel-mode-map' and added to `ghostel-keymap-exceptions' (the semi-char
+;; map otherwise forwards it to the pty). C-; is already an exception via
+;; term-config, so the C-; a family resolves through the global prefix. These
+;; tests require ghostel (so ai-term's `with-eval-after-load' fires) before
+;; ai-term, then confirm the bindings landed and the old F9 family is gone.
+;; `(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-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-ghostel-mode-map ()
+ "Normal: M-SPC is bound in `ghostel-mode-map' so swap works inside an agent."
+ (should (eq (keymap-lookup ghostel-mode-map "M-SPC") #'cj/ai-term-next)))
+
+(ert-deftest test-ai-term-meta-space-in-keymap-exceptions ()
+ "Regression: M-SPC is in `ghostel-keymap-exceptions' so semi-char mode lets it
+reach Emacs instead of forwarding it to the pty."
+ (should (member "M-SPC" ghostel-keymap-exceptions))
+ (should-not (eq (keymap-lookup ghostel-semi-char-mode-map "M-SPC")
+ 'ghostel--send-event)))
+
+(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--reuse-edge-window.el b/tests/test-ai-term--reuse-edge-window.el
index f6259ae50..a9a0529e8 100644
--- a/tests/test-ai-term--reuse-edge-window.el
+++ b/tests/test-ai-term--reuse-edge-window.el
@@ -269,5 +269,46 @@ most-recent agent, which would now be the other one."
(when (get-buffer right-name) (kill-buffer right-name))
(cj/test--kill-agent-buffers))))
+(ert-deftest test-ai-term--reuse-edge-window-3win-toggle-restores-own-window ()
+ "Regression: in a 3-window layout the agent has its own split, so toggling it
+off then on restores it as its own window without displacing a working window.
+Before the fix, toggle-on reused the bottom edge (the user's main window),
+collapsing three windows to two and hiding the main buffer. A toggle must be
+reversible: off then on returns to the same layout."
+ (cj/test--kill-agent-buffers)
+ (let ((agent-name "agent [3win-toggle]")
+ (code-name "*test-3win-code*")
+ (main-name "*test-3win-main*")
+ (cj/--ai-term-last-direction nil)
+ (cj/--ai-term-last-size nil)
+ (cj/--ai-term-last-was-bury nil))
+ (unwind-protect
+ (save-window-excursion
+ (delete-other-windows)
+ (cl-letf (((symbol-function 'cj/--ai-term-default-direction) (lambda (&rest _) 'below)))
+ (let ((code-buf (get-buffer-create code-name))
+ (main-buf (get-buffer-create main-name))
+ (agent-buf (get-buffer-create agent-name)))
+ (set-window-buffer (selected-window) code-buf)
+ (let* ((main-win (split-window (selected-window) nil 'below))
+ (agent-win (split-window main-win nil 'below)))
+ (set-window-buffer main-win main-buf)
+ (set-window-buffer agent-win agent-buf)
+ (should (= (count-windows) 3))
+ (let ((display-buffer-alist (cj/--ai-term-display-rule-list)))
+ (select-window agent-win)
+ (cj/test--call-as-gui #'cj/ai-term) ; off -> code | main
+ (should (= (count-windows) 2))
+ (should-not (member agent-name (cj/test--displayed-buffer-names)))
+ (cj/test--call-as-gui #'cj/ai-term) ; on -> back to 3 windows
+ (should (= (count-windows) 3))
+ (let ((bufs (cj/test--displayed-buffer-names)))
+ (should (member agent-name bufs))
+ (should (member code-name bufs))
+ (should (member main-name bufs))))))))
+ (when (get-buffer code-name) (kill-buffer code-name))
+ (when (get-buffer main-name) (kill-buffer main-name))
+ (cj/test--kill-agent-buffers))))
+
(provide 'test-ai-term--reuse-edge-window)
;;; test-ai-term--reuse-edge-window.el ends here
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-auth-config--plstore-read-fixed.el b/tests/test-auth-config--plstore-read-fixed.el
new file mode 100644
index 000000000..4b14a4a0c
--- /dev/null
+++ b/tests/test-auth-config--plstore-read-fixed.el
@@ -0,0 +1,101 @@
+;;; test-auth-config--plstore-read-fixed.el --- Tests for the oauth2-auto cache fix -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Tests for `cj/oauth2-auto--plstore-read-fixed' in auth-config.el — the
+;; advice that re-enables oauth2-auto's plstore cache. oauth2-auto is not
+;; installed here, so its symbols and the plstore I/O are stubbed at the
+;; boundary; the function's own logic (cache-first read, puthash, the
+;; unwind-protect close) runs for real. `require' is stubbed to no-op only
+;; for oauth2-auto (other requires delegate through), satisfying the
+;; function's `(require 'oauth2-auto)' without loading or provide-ing the
+;; package (a provide would fire auth-config's advice-add side effect).
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'plstore)
+(require 'auth-config)
+
+;; Declared special so the function (which reads these as free package
+;; globals) sees the dynamic let-bindings the tests establish.
+(defvar oauth2-auto--plstore-cache nil)
+(defvar oauth2-auto-plstore nil)
+
+(defvar test-auth--open-count 0 "Times plstore-open was called in a test.")
+(defvar test-auth--closed nil "Whether plstore-close ran in a test.")
+(defvar test-auth--get-fn nil "Stub behavior for plstore-get: (lambda (ps id) ...).")
+
+(defmacro test-auth--with-env (&rest body)
+ "Run BODY with a faked oauth2-auto + plstore environment.
+Resets the open counter and closed flag and gives a fresh cache each time."
+ (declare (indent 0))
+ `(let* ((oauth2-auto--plstore-cache (make-hash-table :test 'equal))
+ (oauth2-auto-plstore "/tmp/oauth2-test.plist")
+ (test-auth--open-count 0)
+ (test-auth--closed nil)
+ (orig-require (symbol-function 'require)))
+ (cl-letf (((symbol-function 'require)
+ (lambda (feat &rest args)
+ (if (eq feat 'oauth2-auto)
+ 'oauth2-auto
+ (apply orig-require feat args))))
+ ((symbol-function 'oauth2-auto--compute-id)
+ (lambda (_u _p) "ID"))
+ ((symbol-function 'plstore-open)
+ (lambda (_f) (cl-incf test-auth--open-count) 'PS))
+ ((symbol-function 'plstore-get)
+ (lambda (ps id) (funcall test-auth--get-fn ps id)))
+ ((symbol-function 'plstore-close)
+ (lambda (_p) (setq test-auth--closed t))))
+ ,@body)))
+
+;;; Normal Cases
+
+(ert-deftest test-auth-config-plstore-read-fixed-cache-hit ()
+ "Normal: a cache hit returns the cached value without opening the plstore."
+ (let ((test-auth--get-fn (lambda (_ps _id) (error "should not read"))))
+ (test-auth--with-env
+ (puthash "ID" "CACHED" oauth2-auto--plstore-cache)
+ (should (equal (cj/oauth2-auto--plstore-read-fixed "u" "p") "CACHED"))
+ (should (= test-auth--open-count 0)))))
+
+(ert-deftest test-auth-config-plstore-read-fixed-cache-miss-reads-and-caches ()
+ "Normal: a miss reads from the plstore, caches the value, and closes."
+ (let ((test-auth--get-fn (lambda (_ps id) (cons id "TOK"))))
+ (test-auth--with-env
+ (should (equal (cj/oauth2-auto--plstore-read-fixed "u" "p") "TOK"))
+ (should (equal (gethash "ID" oauth2-auto--plstore-cache) "TOK"))
+ (should (= test-auth--open-count 1))
+ (should test-auth--closed))))
+
+;;; Boundary Cases
+
+(ert-deftest test-auth-config-plstore-read-fixed-value-cached-after-first-read ()
+ "Boundary: a non-nil value is cached, so a second call does not re-open."
+ (let ((test-auth--get-fn (lambda (_ps id) (cons id "TOK"))))
+ (test-auth--with-env
+ (cj/oauth2-auto--plstore-read-fixed "u" "p")
+ (cj/oauth2-auto--plstore-read-fixed "u" "p")
+ (should (= test-auth--open-count 1)))))
+
+(ert-deftest test-auth-config-plstore-read-fixed-nil-value-rereads ()
+ "Boundary: a nil value caches nil, so every call re-opens the plstore.
+This documents current behavior — `gethash' on a nil entry is a miss."
+ (let ((test-auth--get-fn (lambda (_ps _id) (cons "ID" nil))))
+ (test-auth--with-env
+ (should-not (cj/oauth2-auto--plstore-read-fixed "u" "p"))
+ (should-not (cj/oauth2-auto--plstore-read-fixed "u" "p"))
+ (should (= test-auth--open-count 2)))))
+
+;;; Error Cases
+
+(ert-deftest test-auth-config-plstore-read-fixed-closes-on-error ()
+ "Error: a read failure still closes the plstore via unwind-protect."
+ (let ((test-auth--get-fn (lambda (&rest _) (error "boom"))))
+ (test-auth--with-env
+ (should-error (cj/oauth2-auto--plstore-read-fixed "u" "p"))
+ (should test-auth--closed))))
+
+(provide 'test-auth-config--plstore-read-fixed)
+;;; test-auth-config--plstore-read-fixed.el ends here
diff --git a/tests/test-browser-config.el b/tests/test-browser-config.el
index 7faecbfc8..9fe5b02e4 100644
--- a/tests/test-browser-config.el
+++ b/tests/test-browser-config.el
@@ -273,29 +273,6 @@
(should (string= (plist-get loaded :name) "Second"))))
(test-browser-teardown))
-;;; Public wrappers (message side-effects mocked)
-
-(ert-deftest test-browser-apply-wrapper-success-messages-name ()
- "Normal: =cj/apply-browser-choice= reports the chosen name on success."
- (test-browser-setup)
- (let ((browser (test-browser-make-plist "Wrapper Test"))
- (received nil))
- (cl-letf (((symbol-function 'message)
- (lambda (fmt &rest args) (setq received (apply #'format fmt args)))))
- (cj/apply-browser-choice browser))
- (should (string-match-p "Wrapper Test" received))
- (should (string-match-p "Default browser set" received)))
- (test-browser-teardown))
-
-(ert-deftest test-browser-apply-wrapper-invalid-plist-messages-error ()
- "Error: =cj/apply-browser-choice= surfaces an error message for a bad plist."
- (test-browser-setup)
- (let ((received nil))
- (cl-letf (((symbol-function 'message)
- (lambda (fmt &rest args) (setq received (apply #'format fmt args)))))
- (cj/apply-browser-choice nil))
- (should (string-match-p "Invalid" received)))
- (test-browser-teardown))
(ert-deftest test-browser-initialize-wrapper-loaded-branch-applies ()
"Normal: =cj/initialize-browser= applies the saved browser when one is loaded."
diff --git a/tests/test-build-theme.el b/tests/test-build-theme.el
index 6c2fa3cf5..8793da73a 100644
--- a/tests/test-build-theme.el
+++ b/tests/test-build-theme.el
@@ -95,43 +95,175 @@ drift the way Craig's downloaded exports under scripts/theme-studio/ can.")
;;; ---------------------------------------------------------------------------
;;; build-theme/--attrs (the core attribute builder)
+;;
+;; `--attrs' takes one face-spec alist and emits a face-attribute plist. It
+;; reads the full attribute model and tolerates the legacy boolean
+;; bold/italic/underline/strike fields that older theme.json exports carry.
-(ert-deftest test-build-theme-attrs-fg-and-bold ()
- "Normal: a foreground plus bold yields :foreground and :weight bold."
- (should (equal (build-theme/--attrs nil "#67809c" nil t nil nil nil nil)
+;; --- Legacy boolean fields still work (back-compat with committed presets) ---
+
+(ert-deftest test-build-theme-attrs-legacy-fg-and-bold ()
+ "Normal: legacy bold flag yields :weight bold."
+ (should (equal (build-theme/--attrs '((fg . "#67809c") (bold . t)))
'(:foreground "#67809c" :weight bold))))
-(ert-deftest test-build-theme-attrs-full-ordering ()
- "Normal: every attribute present, in canonical order."
- (should (equal (build-theme/--attrs 'org-level-1 "#e8bd30" "#1a1714" t t t t 1.3)
- '(:inherit org-level-1 :foreground "#e8bd30" :background "#1a1714"
- :weight bold :slant italic :underline t :strike-through t :height 1.3))))
-
-(ert-deftest test-build-theme-attrs-underline-and-strike ()
- "Normal: underline and strike yield :underline t and :strike-through t."
- (should (equal (build-theme/--attrs nil "#67809c" nil nil nil t t nil)
- '(:foreground "#67809c" :underline t :strike-through t)))
- ;; either alone
- (should (equal (build-theme/--attrs nil nil nil nil nil t nil nil)
- '(:underline t)))
- (should (equal (build-theme/--attrs nil nil nil nil nil nil t nil)
- '(:strike-through t))))
+(ert-deftest test-build-theme-attrs-legacy-italic-underline-strike ()
+ "Normal: legacy italic/underline/strike booleans map to their attributes."
+ (should (equal (build-theme/--attrs '((italic . t))) '(:slant italic)))
+ (should (equal (build-theme/--attrs '((underline . t))) '(:underline t)))
+ (should (equal (build-theme/--attrs '((strike . t))) '(:strike-through t))))
(ert-deftest test-build-theme-attrs-empty-is-nil ()
- "Boundary: a fully-cleared face (all nil) yields an empty plist."
- (should (equal (build-theme/--attrs nil nil nil nil nil nil nil nil) '())))
+ "Boundary: a blank face (empty alist, or all-nil fields) yields an empty plist."
+ (should (equal (build-theme/--attrs '()) '()))
+ (should (equal (build-theme/--attrs '((fg) (bg) (bold) (italic) (underline) (strike))) '())))
(ert-deftest test-build-theme-attrs-bold-false-omits-weight ()
- "Boundary: bold false produces no :weight key (only overrides are written)."
- (should (equal (build-theme/--attrs nil "#cdced1" nil nil nil nil nil nil)
- '(:foreground "#cdced1"))))
+ "Boundary: bold false (or absent) writes no :weight -- only overrides appear."
+ (should (equal (build-theme/--attrs '((fg . "#cdced1") (bold . nil)))
+ '(:foreground "#cdced1")))
+ (should (equal (build-theme/--attrs '((fg . "#cdced1"))) '(:foreground "#cdced1"))))
(ert-deftest test-build-theme-attrs-height-one-omitted ()
- "Boundary: a height of exactly 1.0 is omitted (the default multiplier)."
- (should (equal (build-theme/--attrs nil "#cdced1" nil nil nil nil nil 1.0)
- '(:foreground "#cdced1")))
- (should (equal (build-theme/--attrs nil "#cdced1" nil nil nil nil nil 1)
- '(:foreground "#cdced1"))))
+ "Boundary: a height of exactly 1.0 (or integer 1) is omitted as the default."
+ (should (equal (build-theme/--attrs '((fg . "#cdced1") (height . 1.0))) '(:foreground "#cdced1")))
+ (should (equal (build-theme/--attrs '((fg . "#cdced1") (height . 1))) '(:foreground "#cdced1")))
+ (should (equal (build-theme/--attrs '((height . 1.2))) '(:height 1.2))))
+
+;; --- New attributes ---
+
+(ert-deftest test-build-theme-attrs-family ()
+ "Normal/Boundary: a non-empty family string emits :family; empty is omitted."
+ (should (equal (build-theme/--attrs '((family . "Iosevka"))) '(:family "Iosevka")))
+ (should (equal (build-theme/--attrs '((family . ""))) '()))
+ (should (equal (build-theme/--attrs '((family . nil))) '())))
+
+(ert-deftest test-build-theme-attrs-distant-foreground ()
+ "Normal: distant-fg emits :distant-foreground."
+ (should (equal (build-theme/--attrs '((distant-fg . "#ffffff")))
+ '(:distant-foreground "#ffffff"))))
+
+(ert-deftest test-build-theme-attrs-weight-range ()
+ "Normal: an explicit weight string emits that weight symbol."
+ (should (equal (build-theme/--attrs '((weight . "light"))) '(:weight light)))
+ (should (equal (build-theme/--attrs '((weight . "semibold"))) '(:weight semibold)))
+ (should (equal (build-theme/--attrs '((weight . "heavy"))) '(:weight heavy))))
+
+(ert-deftest test-build-theme-attrs-weight-overrides-legacy-bold ()
+ "Boundary: an explicit weight wins over a legacy bold flag on the same face."
+ (should (equal (build-theme/--attrs '((weight . "light") (bold . t)))
+ '(:weight light))))
+
+(ert-deftest test-build-theme-attrs-slant-range ()
+ "Normal: an explicit slant string emits that slant; it wins over legacy italic."
+ (should (equal (build-theme/--attrs '((slant . "oblique"))) '(:slant oblique)))
+ (should (equal (build-theme/--attrs '((slant . "normal"))) '(:slant normal)))
+ (should (equal (build-theme/--attrs '((slant . "oblique") (italic . t))) '(:slant oblique))))
+
+(ert-deftest test-build-theme-attrs-underline-object ()
+ "Normal/Boundary: the structured underline form covers line/wave and color."
+ ;; plain line in the face color collapses to t
+ (should (equal (build-theme/--attrs '((underline . ((style . "line") (color . nil)))))
+ '(:underline t)))
+ ;; wave alone -> a :style plist
+ (should (equal (build-theme/--attrs '((underline . ((style . "wave") (color . nil)))))
+ '(:underline (:style wave))))
+ ;; colored line -> a :color plist
+ (should (equal (build-theme/--attrs '((underline . ((style . "line") (color . "#cb6b4d")))))
+ '(:underline (:color "#cb6b4d"))))
+ ;; colored wave -> both
+ (should (equal (build-theme/--attrs '((underline . ((style . "wave") (color . "#cb6b4d")))))
+ '(:underline (:color "#cb6b4d" :style wave)))))
+
+(ert-deftest test-build-theme-attrs-strike-object ()
+ "Normal: structured strike emits t for no color, or the color string."
+ (should (equal (build-theme/--attrs '((strike . ((color . nil))))) '(:strike-through t)))
+ (should (equal (build-theme/--attrs '((strike . ((color . "#cb6b4d")))))
+ '(:strike-through "#cb6b4d"))))
+
+(ert-deftest test-build-theme-attrs-migrated-shapes-match-legacy ()
+ "Boundary: the shapes the import migration produces emit identically to the
+legacy booleans they replace, so the cutover keeps generated themes byte-identical.
+Mirrors migrateLegacyFace (app-core.js) / migrate_legacy (face_specs.py)."
+ (should (equal (build-theme/--attrs '((weight . "bold")))
+ (build-theme/--attrs '((bold . t)))))
+ (should (equal (build-theme/--attrs '((slant . "italic")))
+ (build-theme/--attrs '((italic . t)))))
+ (should (equal (build-theme/--attrs '((underline . ((style . "line") (color . nil)))))
+ (build-theme/--attrs '((underline . t)))))
+ (should (equal (build-theme/--attrs '((strike . ((color . nil)))))
+ (build-theme/--attrs '((strike . t))))))
+
+(ert-deftest test-build-theme-attrs-overline ()
+ "Normal/Boundary: overline emits t for no color, the color otherwise, nil when unset."
+ (should (equal (build-theme/--attrs '((overline . ((color . nil))))) '(:overline t)))
+ (should (equal (build-theme/--attrs '((overline . ((color . "#a9b2bb")))))
+ '(:overline "#a9b2bb")))
+ (should (equal (build-theme/--attrs '((overline . nil))) '())))
+
+(ert-deftest test-build-theme-attrs-inverse-and-extend ()
+ "Normal/Boundary: inverse and extend emit t when set, nothing when nil."
+ (should (equal (build-theme/--attrs '((inverse . t))) '(:inverse-video t)))
+ (should (equal (build-theme/--attrs '((extend . t))) '(:extend t)))
+ (should (equal (build-theme/--attrs '((inverse . t) (extend . t)))
+ '(:inverse-video t :extend t)))
+ (should (equal (build-theme/--attrs '((inverse . nil) (extend . nil))) '())))
+
+(ert-deftest test-build-theme-attrs-inherit-any-tier ()
+ "Normal: inherit coerces a face-name string to a symbol (now allowed on every tier)."
+ (should (equal (build-theme/--attrs '((inherit . "shadow"))) '(:inherit shadow)))
+ (should (equal (build-theme/--attrs '((inherit . shadow))) '(:inherit shadow)))
+ (should (equal (build-theme/--attrs '((inherit . nil))) '())))
+
+(ert-deftest test-build-theme-attrs-full-ordering ()
+ "Normal: every attribute present, emitted in canonical order."
+ (should (equal (build-theme/--attrs
+ '((inherit . "org-level-1") (family . "Iosevka")
+ (fg . "#e8bd30") (bg . "#1a1714") (distant-fg . "#ffffff")
+ (weight . "semibold") (slant . "italic") (height . 1.3)
+ (underline . ((style . "wave") (color . "#cb6b4d")))
+ (overline . ((color . "#a9b2bb")))
+ (strike . ((color . nil)))
+ (box . ((style . "line") (color . "#67809c")))
+ (inverse . t) (extend . t)))
+ '(:inherit org-level-1 :family "Iosevka"
+ :foreground "#e8bd30" :background "#1a1714" :distant-foreground "#ffffff"
+ :weight semibold :slant italic :height 1.3
+ :underline (:color "#cb6b4d" :style wave) :overline "#a9b2bb"
+ :strike-through t :box (:line-width 1 :color "#67809c")
+ :inverse-video t :extend t))))
+
+;; --- Attribute-helper edge cases (the coercion functions in isolation) ---
+
+(ert-deftest test-build-theme-weight-helper ()
+ "Boundary: weight prefers explicit string, falls back to bold, else nil."
+ (should (eq (build-theme/--weight '((weight . "bold"))) 'bold))
+ (should (eq (build-theme/--weight '((weight . "light") (bold . t))) 'light))
+ (should (eq (build-theme/--weight '((bold . t))) 'bold))
+ (should (null (build-theme/--weight '((weight . "") (bold . nil)))))
+ (should (null (build-theme/--weight '()))))
+
+(ert-deftest test-build-theme-slant-helper ()
+ "Boundary: slant prefers explicit string, falls back to italic, else nil."
+ (should (eq (build-theme/--slant '((slant . "oblique"))) 'oblique))
+ (should (eq (build-theme/--slant '((italic . t))) 'italic))
+ (should (null (build-theme/--slant '((slant . "")))))
+ (should (null (build-theme/--slant '()))))
+
+(ert-deftest test-build-theme-underline-helper ()
+ "Boundary: underline coercion across nil / legacy t / structured forms."
+ (should (null (build-theme/--underline '((underline . nil)))))
+ (should (eq (build-theme/--underline '((underline . t))) t))
+ (should (eq (build-theme/--underline '((underline . ((style . "line") (color . nil))))) t))
+ (should (equal (build-theme/--underline '((underline . ((style . "wave"))))) '(:style wave)))
+ (should (equal (build-theme/--underline '((underline . ((color . "#aa0000"))))) '(:color "#aa0000"))))
+
+(ert-deftest test-build-theme-line-attr-helper ()
+ "Boundary: the overline/strike coercion: nil / t / {color} forms."
+ (should (null (build-theme/--line-attr nil)))
+ (should (eq (build-theme/--line-attr t) t))
+ (should (eq (build-theme/--line-attr '((color . nil))) t))
+ (should (equal (build-theme/--line-attr '((color . "#abcdef"))) "#abcdef")))
;;; ---------------------------------------------------------------------------
;;; build-theme/--face-spec (skips empty faces)
@@ -355,5 +487,46 @@ parse -> spec -> file -> face pipeline preserves the designed contrast."
(should (>= (test-build-theme--contrast fg bg) 4.5))))
(disable-theme 'dupre-fixture))))))
+(ert-deftest test-build-theme-convert-file-new-attributes-round-trip ()
+ "Integration: the new attribute model survives parse -> spec -> file -> face.
+Components integrated:
+- build-theme/convert-file (entry point, real)
+- json parsing of the inline fixture (real)
+- custom-theme-set-faces / load-theme / face-attribute (real)
+Exercises extend, structured underline (wave + color), overline, inverse-video,
+distant-foreground, family, and the weight/slant ranges across the UI and
+package tiers."
+ (test-build-theme--with-sandbox out
+ (let* ((json "{\"name\":\"newattrs\",\"palette\":[[\"#000000\",\"ground\"]],
+ \"syntax\":{\"bg\":{\"fg\":\"#000000\"},\"p\":{\"fg\":\"#ffffff\"}},
+ \"ui\":{
+ \"region\":{\"bg\":\"#264364\",\"extend\":true},
+ \"highlight\":{\"fg\":\"#eddba7\",\"underline\":{\"style\":\"wave\",\"color\":\"#cb6b4d\"},\"overline\":{\"color\":\"#a9b2bb\"}},
+ \"secondary-selection\":{\"bg\":\"#333333\",\"inverse\":true,\"distant-fg\":\"#ffffff\"}
+ },
+ \"packages\":{
+ \"misc\":{
+ \"shadow\":{\"fg\":\"#cdced1\",\"family\":\"Iosevka\",\"weight\":\"light\",\"slant\":\"oblique\",\"source\":\"user\"}
+ }
+ }}")
+ (in (expand-file-name "newattrs.json" out)))
+ (with-temp-file in (insert json))
+ (build-theme/convert-file in out)
+ (let ((custom-theme-load-path (cons out custom-theme-load-path))
+ (load-path (cons out load-path)))
+ (unwind-protect
+ (progn
+ (load-theme 'newattrs t)
+ (should (eq (face-attribute 'region :extend nil t) t))
+ (should (equal (face-attribute 'highlight :underline nil t)
+ '(:color "#cb6b4d" :style wave)))
+ (should (string= (face-attribute 'highlight :overline nil t) "#a9b2bb"))
+ (should (eq (face-attribute 'secondary-selection :inverse-video nil t) t))
+ (should (string= (face-attribute 'secondary-selection :distant-foreground nil t) "#ffffff"))
+ (should (string= (face-attribute 'shadow :family nil t) "Iosevka"))
+ (should (eq (face-attribute 'shadow :weight nil t) 'light))
+ (should (eq (face-attribute 'shadow :slant nil t) 'oblique)))
+ (disable-theme 'newattrs))))))
+
(provide 'test-build-theme)
;;; test-build-theme.el ends here
diff --git a/tests/test-calendar-sync--apply-single-exception.el b/tests/test-calendar-sync--apply-single-exception.el
index 2fcf7c718..f23104d98 100644
--- a/tests/test-calendar-sync--apply-single-exception.el
+++ b/tests/test-calendar-sync--apply-single-exception.el
@@ -63,5 +63,84 @@
(let ((result (calendar-sync--apply-single-exception occ exc)))
(should (equal "Keep" (plist-get result :summary))))))
+;;; Normal Cases — remaining overridable fields
+
+(ert-deftest test-calendar-sync--apply-single-exception-overrides-description ()
+ "Normal: an exception :description overrides the occurrence's."
+ (let ((occ (list :start '(2026 3 15 14 0) :description "old"))
+ (exc (list :start '(2026 3 15 14 0) :description "new")))
+ (should (equal "new"
+ (plist-get (calendar-sync--apply-single-exception occ exc)
+ :description)))))
+
+(ert-deftest test-calendar-sync--apply-single-exception-overrides-location ()
+ "Normal: an exception :location overrides the occurrence's."
+ (let ((occ (list :start '(2026 3 15 14 0) :location "Room A"))
+ (exc (list :start '(2026 3 15 14 0) :location "Room B")))
+ (should (equal "Room B"
+ (plist-get (calendar-sync--apply-single-exception occ exc)
+ :location)))))
+
+(ert-deftest test-calendar-sync--apply-single-exception-overrides-attendees ()
+ "Normal: an exception :attendees overrides the occurrence's."
+ (let ((occ (list :start '(2026 3 15 14 0) :attendees '("a")))
+ (exc (list :start '(2026 3 15 14 0) :attendees '("b" "c"))))
+ (should (equal '("b" "c")
+ (plist-get (calendar-sync--apply-single-exception occ exc)
+ :attendees)))))
+
+(ert-deftest test-calendar-sync--apply-single-exception-overrides-organizer ()
+ "Normal: an exception :organizer overrides the occurrence's."
+ (let ((occ (list :start '(2026 3 15 14 0) :organizer "old@x"))
+ (exc (list :start '(2026 3 15 14 0) :organizer "new@x")))
+ (should (equal "new@x"
+ (plist-get (calendar-sync--apply-single-exception occ exc)
+ :organizer)))))
+
+(ert-deftest test-calendar-sync--apply-single-exception-overrides-url ()
+ "Normal: an exception :url overrides the occurrence's."
+ (let ((occ (list :start '(2026 3 15 14 0) :url "http://old"))
+ (exc (list :start '(2026 3 15 14 0) :url "http://new")))
+ (should (equal "http://new"
+ (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--expand-recurring-event.el b/tests/test-calendar-sync--expand-recurring-event.el
new file mode 100644
index 000000000..41f0afa9c
--- /dev/null
+++ b/tests/test-calendar-sync--expand-recurring-event.el
@@ -0,0 +1,106 @@
+;;; test-calendar-sync--expand-recurring-event.el --- Tests for recurrence dispatch -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for calendar-sync--expand-recurring-event — the dispatcher that maps
+;; an RRULE frequency to the matching expander and applies EXDATE filtering.
+;; The individual expanders, parser, and exdate helpers have their own tests;
+;; here they are stubbed at the boundary so only the dispatch and the
+;; exdate-vs-no-exdate branch are exercised.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+(defmacro test-cs-ere--with (overrides &rest body)
+ "Run BODY with the recurrence helpers stubbed.
+OVERRIDES is an extra list of cl-letf* bindings layered on the defaults:
+RRULE present, parse-event returns 'BASE, no exdates, and every expander
+errors if called (each test re-binds the one it expects). cl-letf* is
+sequential, so a re-bound place in OVERRIDES wins over the default."
+ (declare (indent 1))
+ `(cl-letf* (((symbol-function 'calendar-sync--get-property)
+ (lambda (_e prop) (when (string= prop "RRULE") "R")))
+ ((symbol-function 'calendar-sync--parse-event) (lambda (_e) 'BASE))
+ ((symbol-function 'calendar-sync--collect-exdates) (lambda (_e) nil))
+ ((symbol-function 'calendar-sync--expand-daily)
+ (lambda (&rest _) (error "daily should not be called")))
+ ((symbol-function 'calendar-sync--expand-weekly)
+ (lambda (&rest _) (error "weekly should not be called")))
+ ((symbol-function 'calendar-sync--expand-monthly)
+ (lambda (&rest _) (error "monthly should not be called")))
+ ((symbol-function 'calendar-sync--expand-yearly)
+ (lambda (&rest _) (error "yearly should not be called")))
+ ((symbol-function 'calendar-sync--filter-exdates)
+ (lambda (&rest _) (error "filter-exdates should not be called")))
+ ,@overrides)
+ ,@body))
+
+;;; Normal Cases — frequency dispatch
+
+(ert-deftest test-calendar-sync--expand-recurring-event-dispatches-daily ()
+ "Normal: FREQ=DAILY routes to the daily expander."
+ (test-cs-ere--with
+ (((symbol-function 'calendar-sync--parse-rrule) (lambda (_r) '(:freq daily)))
+ ((symbol-function 'calendar-sync--expand-daily) (lambda (&rest _) '(DAILY))))
+ (should (equal (calendar-sync--expand-recurring-event "evt" 'range) '(DAILY)))))
+
+(ert-deftest test-calendar-sync--expand-recurring-event-dispatches-monthly ()
+ "Normal: FREQ=MONTHLY routes to the monthly expander."
+ (test-cs-ere--with
+ (((symbol-function 'calendar-sync--parse-rrule) (lambda (_r) '(:freq monthly)))
+ ((symbol-function 'calendar-sync--expand-monthly) (lambda (&rest _) '(MONTHLY))))
+ (should (equal (calendar-sync--expand-recurring-event "evt" 'range) '(MONTHLY)))))
+
+(ert-deftest test-calendar-sync--expand-recurring-event-dispatches-yearly ()
+ "Normal: FREQ=YEARLY routes to the yearly expander."
+ (test-cs-ere--with
+ (((symbol-function 'calendar-sync--parse-rrule) (lambda (_r) '(:freq yearly)))
+ ((symbol-function 'calendar-sync--expand-yearly) (lambda (&rest _) '(YEARLY))))
+ (should (equal (calendar-sync--expand-recurring-event "evt" 'range) '(YEARLY)))))
+
+;;; Boundary / Error Cases
+
+(ert-deftest test-calendar-sync--expand-recurring-event-unsupported-freq-nil ()
+ "Error: an unsupported frequency expands to nil, no expander called."
+ (test-cs-ere--with
+ (((symbol-function 'calendar-sync--parse-rrule) (lambda (_r) '(:freq hourly))))
+ (should-not (calendar-sync--expand-recurring-event "evt" 'range))))
+
+(ert-deftest test-calendar-sync--expand-recurring-event-no-rrule-nil ()
+ "Boundary: an event with no RRULE returns nil (not a recurring event)."
+ (test-cs-ere--with
+ (((symbol-function 'calendar-sync--get-property) (lambda (&rest _) nil)))
+ (should-not (calendar-sync--expand-recurring-event "evt" 'range))))
+
+(ert-deftest test-calendar-sync--expand-recurring-event-unparseable-base-nil ()
+ "Boundary: when the base event fails to parse, expansion returns nil."
+ (test-cs-ere--with
+ (((symbol-function 'calendar-sync--parse-rrule) (lambda (_r) '(:freq daily)))
+ ((symbol-function 'calendar-sync--parse-event) (lambda (_e) nil)))
+ (should-not (calendar-sync--expand-recurring-event "evt" 'range))))
+
+;;; EXDATE branch
+
+(ert-deftest test-calendar-sync--expand-recurring-event-applies-exdate-filter ()
+ "Normal: with exdates present, occurrences pass through the exdate filter."
+ (test-cs-ere--with
+ (((symbol-function 'calendar-sync--parse-rrule) (lambda (_r) '(:freq daily)))
+ ((symbol-function 'calendar-sync--expand-daily) (lambda (&rest _) '(O1 O2)))
+ ((symbol-function 'calendar-sync--collect-exdates) (lambda (_e) '(EX)))
+ ((symbol-function 'calendar-sync--filter-exdates)
+ (lambda (occs _ex) (remq 'O2 occs))))
+ (should (equal (calendar-sync--expand-recurring-event "evt" 'range) '(O1)))))
+
+(ert-deftest test-calendar-sync--expand-recurring-event-no-exdate-skips-filter ()
+ "Boundary: with no exdates, the filter is skipped and occurrences pass through."
+ (test-cs-ere--with
+ (((symbol-function 'calendar-sync--parse-rrule) (lambda (_r) '(:freq daily)))
+ ((symbol-function 'calendar-sync--expand-daily) (lambda (&rest _) '(O1 O2))))
+ ;; filter-exdates stays the error stub; it must not be called here
+ (should (equal (calendar-sync--expand-recurring-event "evt" 'range) '(O1 O2)))))
+
+(provide 'test-calendar-sync--expand-recurring-event)
+;;; test-calendar-sync--expand-recurring-event.el ends here
diff --git a/tests/test-calendar-sync--get-all-property-lines.el b/tests/test-calendar-sync--get-all-property-lines.el
index c95041c9a..737d2af0d 100644
--- a/tests/test-calendar-sync--get-all-property-lines.el
+++ b/tests/test-calendar-sync--get-all-property-lines.el
@@ -57,5 +57,23 @@
"Test empty event string returns nil."
(should (null (calendar-sync--get-all-property-lines "" "ATTENDEE"))))
+;;; Boundary Cases — position advancement
+
+(ert-deftest test-calendar-sync--get-all-property-lines-property-at-end-no-newline ()
+ "Boundary: a match at end of string with no trailing newline still returns it.
+Exercises the end-equals-length branch of position advancement."
+ (let ((result (calendar-sync--get-all-property-lines
+ "ATTENDEE:foo@example.com" "ATTENDEE")))
+ (should (= 1 (length result)))
+ (should (string-match-p "foo@example.com" (car result)))))
+
+(ert-deftest test-calendar-sync--get-all-property-lines-second-match-after-continuation ()
+ "Boundary: a first match with a continuation does not hide the second match."
+ (let ((result (calendar-sync--get-all-property-lines
+ "ATTENDEE:a\n more\nATTENDEE:b\nSUMMARY:x" "ATTENDEE")))
+ (should (= 2 (length result)))
+ (should (string-match-p "more" (nth 0 result)))
+ (should (string-match-p "ATTENDEE:b" (nth 1 result)))))
+
(provide 'test-calendar-sync--get-all-property-lines)
;;; test-calendar-sync--get-all-property-lines.el ends here
diff --git a/tests/test-calendar-sync--parse-exception-event.el b/tests/test-calendar-sync--parse-exception-event.el
new file mode 100644
index 000000000..1935d3ebb
--- /dev/null
+++ b/tests/test-calendar-sync--parse-exception-event.el
@@ -0,0 +1,64 @@
+;;; test-calendar-sync--parse-exception-event.el --- Tests for one-event exception parsing -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--parse-exception-event, the per-VEVENT half of
+;; calendar-sync--collect-recurrence-exceptions: it turns a single RECURRENCE-ID
+;; override VEVENT into an exception plist (or nil). One function per file.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "." (file-name-directory load-file-name)))
+(add-to-list 'load-path (expand-file-name "../modules" (file-name-directory load-file-name)))
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+(defun test-cs-parse-exc--override-event (start end)
+ "Return a RECURRENCE-ID override VEVENT string for START..END."
+ (concat "BEGIN:VEVENT\n"
+ "UID:override@google.com\n"
+ "RECURRENCE-ID:20260203T090000Z\n"
+ "SUMMARY:Rescheduled Meeting\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime end) "\n"
+ "END:VEVENT"))
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--parse-exception-event-normal-returns-plist ()
+ "Normal: a RECURRENCE-ID override parses into a plist with its overridden times."
+ (let* ((start (test-calendar-sync-time-days-from-now 7 10 0))
+ (end (test-calendar-sync-time-days-from-now 7 11 0))
+ (plist (calendar-sync--parse-exception-event
+ (test-cs-parse-exc--override-event start end))))
+ (should plist)
+ (should (plist-get plist :recurrence-id))
+ (should (equal "20260203T090000Z" (plist-get plist :recurrence-id-raw)))
+ (should (plist-get plist :start))
+ (should (plist-get plist :end))
+ (should (equal "Rescheduled Meeting" (plist-get plist :summary)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--parse-exception-event-boundary-no-recurrence-id ()
+ "Boundary: a VEVENT with no RECURRENCE-ID is not an override and returns nil."
+ (let* ((start (test-calendar-sync-time-days-from-now 7 10 0))
+ (end (test-calendar-sync-time-days-from-now 7 11 0))
+ (event (test-calendar-sync-make-vevent "Regular Event" start end)))
+ (should-not (calendar-sync--parse-exception-event event))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--parse-exception-event-error-unparseable-times ()
+ "Error: a RECURRENCE-ID override whose times do not parse returns nil rather
+than a half-built plist."
+ (let ((event (concat "BEGIN:VEVENT\n"
+ "UID:broken@google.com\n"
+ "RECURRENCE-ID:not-a-timestamp\n"
+ "SUMMARY:Broken Override\n"
+ "DTSTART:also-garbage\n"
+ "END:VEVENT")))
+ (should-not (calendar-sync--parse-exception-event event))))
+
+(provide 'test-calendar-sync--parse-exception-event)
+;;; test-calendar-sync--parse-exception-event.el ends here
diff --git a/tests/test-calendar-sync--parse-timestamp.el b/tests/test-calendar-sync--parse-timestamp.el
index d05540f7c..6a56ba9e2 100644
--- a/tests/test-calendar-sync--parse-timestamp.el
+++ b/tests/test-calendar-sync--parse-timestamp.el
@@ -55,5 +55,28 @@
"Truncated datetime returns nil."
(should (null (calendar-sync--parse-timestamp "2026031"))))
+;;; Boundary / Error — second capture, TZID fallback, leap day
+
+(ert-deftest test-calendar-sync--parse-timestamp-utc-passes-nonzero-seconds ()
+ "Boundary: the seconds field is captured and passed to the UTC converter."
+ (cl-letf (((symbol-function 'calendar-sync--convert-utc-to-local)
+ (lambda (y mo d h mi s) (list 'utc y mo d h mi s))))
+ (should (equal (calendar-sync--parse-timestamp "20260315T180045Z")
+ '(utc 2026 3 15 18 0 45)))))
+
+(ert-deftest test-calendar-sync--parse-timestamp-tzid-fallback-on-failure ()
+ "Error: when TZID conversion fails, the raw 5-tuple is returned."
+ (cl-letf (((symbol-function 'calendar-sync--convert-tz-to-local)
+ (lambda (&rest _) nil)))
+ (should (equal (calendar-sync--parse-timestamp "20260315T180000" "Fake/Zone")
+ '(2026 3 15 18 0)))))
+
+(ert-deftest test-calendar-sync--parse-timestamp-leap-day-components ()
+ "Boundary: a valid leap day (2024-02-29) is parsed into its components."
+ (cl-letf (((symbol-function 'calendar-sync--convert-utc-to-local)
+ (lambda (y mo d h mi s) (list y mo d h mi s))))
+ (should (equal (calendar-sync--parse-timestamp "20240229T120000Z")
+ '(2024 2 29 12 0 0)))))
+
(provide 'test-calendar-sync--parse-timestamp)
;;; test-calendar-sync--parse-timestamp.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 b912c1328..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."
@@ -693,5 +696,22 @@ Valid events should be parsed, invalid ones skipped."
(should retrieved)
(should (eq 'ok (plist-get retrieved :status))))))
+;;; Tests: calendar-sync--parse-ics — boundary inputs
+
+(ert-deftest test-calendar-sync--parse-ics-nil-content-returns-nil ()
+ "Boundary: nil ICS content is handled gracefully and returns nil."
+ (should (null (calendar-sync--parse-ics nil))))
+
+(ert-deftest test-calendar-sync--parse-ics-drops-out-of-range-event ()
+ "Boundary: a non-recurring event outside the date range is dropped."
+ (let* ((far (test-calendar-sync-make-vevent
+ "OutOfRangeEvent"
+ (test-calendar-sync-time-days-from-now 3650 10 0)
+ (test-calendar-sync-time-days-from-now 3650 11 0)))
+ (ics (test-calendar-sync-make-ics far))
+ (org-content (calendar-sync--parse-ics ics)))
+ (should-not (and org-content
+ (string-match-p "OutOfRangeEvent" org-content)))))
+
(provide 'test-calendar-sync)
;;; test-calendar-sync.el ends here
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-chrono-tools--sound-helpers.el b/tests/test-chrono-tools--sound-helpers.el
new file mode 100644
index 000000000..08f71f9bb
--- /dev/null
+++ b/tests/test-chrono-tools--sound-helpers.el
@@ -0,0 +1,54 @@
+;;; test-chrono-tools--sound-helpers.el --- Tests for the tmr sound-file helpers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/tmr--current-sound-name and cj/tmr--apply-sound-file were extracted from
+;; the deeply-nested cj/tmr-select-sound-file so the "what's the current sound"
+;; and "set the chosen sound" steps are unit-testable apart from the
+;; completing-read UI.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'chrono-tools)
+
+(defvar tmr-sound-file)
+(defvar sounds-dir)
+(defvar notification-sound)
+
+(ert-deftest test-chrono-current-sound-name-existing ()
+ "Normal: returns the basename when the current sound file exists."
+ (let* ((f (make-temp-file "tmr-sound" nil ".wav"))
+ (tmr-sound-file f))
+ (unwind-protect
+ (should (equal (cj/tmr--current-sound-name) (file-name-nondirectory f)))
+ (delete-file f))))
+
+(ert-deftest test-chrono-current-sound-name-missing-or-nil ()
+ "Boundary: a missing file or nil yields nil."
+ (let ((tmr-sound-file "/no/such/file.wav"))
+ (should (null (cj/tmr--current-sound-name))))
+ (let ((tmr-sound-file nil))
+ (should (null (cj/tmr--current-sound-name)))))
+
+(ert-deftest test-chrono-apply-sound-file-sets-and-messages ()
+ "Normal: sets tmr-sound-file under sounds-dir and reports the choice."
+ (let ((sounds-dir "/snd")
+ (notification-sound "/snd/default.wav")
+ (tmr-sound-file nil))
+ (let ((msg (cj/tmr--apply-sound-file "chime.wav")))
+ (should (equal tmr-sound-file "/snd/chime.wav"))
+ (should (string-match-p "Timer sound set to: chime.wav" msg)))))
+
+(ert-deftest test-chrono-apply-sound-file-default-branch ()
+ "Boundary: choosing the notification sound reports it as the default."
+ (let ((sounds-dir "/snd")
+ (notification-sound "/snd/default.wav")
+ (tmr-sound-file nil))
+ (let ((msg (cj/tmr--apply-sound-file "default.wav")))
+ (should (equal tmr-sound-file "/snd/default.wav"))
+ (should (string-match-p "default: default.wav" msg)))))
+
+(provide 'test-chrono-tools--sound-helpers)
+;;; test-chrono-tools--sound-helpers.el ends here
diff --git a/tests/test-cj-window-geometry-lib.el b/tests/test-cj-window-geometry-lib.el
index 05ed95950..d32a48a92 100644
--- a/tests/test-cj-window-geometry-lib.el
+++ b/tests/test-cj-window-geometry-lib.el
@@ -2,7 +2,7 @@
;;; Commentary:
;; Tests the pure helpers in `cj-window-geometry-lib.el':
-;; `cj/window-direction', `cj/window-body-size',
+;; `cj/window-direction', `cj/window-replay-size',
;; `cj/cardinal-to-edge-direction', and `cj/window-at-edge'.
;;; Code:
@@ -52,30 +52,32 @@
(delete-other-windows)
(should (eq (cj/window-direction (selected-window) 'below) 'below))))
-(ert-deftest test-cj-window-geometry--body-size-right-returns-body-cols ()
+(ert-deftest test-cj-window-geometry--replay-size-right-returns-body-cols ()
"Normal: right window with direction='right -> body-width in cols."
(save-window-excursion
(delete-other-windows)
(let ((right (split-window (selected-window) nil 'right)))
- (should (= (cj/window-body-size right 'right)
+ (should (= (cj/window-replay-size right 'right)
(window-body-width right))))))
-(ert-deftest test-cj-window-geometry--body-size-below-returns-body-lines ()
- "Normal: below window with direction='below -> body-height in lines."
+(ert-deftest test-cj-window-geometry--replay-size-below-returns-total-lines ()
+ "Normal: below window with direction='below -> total-height in lines.
+The vertical axis captures total-height (not body-height) so the capture/
+replay round-trip is immune to the mode line's pixel height."
(save-window-excursion
(delete-other-windows)
(let ((below (split-window (selected-window) nil 'below)))
- (should (= (cj/window-body-size below 'below)
- (window-body-height below))))))
+ (should (= (cj/window-replay-size below 'below)
+ (window-total-height below))))))
-(ert-deftest test-cj-window-geometry--body-size-narrow-window ()
+(ert-deftest test-cj-window-geometry--replay-size-narrow-window ()
"Normal: deliberately narrow right window -> matching body cols."
(save-window-excursion
(delete-other-windows)
(let* ((frame-w (frame-width))
(target-cols (/ frame-w 4))
(right (split-window (selected-window) (- target-cols) 'right)))
- (should (= (cj/window-body-size right 'right)
+ (should (= (cj/window-replay-size right 'right)
(window-body-width right))))))
(ert-deftest test-cj-window-geometry--cardinal-to-edge-right ()
@@ -197,5 +199,52 @@ window forms the full-height right half -> nil."
(should (null (cj/window-size-fraction nil 40)))
(should (null (cj/window-size-fraction 20 nil))))
+;; ----------------------------- preferred-dock-direction -----------------------------
+
+(ert-deftest test-cj-window-geometry-dock-wide-frame-is-right ()
+ "Normal: a frame wide enough for both panes to clear 80 docks right."
+ (should (eq (cj/preferred-dock-direction 200 0.5) 'right)))
+
+(ert-deftest test-cj-window-geometry-dock-narrow-frame-is-below ()
+ "Normal: an 0.5 split on a 138-col frame leaves ~68-col panes -> below."
+ (should (eq (cj/preferred-dock-direction 138 0.5) 'below)))
+
+(ert-deftest test-cj-window-geometry-dock-boundary-exactly-min-is-right ()
+ "Boundary: when the narrower pane lands exactly on 80, dock right."
+ ;; 161 cols, 0.5: panel 80, main 161-80-1 = 80, narrower 80 -> right.
+ (should (eq (cj/preferred-dock-direction 161 0.5) 'right)))
+
+(ert-deftest test-cj-window-geometry-dock-boundary-one-under-min-is-below ()
+ "Boundary: one column short of the floor stacks instead."
+ ;; 160 cols, 0.5: panel 80, main 160-80-1 = 79, narrower 79 -> below.
+ (should (eq (cj/preferred-dock-direction 160 0.5) 'below)))
+
+(ert-deftest test-cj-window-geometry-dock-narrow-panel-fraction-governs ()
+ "Normal: a slim panel fraction makes the panel the narrower pane."
+ ;; 200 cols, 0.3: panel 60 < 80 -> below, even though main (139) is wide.
+ (should (eq (cj/preferred-dock-direction 200 0.3) 'below))
+ ;; 300 cols, 0.3: panel 90, main 209 -> right.
+ (should (eq (cj/preferred-dock-direction 300 0.3) 'right)))
+
+(ert-deftest test-cj-window-geometry-dock-honors-explicit-min-cols ()
+ "Boundary: an explicit MIN-COLS overrides the default floor."
+ ;; 138 cols, 0.5 -> ~68-col panes: passes a 60-floor, fails the 80-default.
+ (should (eq (cj/preferred-dock-direction 138 0.5 60) 'right))
+ (should (eq (cj/preferred-dock-direction 138 0.5 80) 'below)))
+
+(ert-deftest test-cj-window-geometry-dock-honors-custom-default-var ()
+ "Boundary: the default floor reads `cj/window-dock-min-columns'."
+ (let ((cj/window-dock-min-columns 30))
+ (should (eq (cj/preferred-dock-direction 138 0.5) 'right))))
+
+(ert-deftest test-cj-window-geometry-dock-degenerate-input-is-below ()
+ "Error: non-positive cols or out-of-range fraction stacks (safe fallback)."
+ (should (eq (cj/preferred-dock-direction 0 0.5) 'below))
+ (should (eq (cj/preferred-dock-direction -10 0.5) 'below))
+ (should (eq (cj/preferred-dock-direction 200 0) 'below))
+ (should (eq (cj/preferred-dock-direction 200 1) 'below))
+ (should (eq (cj/preferred-dock-direction nil 0.5) 'below))
+ (should (eq (cj/preferred-dock-direction 200 nil) 'below)))
+
(provide 'test-cj-window-geometry-lib)
;;; test-cj-window-geometry-lib.el ends here
diff --git a/tests/test-cj-window-toggle-lib.el b/tests/test-cj-window-toggle-lib.el
index 0762e255c..5edd06e96 100644
--- a/tests/test-cj-window-toggle-lib.el
+++ b/tests/test-cj-window-toggle-lib.el
@@ -36,7 +36,9 @@
(window-body-width right))))))
(ert-deftest test-cj-window-toggle-capture-records-below-split ()
- "Normal: below-split window writes direction=below and integer body-lines."
+ "Normal: below-split window writes direction=below and integer total-lines.
+The vertical axis captures total-height, not body-height, so the round-trip
+is immune to the mode line's pixel height (see `cj/window-replay-size')."
(save-window-excursion
(delete-other-windows)
(let ((below (split-window (selected-window) nil 'below))
@@ -49,7 +51,7 @@
(should (eq test-cj-window-toggle--last-direction 'below))
(should (integerp test-cj-window-toggle--last-size))
(should (= test-cj-window-toggle--last-size
- (window-body-height below))))))
+ (window-total-height below))))))
(ert-deftest test-cj-window-toggle-capture-falls-back-to-default-direction ()
"Boundary: window filling the frame uses the supplied default direction."
@@ -156,7 +158,9 @@ transfer; clearing it lets the consumer's default size apply."
(should (eq (cdr (assq 'inhibit-same-window received-alist)) t))))
(ert-deftest test-cj-window-toggle-display-saved-maps-below-to-bottom ()
- "Normal: saved below + integer size -> bottom edge, body-lines cons."
+ "Normal: saved below + integer size -> bottom edge, plain total-line count.
+The height axis replays a total-line integer (not a body-lines cons) so the
+round-trip is immune to the mode line's pixel height."
(let (received-alist
(test-cj-window-toggle--last-direction 'below)
(test-cj-window-toggle--last-size 12))
@@ -169,8 +173,7 @@ transfer; clearing it lets the consumer's default size apply."
'test-cj-window-toggle--last-size
0.7))
(should (eq (cdr (assq 'direction received-alist)) 'bottom))
- (should (equal (cdr (assq 'window-height received-alist))
- '(body-lines . 12)))
+ (should (equal (cdr (assq 'window-height received-alist)) 12))
(should-not (assq 'window-width received-alist))))
(ert-deftest test-cj-window-toggle-display-saved-maps-right-to-rightmost ()
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--changed-lines.el b/tests/test-coverage-core--changed-lines.el
index f271fde15..0662594b4 100644
--- a/tests/test-coverage-core--changed-lines.el
+++ b/tests/test-coverage-core--changed-lines.el
@@ -227,5 +227,106 @@ Binary files a/image.png and b/image.png differ
(should-error (cj/--coverage-changed-lines 'bogus-scope)
:type 'user-error))
+;;; Boundary cases — parser, /dev/null and orphan hunks
+
+(ert-deftest test-coverage-parse-diff-dev-null-resets-current-file ()
+ "Boundary: a \"+++ /dev/null\" target resets state so a following hunk is
+not misattributed to the previous file."
+ (let* ((input (concat "diff --git a/keep.el b/keep.el\n"
+ "--- a/keep.el\n"
+ "+++ b/keep.el\n"
+ "@@ -1,0 +1,2 @@\n"
+ "+k1\n+k2\n"
+ "diff --git a/gone.el b/gone.el\n"
+ "--- a/gone.el\n"
+ "+++ /dev/null\n"
+ "@@ -1,0 +5,2 @@\n"
+ "+orphan1\n+orphan2\n"))
+ (result (cj/--coverage-parse-diff-output input))
+ (keep (gethash "keep.el" result)))
+ (should (= 1 (hash-table-count result))) ; gone.el never recorded
+ (should (= 2 (hash-table-count keep)))
+ (should (gethash 1 keep))
+ (should (gethash 2 keep))
+ (should-not (gethash 5 keep)) ; not misattributed
+ (should-not (gethash 6 keep))))
+
+(ert-deftest test-coverage-parse-diff-hunk-before-any-file-marker ()
+ "Boundary: a hunk header before any file marker is ignored, not crashed on."
+ (let* ((input (concat "@@ -1,0 +1,2 @@\n"
+ "+orphan1\n+orphan2\n"
+ "diff --git a/real.el b/real.el\n"
+ "--- a/real.el\n"
+ "+++ b/real.el\n"
+ "@@ -1,0 +1,1 @@\n"
+ "+r1\n"))
+ (result (cj/--coverage-parse-diff-output input))
+ (real (gethash "real.el" result)))
+ (should (= 1 (hash-table-count result)))
+ (should (= 1 (hash-table-count real)))
+ (should (gethash 1 real))))
+
+;;; merge-base (stubbed git invocation)
+
+(ert-deftest test-coverage-git-merge-base-returns-trimmed-sha ()
+ "Normal: a SHA with trailing newline is trimmed and returned."
+ (cl-letf (((symbol-function 'process-file)
+ (lambda (_program _infile destination _display &rest _args)
+ (with-current-buffer destination (insert "abc123\n"))
+ 0)))
+ (should (equal (cj/--coverage-git-merge-base "main") "abc123"))))
+
+(ert-deftest test-coverage-git-merge-base-empty-output-errors ()
+ "Error: empty merge-base output signals user-error (no common commit)."
+ (cl-letf (((symbol-function 'process-file)
+ (lambda (_program _infile destination _display &rest _args)
+ (with-current-buffer destination (insert ""))
+ 0)))
+ (should-error (cj/--coverage-git-merge-base "main") :type 'user-error)))
+
+(ert-deftest test-coverage-git-merge-base-whitespace-output-errors ()
+ "Error: whitespace-only output trims to empty and signals user-error."
+ (cl-letf (((symbol-function 'process-file)
+ (lambda (_program _infile destination _display &rest _args)
+ (with-current-buffer destination (insert " \n"))
+ 0)))
+ (should-error (cj/--coverage-git-merge-base "main") :type 'user-error)))
+
+;;; changed-lines — remaining scopes (stubbed git invocation)
+
+(ert-deftest test-coverage-changed-lines-staged-stubbed ()
+ "Normal: staged scope invokes git diff --cached via argv."
+ (let (seen-calls)
+ (cl-letf (((symbol-function 'process-file)
+ (lambda (program _infile destination _display &rest args)
+ (push (cons program args) seen-calls)
+ (with-current-buffer destination
+ (insert test-coverage-diff--simple-single-file))
+ 0)))
+ (let ((result (cj/--coverage-changed-lines 'staged)))
+ (should (equal (nreverse seen-calls)
+ '(("git" "diff" "--cached" "--unified=0"))))
+ (should (= 3 (hash-table-count (gethash "foo.el" result))))))))
+
+(ert-deftest test-coverage-changed-lines-branch-vs-main-stubbed ()
+ "Normal: branch-vs-main computes merge-base against main, then diffs."
+ (let (seen-calls)
+ (cl-letf (((symbol-function 'process-file)
+ (lambda (program _infile destination _display &rest args)
+ (push (cons program args) seen-calls)
+ (with-current-buffer destination
+ (insert
+ (pcase args
+ (`("merge-base" "HEAD" "main") "abc123\n")
+ (`("diff" "abc123..HEAD" "--unified=0")
+ test-coverage-diff--simple-single-file)
+ (_ ""))))
+ 0)))
+ (let ((result (cj/--coverage-changed-lines 'branch-vs-main)))
+ (should (equal (nreverse seen-calls)
+ '(("git" "merge-base" "HEAD" "main")
+ ("git" "diff" "abc123..HEAD" "--unified=0"))))
+ (should (= 3 (hash-table-count (gethash "foo.el" result))))))))
+
(provide 'test-coverage-core--changed-lines)
;;; test-coverage-core--changed-lines.el ends here
diff --git a/tests/test-coverage-core--project-root.el b/tests/test-coverage-core--project-root.el
new file mode 100644
index 000000000..9d596217a
--- /dev/null
+++ b/tests/test-coverage-core--project-root.el
@@ -0,0 +1,37 @@
+;;; test-coverage-core--project-root.el --- Tests for cj/--coverage-project-root -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Tests for `cj/--coverage-project-root' in coverage-core.el — returns the
+;; projectile project root when available, else `default-directory'.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'coverage-core)
+
+;;; Normal Cases
+
+(ert-deftest test-coverage-project-root-uses-projectile-when-available ()
+ "Normal: with projectile available and in a project, returns its root."
+ (cl-letf (((symbol-function 'projectile-project-root)
+ (lambda () "/home/u/proj/")))
+ (should (equal (cj/--coverage-project-root) "/home/u/proj/"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-coverage-project-root-falls-back-when-projectile-absent ()
+ "Boundary: with no projectile function, falls back to default-directory."
+ (cl-letf (((symbol-function 'projectile-project-root) nil))
+ (let ((default-directory "/fallback/dir/"))
+ (should (equal (cj/--coverage-project-root) "/fallback/dir/")))))
+
+(ert-deftest test-coverage-project-root-falls-back-when-not-in-project ()
+ "Boundary: projectile present but returns nil (not in a project) falls back."
+ (cl-letf (((symbol-function 'projectile-project-root) (lambda () nil)))
+ (let ((default-directory "/fallback/dir/"))
+ (should (equal (cj/--coverage-project-root) "/fallback/dir/")))))
+
+(provide 'test-coverage-core--project-root)
+;;; test-coverage-core--project-root.el ends here
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-custom-datetime-all-methods.el b/tests/test-custom-datetime-all-methods.el
index c9cfa41e2..62b421bdc 100644
--- a/tests/test-custom-datetime-all-methods.el
+++ b/tests/test-custom-datetime-all-methods.el
@@ -108,5 +108,19 @@
(cj/insert-sortable-date))
(should (string-prefix-p "before 2026-02-15" (buffer-string)))))
+;;; Macro-generated commands stay interactive
+
+(ert-deftest test-custom-datetime-all-methods-are-interactive-commands ()
+ "All six inserters generated by `cj/--define-datetime-inserter' are
+interactive commands (so they keep working via M-x and the C-; d keymap)."
+ (dolist (cmd '(cj/insert-readable-date-time
+ cj/insert-sortable-date-time
+ cj/insert-sortable-time
+ cj/insert-readable-time
+ cj/insert-sortable-date
+ cj/insert-readable-date))
+ (should (fboundp cmd))
+ (should (commandp cmd))))
+
(provide 'test-custom-datetime-all-methods)
;;; test-custom-datetime-all-methods.el ends here
diff --git a/tests/test-custom-line-paragraph-duplicate-line-or-region.el b/tests/test-custom-line-paragraph-duplicate-line-or-region.el
index bd82e00fa..84f5bc2df 100644
--- a/tests/test-custom-line-paragraph-duplicate-line-or-region.el
+++ b/tests/test-custom-line-paragraph-duplicate-line-or-region.el
@@ -447,5 +447,19 @@
(should (string-match-p "line\u000Cwith\u000Dcontrol\nline\u000Cwith\u000Dcontrol" (buffer-string))))
(test-duplicate-line-or-region-teardown)))
+;;; Error Cases
+
+(ert-deftest test-duplicate-line-or-region-comment-without-syntax-errors ()
+ "Error: requesting a comment in a mode with no comment syntax signals
+user-error rather than producing malformed output."
+ (test-duplicate-line-or-region-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (fundamental-mode) ; no comment-start defined
+ (insert "line one")
+ (goto-char (point-min))
+ (should-error (cj/duplicate-line-or-region t) :type 'user-error))
+ (test-duplicate-line-or-region-teardown)))
+
(provide 'test-custom-line-paragraph-duplicate-line-or-region)
;;; test-custom-line-paragraph-duplicate-line-or-region.el ends here
diff --git a/tests/test-custom-ordering--region-helpers.el b/tests/test-custom-ordering--region-helpers.el
new file mode 100644
index 000000000..2ec747966
--- /dev/null
+++ b/tests/test-custom-ordering--region-helpers.el
@@ -0,0 +1,52 @@
+;;; test-custom-ordering--region-helpers.el --- Tests for the shared ordering region helpers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/--ordering-validate-region and cj/--ordering-replace-region were extracted
+;; from the seven pure ordering helpers (the copy-pasted start>end guard) and the
+;; interactive ordering commands (the copy-pasted delete-region + insert tail).
+;; The per-command behavior stays covered by the existing wrapper/transform
+;; tests; these cover the extracted helpers directly.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'custom-ordering)
+
+;;; cj/--ordering-validate-region
+
+(ert-deftest test-custom-ordering-validate-region-accepts-ordered ()
+ "Normal: start < end returns nil without signalling."
+ (should (null (cj/--ordering-validate-region 1 10))))
+
+(ert-deftest test-custom-ordering-validate-region-accepts-equal ()
+ "Boundary: start = end (empty region) is allowed."
+ (should (null (cj/--ordering-validate-region 5 5))))
+
+(ert-deftest test-custom-ordering-validate-region-rejects-inverted ()
+ "Error: start > end signals with both positions in the message."
+ (let ((err (should-error (cj/--ordering-validate-region 10 3) :type 'error)))
+ (should (string-match-p "10" (error-message-string err)))
+ (should (string-match-p "3" (error-message-string err)))))
+
+;;; cj/--ordering-replace-region
+
+(ert-deftest test-custom-ordering-replace-region-swaps-text ()
+ "Normal: the region between START and END is replaced with INSERTION and
+point is left at START."
+ (with-temp-buffer
+ (insert "AAAABBBB")
+ (cj/--ordering-replace-region 1 5 "xx") ; replace the first AAAA
+ (should (equal "xxBBBB" (buffer-string)))
+ (should (= (point) 3)))) ; START (1) + len("xx")
+
+(ert-deftest test-custom-ordering-replace-region-empty-insertion ()
+ "Boundary: an empty INSERTION just deletes the region."
+ (with-temp-buffer
+ (insert "keepDROP")
+ (cj/--ordering-replace-region 5 9 "") ; drop "DROP" (positions 5-8)
+ (should (equal "keep" (buffer-string)))))
+
+(provide 'test-custom-ordering--region-helpers)
+;;; test-custom-ordering--region-helpers.el ends here
diff --git a/tests/test-custom-text-enclose--enclose-region-or-word.el b/tests/test-custom-text-enclose--enclose-region-or-word.el
new file mode 100644
index 000000000..4075fb050
--- /dev/null
+++ b/tests/test-custom-text-enclose--enclose-region-or-word.el
@@ -0,0 +1,62 @@
+;;; test-custom-text-enclose--enclose-region-or-word.el --- Tests for the shared enclose dispatch -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/--enclose-region-or-word is the dispatch+edit skeleton extracted from
+;; cj/surround/wrap/unwrap-word-or-region (region target, else word at point,
+;; else a no-target message). The three commands stay covered by
+;; test-custom-text-enclose-public-wrappers.el; these cover the helper directly,
+;; including the custom and default no-target messages.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'custom-text-enclose)
+
+(ert-deftest test-cte-enclose-region-target ()
+ "Normal: an active region is the target; TRANSFORM is applied to it."
+ (with-temp-buffer
+ (let ((transient-mark-mode t))
+ (insert "abc")
+ (goto-char (point-min))
+ (push-mark (point) t t)
+ (goto-char (point-max))
+ (cj/--enclose-region-or-word #'upcase))
+ (should (equal (buffer-string) "ABC"))
+ (should (= (point) 4)))) ; after the inserted "ABC" (start 1 + 3)
+
+(ert-deftest test-cte-enclose-word-at-point-target ()
+ "Normal: with no region, the word at point is the target."
+ (with-temp-buffer
+ (insert "foo bar")
+ (goto-char (point-min)) ; point on "foo"
+ (cj/--enclose-region-or-word (lambda (s) (concat "<" s ">")))
+ (should (equal (buffer-string) "<foo> bar"))))
+
+(ert-deftest test-cte-enclose-no-target-default-message ()
+ "Boundary: no region and no word => default message, buffer untouched."
+ (with-temp-buffer
+ (insert " ") ; whitespace, no word
+ (goto-char (point-min))
+ (let ((msg nil))
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
+ (cj/--enclose-region-or-word #'upcase))
+ (should (string-match-p "No word at point" msg))
+ (should (equal (buffer-string) " ")))))
+
+(ert-deftest test-cte-enclose-no-target-custom-message ()
+ "Boundary: a supplied NO-TARGET-MESSAGE overrides the default."
+ (with-temp-buffer
+ (insert " ")
+ (goto-char (point-min))
+ (let ((msg nil))
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
+ (cj/--enclose-region-or-word #'upcase "custom no-target text"))
+ (should (equal msg "custom no-target text")))))
+
+(provide 'test-custom-text-enclose--enclose-region-or-word)
+;;; test-custom-text-enclose--enclose-region-or-word.el ends here
diff --git a/tests/test-dashboard-config-launchers.el b/tests/test-dashboard-config-launchers.el
index e7e8d2f33..a9a871979 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)))
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-hard-delete-command.el b/tests/test-dirvish-config-hard-delete-command.el
new file mode 100644
index 000000000..eb12d2830
--- /dev/null
+++ b/tests/test-dirvish-config-hard-delete-command.el
@@ -0,0 +1,47 @@
+;;; test-dirvish-config-hard-delete-command.el --- Tests for cj/--dirvish-hard-delete-command -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/--dirvish-hard-delete-command' is the pure string builder behind the
+;; forced `sudo rm -rf' hard-delete bound to D in dirvish. It shell-quotes
+;; every path and guards the list with `--' so a leading-dash or space-bearing
+;; filename can't be misread. The interactive command (prompt + shell-command)
+;; is verified live, not here.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'dirvish-config)
+
+(ert-deftest test-dirvish-config-hard-delete-command-multiple ()
+ "Normal: two paths are quoted and joined behind `sudo rm -rf -- '."
+ (should (equal (cj/--dirvish-hard-delete-command '("/tmp/a.txt" "/tmp/b.txt"))
+ "sudo rm -rf -- /tmp/a.txt /tmp/b.txt")))
+
+(ert-deftest test-dirvish-config-hard-delete-command-single ()
+ "Boundary: a single path still carries the `--' option terminator."
+ (should (equal (cj/--dirvish-hard-delete-command '("/tmp/report.pdf"))
+ "sudo rm -rf -- /tmp/report.pdf")))
+
+(ert-deftest test-dirvish-config-hard-delete-command-spaces-and-dash ()
+ "Boundary: a path with spaces is shell-quoted, and `--' protects a
+leading-dash filename from being read as an option."
+ (let ((cmd (cj/--dirvish-hard-delete-command
+ '("/tmp/my file.txt" "/tmp/-rf"))))
+ ;; `--' precedes the paths so `-rf' is a target, not an option.
+ (should (string-prefix-p "sudo rm -rf -- " cmd))
+ ;; the space-bearing path is quoted (not a bare " " splitting the args).
+ (should (string-match-p (regexp-quote (shell-quote-argument "/tmp/my file.txt"))
+ cmd))
+ (should (string-match-p (regexp-quote (shell-quote-argument "/tmp/-rf"))
+ cmd))))
+
+(ert-deftest test-dirvish-config-hard-delete-command-empty ()
+ "Error: an empty list yields just the prefix (no targets) -- the
+interactive command never reaches here, guarding `No file at point' first."
+ (should (equal (cj/--dirvish-hard-delete-command '())
+ "sudo rm -rf -- ")))
+
+(provide 'test-dirvish-config-hard-delete-command)
+;;; test-dirvish-config-hard-delete-command.el ends here
diff --git a/tests/test-dirvish-config-playlist.el b/tests/test-dirvish-config-playlist.el
index d059a899a..14bb94ac7 100644
--- a/tests/test-dirvish-config-playlist.el
+++ b/tests/test-dirvish-config-playlist.el
@@ -10,6 +10,7 @@
;;; Code:
(require 'ert)
+(require 'cl-lib)
(require 'package)
(setq package-user-dir (expand-file-name "elpa" user-emacs-directory))
@@ -93,5 +94,59 @@ lowercase extension list."
(dolist (bad '("../evil" "../../etc/cron" "/etc/passwd" "sub/dir/name"))
(should-not (cj/--playlist-name-safe-p bad))))
+;;; cj/--playlist-resolve-target
+;;
+;; Drives the real `file-exists-p' against a temp `music-dir' (mocking a C
+;; primitive triggers a native-comp trampoline rebuild that fails under
+;; --batch); only the ordinary `read-string' / `read-char-choice' prompts are
+;; stubbed.
+
+(ert-deftest test-cj--playlist-resolve-target-returns-path-for-new-name ()
+ "Normal: a safe name with no existing file returns its .m3u path under music-dir."
+ (let* ((music-dir (make-temp-file "cj-playlist-" t)))
+ (unwind-protect
+ (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "roadtrip")))
+ (should (equal (expand-file-name "roadtrip.m3u" music-dir)
+ (cj/--playlist-resolve-target))))
+ (delete-directory music-dir t))))
+
+(ert-deftest test-cj--playlist-resolve-target-reprompts-on-unsafe-name ()
+ "Boundary: an unsafe name (with `/') re-prompts until a safe name is given."
+ (let* ((music-dir (make-temp-file "cj-playlist-" t))
+ (answers '("../escape" "safe"))
+ (asked 0))
+ (unwind-protect
+ (cl-letf (((symbol-function 'read-string)
+ (lambda (&rest _) (prog1 (nth asked answers) (cl-incf asked))))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (should (equal (expand-file-name "safe.m3u" music-dir)
+ (cj/--playlist-resolve-target)))
+ (should (= 2 asked)))
+ (delete-directory music-dir t))))
+
+(ert-deftest test-cj--playlist-resolve-target-overwrite-returns-existing-path ()
+ "Normal: when the target exists, choosing overwrite returns the same path."
+ (let* ((music-dir (make-temp-file "cj-playlist-" t))
+ (existing (expand-file-name "mix.m3u" music-dir)))
+ (unwind-protect
+ (progn
+ (with-temp-file existing (insert "old\n"))
+ (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "mix"))
+ ((symbol-function 'read-char-choice) (lambda (&rest _) ?o)))
+ (should (equal existing (cj/--playlist-resolve-target)))))
+ (delete-directory music-dir t))))
+
+(ert-deftest test-cj--playlist-resolve-target-cancel-signals-user-error ()
+ "Error: when the target exists, choosing cancel aborts with a `user-error'."
+ (let* ((music-dir (make-temp-file "cj-playlist-" t))
+ (existing (expand-file-name "mix.m3u" music-dir)))
+ (unwind-protect
+ (progn
+ (with-temp-file existing (insert "old\n"))
+ (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "mix"))
+ ((symbol-function 'read-char-choice) (lambda (&rest _) ?c)))
+ (should-error (cj/--playlist-resolve-target) :type 'user-error)))
+ (delete-directory music-dir t))))
+
(provide 'test-dirvish-config-playlist)
;;; test-dirvish-config-playlist.el ends here
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-dwim-shell-config-command-fixes.el b/tests/test-dwim-shell-config-command-fixes.el
index 2f49a868f..2cc3ae72b 100644
--- a/tests/test-dwim-shell-config-command-fixes.el
+++ b/tests/test-dwim-shell-config-command-fixes.el
@@ -29,5 +29,60 @@ so the substitution can't sit dead inside single quotes."
(should (string-match-p "\\.[0-9]\\{8\\}_[0-9]\\{6\\}\\.bak'" cmd))
(should-not (string-match-p "\\$(date" cmd))))
+;;; ----------------------- tar-gzip command builder --------------------------
+
+(ert-deftest test-dwim-tar-gzip-command-single-names-after-file ()
+ "Normal: a single marked file names the archive <fne>.tar.gz over <<f>>."
+ (let ((cmd (cj/dwim-shell--tar-gzip-command t)))
+ (should (string-match-p "'<<fne>>\\.tar\\.gz'" cmd))
+ (should (string-match-p "'<<f>>'" cmd))))
+
+(ert-deftest test-dwim-tar-gzip-command-multi-uses-shared-archive ()
+ "Boundary: multiple files tar into a shared archive.tar.gz over <<*>>."
+ (let ((cmd (cj/dwim-shell--tar-gzip-command nil)))
+ (should (string-match-p "archive\\.tar\\.gz" cmd))
+ (should (string-match-p "'<<\\*>>'" cmd))))
+
+;;; --------------------- text-to-speech command builder ----------------------
+
+(ert-deftest test-dwim-text-to-speech-command-darwin-uses-say-voice ()
+ "Normal: on darwin the command uses `say' with the chosen voice."
+ (let ((cmd (cj/dwim-shell--text-to-speech-command 'darwin "Samantha")))
+ (should (string-match-p "\\`say -v Samantha " cmd))
+ (should (string-match-p "'<<fne>>\\.aiff'" cmd))))
+
+(ert-deftest test-dwim-text-to-speech-command-linux-uses-espeak ()
+ "Boundary: a non-darwin system uses `espeak' and ignores the voice."
+ (let ((cmd (cj/dwim-shell--text-to-speech-command 'gnu/linux "ignored")))
+ (should (string-match-p "\\`espeak " cmd))
+ (should (string-match-p "'<<fne>>\\.wav'" cmd))
+ (should-not (string-match-p "ignored" cmd))))
+
+;;; ----------------------- video-trim command builder ------------------------
+
+(ert-deftest test-dwim-video-trim-command-beginning-uses-ss ()
+ "Normal: trimming the beginning emits a leading -ss with the start seconds."
+ (let ((cmd (cj/dwim-shell--video-trim-command "Beginning" 7 0)))
+ (should (string-match-p "-ss 7 " cmd))
+ (should-not (string-match-p "-sseof" cmd))))
+
+(ert-deftest test-dwim-video-trim-command-end-uses-sseof ()
+ "Normal: trimming the end emits -sseof with the end seconds, no -ss."
+ (let ((cmd (cj/dwim-shell--video-trim-command "End" 0 9)))
+ (should (string-match-p "-sseof -9 " cmd))
+ (should-not (string-match-p "-ss [0-9]" cmd))))
+
+(ert-deftest test-dwim-video-trim-command-both-uses-ss-and-sseof ()
+ "Normal: trimming both ends emits both -ss start and -sseof end."
+ (let ((cmd (cj/dwim-shell--video-trim-command "Both" 3 4)))
+ (should (string-match-p "-ss 3 " cmd))
+ (should (string-match-p "-sseof -4 " cmd))))
+
+(ert-deftest test-dwim-video-trim-command-negative-seconds-errors ()
+ "Error: a negative second count for the used side signals a user-error."
+ (should-error (cj/dwim-shell--video-trim-command "Beginning" -1 0) :type 'user-error)
+ (should-error (cj/dwim-shell--video-trim-command "End" 0 -1) :type 'user-error)
+ (should-error (cj/dwim-shell--video-trim-command "Both" 0 -2) :type 'user-error))
+
(provide 'test-dwim-shell-config-command-fixes)
;;; test-dwim-shell-config-command-fixes.el ends here
diff --git a/tests/test-elfeed-config--decode-html-entities.el b/tests/test-elfeed-config--decode-html-entities.el
new file mode 100644
index 000000000..a3fba3c49
--- /dev/null
+++ b/tests/test-elfeed-config--decode-html-entities.el
@@ -0,0 +1,31 @@
+;;; test-elfeed-config--decode-html-entities.el --- Tests for cj/--decode-html-entities -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/--decode-html-entities replaces the six inline replace-regexp-in-string
+;; calls that cj/youtube-to-elfeed-feed-format used to hand-decode an og:title.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'elfeed-config)
+
+(ert-deftest test-elfeed-decode-html-entities-all ()
+ "Normal: every supported entity is decoded."
+ (should (equal (cj/--decode-html-entities
+ "a &amp; b &lt;c&gt; &quot;d&quot; &#39;e&#x27;")
+ "a & b <c> \"d\" 'e'")))
+
+(ert-deftest test-elfeed-decode-html-entities-no-entities ()
+ "Boundary: text without entities is unchanged."
+ (should (equal (cj/--decode-html-entities "plain title") "plain title"))
+ (should (equal (cj/--decode-html-entities "") "")))
+
+(ert-deftest test-elfeed-decode-html-entities-amp-first ()
+ "Boundary: &amp; is decoded before the others (no double-decoding chains)."
+ (should (equal (cj/--decode-html-entities "Tom &amp; Jerry &lt;3")
+ "Tom & Jerry <3")))
+
+(provide 'test-elfeed-config--decode-html-entities)
+;;; test-elfeed-config--decode-html-entities.el ends here
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-elfeed-config-youtube-feed-format.el b/tests/test-elfeed-config-youtube-feed-format.el
index bda90aa7d..f6c82881e 100644
--- a/tests/test-elfeed-config-youtube-feed-format.el
+++ b/tests/test-elfeed-config-youtube-feed-format.el
@@ -65,5 +65,49 @@
(should-error (cj/youtube-to-elfeed-feed-format "https://youtube.com/@t" 'channel))
(should-not (buffer-live-p url-buf)))))
+;;; Playlist branch
+
+(ert-deftest test-elfeed-youtube-playlist-parses-id-and-title ()
+ "Normal: a playlist URL yields the playlist feed line and the og:title."
+ (cl-letf (((symbol-function 'url-retrieve-synchronously)
+ (lambda (&rest _)
+ (test-elfeed--url-buffer
+ "<meta property=\"og:title\" content=\"My Playlist\">"))))
+ (let ((result (cj/youtube-to-elfeed-feed-format
+ "https://www.youtube.com/playlist?list=PLabc123" 'playlist)))
+ (should (string-match-p "playlist_id=PLabc123" result))
+ (should (string-match-p "My Playlist" result)))))
+
+(ert-deftest test-elfeed-youtube-playlist-id-stops-at-ampersand ()
+ "Boundary: extra query params after list= are not captured into the id."
+ (cl-letf (((symbol-function 'url-retrieve-synchronously)
+ (lambda (&rest _)
+ (test-elfeed--url-buffer
+ "<meta property=\"og:title\" content=\"X\">"))))
+ (let ((result (cj/youtube-to-elfeed-feed-format
+ "https://www.youtube.com/playlist?list=PLxyz&index=2" 'playlist)))
+ (should (string-match-p "playlist_id=PLxyz" result))
+ (should-not (string-match-p "index=2" result)))))
+
+(ert-deftest test-elfeed-youtube-playlist-no-list-param-errors ()
+ "Error: a playlist URL with no list= parameter signals an extraction error."
+ (cl-letf (((symbol-function 'url-retrieve-synchronously)
+ (lambda (&rest _) (test-elfeed--url-buffer ""))))
+ (should-error (cj/youtube-to-elfeed-feed-format
+ "https://www.youtube.com/watch?v=abc" 'playlist))))
+
+(ert-deftest test-elfeed-youtube-playlist-decodes-html-entities-in-title ()
+ "Normal: HTML entities in the og:title are decoded in the feed comment."
+ (cl-letf (((symbol-function 'url-retrieve-synchronously)
+ (lambda (&rest _)
+ (test-elfeed--url-buffer
+ (concat "<meta property=\"og:title\" content=\""
+ "Rock &amp; Roll &#39;n&#x27; &lt;Test&gt; &quot;X&quot;"
+ "\">")))))
+ (let ((result (cj/youtube-to-elfeed-feed-format
+ "https://www.youtube.com/playlist?list=PLe" 'playlist)))
+ (should (string-match-p (regexp-quote "Rock & Roll 'n' <Test> \"X\"")
+ result)))))
+
(provide 'test-elfeed-config-youtube-feed-format)
;;; test-elfeed-config-youtube-feed-format.el ends here
diff --git a/tests/test-erc-config--generate-buffer-name.el b/tests/test-erc-config--generate-buffer-name.el
new file mode 100644
index 000000000..cbc716c82
--- /dev/null
+++ b/tests/test-erc-config--generate-buffer-name.el
@@ -0,0 +1,31 @@
+;;; test-erc-config--generate-buffer-name.el --- Tests for cj/erc-generate-buffer-name -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/erc-generate-buffer-name formats an ERC buffer name as SERVER-CHANNEL.
+;; It was defined inside the erc use-package :config (so unreachable under
+;; `make test'); lifting it to top level makes it unit-testable.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'erc-config)
+
+(ert-deftest test-erc-generate-buffer-name-server-and-channel ()
+ "Normal: a target yields SERVER-CHANNEL."
+ (should (equal (cj/erc-generate-buffer-name '(:server "libera" :target "#emacs"))
+ "libera-#emacs")))
+
+(ert-deftest test-erc-generate-buffer-name-server-only ()
+ "Boundary: no target yields just the server name."
+ (should (equal (cj/erc-generate-buffer-name '(:server "libera"))
+ "libera")))
+
+(ert-deftest test-erc-generate-buffer-name-missing-pieces ()
+ "Boundary: missing server/target degrade to empty strings, not nil."
+ (should (equal (cj/erc-generate-buffer-name '(:target "#emacs")) "-#emacs"))
+ (should (equal (cj/erc-generate-buffer-name '()) "")))
+
+(provide 'test-erc-config--generate-buffer-name)
+;;; test-erc-config--generate-buffer-name.el ends here
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-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-font-config--frame-lifecycle.el b/tests/test-font-config--frame-lifecycle.el
new file mode 100644
index 000000000..826edbd69
--- /dev/null
+++ b/tests/test-font-config--frame-lifecycle.el
@@ -0,0 +1,75 @@
+;;; test-font-config--frame-lifecycle.el --- Tests for the lifted font frame helpers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/apply-font-settings-to-frame, cj/cleanup-frame-list, and
+;; cj/maybe-install-all-the-icons-fonts were defined inside use-package
+;; :config / with-eval-after-load (unreachable under `make test'). Lifting
+;; them to top level makes their branching unit-testable; env-gui-p and the
+;; package side-effect calls are mocked at the boundary.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'font-config)
+
+(defvar cj/fontaine-configured-frames)
+
+(ert-deftest test-font-cleanup-frame-list-removes-frame ()
+ "Normal: cleanup drops the given frame from the configured list."
+ (let ((cj/fontaine-configured-frames '(fr1 fr2 fr3)))
+ (cj/cleanup-frame-list 'fr2)
+ (should (equal cj/fontaine-configured-frames '(fr1 fr3)))))
+
+(ert-deftest test-font-apply-gui-unconfigured-sets-preset ()
+ "Normal: a GUI frame not yet configured gets the preset and is tracked."
+ (let ((cj/fontaine-configured-frames nil)
+ (called nil))
+ (cl-letf (((symbol-function 'env-gui-p) (lambda () t))
+ ((symbol-function 'fontaine-set-preset) (lambda (_p) (setq called t))))
+ (cj/apply-font-settings-to-frame (selected-frame)))
+ (should called)
+ (should (member (selected-frame) cj/fontaine-configured-frames))))
+
+(ert-deftest test-font-apply-already-configured-is-noop ()
+ "Boundary: an already-configured frame is not re-preset."
+ (let ((cj/fontaine-configured-frames (list (selected-frame)))
+ (called nil))
+ (cl-letf (((symbol-function 'env-gui-p) (lambda () t))
+ ((symbol-function 'fontaine-set-preset) (lambda (_p) (setq called t))))
+ (cj/apply-font-settings-to-frame (selected-frame)))
+ (should-not called)))
+
+(ert-deftest test-font-apply-non-gui-is-noop ()
+ "Boundary: without a GUI nothing is applied or tracked."
+ (let ((cj/fontaine-configured-frames nil)
+ (called nil))
+ (cl-letf (((symbol-function 'env-gui-p) (lambda () nil))
+ ((symbol-function 'fontaine-set-preset) (lambda (_p) (setq called t))))
+ (cj/apply-font-settings-to-frame (selected-frame)))
+ (should-not called)
+ (should-not (member (selected-frame) cj/fontaine-configured-frames))))
+
+(ert-deftest test-font-maybe-install-icons-gui-missing-installs ()
+ "Normal: GUI present and font missing triggers the install."
+ (let ((installed nil))
+ (cl-letf (((symbol-function 'env-gui-p) (lambda () t))
+ ((symbol-function 'cj/font-installed-p) (lambda (_n) nil))
+ ((symbol-function 'all-the-icons-install-fonts) (lambda (&rest _) (setq installed t)))
+ ((symbol-function 'remove-hook) #'ignore))
+ (cj/maybe-install-all-the-icons-fonts))
+ (should installed)))
+
+(ert-deftest test-font-maybe-install-icons-already-present-skips ()
+ "Boundary: an installed font means no install attempt."
+ (let ((installed nil))
+ (cl-letf (((symbol-function 'env-gui-p) (lambda () t))
+ ((symbol-function 'cj/font-installed-p) (lambda (_n) t))
+ ((symbol-function 'all-the-icons-install-fonts) (lambda (&rest _) (setq installed t))))
+ (cj/maybe-install-all-the-icons-fonts))
+ (should-not installed)))
+
+(provide 'test-font-config--frame-lifecycle)
+;;; test-font-config--frame-lifecycle.el ends here
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 c24ac183a..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,10 +69,35 @@ 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))))
+(ert-deftest test-host-environment-detect-tz-symlink-target-extracts-zone ()
+ "Boundary: with methods 1-3 nil, a /etc/localtime symlink into zoneinfo
+yields the zone after the /zoneinfo/ segment."
+ (cl-letf (((symbol-function 'cj/match-localtime-to-zoneinfo)
+ (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 (_ &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 (_ &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 (_ &rest _) "/var/lib/elsewhere/localtime")))
+ (should-not (cj/detect-system-timezone))))
+
(provide 'test-host-environment--detect-system-timezone)
;;; test-host-environment--detect-system-timezone.el ends here
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..478819b89 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"
diff --git a/tests/test-jumper--location-candidates.el b/tests/test-jumper--location-candidates.el
new file mode 100644
index 000000000..df095830a
--- /dev/null
+++ b/tests/test-jumper--location-candidates.el
@@ -0,0 +1,52 @@
+;;; test-jumper--location-candidates.el --- Tests for jumper--location-candidates -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; jumper--location-candidates is the (display . index) builder extracted from
+;; the verbatim cl-loop in jumper-jump-to-location and jumper-remove-location.
+;; It composes jumper--format-location (which now goes through the extracted
+;; jumper--with-marker-at). The wrappers cover it transitively; this exercises
+;; it directly against stored locations.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'jumper)
+
+(ert-deftest test-jumper-location-candidates-one-pair-per-stored-location ()
+ "Normal: one (display . index) pair per stored location, indices in order."
+ (let ((saved-regs jumper--registers)
+ (saved-idx jumper--next-index))
+ (unwind-protect
+ (progn
+ (setq jumper--registers (make-vector jumper-max-locations nil)
+ jumper--next-index 0)
+ (with-temp-buffer
+ (insert "line one\nline two\nline three\n")
+ (goto-char (point-min))
+ (should (integerp (jumper--do-store-location))) ; index 0
+ (forward-line 2)
+ (should (integerp (jumper--do-store-location))) ; index 1
+ (let ((cands (jumper--location-candidates)))
+ (should (= (length cands) 2))
+ (should (equal (mapcar #'cdr cands) '(0 1)))
+ (should (stringp (car (nth 0 cands))))
+ (should (stringp (car (nth 1 cands)))))))
+ (setq jumper--registers saved-regs
+ jumper--next-index saved-idx))))
+
+(ert-deftest test-jumper-location-candidates-empty-when-none-stored ()
+ "Boundary: no stored locations yields an empty candidate list."
+ (let ((saved-regs jumper--registers)
+ (saved-idx jumper--next-index))
+ (unwind-protect
+ (progn
+ (setq jumper--registers (make-vector jumper-max-locations nil)
+ jumper--next-index 0)
+ (should (null (jumper--location-candidates))))
+ (setq jumper--registers saved-regs
+ jumper--next-index saved-idx))))
+
+(provide 'test-jumper--location-candidates)
+;;; test-jumper--location-candidates.el ends here
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-keybindings-tty-mirror.el b/tests/test-keybindings-tty-mirror.el
new file mode 100644
index 000000000..f63024c0b
--- /dev/null
+++ b/tests/test-keybindings-tty-mirror.el
@@ -0,0 +1,33 @@
+;;; test-keybindings-tty-mirror.el --- TTY mirror prefix for the C-; family -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The personal prefix C-; is GUI-only — terminals can't encode Control-semicolon,
+;; so the whole custom command family is unreachable in a TTY frame (emacs -nw,
+;; emacsclient -nw, Emacs inside vterm/tmux). keybindings.el binds the single
+;; `cj/custom-keymap' under a TTY-safe mirror prefix C-c ; alongside C-;, so the
+;; same leaf keys reach the identical map in both GUI and terminal. These tests
+;; pin that load-time global binding.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'keybindings)
+
+(ert-deftest test-keybindings-tty-mirror-gui-prefix-resolves ()
+ "Normal: the GUI prefix C-; resolves to cj/custom-keymap globally."
+ (should (eq (keymap-lookup (current-global-map) "C-;") cj/custom-keymap)))
+
+(ert-deftest test-keybindings-tty-mirror-tty-prefix-resolves ()
+ "Normal: the TTY mirror C-c ; resolves to the same cj/custom-keymap."
+ (should (eq (keymap-lookup (current-global-map) "C-c ;") cj/custom-keymap)))
+
+(ert-deftest test-keybindings-tty-mirror-both-prefixes-share-one-map ()
+ "Boundary: both prefixes point at the identical keymap object, so a leaf
+key registered once is reachable under either prefix."
+ (should (eq (keymap-lookup (current-global-map) "C-;")
+ (keymap-lookup (current-global-map) "C-c ;"))))
+
+(provide 'test-keybindings-tty-mirror)
+;;; test-keybindings-tty-mirror.el ends here
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-local-repository--car-member.el b/tests/test-local-repository--car-member.el
new file mode 100644
index 000000000..8b8c9a7db
--- /dev/null
+++ b/tests/test-local-repository--car-member.el
@@ -0,0 +1,58 @@
+;;; test-local-repository--car-member.el --- Tests for car-member -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Tests for `car-member' in local-repository.el — the predicate
+;; localrepo-initialize uses to check whether an archive id is already
+;; registered in package-archives / package-archive-priorities.
+
+;;; Code:
+
+(require 'ert)
+(require 'local-repository)
+
+;;; Normal Cases
+
+(ert-deftest test-local-repository-car-member-found ()
+ "Normal: VALUE present as a car returns the matching tail (non-nil)."
+ (should (equal (car-member 'b '((a . 1) (b . 2) (c . 3)))
+ '(b c))))
+
+(ert-deftest test-local-repository-car-member-not-found ()
+ "Normal: VALUE absent from every car returns nil."
+ (should-not (car-member 'z '((a . 1) (b . 2)))))
+
+(ert-deftest test-local-repository-car-member-string-car ()
+ "Normal: car comparison uses `equal', so string keys match by value."
+ (should (car-member "localrepo"
+ '(("gnu" . "url1") ("localrepo" . "url2")))))
+
+;;; Boundary Cases
+
+(ert-deftest test-local-repository-car-member-empty-list ()
+ "Boundary: an empty list never matches."
+ (should-not (car-member 'a nil)))
+
+(ert-deftest test-local-repository-car-member-single-match ()
+ "Boundary: a single-element list whose car matches returns non-nil."
+ (should (car-member 'only '((only . 1)))))
+
+(ert-deftest test-local-repository-car-member-single-no-match ()
+ "Boundary: a single-element list whose car differs returns nil."
+ (should-not (car-member 'x '((only . 1)))))
+
+(ert-deftest test-local-repository-car-member-nil-value-with-nil-car ()
+ "Boundary: a nil VALUE matches a cons whose car is nil."
+ (should (car-member nil '((nil . 1) (a . 2)))))
+
+(ert-deftest test-local-repository-car-member-nil-value-no-nil-car ()
+ "Boundary: a nil VALUE with no nil car returns nil."
+ (should-not (car-member nil '((a . 1) (b . 2)))))
+
+;;; Error Cases
+
+(ert-deftest test-local-repository-car-member-non-cons-element ()
+ "Error: a non-cons element makes `car' signal wrong-type-argument."
+ (should-error (car-member 'x '(1 2)) :type 'wrong-type-argument))
+
+(provide 'test-local-repository--car-member)
+;;; test-local-repository--car-member.el ends here
diff --git a/tests/test-mail-config--account-search-queries.el b/tests/test-mail-config--account-search-queries.el
new file mode 100644
index 000000000..9f1b6b3e6
--- /dev/null
+++ b/tests/test-mail-config--account-search-queries.el
@@ -0,0 +1,53 @@
+;;; test-mail-config--account-search-queries.el --- Tests for the mail account-nav helpers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/--mail-account-search-queries (pure: account name -> the four mu4e search
+;; strings) and cj/--mail-make-account-map (builds the per-account nav keymap)
+;; replace three near-identical defvar-keymap blocks that differed only by
+;; maildir prefix. The map test invokes each binding with mu4e-search mocked,
+;; which also verifies each loop-built closure captured its own query.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'mail-config)
+
+(ert-deftest test-mail-account-search-queries-cmail ()
+ "Normal: the four searches are scoped to the account's INBOX maildir."
+ (should (equal (cj/--mail-account-search-queries "cmail")
+ '(("i" . "maildir:/cmail/INBOX")
+ ("u" . "maildir:/cmail/INBOX AND flag:unread AND NOT flag:trashed")
+ ("s" . "maildir:/cmail/INBOX AND flag:flagged")
+ ("l" . "maildir:/cmail/INBOX AND size:5M..999M")))))
+
+(ert-deftest test-mail-account-search-queries-prefix-varies ()
+ "Boundary: only the maildir prefix changes between accounts."
+ (should (equal (cdr (assoc "i" (cj/--mail-account-search-queries "dmail")))
+ "maildir:/dmail/INBOX"))
+ (should (equal (cdr (assoc "i" (cj/--mail-account-search-queries "gmail")))
+ "maildir:/gmail/INBOX")))
+
+(ert-deftest test-mail-make-account-map-binds-four-keys ()
+ "Normal: the built keymap binds i/u/s/l to commands."
+ (let ((map (cj/--mail-make-account-map "cmail")))
+ (dolist (key '("i" "u" "s" "l"))
+ (should (commandp (keymap-lookup map key))))))
+
+(ert-deftest test-mail-make-account-map-closures-capture-distinct-queries ()
+ "Normal: each binding runs its own account-scoped search (no closure leak).
+mu4e-search is mocked to capture the query each command passes."
+ (let ((searched '()))
+ (cl-letf (((symbol-function 'mu4e-search)
+ (lambda (q) (push q searched))))
+ (let ((map (cj/--mail-make-account-map "dmail")))
+ (funcall (keymap-lookup map "i"))
+ (funcall (keymap-lookup map "u"))))
+ (should (member "maildir:/dmail/INBOX" searched))
+ (should (member "maildir:/dmail/INBOX AND flag:unread AND NOT flag:trashed"
+ searched))))
+
+(provide 'test-mail-config--account-search-queries)
+;;; test-mail-config--account-search-queries.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-modeline-config--click-map.el b/tests/test-modeline-config--click-map.el
new file mode 100644
index 000000000..6c5ba4c7e
--- /dev/null
+++ b/tests/test-modeline-config--click-map.el
@@ -0,0 +1,29 @@
+;;; test-modeline-config--click-map.el --- Tests for cj/--modeline-click-map -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/--modeline-click-map is the shared mode-line `local-map' builder extracted
+;; from three clickable segments (buffer-name, vc, major-mode) that each spelled
+;; out the same make-sparse-keymap + define-key dance.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'modeline-config)
+
+(ert-deftest test-modeline-click-map-binds-mouse-1-and-3 ()
+ "Normal: with both commands, mouse-1 and mouse-3 are bound."
+ (let ((map (cj/--modeline-click-map 'vc-diff 'vc-root-diff)))
+ (should (keymapp map))
+ (should (eq (lookup-key map [mode-line mouse-1]) 'vc-diff))
+ (should (eq (lookup-key map [mode-line mouse-3]) 'vc-root-diff))))
+
+(ert-deftest test-modeline-click-map-mouse-1-only ()
+ "Boundary: with no MOUSE-3, only mouse-1 is bound."
+ (let ((map (cj/--modeline-click-map 'describe-mode)))
+ (should (eq (lookup-key map [mode-line mouse-1]) 'describe-mode))
+ (should (null (lookup-key map [mode-line mouse-3])))))
+
+(provide 'test-modeline-config--click-map)
+;;; test-modeline-config--click-map.el ends here
diff --git a/tests/test-modeline-config-string-cut-middle.el b/tests/test-modeline-config-string-cut-middle.el
index 40cc0bccc..d68431b49 100644
--- a/tests/test-modeline-config-string-cut-middle.el
+++ b/tests/test-modeline-config-string-cut-middle.el
@@ -17,14 +17,6 @@
;; Add modules directory to load path
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-;; Stub dependencies before loading the module
-(unless (boundp 'cj/buffer-status-colors)
- (defvar cj/buffer-status-colors
- '((unmodified . "#FFFFFF")
- (modified . "#00FF00")
- (read-only . "#FF0000")
- (overwrite . "#FFD700"))))
-
(require 'modeline-config)
;;; Test Helpers
diff --git a/tests/test-modeline-config-string-truncate-p.el b/tests/test-modeline-config-string-truncate-p.el
index 09378b0d1..94ea74171 100644
--- a/tests/test-modeline-config-string-truncate-p.el
+++ b/tests/test-modeline-config-string-truncate-p.el
@@ -19,14 +19,6 @@
;; Add modules directory to load path
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-;; Stub dependencies before loading the module
-(unless (boundp 'cj/buffer-status-colors)
- (defvar cj/buffer-status-colors
- '((unmodified . "#FFFFFF")
- (modified . "#00FF00")
- (read-only . "#FF0000")
- (overwrite . "#FFD700"))))
-
(require 'modeline-config)
;;; Test Helpers
diff --git a/tests/test-mousetrap-mode--bind-events.el b/tests/test-mousetrap-mode--bind-events.el
new file mode 100644
index 000000000..6772d6fa3
--- /dev/null
+++ b/tests/test-mousetrap-mode--bind-events.el
@@ -0,0 +1,41 @@
+;;; test-mousetrap-mode--bind-events.el --- Tests for mouse-trap--bind-events-to-ignore -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; mouse-trap--bind-events-to-ignore is the per-category binding loop extracted
+;; from mouse-trap--build-keymap-1 (which previously nested it five deep). It
+;; binds a category's events, across modifier prefixes, to `ignore'. The full
+;; keymap build stays covered by test-mousetrap-mode--build-keymap.el.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'mousetrap-mode)
+
+(ert-deftest test-mousetrap-bind-events-wheel ()
+ "Normal: wheel events are bound to ignore across every prefix variant."
+ (let ((map (make-sparse-keymap))
+ (spec '((wheel . ("wheel-up" "wheel-down")))))
+ (mouse-trap--bind-events-to-ignore spec '("" "C-") map)
+ (should (eq (lookup-key map (kbd "<wheel-up>")) #'ignore))
+ (should (eq (lookup-key map (kbd "<C-wheel-up>")) #'ignore))
+ (should (eq (lookup-key map (kbd "<wheel-down>")) #'ignore))))
+
+(ert-deftest test-mousetrap-bind-events-click ()
+ "Normal: type x button click events are bound to ignore."
+ (let ((map (make-sparse-keymap))
+ (spec '((types . ("mouse" "down-mouse")) (buttons . (1 3)))))
+ (mouse-trap--bind-events-to-ignore spec '("") map)
+ (should (eq (lookup-key map (kbd "<mouse-1>")) #'ignore))
+ (should (eq (lookup-key map (kbd "<mouse-3>")) #'ignore))
+ (should (eq (lookup-key map (kbd "<down-mouse-1>")) #'ignore))))
+
+(ert-deftest test-mousetrap-bind-events-empty-spec-no-op ()
+ "Boundary: a spec with neither wheel nor types/buttons binds nothing."
+ (let ((map (make-sparse-keymap)))
+ (mouse-trap--bind-events-to-ignore '((other . t)) '("") map)
+ (should (null (lookup-key map (kbd "<mouse-1>"))))))
+
+(provide 'test-mousetrap-mode--bind-events)
+;;; test-mousetrap-mode--bind-events.el ends here
diff --git a/tests/test-music-config--playlist-side.el b/tests/test-music-config--playlist-side.el
new file mode 100644
index 000000000..f49694690
--- /dev/null
+++ b/tests/test-music-config--playlist-side.el
@@ -0,0 +1,45 @@
+;;; test-music-config--playlist-side.el --- Tests for the F10 dock-side helper -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/--music-playlist-side' maps the shared dock rule's verdict to a
+;; `display-buffer-in-side-window' side: `right' stays `right', anything
+;; else becomes `bottom'. The decision itself lives in
+;; `cj/preferred-dock-direction' (tested in test-cj-window-geometry-lib.el);
+;; here we stub it (an ordinary defun -- safe to `cl-letf', unlike the
+;; frame-* subrs) to prove the mapping and that the width fraction is
+;; passed through.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'music-config)
+
+(ert-deftest test-music-config--playlist-side-right-verdict-is-right ()
+ "Normal: a `right' verdict from the dock rule docks the playlist right."
+ (cl-letf (((symbol-function 'cj/preferred-dock-direction)
+ (lambda (&rest _) 'right)))
+ (should (eq (cj/--music-playlist-side) 'right))))
+
+(ert-deftest test-music-config--playlist-side-below-verdict-is-bottom ()
+ "Normal: a `below' verdict maps to the `bottom' side window."
+ (cl-letf (((symbol-function 'cj/preferred-dock-direction)
+ (lambda (&rest _) 'below)))
+ (should (eq (cj/--music-playlist-side) 'bottom))))
+
+(ert-deftest test-music-config--playlist-side-passes-width-fraction ()
+ "Normal: the playlist's width fraction reaches the dock rule."
+ (let ((cj/music-playlist-window-width 0.4)
+ captured)
+ (cl-letf (((symbol-function 'cj/preferred-dock-direction)
+ (lambda (cols frac &rest _)
+ (setq captured (list cols frac))
+ 'below)))
+ (cj/--music-playlist-side)
+ (should (= (nth 1 captured) 0.4))
+ (should (integerp (nth 0 captured))))))
+
+(provide 'test-music-config--playlist-side)
+;;; test-music-config--playlist-side.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
new file mode 100644
index 000000000..bd202a195
--- /dev/null
+++ b/tests/test-org-agenda-config--base-files.el
@@ -0,0 +1,59 @@
+;;; test-org-agenda-config--base-files.el --- Tests for the agenda base-file helper -*- lexical-binding: t; -*-
+
+;;; 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. 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:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-agenda-config)
+
+(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* ((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--find-or-create-top-heading.el b/tests/test-org-capture-config--find-or-create-top-heading.el
new file mode 100644
index 000000000..236c87c87
--- /dev/null
+++ b/tests/test-org-capture-config--find-or-create-top-heading.el
@@ -0,0 +1,45 @@
+;;; test-org-capture-config--find-or-create-top-heading.el --- Tests for the shared find-or-create helper -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/--org-find-or-create-top-heading is the search-or-append positioning block
+;; extracted from cj/org-capture--goto-file-headline, cj/--org-capture-goto-open-work,
+;; and cj/--org-capture-goto-exact-headline. The three call sites stay covered by
+;; test-org-capture-config-project-target.el (open-work, exact-headline) and the
+;; target-cache test; these cover the generic helper directly with a plain regexp
+;; (so the test doesn't depend on org's complex-heading format).
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-capture-config)
+
+(ert-deftest test-org-find-or-create-top-heading-finds-existing ()
+ "Normal: an existing heading is found; point lands at its line start and the
+buffer is unchanged."
+ (with-temp-buffer
+ (insert "* Alpha\nbody\n* Target\nmore\n")
+ (cj/--org-find-or-create-top-heading "^\\* Target$" "* Target")
+ (should (looking-at-p "\\* Target$"))
+ (should (equal (buffer-string) "* Alpha\nbody\n* Target\nmore\n"))))
+
+(ert-deftest test-org-find-or-create-top-heading-creates-when-absent ()
+ "Boundary: with no match, the heading line is appended (a separating newline
+added because the buffer doesn't end in one) and point lands on it."
+ (with-temp-buffer
+ (insert "some text") ; no trailing newline
+ (cj/--org-find-or-create-top-heading "^\\* Missing$" "* Missing")
+ (should (equal (buffer-string) "some text\n* Missing\n"))
+ (should (looking-at-p "\\* Missing$"))))
+
+(ert-deftest test-org-find-or-create-top-heading-empty-buffer ()
+ "Boundary: in an empty buffer the heading is inserted at the top, no extra
+leading newline."
+ (with-temp-buffer
+ (cj/--org-find-or-create-top-heading "^\\* X$" "* X")
+ (should (equal (buffer-string) "* X\n"))
+ (should (looking-at-p "\\* X$"))))
+
+(provide 'test-org-capture-config--find-or-create-top-heading)
+;;; test-org-capture-config--find-or-create-top-heading.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--deadgrep.el b/tests/test-prog-general--deadgrep.el
new file mode 100644
index 000000000..21223105d
--- /dev/null
+++ b/tests/test-prog-general--deadgrep.el
@@ -0,0 +1,44 @@
+;;; test-prog-general--deadgrep.el --- Tests for the deadgrep helpers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/deadgrep--initial-term (region text or symbol at point) and cj/--deadgrep-run
+;; (the normalize-root + read-term + invoke tail shared by cj/deadgrep-here and
+;; cj/deadgrep-in-dir) were lifted out of the deadgrep use-package :config.
+;; deadgrep is mocked at the boundary.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'prog-general)
+
+(ert-deftest test-prg-deadgrep-initial-term-symbol-at-point ()
+ "Normal: with no region, the symbol at point seeds the search."
+ (with-temp-buffer
+ (insert "hello world")
+ (goto-char (point-min))
+ (should (equal (cj/deadgrep--initial-term) "hello"))))
+
+(ert-deftest test-prg-deadgrep-initial-term-region ()
+ "Normal: an active region's text seeds the search."
+ (with-temp-buffer
+ (insert "needle")
+ (transient-mark-mode 1)
+ (set-mark (point-min))
+ (goto-char (point-max))
+ (activate-mark)
+ (should (equal (cj/deadgrep--initial-term) "needle"))))
+
+(ert-deftest test-prg-deadgrep-run-normalizes-root-and-passes-term ()
+ "Normal: ROOT is normalized to a directory and TERM is passed through."
+ (let (got-term got-root)
+ (cl-letf (((symbol-function 'deadgrep)
+ (lambda (term root) (setq got-term term got-root root))))
+ (cj/--deadgrep-run "/tmp/foo" "needle"))
+ (should (equal got-term "needle"))
+ (should (equal got-root "/tmp/foo/"))))
+
+(provide 'test-prog-general--deadgrep)
+;;; test-prog-general--deadgrep.el ends here
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--find-project-root-file.el b/tests/test-prog-general--find-project-root-file.el
new file mode 100644
index 000000000..97db0b979
--- /dev/null
+++ b/tests/test-prog-general--find-project-root-file.el
@@ -0,0 +1,49 @@
+;;; test-prog-general--find-project-root-file.el --- Tests for cj/find-project-root-file -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/find-project-root-file returns the first file in the current Projectile
+;; project root matching a regexp (string or rx form), case-insensitively. It
+;; was defined inside the projectile use-package :config (unreachable under
+;; `make test'); lifting it to top level makes it unit-testable. projectile's
+;; root and directory-files are mocked at the boundary.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'seq)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'prog-general)
+
+(defmacro test-prg--with-root (files &rest body)
+ "Run BODY with projectile-project-root \"/proj/\" and directory-files = FILES."
+ (declare (indent 1))
+ `(cl-letf (((symbol-function 'projectile-project-root) (lambda (&rest _) "/proj/"))
+ ((symbol-function 'directory-files) (lambda (&rest _) ,files)))
+ ,@body))
+
+(ert-deftest test-prg-find-root-file-string-regexp ()
+ "Normal: a string regexp matches case-insensitively."
+ (test-prg--with-root '("README.md" "TODO.org" "src")
+ (should (equal (cj/find-project-root-file "^todo\\.org$") "TODO.org"))))
+
+(ert-deftest test-prg-find-root-file-rx-form ()
+ "Normal: an rx form is converted and matched."
+ (test-prg--with-root '("notes.txt" "todo.md" "x")
+ (should (equal (cj/find-project-root-file
+ '(seq bos "todo." (or "org" "md" "txt") eos))
+ "todo.md"))))
+
+(ert-deftest test-prg-find-root-file-no-match ()
+ "Boundary: no matching file yields nil."
+ (test-prg--with-root '("a.el" "b.el")
+ (should (null (cj/find-project-root-file "^todo\\.org$")))))
+
+(ert-deftest test-prg-find-root-file-no-project ()
+ "Boundary: outside a project (nil root) yields nil."
+ (cl-letf (((symbol-function 'projectile-project-root) (lambda (&rest _) nil)))
+ (should (null (cj/find-project-root-file "^todo\\.org$")))))
+
+(provide 'test-prog-general--find-project-root-file)
+;;; test-prog-general--find-project-root-file.el ends here
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-reconcile--dirty-p.el b/tests/test-reconcile--dirty-p.el
new file mode 100644
index 000000000..a4c372b66
--- /dev/null
+++ b/tests/test-reconcile--dirty-p.el
@@ -0,0 +1,49 @@
+;;; test-reconcile--dirty-p.el --- Tests for cj/reconcile--dirty-p -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Tests for `cj/reconcile--dirty-p' in reconcile-open-repos.el. It runs
+;; git status --porcelain via `cj/reconcile--git' and reports clean (nil),
+;; dirty (non-nil), or 'status-failed when git itself errors. The git call
+;; is stubbed at the `cj/reconcile--git' boundary (it returns a plist).
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'reconcile-open-repos)
+
+(defmacro test-reconcile-dirty--with-git (plist &rest body)
+ "Run BODY with `cj/reconcile--git' stubbed to return PLIST."
+ (declare (indent 1))
+ `(cl-letf (((symbol-function 'cj/reconcile--git)
+ (lambda (&rest _) ,plist)))
+ ,@body))
+
+;;; Normal Cases
+
+(ert-deftest test-reconcile-dirty-p-clean-returns-nil ()
+ "Normal: exit 0 with empty porcelain output means clean (nil)."
+ (test-reconcile-dirty--with-git '(:exit 0 :output "")
+ (should-not (cj/reconcile--dirty-p "/repo"))))
+
+(ert-deftest test-reconcile-dirty-p-dirty-returns-non-nil ()
+ "Normal: exit 0 with porcelain content means dirty (non-nil)."
+ (test-reconcile-dirty--with-git '(:exit 0 :output " M file.el\n")
+ (should (cj/reconcile--dirty-p "/repo"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-reconcile-dirty-p-whitespace-only-is-clean ()
+ "Boundary: whitespace-only output trims to empty and counts as clean."
+ (test-reconcile-dirty--with-git '(:exit 0 :output " \n")
+ (should-not (cj/reconcile--dirty-p "/repo"))))
+
+;;; Error Cases
+
+(ert-deftest test-reconcile-dirty-p-git-failure-returns-status-failed ()
+ "Error: a non-zero git exit returns the symbol 'status-failed."
+ (test-reconcile-dirty--with-git '(:exit 128 :output "fatal: not a repo")
+ (should (eq (cj/reconcile--dirty-p "/repo") 'status-failed))))
+
+(provide 'test-reconcile--dirty-p)
+;;; test-reconcile--dirty-p.el ends here
diff --git a/tests/test-show-kill-ring--insert-item.el b/tests/test-show-kill-ring--insert-item.el
new file mode 100644
index 000000000..a29ca75e6
--- /dev/null
+++ b/tests/test-show-kill-ring--insert-item.el
@@ -0,0 +1,73 @@
+;;; test-show-kill-ring--insert-item.el --- Tests for show-kill-insert-item -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Tests for `show-kill-insert-item' in show-kill-ring.el — inserts a
+;; kill-ring entry into the current buffer, truncating to
+;; `show-kill-max-item-size' with an ellipsis when too long. The ellipsis
+;; sits inline for short items and on its own line for items wider than the
+;; frame. Frame width is read at runtime so the test is environment-stable.
+
+;;; Code:
+
+(require 'ert)
+(require 'show-kill-ring)
+
+;;; Normal Cases
+
+(ert-deftest test-show-kill-ring-insert-item-short-verbatim ()
+ "Normal: an item shorter than the max is inserted unchanged."
+ (let ((show-kill-max-item-size 1000))
+ (with-temp-buffer
+ (show-kill-insert-item "hello")
+ (should (string= (buffer-string) "hello")))))
+
+(ert-deftest test-show-kill-ring-insert-item-inline-ellipsis ()
+ "Normal: an over-max item narrower than the frame gets an inline ellipsis."
+ (let* ((show-kill-max-item-size 5)
+ (len (/ (frame-width) 2)) ; > max, < (frame-width - 5)
+ (item (make-string len ?b)))
+ (with-temp-buffer
+ (show-kill-insert-item item)
+ (should (string= (buffer-string) "bbbbb...")))))
+
+;;; Boundary Cases
+
+(ert-deftest test-show-kill-ring-insert-item-length-equals-max-truncates ()
+ "Boundary: length exactly equal to max truncates — the guard is (< len max)."
+ (let ((show-kill-max-item-size 5))
+ (with-temp-buffer
+ (show-kill-insert-item "hello") ; length 5, equals max
+ (should (string= (buffer-string) "hello...")))))
+
+(ert-deftest test-show-kill-ring-insert-item-wide-newline-ellipsis ()
+ "Boundary: an item wider than the frame puts the ellipsis on its own line."
+ (let* ((show-kill-max-item-size 5)
+ (item (make-string (+ (frame-width) 10) ?a)))
+ (with-temp-buffer
+ (show-kill-insert-item item)
+ (should (string= (buffer-string) "aaaaa\n...")))))
+
+(ert-deftest test-show-kill-ring-insert-item-max-nil-verbatim ()
+ "Boundary: a non-numeric max disables truncation."
+ (let ((show-kill-max-item-size nil))
+ (with-temp-buffer
+ (show-kill-insert-item "anything long enough to exceed nothing")
+ (should (string= (buffer-string)
+ "anything long enough to exceed nothing")))))
+
+(ert-deftest test-show-kill-ring-insert-item-max-negative-verbatim ()
+ "Boundary: a negative max disables truncation."
+ (let ((show-kill-max-item-size -1))
+ (with-temp-buffer
+ (show-kill-insert-item "abc")
+ (should (string= (buffer-string) "abc")))))
+
+(ert-deftest test-show-kill-ring-insert-item-empty-string ()
+ "Boundary: an empty item inserts nothing and does not error."
+ (let ((show-kill-max-item-size 1000))
+ (with-temp-buffer
+ (show-kill-insert-item "")
+ (should (string= (buffer-string) "")))))
+
+(provide 'test-show-kill-ring--insert-item)
+;;; test-show-kill-ring--insert-item.el ends here
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-system-lib--format-region-with-program.el b/tests/test-system-lib--format-region-with-program.el
new file mode 100644
index 000000000..29b392b84
--- /dev/null
+++ b/tests/test-system-lib--format-region-with-program.el
@@ -0,0 +1,68 @@
+;;; test-system-lib--format-region-with-program.el --- Tests for cj/format-region-with-program -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/format-region-with-program' runs an external formatter over the whole
+;; buffer via `call-process-region' (argv, no shell) and replaces the buffer
+;; only when the program exits zero. Extracted from the byte-identical
+;; per-language helpers in prog-json.el / prog-yaml.el, so this is the first
+;; direct unit coverage of the logic. call-process-region is mocked at the
+;; boundary (the established pattern in test-prog-json--json-format-buffer.el).
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'system-lib)
+
+(ert-deftest test-system-lib-format-region-with-program-replaces-on-success ()
+ "Normal: on exit 0 the buffer is replaced with the program's output, returns t."
+ (cl-letf (((symbol-function 'call-process-region)
+ (lambda (_start _end _prog &rest rest)
+ (with-current-buffer (nth 1 rest) (insert "FORMATTED"))
+ 0)))
+ (with-temp-buffer
+ (insert "raw")
+ (should (eq t (cj/format-region-with-program "fmt")))
+ (should (equal "FORMATTED" (buffer-string))))))
+
+(ert-deftest test-system-lib-format-region-with-program-forwards-argv ()
+ "Normal: PROGRAM and ARGS reach call-process-region as argv (no shell)."
+ (let (got-prog got-args)
+ (cl-letf (((symbol-function 'call-process-region)
+ (lambda (_start _end prog &rest rest)
+ (setq got-prog prog
+ got-args (nthcdr 3 rest))
+ (with-current-buffer (nth 1 rest) (insert "x"))
+ 0)))
+ (with-temp-buffer
+ (cj/format-region-with-program "jq" "--sort-keys" ".")))
+ (should (equal "jq" got-prog))
+ (should (equal '("--sort-keys" ".") got-args))))
+
+(ert-deftest test-system-lib-format-region-with-program-empty-output ()
+ "Boundary: empty program output empties the buffer and still returns t."
+ (cl-letf (((symbol-function 'call-process-region)
+ (lambda (_start _end _prog &rest _rest) 0))) ; writes nothing
+ (with-temp-buffer
+ (insert "raw")
+ (should (eq t (cj/format-region-with-program "fmt")))
+ (should (equal "" (buffer-string))))))
+
+(ert-deftest test-system-lib-format-region-with-program-nonzero-untouched ()
+ "Error: a non-zero exit leaves the buffer untouched and signals user-error
+carrying the program's stderr text."
+ (cl-letf (((symbol-function 'call-process-region)
+ (lambda (_start _end _prog &rest rest)
+ (with-current-buffer (nth 1 rest) (insert "boom: bad input"))
+ 1)))
+ (with-temp-buffer
+ (insert "raw")
+ (let ((err (should-error (cj/format-region-with-program "fmt")
+ :type 'user-error)))
+ (should (string-match-p "boom: bad input" (error-message-string err))))
+ (should (equal "raw" (buffer-string))))))
+
+(provide 'test-system-lib--format-region-with-program)
+;;; test-system-lib--format-region-with-program.el ends here
diff --git a/tests/test-system-lib-font-lock-global-modes.el b/tests/test-system-lib-font-lock-global-modes.el
new file mode 100644
index 000000000..e074bd256
--- /dev/null
+++ b/tests/test-system-lib-font-lock-global-modes.el
@@ -0,0 +1,46 @@
+;;; test-system-lib-font-lock-global-modes.el --- Tests for the font-lock exclusion helper -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; ERT tests for `cj/--font-lock-global-modes-excluding', the pure transform
+;; behind `cj/exclude-from-global-font-lock'. Some major modes (dashboard,
+;; mu4e) paint their buffers with manual `face' text properties; global
+;; font-lock then strips those. The helper adds a mode to the
+;; `font-lock-global-modes' exclusion, handling its three shapes: t (all
+;; modes on), a (not M...) exclusion list, and an (M...) inclusion list.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'system-lib)
+
+(ert-deftest test-system-lib-flgm-from-t-builds-not-list ()
+ "Normal: t (all modes on) becomes a (not MODE) exclusion."
+ (let ((r (cj/--font-lock-global-modes-excluding t 'dashboard-mode)))
+ (should (eq (car r) 'not))
+ (should (memq 'dashboard-mode (cdr r)))))
+
+(ert-deftest test-system-lib-flgm-adds-to-existing-not-list ()
+ "Normal: a second mode is added to an existing (not ...) list."
+ (let ((r (cj/--font-lock-global-modes-excluding '(not dashboard-mode) 'mu4e-headers-mode)))
+ (should (eq (car r) 'not))
+ (should (memq 'dashboard-mode (cdr r)))
+ (should (memq 'mu4e-headers-mode (cdr r)))))
+
+(ert-deftest test-system-lib-flgm-idempotent-on-already-excluded ()
+ "Boundary: excluding an already-excluded mode does not duplicate it."
+ (let ((r (cj/--font-lock-global-modes-excluding '(not a-mode) 'a-mode)))
+ (should (eq (car r) 'not))
+ (should (= 1 (cl-count 'a-mode (cdr r))))))
+
+(ert-deftest test-system-lib-flgm-removes-from-inclusion-list ()
+ "Boundary: in an (M...) inclusion list, excluding a mode removes it."
+ (should (equal (cj/--font-lock-global-modes-excluding '(foo-mode bar-mode) 'foo-mode)
+ '(bar-mode))))
+
+(ert-deftest test-system-lib-flgm-nil-stays-nil ()
+ "Boundary: nil (no mode gets global font-lock) already excludes everything."
+ (should (equal (cj/--font-lock-global-modes-excluding nil 'x-mode) nil)))
+
+(provide 'test-system-lib-font-lock-global-modes)
+;;; test-system-lib-font-lock-global-modes.el ends here
diff --git a/tests/test-system-utils-scratch-background.el b/tests/test-system-utils-scratch-background.el
deleted file mode 100644
index 422590f4b..000000000
--- a/tests/test-system-utils-scratch-background.el
+++ /dev/null
@@ -1,30 +0,0 @@
-;;; test-system-utils-scratch-background.el --- Tests for the scratch tint -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; cj/--scratch-lightened-background lightens the default background by a
-;; tunable percent for the *scratch* buffer's buffer-local face remap. The
-;; colour arithmetic (color-lighten-name -> color-name-to-rgb) is
-;; display-dependent and returns zeros under --batch, so the actual lightening
-;; is verified live in the daemon; here we cover the display-independent
-;; contract: a usable colour string yields a string, junk yields nil.
-
-;;; Code:
-
-(require 'ert)
-(require 'color)
-(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'system-utils)
-
-(ert-deftest test-system-utils-scratch-lightened-background-returns-string ()
- "Normal: a valid hex colour yields a colour string (not nil)."
- (let ((cj/scratch-background-lighten 5))
- (should (stringp (cj/--scratch-lightened-background "#100f0f")))))
-
-(ert-deftest test-system-utils-scratch-lightened-background-bad-input ()
- "Error: non-colour input yields nil rather than signalling."
- (should (null (cj/--scratch-lightened-background nil)))
- (should (null (cj/--scratch-lightened-background 'unspecified)))
- (should (null (cj/--scratch-lightened-background "not-a-color"))))
-
-(provide 'test-system-utils-scratch-background)
-;;; test-system-utils-scratch-background.el ends here
diff --git a/tests/test-term-tmux-history.el b/tests/test-term-tmux-history.el
index 51e9725c4..0ea7cf37d 100644
--- a/tests/test-term-tmux-history.el
+++ b/tests/test-term-tmux-history.el
@@ -75,7 +75,7 @@ RESPONSES is an alist of (ARGS EXIT-CODE OUTPUT)."
(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")
@@ -106,7 +106,7 @@ the terminal's frame slot rather than splitting or popping a new window."
(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")
@@ -194,7 +194,7 @@ ghostel-mode terminal."
(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/1\t%1\n/dev/pts/8\t%8\n"))
@@ -210,7 +210,7 @@ ghostel-mode terminal."
(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"))
@@ -226,7 +226,7 @@ ghostel-mode terminal."
(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/1\t%1\n"))
@@ -242,7 +242,7 @@ ghostel-mode terminal."
(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"))
@@ -273,7 +273,7 @@ puts it at column 0 so it runs up the left."
(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"))
((symbol-function 'ghostel-send-string)
(lambda (s) (push s sent)))
((symbol-function 'ghostel-copy-mode)
@@ -301,7 +301,7 @@ scrolling, parity with the tmux branch's trailing C-a."
(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"))
((symbol-function 'ghostel-send-string)
(lambda (s) (push s sent)))
((symbol-function 'ghostel-copy-mode)
@@ -336,14 +336,15 @@ instead of being forwarded to the terminal program."
(should-not (eq (keymap-lookup ghostel-semi-char-mode-map "C-M-<left>")
'ghostel--send-event)))
-(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)))
+(ert-deftest test-term-f10-music-in-keymap-exceptions ()
+ "Regression: F10 (music playlist toggle) is in `ghostel-keymap-exceptions'
+so it reaches Emacs from inside a ghostel buffer instead of being forwarded
+to the terminal program. It is a global binding, so dropping it from the
+semi-char map lets the lookup fall through to the global map. Server
+shutdown moved off C-F10 to C-x C, which is deliberately NOT an exception
+(C-x C stays forwarding to the terminal program inside an agent buffer)."
+ (should (member "<f10>" ghostel-keymap-exceptions))
+ (should-not (member "C-<f10>" ghostel-keymap-exceptions))
(should-not (eq (keymap-lookup ghostel-semi-char-mode-map "<f10>")
'ghostel--send-event)))
@@ -354,5 +355,100 @@ 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)))
+;; ----------------------------- copy-mode scroll ------------------------------
+
+(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-ghostel-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 'ghostel-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 "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-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 (which would reset the cursor)."
+ (let ((agent (cj/test--make-fake-ghostel-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 'ghostel-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-copy-mode-up-nontmux-enters-then-moves-up ()
+ "Boundary: without tmux and not yet in copy-mode, C-<up> enters
+ghostel-copy-mode then moves point up a line, sending nothing to the pty."
+ (with-temp-buffer
+ (insert "abc\ndef\nghi\n")
+ (goto-char (point-min))
+ (forward-line 2) ; land on line 3
+ (let ((sent nil) (entered nil))
+ (cl-letf (((symbol-function 'ghostel-send-string) (lambda (s) (push s sent)))
+ ((symbol-function 'ghostel-copy-mode) (lambda () (setq entered t))))
+ (cj/term-copy-mode-up)
+ (should entered)
+ (should-not sent)
+ (should (= (line-number-at-pos) 2))))))
+
+(ert-deftest test-term-copy-mode-up-nontmux-already-in-copy-just-moves ()
+ "Normal: when ghostel is already in copy-mode, C-<up> just moves point up --
+it does not call `ghostel-copy-mode' again (which would toggle copy-mode off)."
+ (with-temp-buffer
+ (insert "abc\ndef\nghi\n")
+ (goto-char (point-min))
+ (forward-line 2) ; land on line 3
+ (setq-local ghostel--input-mode 'copy)
+ (let ((sent nil) (entered nil))
+ (cl-letf (((symbol-function 'ghostel-send-string) (lambda (s) (push s sent)))
+ ((symbol-function 'ghostel-copy-mode) (lambda () (setq entered t))))
+ (cj/term-copy-mode-up)
+ (should-not entered)
+ (should-not sent)
+ (should (= (line-number-at-pos) 2))))))
+
+(ert-deftest test-term-copy-mode-only-c-up-bound ()
+ "Normal/Regression: only C-<up> enters copy-mode in ghostel-mode-map; the
+other arrows are not bound to it, so they pass through to the terminal."
+ (should (eq (keymap-lookup ghostel-mode-map "C-<up>") #'cj/term-copy-mode-up))
+ (dolist (key '("C-<down>" "C-<left>" "C-<right>"
+ "M-<up>" "M-<down>" "M-<left>" "M-<right>"))
+ (should-not (eq (keymap-lookup ghostel-mode-map key) #'cj/term-copy-mode-up))))
+
+(ert-deftest test-term-copy-mode-only-c-up-in-keymap-exceptions ()
+ "Regression (C-arrow copy-mode bug): only C-<up> is in
+`ghostel-keymap-exceptions'. C-<left>/<right>/<down> are readline word-motion
+at the shell prompt and the M-arrows have no copy-mode role, so none are
+exceptions -- they reach the terminal program instead of Emacs."
+ (should (member "C-<up>" ghostel-keymap-exceptions))
+ (dolist (key '("C-<down>" "C-<left>" "C-<right>"
+ "M-<up>" "M-<down>" "M-<left>" "M-<right>"))
+ (should-not (member key ghostel-keymap-exceptions))))
+
(provide 'test-term-tmux-history)
;;; test-term-tmux-history.el ends here
diff --git a/tests/test-term-toggle--display.el b/tests/test-term-toggle--display.el
index 0943a4888..d6dd33da2 100644
--- a/tests/test-term-toggle--display.el
+++ b/tests/test-term-toggle--display.el
@@ -17,7 +17,9 @@
(require 'term-config)
(ert-deftest test-term-toggle--capture-state-records-direction-and-size ()
- "Normal: capture-state writes direction and integer body size."
+ "Normal: capture-state writes direction and integer size.
+The vertical axis captures total-height (not body-height) so the toggle
+round-trip is immune to the mode line's pixel height."
(save-window-excursion
(delete-other-windows)
(let ((below (split-window (selected-window) nil 'below))
@@ -26,7 +28,7 @@
(cj/--term-toggle-capture-state below)
(should (eq cj/--term-toggle-last-direction 'below))
(should (integerp cj/--term-toggle-last-size))
- (should (= cj/--term-toggle-last-size (window-body-height below))))))
+ (should (= cj/--term-toggle-last-size (window-total-height below))))))
(ert-deftest test-term-toggle--capture-state-noop-on-dead-window ()
"Boundary: nil window -> state remains unchanged."
@@ -50,7 +52,9 @@
(should (eq (cdr (assq 'inhibit-same-window received-alist)) t))))
(ert-deftest test-term-toggle--display-saved-maps-cardinal-to-edge ()
- "Normal: saved 'below maps to bottom edge; integer size wraps in body-lines."
+ "Normal: saved 'below maps to bottom edge; integer size is a plain total-line count.
+The height axis replays a total-line integer (not a body-lines cons) so the
+round-trip is immune to the mode line's pixel height."
(let (received-alist
(cj/--term-toggle-last-direction 'below)
(cj/--term-toggle-last-size 12))
@@ -58,8 +62,7 @@
(lambda (_b a) (setq received-alist a) 'fake-window)))
(cj/--term-toggle-display-saved 'fake-buf nil))
(should (eq (cdr (assq 'direction received-alist)) 'bottom))
- (should (equal (cdr (assq 'window-height received-alist))
- '(body-lines . 12)))
+ (should (equal (cdr (assq 'window-height received-alist)) 12))
(should-not (assq 'window-width received-alist))))
(ert-deftest test-term-toggle--display-saved-strips-conflicting-alist-entries ()
@@ -83,5 +86,29 @@
received-alist)))
(should (null wh-cells)))))
+(ert-deftest test-term-toggle--default-size-pairs-width-with-right ()
+ "Normal: the default size for `right' is the width fraction."
+ (let ((cj/term-toggle-window-width 0.5)
+ (cj/term-toggle-window-height 0.7))
+ (should (= (cj/--term-toggle-default-size 'right) 0.5))))
+
+(ert-deftest test-term-toggle--default-size-pairs-height-with-below ()
+ "Normal: the default size for `below' is the height fraction."
+ (let ((cj/term-toggle-window-width 0.5)
+ (cj/term-toggle-window-height 0.7))
+ (should (= (cj/--term-toggle-default-size 'below) 0.7))))
+
+(ert-deftest test-term-toggle--default-direction-delegates-to-dock-rule ()
+ "Normal: default-direction passes the width fraction to the dock rule."
+ (let ((cj/term-toggle-window-width 0.5)
+ captured)
+ (cl-letf (((symbol-function 'cj/preferred-dock-direction)
+ (lambda (cols frac &rest _)
+ (setq captured (list cols frac))
+ 'right)))
+ (should (eq (cj/--term-toggle-default-direction) 'right))
+ (should (= (nth 1 captured) 0.5))
+ (should (integerp (nth 0 captured))))))
+
(provide 'test-term-toggle--display)
;;; test-term-toggle--display.el ends here
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-buffer-status-colors.el b/tests/test-ui-buffer-status-colors.el
deleted file mode 100644
index 06e466b85..000000000
--- a/tests/test-ui-buffer-status-colors.el
+++ /dev/null
@@ -1,98 +0,0 @@
-;;; test-ui-buffer-status-colors.el --- Tests for buffer-status faces -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; The buffer-status state classifier (`cj/buffer-status-state'), the state->face
-;; map (`cj/buffer-status-faces'), and the resolver (`cj/buffer-status-color')
-;; drive both the cursor color and the modeline buffer-name color, kept in sync.
-;; Theme faces (error / warning / success) replace the old hard-coded hexes so
-;; the colors follow whatever theme is loaded.
-
-;;; Code:
-
-(require 'ert)
-(require 'user-constants)
-(require 'ui-config)
-(require 'modeline-config)
-
-;;; State -> face map
-
-(ert-deftest test-buffer-status-faces-has-all-states ()
- "Normal: every buffer state is mapped to a face."
- (dolist (state '(read-only overwrite modified unmodified))
- (should (alist-get state cj/buffer-status-faces))))
-
-(ert-deftest test-buffer-status-faces-values-are-real-faces ()
- "Normal: every mapped value is an existing face."
- (dolist (entry cj/buffer-status-faces)
- (should (facep (cdr entry)))))
-
-(ert-deftest test-buffer-status-faces-mapping ()
- "Normal: read-only->error, overwrite/modified->warning, unmodified->success."
- (should (eq (alist-get 'read-only cj/buffer-status-faces) 'error))
- (should (eq (alist-get 'overwrite cj/buffer-status-faces) 'warning))
- (should (eq (alist-get 'modified cj/buffer-status-faces) 'warning))
- (should (eq (alist-get 'unmodified cj/buffer-status-faces) 'success)))
-
-;;; State classifier (the shared function, exercised directly)
-
-(ert-deftest test-buffer-status-state-read-only ()
- "Normal: a read-only buffer reports `read-only'."
- (with-temp-buffer
- (setq buffer-read-only t)
- (should (eq (cj/buffer-status-state) 'read-only))))
-
-(ert-deftest test-buffer-status-state-overwrite-wins-over-modified ()
- "Boundary: overwrite-mode takes priority over the modified state."
- (with-temp-buffer
- (insert "x")
- (overwrite-mode 1)
- (should (eq (cj/buffer-status-state) 'overwrite))))
-
-(ert-deftest test-buffer-status-state-modified ()
- "Normal: a writeable buffer with unsaved changes reports `modified'."
- (with-temp-buffer
- (insert "x")
- (should (eq (cj/buffer-status-state) 'modified))))
-
-(ert-deftest test-buffer-status-state-unmodified ()
- "Normal: a clean writeable buffer reports `unmodified'."
- (with-temp-buffer
- (set-buffer-modified-p nil)
- (should (eq (cj/buffer-status-state) 'unmodified))))
-
-(ert-deftest test-buffer-status-state-read-only-wins-over-modified ()
- "Boundary: read-only takes priority over modified."
- (with-temp-buffer
- (insert "x")
- (set-buffer-modified-p t)
- (setq buffer-read-only t)
- (should (eq (cj/buffer-status-state) 'read-only))))
-
-;;; Resolver
-
-(ert-deftest test-buffer-status-color-resolves-through-the-face ()
- "Normal: the color is the mapped face's foreground."
- (let ((orig (face-attribute 'error :foreground nil t)))
- (unwind-protect
- (progn
- (set-face-foreground 'error "#abcdef")
- (should (equal (cj/buffer-status-color 'read-only) "#abcdef")))
- (when (stringp orig) (set-face-foreground 'error orig)))))
-
-(ert-deftest test-buffer-status-color-nil-for-unknown-state ()
- "Error: an unknown state has no face, so no color."
- (should-not (cj/buffer-status-color 'nonexistent)))
-
-;;; Modeline integration
-
-(ert-deftest test-modeline-buffer-name-variable-exists ()
- "Normal: the modeline buffer-name construct is defined."
- (should (boundp 'cj/modeline-buffer-name)))
-
-(ert-deftest test-modeline-buffer-name-is-mode-line-construct ()
- "Normal: it is an :eval mode-line construct."
- (should (listp cj/modeline-buffer-name))
- (should (eq (car cj/modeline-buffer-name) :eval)))
-
-(provide 'test-ui-buffer-status-colors)
-;;; test-ui-buffer-status-colors.el ends here
diff --git a/tests/test-ui-config--buffer-cursor-state.el b/tests/test-ui-config--buffer-cursor-state.el
deleted file mode 100644
index 99cfc4b9d..000000000
--- a/tests/test-ui-config--buffer-cursor-state.el
+++ /dev/null
@@ -1,74 +0,0 @@
-;;; test-ui-config--buffer-cursor-state.el --- Tests for cursor-state classification -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; `cj/buffer-status-state' picks the buffer-state symbol the modeline
-;; buffer-name indicator maps to a face via `cj/buffer-status-color'. The
-;; subtle case: a live ghostel terminal is
-;; technically `buffer-read-only' but the user types into it -- keystrokes go
-;; to the terminal process -- so it must report a writeable state, not
-;; `read-only'. ghostel's `copy' / `emacs' input modes are the exception:
-;; there the buffer really is a read-only Emacs buffer the user navigates, so
-;; `read-only' (the orange cursor) is correct and kept.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-
-(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)
-(defvar ghostel--input-mode nil)
-(require 'ui-config)
-(require 'testutil-ghostel-buffers)
-
-(ert-deftest test-ui-config-buffer-cursor-state-readwrite-unmodified ()
- "Normal: a clean writeable buffer reports `unmodified'."
- (with-temp-buffer
- (set-buffer-modified-p nil)
- (should (eq (cj/buffer-status-state) 'unmodified))))
-
-(ert-deftest test-ui-config-buffer-cursor-state-readwrite-modified ()
- "Normal: a writeable buffer with unsaved changes reports `modified'."
- (with-temp-buffer
- (insert "x")
- (should (eq (cj/buffer-status-state) 'modified))))
-
-(ert-deftest test-ui-config-buffer-cursor-state-read-only ()
- "Normal: a plain read-only buffer reports `read-only'."
- (with-temp-buffer
- (setq buffer-read-only t)
- (should (eq (cj/buffer-status-state) 'read-only))))
-
-(ert-deftest test-ui-config-buffer-cursor-state-overwrite ()
- "Boundary: `overwrite-mode' wins over the modified/unmodified split."
- (with-temp-buffer
- (insert "x")
- (overwrite-mode 1)
- (should (eq (cj/buffer-status-state) 'overwrite))))
-
-(ert-deftest test-ui-config-buffer-cursor-state-live-ghostel-is-writeable ()
- "Boundary: a live ghostel buffer is `buffer-read-only' but reports a
-writeable state -- the user types into the terminal process there, so the
-read-only (orange) cursor would be misleading."
- (let ((buf (cj/test--make-fake-ghostel-buffer "*test-ghostel-cursor-state*")))
- (unwind-protect
- (with-current-buffer buf
- (setq buffer-read-only t) ; ghostel keeps the buffer read-only
- (setq-local ghostel--input-mode 'semi-char)
- (should-not (eq (cj/buffer-status-state) 'read-only)))
- (when (buffer-live-p buf) (kill-buffer buf)))))
-
-(ert-deftest test-ui-config-buffer-cursor-state-ghostel-copy-mode-is-read-only ()
- "Boundary: in ghostel `copy' mode the buffer is a read-only Emacs buffer
-the user navigates, so `read-only' (orange) is kept."
- (let ((buf (cj/test--make-fake-ghostel-buffer "*test-ghostel-cursor-state-copy*")))
- (unwind-protect
- (with-current-buffer buf
- (setq buffer-read-only t)
- (setq-local ghostel--input-mode 'copy)
- (should (eq (cj/buffer-status-state) 'read-only)))
- (when (buffer-live-p buf) (kill-buffer buf)))))
-
-(provide 'test-ui-config--buffer-cursor-state)
-;;; test-ui-config--buffer-cursor-state.el ends here
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-dashboard.el b/tests/test-ui-navigation--split-dashboard.el
index b815a4c59..407335f80 100644
--- a/tests/test-ui-navigation--split-dashboard.el
+++ b/tests/test-ui-navigation--split-dashboard.el
@@ -54,6 +54,27 @@
(should (eq (car captured) #'split-window-right))
(should (eq (cadr captured) 'dashboard))))
+(ert-deftest test-ui-navigation-split-from-dashboard-p ()
+ "Normal/Boundary: only the dashboard buffer routes the companion to *scratch*."
+ (should (cj/--split-from-dashboard-p "*dashboard*"))
+ (should-not (cj/--split-from-dashboard-p "todo.org"))
+ (should-not (cj/--split-from-dashboard-p "*scratch*")))
+
+(ert-deftest test-ui-navigation-split-companion-scratch-from-dashboard ()
+ "Normal: splitting from the dashboard yields the *scratch* buffer, not the
+dashboard again."
+ (cl-letf (((symbol-function 'cj/--split-from-dashboard-p) (lambda (_) t))
+ ((symbol-function 'get-scratch-buffer-create) (lambda () 'scratch))
+ ((symbol-function 'cj/--dashboard-buffer) (lambda () 'dashboard)))
+ (should (eq (cj/--split-companion-buffer) 'scratch))))
+
+(ert-deftest test-ui-navigation-split-companion-dashboard-otherwise ()
+ "Normal: splitting from any other buffer yields the dashboard."
+ (cl-letf (((symbol-function 'cj/--split-from-dashboard-p) (lambda (_) nil))
+ ((symbol-function 'get-scratch-buffer-create) (lambda () 'scratch))
+ ((symbol-function 'cj/--dashboard-buffer) (lambda () 'dashboard)))
+ (should (eq (cj/--split-companion-buffer) 'dashboard))))
+
(ert-deftest test-ui-navigation-dashboard-buffer-returns-existing ()
"Boundary: cj/--dashboard-buffer returns an existing *dashboard* without opening."
(let ((db (get-buffer-create "*dashboard*"))
diff --git a/tests/test-ui-navigation--window-resize.el b/tests/test-ui-navigation--window-resize.el
index 3be0313b8..553219755 100644
--- a/tests/test-ui-navigation--window-resize.el
+++ b/tests/test-ui-navigation--window-resize.el
@@ -24,8 +24,11 @@
(should (eq (keymap-lookup cj/window-resize-map "<down>") #'windsize-down)))
(ert-deftest test-ui-navigation-window-resize-sticky-dispatches-and-arms ()
- "Normal: `cj/window-resize-sticky' runs the `windsize' command matching the
-arrow key that triggered it, then arms the sticky-repeat map."
+ "Normal: with more than one window, `cj/window-resize-sticky' runs the
+`windsize' command matching the arrow key that triggered it, then arms the
+sticky-repeat map. `one-window-p' is forced nil so the resize path is taken
+deterministically -- in `--batch' the sole frame is one-window-p, which would
+otherwise route to the pull-away path."
(dolist (case '((left . windsize-left)
(right . windsize-right)
(up . windsize-up)
@@ -33,13 +36,45 @@ arrow key that triggered it, then arms the sticky-repeat map."
(let ((ran nil)
(overriding-terminal-local-map nil)
(pre-command-hook nil))
- (cl-letf (((symbol-function (cdr case))
+ (cl-letf (((symbol-function 'one-window-p) (lambda (&rest _) nil))
+ ((symbol-function (cdr case))
(lambda (&rest _) (interactive) (setq ran t))))
(let ((last-command-event (car case)))
(cj/window-resize-sticky)))
(should ran) ; dispatched to the right command
(should overriding-terminal-local-map)))) ; loop armed
+(ert-deftest test-ui-navigation-window-pull-side ()
+ "Normal/Error: each arrow maps to the *opposite* side (where the revealed
+window opens, so the current window keeps the arrow's edge); anything else
+is nil."
+ (should (eq (cj/window-pull-side "<down>") 'above))
+ (should (eq (cj/window-pull-side "<up>") 'below))
+ (should (eq (cj/window-pull-side "<left>") 'right))
+ (should (eq (cj/window-pull-side "<right>") 'left))
+ (should (null (cj/window-pull-side "<prior>")))
+ (should (null (cj/window-pull-side "x"))))
+
+(ert-deftest test-ui-navigation-window-resize-sticky-sole-window-pulls-away ()
+ "Normal: with a single window, the arrow pulls a sliver away on the side
+opposite the arrow (via `cj/window--pull-away') rather than resizing, then
+arms the loop. `cj/window--pull-away' is stubbed to capture the side so no
+real window split happens under `--batch'."
+ (dolist (case '((down . above)
+ (up . below)
+ (left . right)
+ (right . left)))
+ (let ((pulled nil)
+ (overriding-terminal-local-map nil)
+ (pre-command-hook nil))
+ (cl-letf (((symbol-function 'one-window-p) (lambda (&rest _) t))
+ ((symbol-function 'cj/window--pull-away)
+ (lambda (dir) (setq pulled dir))))
+ (let ((last-command-event (car case)))
+ (cj/window-resize-sticky)))
+ (should (eq pulled (cdr case))) ; pulled toward the arrow
+ (should overriding-terminal-local-map)))) ; loop armed
+
(ert-deftest test-ui-navigation-window-resize-bound-under-c-semicolon-b ()
"Normal: `C-; b <arrow>' (each direction) reaches the sticky-resize command."
(require 'custom-buffer-file)
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-ui-theme-commands.el b/tests/test-ui-theme-commands.el
index 4e3ce7f28..1b273cf57 100644
--- a/tests/test-ui-theme-commands.el
+++ b/tests/test-ui-theme-commands.el
@@ -7,7 +7,6 @@
;; cj/switch-themes
;; cj/save-theme-to-file
;; cj/get-active-theme-name
-;; cj/load-fallback-theme
;;; Code:
@@ -68,23 +67,6 @@ does not raise."
(cj/save-theme-to-file))
(should (string-match-p "Cannot save theme" messaged))))
-;;; cj/load-fallback-theme
-
-(ert-deftest test-ui-theme-load-fallback-disables-then-loads ()
- "Normal: load-fallback-theme disables all then loads the fallback."
- (let ((fallback-theme-name "modus-vivendi")
- (custom-enabled-themes '(old-one old-two))
- disabled loaded)
- (cl-letf (((symbol-function 'disable-theme)
- (lambda (theme) (push theme disabled)))
- ((symbol-function 'load-theme)
- (lambda (theme &optional _no-confirm _no-enable)
- (push theme loaded)))
- ((symbol-function 'message) #'ignore))
- (cj/load-fallback-theme "boom"))
- (should (equal (sort (copy-sequence disabled) #'string<) '(old-one old-two)))
- (should (equal loaded '(modus-vivendi)))))
-
;;; cj/switch-themes
(ert-deftest test-ui-theme-switch-disables-loads-then-saves ()
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-user-constants.el b/tests/test-user-constants.el
index 8dd9284ff..0c12eecf4 100644
--- a/tests/test-user-constants.el
+++ b/tests/test-user-constants.el
@@ -120,5 +120,48 @@ The whole point of the split — a bare require must not touch the filesystem."
(should (eq (nth 1 warn-args) :error)))
(delete-directory dir t))))
+;;; verify-or-create no-op branches (target already present)
+
+(ert-deftest test-user-constants-verify-dir-existing-is-noop ()
+ "Boundary: an existing directory is a no-op — make-directory is not called."
+ (test-user-constants--load)
+ (let ((dir (make-temp-file "uc-exdir-" t)))
+ (unwind-protect
+ (cl-letf (((symbol-function 'make-directory)
+ (lambda (&rest _) (error "should not create an existing dir"))))
+ (cj/verify-or-create-dir dir) ; must not error
+ (should (file-directory-p dir)))
+ (delete-directory dir t))))
+
+(ert-deftest test-user-constants-verify-file-existing-is-noop ()
+ "Boundary: an existing file is left untouched — write-region is not called."
+ (test-user-constants--load)
+ (let* ((dir (make-temp-file "uc-exfile-" t))
+ (file (expand-file-name "keep.org" dir)))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert "original"))
+ (cl-letf (((symbol-function 'write-region)
+ (lambda (&rest _) (error "should not overwrite an existing file"))))
+ (cj/verify-or-create-file file)
+ (should (equal (with-temp-buffer
+ (insert-file-contents file) (buffer-string))
+ "original"))))
+ (delete-directory dir t))))
+
+(ert-deftest test-user-constants-verify-file-optional-failure-logs ()
+ "Error: an optional file failure is logged, never warned or signalled."
+ (test-user-constants--load)
+ (let ((dir (make-temp-file "uc-optfile-" t))
+ (warned nil) (messaged nil))
+ (unwind-protect
+ (cl-letf (((symbol-function 'write-region) (lambda (&rest _) (error "boom")))
+ ((symbol-function 'display-warning) (lambda (&rest _) (setq warned t)))
+ ((symbol-function 'message) (lambda (&rest _) (setq messaged t))))
+ (cj/verify-or-create-file (expand-file-name "optional.org" dir))
+ (should messaged)
+ (should-not warned))
+ (delete-directory dir t))))
+
(provide 'test-user-constants)
;;; test-user-constants.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.