aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/run-coverage-file.el2
-rw-r--r--tests/test-ai-config--apply-model-selection.el45
-rw-r--r--tests/test-ai-config-commands.el4
-rw-r--r--tests/test-ai-config-gptel-commands.el9
-rw-r--r--tests/test-ai-config-model-to-symbol.el61
-rw-r--r--tests/test-ai-term--capture-state.el6
-rw-r--r--tests/test-ai-term--close.el31
-rw-r--r--tests/test-ai-term--default-geometry.el66
-rw-r--r--tests/test-ai-term--display-rule.el2
-rw-r--r--tests/test-ai-term--display-saved.el15
-rw-r--r--tests/test-ai-term--f9-in-term.el18
-rw-r--r--tests/test-ai-term--next-agent-buffer.el73
-rw-r--r--tests/test-ai-term--reuse-edge-window.el55
-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.el532
-rw-r--r--tests/test-calendar-sync--apply-single-exception.el42
-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.el17
-rw-r--r--tests/test-calibredb-epub-config--bookmark-name.el87
-rw-r--r--tests/test-calibredb-epub-config--menu.el52
-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-cache-lib.el2
-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-keymap-bindings.el30
-rw-r--r--tests/test-custom-buffer-file-print-diff-eww.el14
-rw-r--r--tests/test-custom-comments-comment-heavy-box.el8
-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-font-lock.el35
-rw-r--r--tests/test-dashboard-config-launchers.el26
-rw-r--r--tests/test-dashboard-config-recentf-exclude.el33
-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-dired-line-directory.el56
-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-mark-all-visible.el68
-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.el23
-rw-r--r--tests/test-dirvish-config-wrappers.el2
-rw-r--r--tests/test-dupre-theme.el227
-rw-r--r--tests/test-dwim-shell-config-command-fixes.el88
-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.el48
-rw-r--r--tests/test-face-diagnostic.el332
-rw-r--r--tests/test-flyspell-and-abbrev.el4
-rw-r--r--tests/test-font-config--frame-lifecycle.el75
-rw-r--r--tests/test-gptel-tools-web-fetch.el6
-rw-r--r--tests/test-help-config.el32
-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.el4
-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-local-repository--car-member.el58
-rw-r--r--tests/test-mail-config--account-search-queries.el53
-rw-r--r--tests/test-mail-config-refile-folder.el40
-rw-r--r--tests/test-mail-config-transport.el2
-rw-r--r--tests/test-markdown-config.el10
-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-flycheck-segment.el2
-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-modeline-config-vc-cache-key.el60
-rw-r--r--tests/test-modeline-config-vc-cache.el7
-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-org-agenda-config--base-files.el36
-rw-r--r--tests/test-org-agenda-config-commands.el18
-rw-r--r--tests/test-org-agenda-config-skip-functions.el81
-rw-r--r--tests/test-org-capture-config--find-or-create-top-heading.el45
-rw-r--r--tests/test-org-capture-config-popup-window.el195
-rw-r--r--tests/test-org-capture-config-project-target.el174
-rw-r--r--tests/test-org-config-table-header.el115
-rw-r--r--tests/test-org-drill-config-commands.el51
-rw-r--r--tests/test-org-drill-config.el4
-rw-r--r--tests/test-org-faces-config.el54
-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-reveal-config-header-template.el4
-rw-r--r--tests/test-org-roam-config-dailies-head.el29
-rw-r--r--tests/test-org-webclipper-commands.el6
-rw-r--r--tests/test-prog-c-mode-settings.el12
-rw-r--r--tests/test-prog-general--deadgrep.el44
-rw-r--r--tests/test-prog-general--electric-pair-angle.el54
-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.el18
-rw-r--r--tests/test-prog-json--json-format-buffer.el6
-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-reconcile--find-git-repos.el9
-rw-r--r--tests/test-selection-framework--consult-line-or-repeat.el6
-rw-r--r--tests/test-show-kill-ring--insert-item.el73
-rw-r--r--tests/test-signal-config-notify.el150
-rw-r--r--tests/test-signel-notify-function.el89
-rw-r--r--tests/test-slack-config-close-all.el32
-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.el27
-rw-r--r--tests/test-system-lib--format-region-with-program.el68
-rw-r--r--tests/test-system-lib-confirm-strong.el37
-rw-r--r--tests/test-system-lib-font-lock-global-modes.el46
-rw-r--r--tests/test-term-config--f8-in-term.el42
-rw-r--r--tests/test-term-tmux-history.el33
-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-ui-buffer-status-colors.el221
-rw-r--r--tests/test-ui-config--buffer-cursor-state.el96
-rw-r--r--tests/test-ui-config-transparency-and-cursor.el12
-rw-r--r--tests/test-ui-cursor-color-integration.el175
-rw-r--r--tests/test-ui-navigation--split-dashboard.el90
-rw-r--r--tests/test-ui-navigation--window-resize.el41
-rw-r--r--tests/test-ui-navigation-split-follow-undo-kill.el58
-rw-r--r--tests/test-ui-theme-commands.el30
-rw-r--r--tests/test-ui-theme-persistence.el4
-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
163 files changed, 5797 insertions, 1319 deletions
diff --git a/tests/run-coverage-file.el b/tests/run-coverage-file.el
index 6ac65300b..0d96f1918 100644
--- a/tests/run-coverage-file.el
+++ b/tests/run-coverage-file.el
@@ -9,7 +9,7 @@
;; Per-file isolation matches the project's `make test-unit' pattern:
;; each test file runs in its own Emacs process, so tests that work
;; under `make test' will also work under `make coverage'. See
-;; docs/design/coverage.org for the rationale.
+;; docs/specs/coverage-spec-implemented.org for the rationale.
;;; Code:
diff --git a/tests/test-ai-config--apply-model-selection.el b/tests/test-ai-config--apply-model-selection.el
new file mode 100644
index 000000000..4ccd6d7a0
--- /dev/null
+++ b/tests/test-ai-config--apply-model-selection.el
@@ -0,0 +1,45 @@
+;;; test-ai-config--apply-model-selection.el --- Tests for cj/--gptel-apply-model-selection -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/--gptel-apply-model-selection is the apply step extracted from the
+;; interactive cj/gptel-change-model: it sets gptel-backend/gptel-model globally
+;; or buffer-locally and returns the confirmation message. The extraction also
+;; dropped a dead `(if (stringp model) ...)' branch (model is always a symbol by
+;; that point).
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-config)
+
+(defvar gptel-backend)
+(defvar gptel-model)
+
+(ert-deftest test-ai-config-apply-model-global-sets-globals ()
+ "Normal: global scope assigns the global vars and reports (global)."
+ (let ((gptel-backend nil) (gptel-model nil))
+ (let ((msg (cj/--gptel-apply-model-selection "global" 'mybackend 'mymodel "MyAI")))
+ (should (eq gptel-backend 'mybackend))
+ (should (eq gptel-model 'mymodel))
+ (should (string-match-p "MyAI" msg))
+ (should (string-match-p "mymodel" msg))
+ (should (string-match-p "global" msg)))))
+
+(ert-deftest test-ai-config-apply-model-buffer-sets-buffer-locals ()
+ "Normal: buffer scope makes the vars buffer-local and reports (buffer-local)."
+ (let ((gptel-backend 'orig) (gptel-model 'origm))
+ (with-temp-buffer
+ (let ((msg (cj/--gptel-apply-model-selection "buffer" 'be 'mo "Name")))
+ (should (local-variable-p 'gptel-backend))
+ (should (local-variable-p 'gptel-model))
+ (should (eq gptel-backend 'be))
+ (should (eq gptel-model 'mo))
+ (should (string-match-p "buffer-local" msg))))
+ ;; outside the temp buffer the globals are untouched
+ (should (eq gptel-backend 'orig))
+ (should (eq gptel-model 'origm))))
+
+(provide 'test-ai-config--apply-model-selection)
+;;; test-ai-config--apply-model-selection.el ends here
diff --git a/tests/test-ai-config-commands.el b/tests/test-ai-config-commands.el
index 8da2e4b01..fed06d82b 100644
--- a/tests/test-ai-config-commands.el
+++ b/tests/test-ai-config-commands.el
@@ -86,7 +86,7 @@ globally and reports via `message'."
added)
(unwind-protect
(cl-letf (((symbol-function 'featurep)
- (lambda (sym) (not (eq sym 'projectile))))
+ (lambda (sym &rest _) (not (eq sym 'projectile))))
((symbol-function 'read-file-name)
(lambda (&rest _) target))
((symbol-function 'gptel-add-file)
@@ -133,7 +133,7 @@ globally and reports via `message'."
(cl-letf (((symbol-function 'gptel-context-remove-all)
(lambda () (setq called t)))
((symbol-function 'call-interactively)
- (lambda (fn) (funcall fn)))
+ (lambda (fn &rest _) (funcall fn)))
((symbol-function 'message)
(lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
(cj/gptel-context-clear))
diff --git a/tests/test-ai-config-gptel-commands.el b/tests/test-ai-config-gptel-commands.el
index b87c4975e..cab23572e 100644
--- a/tests/test-ai-config-gptel-commands.el
+++ b/tests/test-ai-config-gptel-commands.el
@@ -77,7 +77,10 @@
(lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
(cj/gptel-switch-backend))
(should (eq gptel-backend 'anthropic-backend))
- (should (equal gptel-model "claude-opus"))
+ ;; 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 ()
@@ -125,7 +128,7 @@
(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))
+ (cl-letf (((symbol-function 'featurep) (lambda (_ &rest _) t))
((symbol-function 'gptel-add)
(lambda (a) (setq arg a)))
((symbol-function 'message) #'ignore))
@@ -141,7 +144,7 @@
(deleted nil))
(unwind-protect
(cl-letf (((symbol-function 'get-buffer-window)
- (lambda (_b) 'fake-window))
+ (lambda (_b &rest _) 'fake-window))
((symbol-function 'delete-window)
(lambda (w) (setq deleted w))))
(cj/toggle-gptel))
diff --git a/tests/test-ai-config-model-to-symbol.el b/tests/test-ai-config-model-to-symbol.el
new file mode 100644
index 000000000..de6f18ff8
--- /dev/null
+++ b/tests/test-ai-config-model-to-symbol.el
@@ -0,0 +1,61 @@
+;;; 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-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--close.el b/tests/test-ai-term--close.el
index 654e85f01..4098c091e 100644
--- a/tests/test-ai-term--close.el
+++ b/tests/test-ai-term--close.el
@@ -13,7 +13,9 @@
(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))
(require 'ai-term)
+(require 'testutil-ghostel-buffers)
(ert-deftest test-ai-term--kill-tmux-session-runs-kill-session ()
"Normal: invokes `tmux kill-session -t <session>'."
@@ -58,6 +60,35 @@
(should (buffer-live-p buf)))
(when (buffer-live-p buf) (kill-buffer buf)))))
+(ert-deftest test-ai-term--close-buffer-keeps-window-split ()
+ "Regression: closing an agent in a split keeps its window in the layout,
+showing a non-agent buffer, instead of deleting the split. Craig's M-F9
+annoyance -- a close must not tear down the window arrangement (the F9 hide
+toggle is what collapses the split; close should not)."
+ (cj/test--kill-agent-buffers)
+ (let ((work (get-buffer-create "*test-close-keep-work*"))
+ (agent (get-buffer-create "agent [close-keep]")))
+ (with-current-buffer agent (setq-local default-directory "/tmp/close-keep/"))
+ (unwind-protect
+ (save-window-excursion
+ (delete-other-windows)
+ (set-window-buffer (selected-window) work)
+ (let ((agent-win (split-window (selected-window) nil 'below)))
+ (set-window-buffer agent-win agent)
+ (should-not (one-window-p))
+ (cl-letf (((symbol-function 'cj/--ai-term-kill-tmux-session)
+ (lambda (_s) 0)))
+ (cj/--ai-term-close-buffer agent))
+ ;; The window survives the close ...
+ (should (window-live-p agent-win))
+ (should-not (one-window-p))
+ ;; ... now showing a non-agent buffer ...
+ (should-not (cj/--ai-term-buffer-p (window-buffer agent-win)))
+ ;; ... and the agent buffer itself is gone.
+ (should-not (buffer-live-p agent))))
+ (when (get-buffer "*test-close-keep-work*") (kill-buffer "*test-close-keep-work*"))
+ (cj/test--kill-agent-buffers))))
+
(ert-deftest test-ai-term--close-target-current-agent-buffer ()
"Normal: returns the current buffer when it is an agent buffer."
(let ((buf (get-buffer-create "agent [cur]")))
diff --git a/tests/test-ai-term--default-geometry.el b/tests/test-ai-term--default-geometry.el
index 833f2ef4c..1180c1979 100644
--- a/tests/test-ai-term--default-geometry.el
+++ b/tests/test-ai-term--default-geometry.el
@@ -1,15 +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 host-aware: a laptop opens the
-;; agent from the bottom (75% height), a desktop opens it from the right
-;; (50% width). `cj/--ai-term-default-direction' and
-;; `cj/--ai-term-default-size' encapsulate the `env-laptop-p' branch;
-;; 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'.
;;
-;; `env-laptop-p' is stubbed per-test so the assertions are deterministic
-;; regardless of the host the suite runs on.
+;; 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:
@@ -19,37 +24,48 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'ai-term)
-(ert-deftest test-ai-term--default-direction-laptop ()
- "Normal: on a laptop the default direction is `below'."
- (cl-letf (((symbol-function 'env-laptop-p) (lambda () t)))
- (should (eq (cj/--ai-term-default-direction) 'below))))
+(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--default-direction-desktop ()
- "Normal: on a desktop the default direction is `right'."
- (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+(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-laptop ()
- "Normal: on a laptop the default size is `cj/ai-term-laptop-height'."
+(ert-deftest test-ai-term--default-size-pairs-width-with-right ()
+ "Normal: when the direction is `right' the size is the width fraction."
(let ((cj/ai-term-laptop-height 0.75)
(cj/ai-term-desktop-width 0.5))
- (cl-letf (((symbol-function 'env-laptop-p) (lambda () t)))
- (should (= (cj/--ai-term-default-size) 0.75)))))
+ (cl-letf (((symbol-function 'cj/--ai-term-default-direction) (lambda (&rest _) 'right)))
+ (should (= (cj/--ai-term-default-size) 0.5)))))
-(ert-deftest test-ai-term--default-size-desktop ()
- "Normal: on a desktop the default size is `cj/ai-term-desktop-width'."
+(ert-deftest test-ai-term--default-size-pairs-height-with-below ()
+ "Normal: when the direction is `below' the size is the height fraction."
(let ((cj/ai-term-laptop-height 0.75)
(cj/ai-term-desktop-width 0.5))
- (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
- (should (= (cj/--ai-term-default-size) 0.5)))))
+ (cl-letf (((symbol-function 'cj/--ai-term-default-direction) (lambda (&rest _) 'below)))
+ (should (= (cj/--ai-term-default-size) 0.75)))))
(ert-deftest test-ai-term--default-size-respects-custom-values ()
"Boundary: the helper returns the customized values, not the literals."
(let ((cj/ai-term-laptop-height 0.6)
(cj/ai-term-desktop-width 0.33))
- (cl-letf (((symbol-function 'env-laptop-p) (lambda () t)))
+ (cl-letf (((symbol-function 'cj/--ai-term-default-direction) (lambda (&rest _) 'below)))
(should (= (cj/--ai-term-default-size) 0.6)))
- (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (cl-letf (((symbol-function 'cj/--ai-term-default-direction) (lambda (&rest _) 'right)))
(should (= (cj/--ai-term-default-size) 0.33)))))
(provide 'test-ai-term--default-geometry)
diff --git a/tests/test-ai-term--display-rule.el b/tests/test-ai-term--display-rule.el
index 906a47680..4a148a5c0 100644
--- a/tests/test-ai-term--display-rule.el
+++ b/tests/test-ai-term--display-rule.el
@@ -38,7 +38,7 @@ desktop branch; on a laptop the agent would land below instead."
(let ((name "agent [display-rule-test]"))
(test-ai-term--cleanup name)
(unwind-protect
- (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (cl-letf (((symbol-function 'cj/--ai-term-default-direction) (lambda (&rest _) 'right)))
(test-ai-term--with-clean-frame
(let* ((buf (get-buffer-create name))
(win (display-buffer buf)))
diff --git a/tests/test-ai-term--display-saved.el b/tests/test-ai-term--display-saved.el
index 8b689aa6b..51c22fde9 100644
--- a/tests/test-ai-term--display-saved.el
+++ b/tests/test-ai-term--display-saved.el
@@ -32,13 +32,12 @@
"Normal: nil state on a desktop -> rightmost, size=cj/ai-term-desktop-width.
The cardinal `right' default maps to the frame-edge variant
`rightmost' so agent lands at the frame's right edge regardless of
-which window is selected. `env-laptop-p' is stubbed nil to pin the
-desktop branch."
+which window is selected. The default direction is stubbed `right'."
(let (received-buf received-alist
(cj/--ai-term-last-direction nil)
(cj/--ai-term-last-size nil)
(cj/ai-term-desktop-width 0.5))
- (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))
+ (cl-letf (((symbol-function 'cj/--ai-term-default-direction) (lambda (&rest _) 'right))
((symbol-function 'display-buffer-in-direction)
(lambda (b a)
(setq received-buf b received-alist a)
@@ -49,16 +48,16 @@ desktop branch."
(should (= (cdr (assq 'window-width received-alist)) 0.5))
(should (eq (cdr (assq 'inhibit-same-window received-alist)) t))))
-(ert-deftest test-ai-term--display-saved-uses-laptop-defaults-when-state-nil ()
- "Normal: nil state on a laptop -> bottom, size=cj/ai-term-laptop-height.
+(ert-deftest test-ai-term--display-saved-uses-below-default-when-state-nil ()
+ "Normal: nil state with a `below' default -> bottom, size=cj/ai-term-laptop-height.
The cardinal `below' default maps to the frame-edge variant `bottom'
-and the size lands on the `window-height' axis. `env-laptop-p' is
-stubbed t to pin the laptop branch."
+and the size lands on the `window-height' axis. The default direction
+is stubbed `below' (the size helper follows it)."
(let (received-alist
(cj/--ai-term-last-direction nil)
(cj/--ai-term-last-size nil)
(cj/ai-term-laptop-height 0.75))
- (cl-letf (((symbol-function 'env-laptop-p) (lambda () t))
+ (cl-letf (((symbol-function 'cj/--ai-term-default-direction) (lambda (&rest _) 'below))
((symbol-function 'display-buffer-in-direction)
(lambda (_b a) (setq received-alist a) 'fake-window)))
(cj/--ai-term-display-saved 'fake-buf '((inhibit-same-window . t))))
diff --git a/tests/test-ai-term--f9-in-term.el b/tests/test-ai-term--f9-in-term.el
index dad11ffc0..0477f2517 100644
--- a/tests/test-ai-term--f9-in-term.el
+++ b/tests/test-ai-term--f9-in-term.el
@@ -26,27 +26,29 @@
(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'."
+ "Normal: the C-/s-/M- F9 variants are bound in `ghostel-mode-map' too.
+`s-<f9>' steps to the next agent; `M-<f9>' closes 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)))
+ (should (eq (keymap-lookup ghostel-mode-map "s-<f9>") #'cj/ai-term-next))
+ (should (eq (keymap-lookup ghostel-mode-map "M-<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'."
+agent; `s-<f9>' steps to the next agent; `M-<f9>' closes 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)))
+ (should (eq (lookup-key (current-global-map) (kbd "s-<f9>")) #'cj/ai-term-next))
+ (should (eq (lookup-key (current-global-map) (kbd "M-<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>"))
+ (dolist (key '("<f9>" "C-<f9>" "s-<f9>" "M-<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>")
diff --git a/tests/test-ai-term--next-agent-buffer.el b/tests/test-ai-term--next-agent-buffer.el
new file mode 100644
index 000000000..330714a92
--- /dev/null
+++ b/tests/test-ai-term--next-agent-buffer.el
@@ -0,0 +1,73 @@
+;;; test-ai-term--next-agent-buffer.el --- Tests for cj/--ai-term-next-agent-buffer -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The pure decision helper behind `cj/ai-term-next' (s-F9). Given the
+;; current agent buffer and the ordered list of live agent buffers, it
+;; returns the next buffer in the queue, wrapping after the last. A nil
+;; or non-member CURRENT returns the first; an empty list returns nil.
+;; No buffer or window side effects -- list logic only.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(ert-deftest test-ai-term--next-agent-buffer-advances-from-first ()
+ "Normal: current is the first element -> returns the second."
+ (let ((a (get-buffer-create "agent [a]"))
+ (b (get-buffer-create "agent [b]"))
+ (c (get-buffer-create "agent [c]")))
+ (unwind-protect
+ (should (eq b (cj/--ai-term-next-agent-buffer a (list a b c))))
+ (mapc #'kill-buffer (list a b c)))))
+
+(ert-deftest test-ai-term--next-agent-buffer-advances-from-middle ()
+ "Normal: current in the middle -> returns the following element."
+ (let ((a (get-buffer-create "agent [a]"))
+ (b (get-buffer-create "agent [b]"))
+ (c (get-buffer-create "agent [c]")))
+ (unwind-protect
+ (should (eq c (cj/--ai-term-next-agent-buffer b (list a b c))))
+ (mapc #'kill-buffer (list a b c)))))
+
+(ert-deftest test-ai-term--next-agent-buffer-wraps-after-last ()
+ "Boundary: current is the last element -> wraps to the first."
+ (let ((a (get-buffer-create "agent [a]"))
+ (b (get-buffer-create "agent [b]"))
+ (c (get-buffer-create "agent [c]")))
+ (unwind-protect
+ (should (eq a (cj/--ai-term-next-agent-buffer c (list a b c))))
+ (mapc #'kill-buffer (list a b c)))))
+
+(ert-deftest test-ai-term--next-agent-buffer-single-element-returns-itself ()
+ "Boundary: a one-agent queue wraps current back to itself."
+ (let ((a (get-buffer-create "agent [a]")))
+ (unwind-protect
+ (should (eq a (cj/--ai-term-next-agent-buffer a (list a))))
+ (kill-buffer a))))
+
+(ert-deftest test-ai-term--next-agent-buffer-nil-current-returns-first ()
+ "Boundary: nil current (no agent displayed) -> returns the first."
+ (let ((a (get-buffer-create "agent [a]"))
+ (b (get-buffer-create "agent [b]")))
+ (unwind-protect
+ (should (eq a (cj/--ai-term-next-agent-buffer nil (list a b))))
+ (mapc #'kill-buffer (list a b)))))
+
+(ert-deftest test-ai-term--next-agent-buffer-non-member-current-returns-first ()
+ "Error: current not in the queue -> returns the first rather than nil."
+ (let ((a (get-buffer-create "agent [a]"))
+ (b (get-buffer-create "agent [b]"))
+ (stray (get-buffer-create "agent [stray]")))
+ (unwind-protect
+ (should (eq a (cj/--ai-term-next-agent-buffer stray (list a b))))
+ (mapc #'kill-buffer (list a b stray)))))
+
+(ert-deftest test-ai-term--next-agent-buffer-empty-queue-returns-nil ()
+ "Boundary: an empty queue returns nil (nothing to switch to)."
+ (should (null (cj/--ai-term-next-agent-buffer nil '()))))
+
+(provide 'test-ai-term--next-agent-buffer)
+;;; test-ai-term--next-agent-buffer.el ends here
diff --git a/tests/test-ai-term--reuse-edge-window.el b/tests/test-ai-term--reuse-edge-window.el
index c41aab73a..a9a0529e8 100644
--- a/tests/test-ai-term--reuse-edge-window.el
+++ b/tests/test-ai-term--reuse-edge-window.el
@@ -45,7 +45,7 @@ right half: the frame stays at two windows [left | agent]."
(unwind-protect
(save-window-excursion
(delete-other-windows)
- (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (cl-letf (((symbol-function 'cj/--ai-term-default-direction) (lambda (&rest _) 'right)))
(let ((left-buf (get-buffer-create left-name))
(right-buf (get-buffer-create right-name))
(agent-buf (get-buffer-create agent-name)))
@@ -77,7 +77,7 @@ bottom half: the frame stays at two windows."
(unwind-protect
(save-window-excursion
(delete-other-windows)
- (cl-letf (((symbol-function 'env-laptop-p) (lambda () t)))
+ (cl-letf (((symbol-function 'cj/--ai-term-default-direction) (lambda (&rest _) 'below)))
(let ((top-buf (get-buffer-create top-name))
(bottom-buf (get-buffer-create bottom-name))
(agent-buf (get-buffer-create agent-name)))
@@ -107,7 +107,7 @@ the frame goes from one window to two with the agent present."
(unwind-protect
(save-window-excursion
(delete-other-windows)
- (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (cl-letf (((symbol-function 'cj/--ai-term-default-direction) (lambda (&rest _) 'right)))
(let ((sole-buf (get-buffer-create sole-name))
(agent-buf (get-buffer-create agent-name)))
(set-window-buffer (selected-window) sole-buf)
@@ -133,7 +133,7 @@ ends up displayed."
(unwind-protect
(save-window-excursion
(delete-other-windows)
- (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (cl-letf (((symbol-function 'cj/--ai-term-default-direction) (lambda (&rest _) 'right)))
(let ((top-buf (get-buffer-create top-name))
(bottom-buf (get-buffer-create bottom-name))
(agent-buf (get-buffer-create agent-name)))
@@ -165,7 +165,7 @@ window rather than restoring the displaced buffer into a kept slot."
(unwind-protect
(save-window-excursion
(delete-other-windows)
- (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (cl-letf (((symbol-function 'cj/--ai-term-default-direction) (lambda (&rest _) 'right)))
(let ((left-buf (get-buffer-create left-name))
(right-buf (get-buffer-create right-name))
(agent-buf (get-buffer-create agent-name)))
@@ -202,7 +202,7 @@ preserved across the toggle (respect-split-width)."
(unwind-protect
(save-window-excursion
(delete-other-windows)
- (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (cl-letf (((symbol-function 'cj/--ai-term-default-direction) (lambda (&rest _) 'right)))
(let ((left-buf (get-buffer-create left-name))
(right-buf (get-buffer-create right-name))
(agent-buf (get-buffer-create agent-name))
@@ -246,7 +246,7 @@ most-recent agent, which would now be the other one."
(unwind-protect
(save-window-excursion
(delete-other-windows)
- (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (cl-letf (((symbol-function 'cj/--ai-term-default-direction) (lambda (&rest _) 'right)))
(let ((a1 (get-buffer-create a1-name))
(a2 (get-buffer-create a2-name))
(left-buf (get-buffer-create left-name))
@@ -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-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
new file mode 100644
index 000000000..8793da73a
--- /dev/null
+++ b/tests/test-build-theme.el
@@ -0,0 +1,532 @@
+;;; test-build-theme.el --- Tests for the theme.json -> deftheme converter -*- lexical-binding: t -*-
+
+;;; Commentary:
+
+;; ERT tests for scripts/theme-studio/build-theme.el, the converter that
+;; turns a theme.json exported by the theme-studio into a loadable Emacs
+;; deftheme file. This is the correctness-sensitive end of the pipeline, so
+;; it is covered Normal / Boundary / Error per category.
+
+;;; Code:
+
+(require 'ert)
+(require 'json)
+
+;; The converter lives under scripts/, not on the normal load-path. Add it at
+;; compile time too (the validate hook byte-compiles this file in isolation and
+;; only -L's the project, modules, tests, and themes dirs).
+(eval-and-compile
+ (add-to-list 'load-path
+ (expand-file-name
+ "../scripts/theme-studio"
+ (file-name-directory
+ (or load-file-name
+ (bound-and-true-p byte-compile-current-file)
+ buffer-file-name
+ default-directory)))))
+
+(require 'build-theme)
+
+;;; ---------------------------------------------------------------------------
+;;; Fixtures
+
+(defconst test-build-theme--fixture-json
+ "{
+ \"name\": \"dupre-fixture\",
+ \"palette\": [[\"#000000\",\"ground\"],[\"#7a9abe\",\"blue\"],[\"#84b068\",\"green\"]],
+ \"syntax\": {
+ \"bg\": {\"fg\":\"#000000\",\"bg\":null,\"bold\":false,\"italic\":false},
+ \"p\": {\"fg\":\"#cdced1\",\"bg\":null,\"bold\":false,\"italic\":false},
+ \"kw\": {\"fg\":\"#7a9abe\",\"bg\":null,\"bold\":true,\"italic\":false},
+ \"str\":{\"fg\":\"#84b068\",\"bg\":null,\"bold\":false,\"italic\":false},
+ \"cm\": {\"fg\":\"#838d97\",\"bg\":null,\"bold\":false,\"italic\":true},
+ \"dec\":{\"fg\":\"#e8bd30\",\"bg\":null,\"bold\":false,\"italic\":false}
+ },
+ \"ui\": {
+ \"region\": {\"fg\":null, \"bg\":\"#264364\"},
+ \"mode-line\": {\"fg\":\"#cdced1\", \"bg\":\"#2f343a\"}
+ },
+ \"packages\": {
+ \"org-mode\": {
+ \"org-level-1\": {\"fg\":\"#67809c\",\"bg\":null,\"bold\":true,\"italic\":false,\"inherit\":null,\"source\":\"default\"},
+ \"org-level-2\": {\"fg\":\"#e8bd30\",\"bg\":null,\"bold\":false,\"italic\":false,\"inherit\":\"org-level-1\",\"height\":1.2,\"source\":\"user\"},
+ \"org-tag\": {\"fg\":null,\"bg\":null,\"bold\":false,\"italic\":false,\"inherit\":null,\"source\":\"cleared\"}
+ }
+ }
+}"
+ "A self-contained theme.json exercising every tier: default, syntax (bold +
+italic + the unmappable dec key), UI, and packages (a plain face, an
+inherit+height face, and a cleared face). Uses the nested \"syntax\" format the
+converter reads -- each category is an object with fg/bg/bold/italic, and bg/p
+are themselves category objects carrying fg. Owned by the test so it can't
+drift the way Craig's downloaded exports under scripts/theme-studio/ can.")
+
+(defun test-build-theme--write-fixture (dir)
+ "Write the fixture JSON into DIR and return its path."
+ (let ((path (expand-file-name "dupre-fixture.json" dir)))
+ (with-temp-file path (insert test-build-theme--fixture-json))
+ path))
+
+(defmacro test-build-theme--with-sandbox (var &rest body)
+ "Bind VAR to a fresh temp directory, run BODY, then delete it."
+ (declare (indent 1))
+ `(let ((,var (make-temp-file "build-theme-test-" t)))
+ (unwind-protect (progn ,@body)
+ (delete-directory ,var t))))
+
+;; --- WCAG contrast helpers ---
+
+(defun test-build-theme--channel-luminance (c)
+ "Linearize an 8-bit channel value C (0-255) per the WCAG formula."
+ (let ((x (/ c 255.0)))
+ (if (<= x 0.03928) (/ x 12.92) (expt (/ (+ x 0.055) 1.055) 2.4))))
+
+(defun test-build-theme--relative-luminance (hex)
+ "WCAG relative luminance of HEX color \"#rrggbb\"."
+ (+ (* 0.2126 (test-build-theme--channel-luminance (string-to-number (substring hex 1 3) 16)))
+ (* 0.7152 (test-build-theme--channel-luminance (string-to-number (substring hex 3 5) 16)))
+ (* 0.0722 (test-build-theme--channel-luminance (string-to-number (substring hex 5 7) 16)))))
+
+(defun test-build-theme--contrast (fg bg)
+ "WCAG contrast ratio between hex colors FG and BG."
+ (let ((l1 (test-build-theme--relative-luminance fg))
+ (l2 (test-build-theme--relative-luminance bg)))
+ (/ (+ (max l1 l2) 0.05) (+ (min l1 l2) 0.05))))
+
+;;; ---------------------------------------------------------------------------
+;;; 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.
+
+;; --- 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-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 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 (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 (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)
+
+(ert-deftest test-build-theme-face-spec-normal ()
+ "Normal: a face with attrs becomes a custom-theme-set-faces spec."
+ (should (equal (build-theme/--face-spec 'font-lock-string-face '(:foreground "#84b068"))
+ '(font-lock-string-face ((t (:foreground "#84b068")))))))
+
+(ert-deftest test-build-theme-face-spec-empty-skipped ()
+ "Boundary: a face with no attributes (cleared) yields nil, not an empty spec."
+ (should (null (build-theme/--face-spec 'whatever '()))))
+
+;;; ---------------------------------------------------------------------------
+;;; Syntax tier
+
+(ert-deftest test-build-theme-syntax-keyword-bold ()
+ "Normal: kw maps to font-lock-keyword-face and carries its bold flag.
+Each syntax category is a nested object with fg/bold/italic."
+ (let* ((syntax '((kw . ((fg . "#7a9abe") (bold . t)))
+ (str . ((fg . "#84b068")))))
+ (specs (build-theme/--syntax-face-specs syntax)))
+ (should (member '(font-lock-keyword-face ((t (:foreground "#7a9abe" :weight bold))))
+ specs))
+ (should (member '(font-lock-string-face ((t (:foreground "#84b068"))))
+ specs))))
+
+(ert-deftest test-build-theme-syntax-one-to-many ()
+ "Normal: punc fans out to every punctuation/bracket/delimiter face."
+ (let ((specs (build-theme/--syntax-face-specs '((punc . ((fg . "#a9b2bb")))))))
+ (dolist (face '(font-lock-punctuation-face font-lock-bracket-face
+ font-lock-delimiter-face font-lock-misc-punctuation-face))
+ (should (member `(,face ((t (:foreground "#a9b2bb")))) specs)))))
+
+(ert-deftest test-build-theme-syntax-decorator-omitted ()
+ "Boundary: dec has no independent Emacs face, so it maps to nothing.
+Emacs renders decorators with font-lock-type-face, which ty already owns;
+mapping dec would clobber the type color."
+ (let ((specs (build-theme/--syntax-face-specs '((dec . ((fg . "#e8bd30")))))))
+ (should (null specs))))
+
+(ert-deftest test-build-theme-syntax-comment-italic ()
+ "Normal: cm with its italic flag yields :slant italic on the comment face."
+ (let ((specs (build-theme/--syntax-face-specs '((cm . ((fg . "#a9b2bb") (italic . t)))))))
+ (should (member '(font-lock-comment-face ((t (:foreground "#a9b2bb" :slant italic))))
+ specs))))
+
+;;; ---------------------------------------------------------------------------
+;;; Default face
+
+(ert-deftest test-build-theme-default-face ()
+ "Normal: default takes background from syntax.bg.fg and foreground from syntax.p.fg."
+ (should (equal (build-theme/--default-spec '((bg . ((fg . "#000000")))
+ (p . ((fg . "#cdced1")))))
+ '(default ((t (:foreground "#cdced1" :background "#000000")))))))
+
+;;; ---------------------------------------------------------------------------
+;;; UI tier
+
+(ert-deftest test-build-theme-ui-passthrough ()
+ "Normal: a ui face passes fg/bg straight through."
+ (let ((specs (build-theme/--ui-face-specs
+ '((region . ((fg . nil) (bg . "#264364")))
+ (mode-line . ((fg . "#cdced1") (bg . "#2f343a")))))))
+ (should (member '(region ((t (:background "#264364")))) specs))
+ (should (member '(mode-line ((t (:foreground "#cdced1" :background "#2f343a")))) specs))))
+
+(ert-deftest test-build-theme-box-styles ()
+ "Normal/Boundary: a face box spec converts to the right Emacs :box value."
+ (should (equal (build-theme/--box '((style . "released") (width . 1)))
+ '(:line-width 1 :style released-button)))
+ (should (equal (build-theme/--box '((style . "pressed") (width . 2)))
+ '(:line-width 2 :style pressed-button)))
+ (should (equal (build-theme/--box '((style . "line") (color . "#67809c")))
+ '(:line-width 1 :color "#67809c")))
+ (should (equal (build-theme/--box '((style . "line"))) '(:line-width 1)))
+ (should (null (build-theme/--box nil)))
+ (should (null (build-theme/--box '((style . "none"))))))
+
+(ert-deftest test-build-theme-ui-face-emits-box ()
+ "Normal: a ui face with a box exports a :box attribute."
+ (let ((specs (build-theme/--ui-face-specs
+ '((mode-line . ((fg . "#cdced1") (bg . "#2f343a")
+ (box . ((style . "released") (width . 1)))))))))
+ (should (member '(mode-line ((t (:foreground "#cdced1" :background "#2f343a"
+ :box (:line-width 1 :style released-button)))))
+ specs))))
+
+;;; ---------------------------------------------------------------------------
+;;; Package tier
+
+(ert-deftest test-build-theme-package-inherit-and-height ()
+ "Normal: a package face writes :inherit plus overrides plus :height."
+ (let ((specs (build-theme/--package-face-specs
+ '((org-mode . ((org-level-2 . ((fg . "#e8bd30") (bg . nil)
+ (bold . nil) (italic . nil)
+ (inherit . "org-level-1") (height . 1.2)
+ (source . "user")))))))))
+ (should (member '(org-level-2 ((t (:inherit org-level-1 :foreground "#e8bd30" :height 1.2))))
+ specs))))
+
+(ert-deftest test-build-theme-package-underline-and-strike ()
+ "Normal: a package face writes :underline and :strike-through from the flags."
+ (let ((specs (build-theme/--package-face-specs
+ '((shr . ((shr-link . ((fg . "#67809c") (bg . nil) (bold . nil) (italic . nil)
+ (underline . t) (strike . nil) (inherit . nil) (source . "default")))
+ (shr-strike-through . ((fg . "#5e6770") (bg . nil) (bold . nil) (italic . nil)
+ (underline . nil) (strike . t) (inherit . nil) (source . "default")))))))))
+ (should (member '(shr-link ((t (:foreground "#67809c" :underline t)))) specs))
+ (should (member '(shr-strike-through ((t (:foreground "#5e6770" :strike-through t)))) specs))))
+
+(ert-deftest test-build-theme-package-cleared-skipped ()
+ "Boundary: a cleared package face (no renderable attrs) is not emitted."
+ (let ((specs (build-theme/--package-face-specs
+ '((org-mode . ((org-tag . ((fg . nil) (bg . nil) (bold . nil)
+ (italic . nil) (inherit . nil)
+ (height . nil) (source . "cleared")))))))))
+ (should (null specs))))
+
+;;; ---------------------------------------------------------------------------
+;;; Hex validation
+
+(ert-deftest test-build-theme-hex-p ()
+ "Normal/Error: only #rrggbb strings validate."
+ (should (build-theme/--hex-p "#0d0b0a"))
+ (should (build-theme/--hex-p "#FFFFFF"))
+ (should-not (build-theme/--hex-p "0d0b0a"))
+ (should-not (build-theme/--hex-p "#fff"))
+ (should-not (build-theme/--hex-p nil)))
+
+;;; ---------------------------------------------------------------------------
+;;; End-to-end: convert a file and load the result
+
+(ert-deftest test-build-theme-convert-file-writes-loadable-theme ()
+ "Integration: converting the fixture produces a theme Emacs can load.
+Components integrated:
+- build-theme/convert-file (entry point, real)
+- json parsing of the inline fixture (real)
+- custom-theme-set-faces / load-theme (real)
+Validates the syntax, default, UI, and package tiers all reach real faces,
+including an inherit+height package face."
+ (require 'org)
+ (test-build-theme--with-sandbox out
+ (let* ((in (test-build-theme--write-fixture out))
+ (path (build-theme/convert-file in out)))
+ (should (file-exists-p path))
+ (should (string-suffix-p "dupre-fixture-theme.el" path))
+ (let ((custom-theme-load-path (cons out custom-theme-load-path))
+ (load-path (cons out load-path)))
+ (unwind-protect
+ (progn
+ (load-theme 'dupre-fixture t)
+ ;; default tier
+ (should (string= (face-attribute 'default :background nil t) "#000000"))
+ (should (string= (face-attribute 'default :foreground nil t) "#cdced1"))
+ ;; syntax tier (kw is blue + bold in the fixture)
+ (should (string= (face-attribute 'font-lock-keyword-face :foreground nil t) "#7a9abe"))
+ (should (eq (face-attribute 'font-lock-keyword-face :weight nil t) 'bold))
+ ;; ui tier
+ (should (string= (face-attribute 'region :background nil t) "#264364"))
+ ;; package tier — plain face and an inherit+height face
+ (should (string= (face-attribute 'org-level-1 :foreground nil t) "#67809c"))
+ (should (eq (face-attribute 'org-level-2 :inherit nil t) 'org-level-1))
+ (should (= (face-attribute 'org-level-2 :height nil t) 1.2)))
+ (disable-theme 'dupre-fixture))))))
+
+(ert-deftest test-build-theme-convert-file-old-json-without-packages ()
+ "Boundary: a theme.json with no packages key still converts and loads."
+ (test-build-theme--with-sandbox out
+ (let* ((json "{\"name\":\"noformat\",\"palette\":[[\"#000000\",\"ground\"]],\"syntax\":{\"bg\":{\"fg\":\"#000000\"},\"p\":{\"fg\":\"#ffffff\"},\"kw\":{\"fg\":\"#67809c\",\"bold\":true}},\"ui\":{}}")
+ (in (expand-file-name "noformat.json" out)))
+ (with-temp-file in (insert json))
+ (let ((path (build-theme/convert-file in out)))
+ (should (file-exists-p path))
+ (let ((custom-theme-load-path (cons out custom-theme-load-path))
+ (load-path (cons out load-path)))
+ (unwind-protect
+ (progn
+ (load-theme 'noformat t)
+ (should (string= (face-attribute 'default :background nil t) "#000000"))
+ (should (string= (face-attribute 'font-lock-keyword-face :foreground nil t) "#67809c")))
+ (disable-theme 'noformat)))))))
+
+(ert-deftest test-build-theme-convert-file-missing-input-errors ()
+ "Error: a missing input file signals rather than writing garbage."
+ (test-build-theme--with-sandbox out
+ (should-error (build-theme/convert-file (expand-file-name "does-not-exist.json" out) out))))
+
+(ert-deftest test-build-theme-name-from-filename-not-json-field ()
+ "Normal/Regression: the output name comes from the JSON file's basename, not
+its internal name field, so each draft exports under its own name (a WIP.json
+becomes WIP-theme.el, never theme-theme.el)."
+ (test-build-theme--with-sandbox out
+ ;; The fixture's internal name field is \"dupre-fixture\"; the file is sterling.json.
+ (let ((in (expand-file-name "sterling.json" out)))
+ (with-temp-file in (insert test-build-theme--fixture-json))
+ (let ((path (build-theme/convert-file in out)))
+ (should (string-suffix-p "sterling-theme.el" path))
+ (should-not (string-match-p "dupre-fixture" path))
+ (let ((custom-theme-load-path (cons out custom-theme-load-path))
+ (load-path (cons out load-path)))
+ (unwind-protect
+ (progn
+ (load-theme 'sterling t)
+ (should (string= (face-attribute 'default :background nil t) "#000000")))
+ (disable-theme 'sterling)))))))
+
+(ert-deftest test-build-theme-generated-default-meets-wcag-aa ()
+ "Error/Regression: the generated default face stays legible.
+A WCAG-AA (>= 4.5:1) assertion on the round-tripped result -- proves the whole
+parse -> spec -> file -> face pipeline preserves the designed contrast."
+ (test-build-theme--with-sandbox out
+ (let ((path (build-theme/convert-file (test-build-theme--write-fixture out) out)))
+ (let ((custom-theme-load-path (cons out custom-theme-load-path))
+ (load-path (cons out load-path)))
+ (unwind-protect
+ (progn
+ (load-theme 'dupre-fixture t)
+ (let ((fg (face-attribute 'default :foreground nil t))
+ (bg (face-attribute 'default :background nil t)))
+ (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..3d2342708 100644
--- a/tests/test-calendar-sync--apply-single-exception.el
+++ b/tests/test-calendar-sync--apply-single-exception.el
@@ -63,5 +63,47 @@
(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)))))
+
(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.el b/tests/test-calendar-sync.el
index b912c1328..62b00aba1 100644
--- a/tests/test-calendar-sync.el
+++ b/tests/test-calendar-sync.el
@@ -693,5 +693,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--bookmark-name.el b/tests/test-calibredb-epub-config--bookmark-name.el
new file mode 100644
index 000000000..2e1d253e9
--- /dev/null
+++ b/tests/test-calibredb-epub-config--bookmark-name.el
@@ -0,0 +1,87 @@
+;;; test-calibredb-epub-config--bookmark-name.el --- Nov bookmark naming tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the clean "Author, Title" bookmark naming that replaces nov.el's
+;; filename-based default. The name is parsed from the EPUB filename (Calibre's
+;; "<Title> - <Author>.epub" convention), restoring colons that Calibre
+;; sanitized to underscores and reordering to "Author, Title".
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'calibredb-epub-config)
+
+;;; cj/--nov-clean-title
+
+(ert-deftest test-nov-clean-title-passthrough ()
+ "Normal: a clean string is returned unchanged."
+ (should (equal (cj/--nov-clean-title "Agatha Christie") "Agatha Christie"))
+ (should (equal (cj/--nov-clean-title "The A.B.C. Murders") "The A.B.C. Murders")))
+
+(ert-deftest test-nov-clean-title-restores-colon ()
+ "Boundary: Calibre's \"_ \" colon substitution is restored to \": \"."
+ (should (equal (cj/--nov-clean-title "Frege_ A Guide for the Perplexed")
+ "Frege: A Guide for the Perplexed"))
+ (should (equal (cj/--nov-clean-title "The Fool's Progress_ An Honest Novel")
+ "The Fool's Progress: An Honest Novel")))
+
+(ert-deftest test-nov-clean-title-stray-underscore-and-whitespace ()
+ "Boundary: a non-colon underscore becomes a space; whitespace collapses."
+ (should (equal (cj/--nov-clean-title "a_b") "a b"))
+ (should (equal (cj/--nov-clean-title " x y ") "x y")))
+
+(ert-deftest test-nov-clean-title-rejects-blank-and-nonstring ()
+ "Error: nil, empty, all-whitespace, or non-string yields nil."
+ (should-not (cj/--nov-clean-title nil))
+ (should-not (cj/--nov-clean-title ""))
+ (should-not (cj/--nov-clean-title " "))
+ (should-not (cj/--nov-clean-title 42)))
+
+;;; cj/--nov-bookmark-name-from-file
+
+(ert-deftest test-nov-bookmark-name-real-examples ()
+ "Normal: real Calibre filenames become \"Author, Title\" with colons restored."
+ (should (equal (cj/--nov-bookmark-name-from-file
+ "/books/Frege_ A Guide for the Perplexed - Edward Kanterian.epub")
+ "Edward Kanterian, Frege: A Guide for the Perplexed"))
+ (should (equal (cj/--nov-bookmark-name-from-file
+ "/books/The A.B.C. Murders - Agatha Christie.epub")
+ "Agatha Christie, The A.B.C. Murders"))
+ (should (equal (cj/--nov-bookmark-name-from-file
+ "/books/The Fool's Progress_ An Honest Novel - Edward Abbey.epub")
+ "Edward Abbey, The Fool's Progress: An Honest Novel")))
+
+(ert-deftest test-nov-bookmark-name-splits-on-last-separator ()
+ "Boundary: a title containing \" - \" splits on the LAST separator."
+ (should (equal (cj/--nov-bookmark-name-from-file "/b/Title - Part Two - Some Author.epub")
+ "Some Author, Title - Part Two")))
+
+(ert-deftest test-nov-bookmark-name-no-separator ()
+ "Boundary: a filename with no \" - \" falls back to the cleaned whole name."
+ (should (equal (cj/--nov-bookmark-name-from-file "/b/Untitled_ Draft.epub")
+ "Untitled: Draft")))
+
+(ert-deftest test-nov-bookmark-name-nil-and-empty ()
+ "Error: nil or empty path yields nil."
+ (should-not (cj/--nov-bookmark-name-from-file nil))
+ (should-not (cj/--nov-bookmark-name-from-file "")))
+
+;;; cj/--nov-bookmark-rename-record
+
+(ert-deftest test-nov-bookmark-rename-record-replaces-name ()
+ "Normal: the record's name is rebuilt from its filename; the alist is kept."
+ (let* ((record (cons "The A.B.C. Murders - Agatha Christie.epub"
+ '((filename . "/b/The A.B.C. Murders - Agatha Christie.epub")
+ (index . 0))))
+ (out (cj/--nov-bookmark-rename-record record)))
+ (should (equal (car out) "Agatha Christie, The A.B.C. Murders"))
+ (should (equal (cdr out) (cdr record)))))
+
+(ert-deftest test-nov-bookmark-rename-record-keeps-original-without-filename ()
+ "Boundary: a record with no usable filename is returned unchanged."
+ (let ((record (cons "whatever" '((index . 0)))))
+ (should (equal (cj/--nov-bookmark-rename-record record) record))))
+
+(provide 'test-calibredb-epub-config--bookmark-name)
+;;; test-calibredb-epub-config--bookmark-name.el ends here
diff --git a/tests/test-calibredb-epub-config--menu.el b/tests/test-calibredb-epub-config--menu.el
new file mode 100644
index 000000000..4860efc30
--- /dev/null
+++ b/tests/test-calibredb-epub-config--menu.el
@@ -0,0 +1,52 @@
+;;; test-calibredb-epub-config--menu.el --- calibredb curated-menu tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the docked book-description command bound into the curated calibredb
+;; menu. The transient itself, its `?'/`H' keybindings, and the
+;; display-buffer-alist dock live in calibredb's deferred `use-package' config
+;; (they need the elpa transient, which batch does not load) and are verified
+;; live in the daemon; here we cover the describe command, which has no transient
+;; dependency.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'calibredb-epub-config)
+
+;; calibredb vars (defvar'd here so the tests' `let' bindings are dynamic; the
+;; module's bare defvars are file-local to its own compilation unit).
+(defvar calibredb-sort-by)
+(defvar calibredb-search-filter)
+(defvar calibredb-format-filter-p)
+
+(ert-deftest test-calibredb-describe-at-point-shows-entry-without-switch ()
+ "Normal: describe calls `calibredb-show-entry' on the entry at point with no
+switch argument, so the entry lands in the docked window with focus (q quits)."
+ (let (call)
+ (cl-letf (((symbol-function 'calibredb-find-candidate-at-point)
+ (lambda () '(the-entry extra)))
+ ((symbol-function 'calibredb-show-entry)
+ (lambda (&rest args) (setq call args))))
+ (cj/calibredb-describe-at-point)
+ ;; one argument only -- the entry -- and switch is therefore nil
+ (should (equal call '(the-entry))))))
+
+(ert-deftest test-calibredb-sort-preserving-filter-keeps-filter ()
+ "Normal: the filter-preserving sort sets the field and refreshes via
+`calibredb-search-refresh-or-resume' without touching the active filter."
+ (let ((calibredb-sort-by 'id)
+ (calibredb-search-filter "epub")
+ (calibredb-format-filter-p t)
+ (refreshed nil))
+ (cl-letf (((symbol-function 'calibredb-search-refresh-or-resume)
+ (lambda (&rest _) (setq refreshed t))))
+ (cj/--calibredb-sort-preserving-filter 'author)
+ (should (eq calibredb-sort-by 'author)) ; field updated
+ (should refreshed) ; refreshed
+ (should (equal calibredb-search-filter "epub")) ; filter kept
+ (should calibredb-format-filter-p)))) ; filter flag kept
+
+(provide 'test-calibredb-epub-config--menu)
+;;; test-calibredb-epub-config--menu.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-cache-lib.el b/tests/test-cj-cache-lib.el
index aeb329dda..7de7edb8c 100644
--- a/tests/test-cj-cache-lib.el
+++ b/tests/test-cj-cache-lib.el
@@ -4,7 +4,7 @@
;; Unit tests for the TTL+building cache helper. Covers cache-make /
;; cache-valid-p / cache-value-or-rebuild / cache-building-p /
;; cache-invalidate against the contract in
-;; docs/design/cache-helper-design.org.
+;; docs/specs/cache-helper-design-spec-implemented.org.
;;; Code:
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-keymap-bindings.el b/tests/test-custom-buffer-file-keymap-bindings.el
new file mode 100644
index 000000000..ea9ceb263
--- /dev/null
+++ b/tests/test-custom-buffer-file-keymap-bindings.el
@@ -0,0 +1,30 @@
+;;; test-custom-buffer-file-keymap-bindings.el --- d/D bindings in the buffer-and-file keymap -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/buffer-and-file-map' should put the destructive op on the capital key and
+;; the frequently-used op on the easy lowercase key: D = delete-buffer-and-file,
+;; d = diff-buffer-with-file. Guards the swap against silently reverting.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module (mirrors the sibling tests).
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+(provide 'ps-print)
+
+(require 'custom-buffer-file)
+
+(ert-deftest test-custom-buffer-file-keymap-diff-on-lowercase-d ()
+ "Normal: lowercase d runs diff -- the frequently-used, non-destructive op."
+ (should (eq (keymap-lookup cj/buffer-and-file-map "d") #'cj/diff-buffer-with-file)))
+
+(ert-deftest test-custom-buffer-file-keymap-delete-on-capital-d ()
+ "Normal: capital D runs delete -- the destructive op on the capital key."
+ (should (eq (keymap-lookup cj/buffer-and-file-map "D") #'cj/delete-buffer-and-file)))
+
+(provide 'test-custom-buffer-file-keymap-bindings)
+;;; test-custom-buffer-file-keymap-bindings.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-comments-comment-heavy-box.el b/tests/test-custom-comments-comment-heavy-box.el
index 94d4aaa5f..8acb9ff0b 100644
--- a/tests/test-custom-comments-comment-heavy-box.el
+++ b/tests/test-custom-comments-comment-heavy-box.el
@@ -64,8 +64,8 @@ Returns the buffer string for assertions."
(should (string-match-p "^;; \\*" result))
;; Middle line should contain centered text
(should (string-match-p "Section Header" result))
- ;; Should have side borders
- (should (string-match-p "^\\*.*\\*$" result))))
+ ;; Interior side-border lines carry the comment prefix/suffix (not a bare *)
+ (should (string-match-p "^;; \\*.*\\* ;;$" result))))
(ert-deftest test-heavy-box-elisp-custom-decoration ()
"Should use custom decoration character."
@@ -83,8 +83,8 @@ Returns the buffer string for assertions."
(let ((result (test-heavy-box-at-column 0 ";;" "" "*" "" 70)))
;; Should still generate 5 lines
(should (= 5 (length (split-string result "\n" t))))
- ;; Middle line should just have side borders and spaces
- (should (string-match-p "^\\*.*\\*$" result))))
+ ;; Middle line should just have side borders and spaces, comment-prefixed
+ (should (string-match-p "^;; \\*.*\\* ;;$" result))))
(ert-deftest test-heavy-box-elisp-at-column-0 ()
"Should work at column 0."
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-font-lock.el b/tests/test-dashboard-config-font-lock.el
new file mode 100644
index 000000000..d55909723
--- /dev/null
+++ b/tests/test-dashboard-config-font-lock.el
@@ -0,0 +1,35 @@
+;;; test-dashboard-config-font-lock.el --- dashboard-mode excluded from global font-lock -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `global-font-lock-mode' fontifies the *dashboard* buffer and strips the
+;; manually-applied `face' text properties dashboard puts on the banner title
+;; (`dashboard-banner-logo-title') and the section headings
+;; (`dashboard-heading'), so they render in the default face instead of the
+;; theme colors. dashboard-config excludes dashboard-mode from global
+;; font-lock so those text-property faces survive.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+
+;; Stub package-level deps dashboard-config pulls transitively.
+(unless (fboundp 'cj/kill-all-other-buffers-and-windows)
+ (defun cj/kill-all-other-buffers-and-windows () nil))
+(unless (fboundp 'cj/make-buffer-undead)
+ (defun cj/make-buffer-undead (_name) nil))
+
+(require 'dashboard-config)
+
+(ert-deftest test-dashboard-config-excludes-dashboard-mode-from-global-font-lock ()
+ "Normal: dashboard-mode is excluded from `font-lock-global-modes'.
+Global font-lock must not run in the dashboard buffer, or it strips the
+manual face text properties dashboard applies to the banner and headings."
+ (should (consp font-lock-global-modes))
+ (should (eq (car font-lock-global-modes) 'not))
+ (should (memq 'dashboard-mode (cdr font-lock-global-modes))))
+
+(provide 'test-dashboard-config-font-lock)
+;;; test-dashboard-config-font-lock.el ends here
diff --git a/tests/test-dashboard-config-launchers.el b/tests/test-dashboard-config-launchers.el
index 0ac37f878..e7e8d2f33 100644
--- a/tests/test-dashboard-config-launchers.el
+++ b/tests/test-dashboard-config-launchers.el
@@ -25,20 +25,22 @@
(require 'dashboard-config)
-(defconst test-dash--keys '("c" "d" "t" "a" "r" "b" "f" "m" "e" "i" "g" "s" "l"))
+;; Telegram moved from "g" to "G" so "g" is free for dashboard refresh.
+;; Signal ("S") added as the 14th launcher.
+(defconst test-dash--keys '("c" "d" "t" "a" "r" "b" "f" "m" "e" "i" "G" "s" "l" "S"))
;; ----------------------------- launcher table --------------------------------
(ert-deftest test-dashboard-launchers-keys-in-order ()
- "Normal: 13 launchers with the expected keys in display order."
- (should (= 13 (length cj/dashboard--launchers)))
+ "Normal: 14 launchers with the expected keys in display order."
+ (should (= 14 (length cj/dashboard--launchers)))
(should (equal test-dash--keys (mapcar (lambda (l) (nth 0 l)) cj/dashboard--launchers))))
(ert-deftest test-dashboard-launchers-labels-in-order ()
"Normal: labels in display order (Telegram and Slack reordered so Slack sits
next to Linear on the last navigator row)."
(should (equal '("Code" "Files" "Terminal" "Agenda" "Feeds" "Books"
- "Flashcards" "Music" "Email" "IRC" "Telegram" "Slack" "Linear")
+ "Flashcards" "Music" "Email" "IRC" "Telegram" "Slack" "Linear" "Signal")
(mapcar (lambda (l) (nth 3 l)) cj/dashboard--launchers))))
(ert-deftest test-dashboard-row-sizes-cover-all-launchers ()
@@ -48,19 +50,19 @@ next to Linear on the last navigator row)."
;; --------------------------- navigator rows ----------------------------------
-(ert-deftest test-dashboard-navigator-rows-grouped-4-4-3-2 ()
- "Normal: navigator derives rows per `cj/dashboard--row-sizes' (4 4 3 2), with
-Slack and Linear sharing the last row."
+(ert-deftest test-dashboard-navigator-rows-grouped-4-4-3-3 ()
+ "Normal: navigator derives rows per `cj/dashboard--row-sizes' (4 4 3 3), with
+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))))
(let ((rows (cj/dashboard--navigator-rows)))
(should (= 4 (length rows)))
- (should (equal '(4 4 3 2) (mapcar #'length rows)))
+ (should (equal '(4 4 3 3) (mapcar #'length rows)))
(should (equal '("Code" "Files" "Terminal" "Agenda")
(mapcar (lambda (b) (nth 1 b)) (nth 0 rows))))
- (should (equal '("Slack" "Linear")
+ (should (equal '("Slack" "Linear" "Signal")
(mapcar (lambda (b) (nth 1 b)) (nth 3 rows))))
(let ((btn (car (car rows)))) ; (icon label tooltip action nil " " "")
(should (string= "I:nf-fa-code" (nth 0 btn)))
@@ -94,7 +96,8 @@ Slack and Linear sharing the last row."
((symbol-function 'cj/erc-switch-to-buffer-with-completion) (lambda (&rest _) (push 'irc calls)))
((symbol-function 'cj/slack-start) (lambda (&rest _) (push 'slack calls)))
((symbol-function 'cj/telega) (lambda (&rest _) (push 'tg calls)))
- ((symbol-function 'pearl-list-issues) (lambda (&rest _) (push 'linear calls))))
+ ((symbol-function 'pearl-list-issues) (lambda (&rest _) (push 'linear calls)))
+ ((symbol-function 'cj/signel-message) (lambda (&rest _) (push 'signal calls))))
(cj/dashboard--bind-launchers map)
(dolist (key test-dash--keys)
(call-interactively (keymap-lookup map key)))
@@ -103,7 +106,8 @@ Slack and Linear sharing the last row."
(should (memq 'linear calls))
(should (memq 'm-toggle calls))
(should (memq 'm-load calls))
- (should (= 14 (length calls)))))) ; 13 keys, Music fires two
+ (should (memq 'signal calls))
+ (should (= 15 (length calls)))))) ; 14 keys, Music fires two
(provide 'test-dashboard-config-launchers)
;;; test-dashboard-config-launchers.el ends here
diff --git a/tests/test-dashboard-config-recentf-exclude.el b/tests/test-dashboard-config-recentf-exclude.el
new file mode 100644
index 000000000..f35b3eda1
--- /dev/null
+++ b/tests/test-dashboard-config-recentf-exclude.el
@@ -0,0 +1,33 @@
+;;; test-dashboard-config-recentf-exclude.el --- recentf-exclude is not clobbered -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/--dashboard-exclude-emms-from-recentf' adds the EMMS history pattern
+;; to `recentf-exclude'. It must ADD to the list, not replace it, or it
+;; wipes the exclusions system-defaults.el set earlier in init order
+;; (emacs_bookmarks, elpa, recentf, ElfeedDB, airootfs).
+
+;;; Code:
+
+(require 'ert)
+(require 'recentf) ; makes `recentf-exclude' special so the let below is dynamic
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'testutil-general)
+(require 'dashboard-config)
+
+(ert-deftest test-dashboard-config-exclude-emms-preserves-existing-entries ()
+ "Error: excluding the EMMS history preserves prior recentf-exclude entries."
+ (let ((recentf-exclude (list "emacs_bookmarks" "airootfs")))
+ (cj/--dashboard-exclude-emms-from-recentf)
+ (should (member "/emms/history" recentf-exclude))
+ (should (member "emacs_bookmarks" recentf-exclude))
+ (should (member "airootfs" recentf-exclude))))
+
+(ert-deftest test-dashboard-config-exclude-emms-adds-the-pattern ()
+ "Normal: the EMMS history pattern is present after the call."
+ (let ((recentf-exclude nil))
+ (cj/--dashboard-exclude-emms-from-recentf)
+ (should (member "/emms/history" recentf-exclude))))
+
+(provide 'test-dashboard-config-recentf-exclude)
+;;; test-dashboard-config-recentf-exclude.el ends here
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-dired-line-directory.el b/tests/test-dirvish-config-dired-line-directory.el
deleted file mode 100644
index 7f344c7c0..000000000
--- a/tests/test-dirvish-config-dired-line-directory.el
+++ /dev/null
@@ -1,56 +0,0 @@
-;;; test-dirvish-config-dired-line-directory.el --- Tests for the directory-line predicate -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; `cj/--dired-line-is-directory-p' is the testable predicate behind
-;; `cj/dired-mark-all-visible-files'. Dired buffers prefix each file
-;; line with a one-char mark column followed by the `ls -l' output, so
-;; column 2 is the file-type letter (`d' for directory, `-' for regular
-;; file). The wrapper iterates the buffer and skips lines this
-;; predicate returns t for; the iteration stays dired-coupled and
-;; untested, but the line-classification logic is now isolated.
-
-;;; 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))
-(add-to-list 'load-path (expand-file-name "elpa/dirvish-2.3.0/extensions"
- user-emacs-directory))
-(require 'user-constants)
-(require 'keybindings)
-(require 'dirvish-config)
-
-(ert-deftest test-cj--dired-line-is-directory-p-unmarked-directory ()
- "Normal: an unmarked directory line (` drwx...') matches."
- (should (cj/--dired-line-is-directory-p
- " drwxr-xr-x 1 me me 4096 May 10 13:00 subdir/")))
-
-(ert-deftest test-cj--dired-line-is-directory-p-marked-directory ()
- "Normal: a star-marked directory line (`* drwx...') matches."
- (should (cj/--dired-line-is-directory-p
- "* drwxr-xr-x 1 me me 4096 May 10 13:00 subdir/")))
-
-(ert-deftest test-cj--dired-line-is-directory-p-regular-file ()
- "Normal: a regular file line (` -rw...') does not match."
- (should-not (cj/--dired-line-is-directory-p
- " -rw-r--r-- 1 me me 42 May 10 13:00 notes.txt")))
-
-(ert-deftest test-cj--dired-line-is-directory-p-symlink-line ()
- "Boundary: a symlink line (` lrwx...') does not match -- only `d' is a dir."
- (should-not (cj/--dired-line-is-directory-p
- " lrwxrwxrwx 1 me me 12 May 10 13:00 link -> target")))
-
-(ert-deftest test-cj--dired-line-is-directory-p-empty-line ()
- "Boundary: an empty string does not match."
- (should-not (cj/--dired-line-is-directory-p "")))
-
-(ert-deftest test-cj--dired-line-is-directory-p-header-line ()
- "Boundary: a dired header (` /path/to:') or `total' line does not match."
- (should-not (cj/--dired-line-is-directory-p " /home/me/projects:"))
- (should-not (cj/--dired-line-is-directory-p " total 24")))
-
-(provide 'test-dirvish-config-dired-line-directory)
-;;; test-dirvish-config-dired-line-directory.el ends here
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-mark-all-visible.el b/tests/test-dirvish-config-mark-all-visible.el
new file mode 100644
index 000000000..5ed01440c
--- /dev/null
+++ b/tests/test-dirvish-config-mark-all-visible.el
@@ -0,0 +1,68 @@
+;;; test-dirvish-config-mark-all-visible.el --- Tests for marking all visible files -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/dired-mark-all-visible-files' marks every regular file in a Dired
+;; buffer and leaves directories unmarked. The loop is exercised here against
+;; a real Dired buffer over a temp directory (the line predicate has its own
+;; unit tests). The regression this pins: `dired-mark' advances point itself,
+;; so an extra `forward-line' skipped every other file and only alternate files
+;; got marked.
+
+;;; 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))
+(add-to-list 'load-path (expand-file-name "elpa/dirvish-2.3.0/extensions"
+ user-emacs-directory))
+(require 'user-constants)
+(require 'keybindings)
+(require 'dirvish-config)
+(require 'dired)
+
+(defun test-dirvish--marked-count ()
+ "Return the number of `*'-marked lines in the current Dired buffer."
+ (let ((n 0))
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (when (looking-at-p "^\\*") (setq n (1+ n)))
+ (forward-line 1)))
+ n))
+
+(ert-deftest test-dirvish-mark-all-visible-marks-every-file ()
+ "Normal: all regular files get marked, no skips.
+Three files plus a subdirectory; the count of marks must equal the file count."
+ (let ((dir (make-temp-file "dirvish-mark-test-" t)))
+ (unwind-protect
+ (progn
+ (dolist (f '("a.txt" "b.txt" "c.txt"))
+ (write-region "" nil (expand-file-name f dir)))
+ (make-directory (expand-file-name "subdir" dir))
+ (let ((buf (dired-noselect dir)))
+ (unwind-protect
+ (with-current-buffer buf
+ (cj/dired-mark-all-visible-files)
+ (should (= 3 (test-dirvish--marked-count))))
+ (kill-buffer buf))))
+ (delete-directory dir t))))
+
+(ert-deftest test-dirvish-mark-all-visible-leaves-directories-unmarked ()
+ "Boundary: a directory line is never marked."
+ (let ((dir (make-temp-file "dirvish-mark-test-" t)))
+ (unwind-protect
+ (progn
+ (write-region "" nil (expand-file-name "only.txt" dir))
+ (make-directory (expand-file-name "adir" dir))
+ (let ((buf (dired-noselect dir)))
+ (unwind-protect
+ (with-current-buffer buf
+ (cj/dired-mark-all-visible-files)
+ (should (= 1 (test-dirvish--marked-count))))
+ (kill-buffer buf))))
+ (delete-directory dir t))))
+
+(provide 'test-dirvish-config-mark-all-visible)
+;;; test-dirvish-config-mark-all-visible.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 0a9998646..d1141d33a 100644
--- a/tests/test-dirvish-config-public-wrappers.el
+++ b/tests/test-dirvish-config-public-wrappers.el
@@ -101,22 +101,9 @@ confused when several built-ins are overridden in the same test."
(when (file-exists-p dst) (delete-file dst)))))
;;; cj/dired-mark-all-visible-files
-
-(ert-deftest test-dirvish-mark-all-visible-skips-directories ()
- "Normal: directory lines are skipped, file lines are marked."
- (let ((marks 0))
- (with-temp-buffer
- ;; Real dired listing has lines like " drwxr... dir/" or " -rw... file".
- ;; The helper `cj/--dired-line-is-directory-p' matches "<space>d".
- (insert " drwxr-xr-x subdir\n"
- " -rw-r--r-- file1.txt\n"
- " -rw-r--r-- file2.txt\n")
- (goto-char (point-min))
- (cl-letf (((symbol-function 'dired-mark)
- (lambda (&rest _) (cl-incf marks))))
- (cj/dired-mark-all-visible-files)))
- ;; 2 file lines marked; the directory line + the trailing empty line skipped.
- (should (= marks 2))))
+;; Covered by test-dirvish-config-mark-all-visible.el, which exercises the loop
+;; against a real Dired buffer (the previous fake-buffer mock coupled to the
+;; retired regex helper).
;;; cj/dired-copy-path-as-kill
@@ -137,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))
@@ -152,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-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-dupre-theme.el b/tests/test-dupre-theme.el
deleted file mode 100644
index dec648d14..000000000
--- a/tests/test-dupre-theme.el
+++ /dev/null
@@ -1,227 +0,0 @@
-;;; test-dupre-theme.el --- Tests for dupre-theme -*- lexical-binding: t -*-
-
-;;; Commentary:
-
-;; ERT tests for the dupre-theme.
-
-;;; Code:
-
-(require 'ert)
-
-;; Add themes directory to load-path and custom-theme-load-path
-(let ((themes-dir (expand-file-name "../themes" (file-name-directory (or load-file-name buffer-file-name)))))
- (add-to-list 'load-path themes-dir)
- (add-to-list 'custom-theme-load-path themes-dir))
-
-(require 'dupre-palette)
-
-;;; Palette tests
-
-(ert-deftest dupre-palette-exists ()
- "Palette constant should be defined."
- (should (boundp 'dupre-palette))
- (should (listp dupre-palette)))
-
-(ert-deftest dupre-palette-has-base-colors ()
- "Palette should contain essential base colors."
- (should (assq 'bg dupre-palette))
- (should (assq 'fg dupre-palette))
- (should (assq 'bg+1 dupre-palette))
- (should (assq 'bg+2 dupre-palette)))
-
-(ert-deftest dupre-palette-has-accent-colors ()
- "Palette should contain accent colors."
- (should (assq 'yellow dupre-palette))
- (should (assq 'blue dupre-palette))
- (should (assq 'green dupre-palette))
- (should (assq 'red dupre-palette)))
-
-(ert-deftest dupre-palette-colors-are-hex ()
- "All palette colors should be valid hex strings."
- (dolist (entry dupre-palette)
- (let ((color (cadr entry)))
- (should (stringp color))
- (should (string-match-p "^#[0-9a-fA-F]\\{6\\}$" color)))))
-
-(ert-deftest dupre-get-color-base ()
- "dupre-get-color should retrieve base colors."
- (should (string= (dupre-get-color 'bg) "#151311"))
- (should (string= (dupre-get-color 'fg) "#f0fef0"))
- (should (string= (dupre-get-color 'yellow) "#d7af5f")))
-
-(ert-deftest dupre-get-color-semantic ()
- "dupre-get-color should resolve semantic mappings."
- (should (string= (dupre-get-color 'accent) (dupre-get-color 'yellow)))
- (should (string= (dupre-get-color 'err) (dupre-get-color 'intense-red)))
- (should (string= (dupre-get-color 'success) (dupre-get-color 'green))))
-
-(ert-deftest dupre-get-color-unknown-errors ()
- "dupre-get-color should error on unknown colors."
- (should-error (dupre-get-color 'nonexistent-color)))
-
-(ert-deftest dupre-with-colors-binds-values ()
- "dupre-with-colors should bind palette colors as variables."
- (dupre-with-colors
- (should (string= bg "#151311"))
- (should (string= fg "#f0fef0"))
- (should (string= yellow "#d7af5f"))
- (should (string= blue "#67809c"))))
-
-(ert-deftest dupre-with-colors-binds-semantic ()
- "dupre-with-colors should bind semantic colors resolved to values."
- (dupre-with-colors
- (should (string= accent "#d7af5f"))
- (should (string= err "#ff2a00"))
- (should (string= success "#a4ac64"))))
-
-;;; Theme loading tests
-
-(ert-deftest dupre-theme-loads ()
- "Theme should load without errors."
- (load-theme 'dupre t)
- (should (memq 'dupre custom-enabled-themes)))
-
-(ert-deftest dupre-theme-default-face ()
- "dupre-theme should set the default face correctly."
- (load-theme 'dupre t)
- (let ((bg (face-attribute 'default :background))
- (fg (face-attribute 'default :foreground)))
- (should (string= bg "#151311"))
- (should (string= fg "#f0fef0"))))
-
-(ert-deftest dupre-theme-comment-face-italic ()
- "Comments should be rendered in italic slant."
- (load-theme 'dupre t)
- (should (eq (face-attribute 'font-lock-comment-face :slant) 'italic)))
-
-(ert-deftest dupre-theme-keyword-face ()
- "Keywords should use blue color."
- (load-theme 'dupre t)
- (should (string= (face-attribute 'font-lock-keyword-face :foreground) "#67809c")))
-
-(ert-deftest dupre-theme-string-face ()
- "Strings should use green color."
- (load-theme 'dupre t)
- (should (string= (face-attribute 'font-lock-string-face :foreground) "#a4ac64")))
-
-(ert-deftest dupre-theme-function-face ()
- "Functions should use terracotta color."
- (load-theme 'dupre t)
- (should (string= (face-attribute 'font-lock-function-name-face :foreground) "#a7502d")))
-
-;;; Org-mode face tests (require org to be loaded)
-;; Note: org-level-N faces use :inherit dupre-heading-N
-;; We verify inheritance is set up correctly by checking the inherit attribute
-
-(ert-deftest dupre-theme-org-level-1 ()
- "Org level 1 should inherit from dupre-heading-1."
- (require 'org)
- (load-theme 'dupre t)
- ;; Verify the inheritance relationship is set
- (should (eq (face-attribute 'org-level-1 :inherit) 'dupre-heading-1)))
-
-(ert-deftest dupre-theme-org-level-2 ()
- "Org level 2 should inherit from dupre-heading-2."
- (require 'org)
- (load-theme 'dupre t)
- ;; Verify the inheritance relationship is set
- (should (eq (face-attribute 'org-level-2 :inherit) 'dupre-heading-2)))
-
-(ert-deftest dupre-theme-org-todo ()
- "Org TODO should use intense-red."
- (require 'org)
- (load-theme 'dupre t)
- (should (string= (face-attribute 'org-todo :foreground) "#ff2a00")))
-
-(ert-deftest dupre-theme-org-done ()
- "Org DONE should use green."
- (require 'org)
- (load-theme 'dupre t)
- (should (string= (face-attribute 'org-done :foreground) "#a4ac64")))
-
-;;; Diff face tests (require diff-mode to be loaded)
-
-(ert-deftest dupre-theme-diff-added ()
- "Diff added should use green foreground."
- (require 'diff-mode)
- (load-theme 'dupre t)
- (should (string= (face-attribute 'diff-added :foreground) "#a4ac64")))
-
-(ert-deftest dupre-theme-diff-removed ()
- "Diff removed should use red foreground."
- (require 'diff-mode)
- (load-theme 'dupre t)
- (should (string= (face-attribute 'diff-removed :foreground) "#d47c59")))
-
-;;; UI face tests
-
-(ert-deftest dupre-theme-mode-line ()
- "Mode line should have correct background."
- (load-theme 'dupre t)
- (should (string= (face-attribute 'mode-line :background) "#474544")))
-
-(ert-deftest dupre-theme-region ()
- "Region should use bg+2 as background."
- (load-theme 'dupre t)
- (should (string= (face-attribute 'region :background) "#474544")))
-
-;;; Vertico face tests (skip if vertico not available)
-
-(ert-deftest dupre-theme-vertico-current ()
- "Vertico current should use bg+2 background."
- (skip-unless (require 'vertico nil t))
- (load-theme 'dupre t)
- (should (string= (face-attribute 'vertico-current :background) "#474544")))
-
-;;; Rainbow-delimiters tests (skip if package not available)
-
-(ert-deftest dupre-theme-rainbow-depth-1 ()
- "Rainbow depth 1 should use blue."
- (skip-unless (require 'rainbow-delimiters nil t))
- (load-theme 'dupre t)
- (should (string= (face-attribute 'rainbow-delimiters-depth-1-face :foreground) "#67809c")))
-
-(ert-deftest dupre-theme-rainbow-depth-2 ()
- "Rainbow depth 2 should use gray+2."
- (skip-unless (require 'rainbow-delimiters nil t))
- (load-theme 'dupre t)
- (should (string= (face-attribute 'rainbow-delimiters-depth-2-face :foreground) "#d0cbc0")))
-
-;;; Error/warning face tests
-
-(ert-deftest dupre-theme-error-face ()
- "Error face should use intense-red."
- (load-theme 'dupre t)
- (should (string= (face-attribute 'error :foreground) "#ff2a00")))
-
-(ert-deftest dupre-theme-warning-face ()
- "Warning face should use yellow+1."
- (load-theme 'dupre t)
- (should (string= (face-attribute 'warning :foreground) "#ffd75f")))
-
-(ert-deftest dupre-theme-success-face ()
- "Success face should use green."
- (load-theme 'dupre t)
- (should (string= (face-attribute 'success :foreground) "#a4ac64")))
-
-;;; Face registration
-
-(ert-deftest dupre-semantic-faces-are-registered ()
- "Dupre's own faces must be real faces, not just theme specs.
-An unregistered face renders only through `:inherit'; applied directly as
-a text property (e.g. via `org-todo-keyword-faces') it silently fails.
-The defface registration in dupre-faces.el is what makes direct use work."
- (load-theme 'dupre t)
- (dolist (face '(dupre-accent dupre-heading-1
- dupre-org-todo dupre-org-todo-dim
- dupre-org-failed dupre-org-priority-a
- dupre-org-priority-a-dim))
- (should (facep face)))
- ;; and the theme colours them from the palette
- (should (string= (face-attribute 'dupre-org-todo :foreground nil 'default)
- "#a4ac64"))
- (should (string= (face-attribute 'dupre-org-todo-dim :foreground nil 'default)
- "#869038")))
-
-(provide 'test-dupre-theme)
-;;; test-dupre-theme.el ends here
diff --git a/tests/test-dwim-shell-config-command-fixes.el b/tests/test-dwim-shell-config-command-fixes.el
new file mode 100644
index 000000000..2cc3ae72b
--- /dev/null
+++ b/tests/test-dwim-shell-config-command-fixes.el
@@ -0,0 +1,88 @@
+;;; test-dwim-shell-config-command-fixes.el --- zip/backup command builders -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Two audit fixes, extracted into top-level command-string builders so they're
+;; testable without loading the dwim-shell-command package (the command defuns
+;; that call them live inside its use-package :config, which the batch test
+;; harness doesn't instantiate):
+;; - cj/dwim-shell--zip-single-file-command names the archive <fne>.zip
+;; - cj/dwim-shell--dated-backup-command carries a real timestamp, not "$(date)"
+;; The third fix (dired menu key M-S-d -> M-D) is a keybinding inside the same
+;; :config block; it's verified in the live daemon, not here.
+
+;;; Code:
+
+(require 'ert)
+(require 'dwim-shell-config)
+
+(ert-deftest test-dwim-zip-single-file-command-names-archive-dot-zip ()
+ "Normal: the single-file zip template names the archive <fne>.zip, with no
+leftover <<e>> that would rebuild the input filename."
+ (let ((cmd (cj/dwim-shell--zip-single-file-command)))
+ (should (string-match-p "'<<fne>>\\.zip'" cmd))
+ (should-not (string-match-p "<<e>>" cmd))))
+
+(ert-deftest test-dwim-dated-backup-command-carries-real-timestamp ()
+ "Normal: the dated-backup template interpolates a real YYYYMMDD_HHMMSS stamp,
+so the substitution can't sit dead inside single quotes."
+ (let ((cmd (cj/dwim-shell--dated-backup-command)))
+ (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
new file mode 100644
index 000000000..7d4540d68
--- /dev/null
+++ b/tests/test-erc-config-connected-servers.el
@@ -0,0 +1,48 @@
+;;; test-erc-config-connected-servers.el --- cj/erc-connected-servers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/erc-connected-servers must return only ERC *server* buffers with a live
+;; 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.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'erc-config)
+
+(ert-deftest test-erc-connected-servers-keeps-only-live-server-buffers ()
+ "Normal: only buffers that are ERC server buffers with a live process are
+returned; a channel buffer and a dead-connection server buffer are excluded."
+ (let ((b-server (generate-new-buffer " *erc-server*"))
+ (b-channel (generate-new-buffer " *erc-#chan*"))
+ (b-dead (generate-new-buffer " *erc-dead*")))
+ (unwind-protect
+ (cl-letf (((symbol-function 'erc-buffer-list)
+ (lambda (&rest _) (list b-server b-channel b-dead)))
+ ((symbol-function 'erc-server-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))))
+ (should (equal (cj/erc-connected-servers)
+ (list (buffer-name b-server)))))
+ (mapc #'kill-buffer (list b-server b-channel b-dead)))))
+
+(ert-deftest test-erc-connected-servers-empty-when-none-alive ()
+ "Boundary: no live server buffers yields an empty list."
+ (let ((b-channel (generate-new-buffer " *erc-#chan*")))
+ (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-process-alive) (lambda (&rest _) nil)))
+ (should (null (cj/erc-connected-servers))))
+ (kill-buffer b-channel))))
+
+(provide 'test-erc-config-connected-servers)
+;;; test-erc-config-connected-servers.el ends here
diff --git a/tests/test-face-diagnostic.el b/tests/test-face-diagnostic.el
new file mode 100644
index 000000000..241425fc5
--- /dev/null
+++ b/tests/test-face-diagnostic.el
@@ -0,0 +1,332 @@
+;;; test-face-diagnostic.el --- Tests for the Phase 1 face-diagnosis core -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the pure read model of the face/font diagnostic (Phase 1):
+;; buffer classification, character context, and the face stack separated by
+;; source. All against temp-buffer fixtures with planted text properties,
+;; overlays, and face remaps -- no display, no prompts.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'face-diagnostic)
+
+;;; cj/--face-diag-classify-buffer
+
+(ert-deftest test-face-diag-classify-theme-faced ()
+ "Normal: an ordinary buffer classifies as theme-faced."
+ (with-temp-buffer
+ (fundamental-mode)
+ (should (eq (cj/--face-diag-classify-buffer) 'theme-faced))))
+
+(ert-deftest test-face-diag-classify-terminal ()
+ "Boundary: a terminal-family mode classifies as terminal-ansi."
+ (with-temp-buffer
+ (setq major-mode 'term-mode)
+ (should (eq (cj/--face-diag-classify-buffer) 'terminal-ansi))))
+
+(ert-deftest test-face-diag-classify-document ()
+ "Boundary: an shr-rendering mode classifies as document-shr."
+ (with-temp-buffer
+ (setq major-mode 'eww-mode)
+ (should (eq (cj/--face-diag-classify-buffer) 'document-shr))))
+
+(ert-deftest test-face-diag-classify-image ()
+ "Boundary: an image/document-view mode classifies as image-no-text."
+ (with-temp-buffer
+ (setq major-mode 'image-mode)
+ (should (eq (cj/--face-diag-classify-buffer) 'image-no-text))))
+
+;;; cj/--face-diag-char-context
+
+(ert-deftest test-face-diag-char-context-normal ()
+ "Normal: an ASCII letter reports char, codepoint, name, and script."
+ (with-temp-buffer
+ (insert "A")
+ (let ((ctx (cj/--face-diag-char-context (point-min))))
+ (should (= (plist-get ctx :char) ?A))
+ (should (= (plist-get ctx :codepoint) 65))
+ (should (equal (plist-get ctx :name) "LATIN CAPITAL LETTER A"))
+ (should (eq (plist-get ctx :script) 'latin)))))
+
+(ert-deftest test-face-diag-char-context-eob-nil ()
+ "Boundary/Error: end of an empty buffer has no character, so nil."
+ (with-temp-buffer
+ (should-not (cj/--face-diag-char-context (point-max)))))
+
+;;; cj/--face-diag-normalize-faces
+
+(ert-deftest test-face-diag-normalize-faces ()
+ "Normal/Boundary: symbol, list, anonymous spec, and nil normalize correctly."
+ (should (equal (cj/--face-diag-normalize-faces 'bold) '(bold)))
+ (should (equal (cj/--face-diag-normalize-faces '(bold italic)) '(bold italic)))
+ (should (equal (cj/--face-diag-normalize-faces '(:foreground "red"))
+ '((:foreground "red"))))
+ (should-not (cj/--face-diag-normalize-faces nil)))
+
+;;; cj/--face-diag-text-property-faces
+
+(ert-deftest test-face-diag-text-property-faces-symbol ()
+ "Normal: a `face' property symbol appears in the list."
+ (with-temp-buffer
+ (insert (propertize "x" 'face 'bold))
+ (should (equal (cj/--face-diag-text-property-faces (point-min)) '(bold)))))
+
+(ert-deftest test-face-diag-text-property-faces-includes-font-lock ()
+ "Normal: `face' and `font-lock-face' are both collected, face first."
+ (with-temp-buffer
+ (insert (propertize "x" 'face 'bold 'font-lock-face 'italic))
+ (should (equal (cj/--face-diag-text-property-faces (point-min)) '(bold italic)))))
+
+(ert-deftest test-face-diag-text-property-faces-none ()
+ "Boundary: unpropertized text yields no faces."
+ (with-temp-buffer
+ (insert "x")
+ (should-not (cj/--face-diag-text-property-faces (point-min)))))
+
+;;; cj/--face-diag-overlay-faces
+
+(ert-deftest test-face-diag-overlay-faces-sorted-by-priority ()
+ "Normal: overlay faces are returned highest priority first."
+ (with-temp-buffer
+ (insert "xyz")
+ (let ((lo (make-overlay 1 3))
+ (hi (make-overlay 1 3)))
+ (overlay-put lo 'face 'region)
+ (overlay-put lo 'priority 1)
+ (overlay-put hi 'face 'highlight)
+ (overlay-put hi 'priority 10)
+ (let ((entries (cj/--face-diag-overlay-faces 1)))
+ (should (= (length entries) 2))
+ (should (eq (plist-get (car entries) :face) 'highlight))
+ (should (eq (plist-get (cadr entries) :face) 'region))))))
+
+(ert-deftest test-face-diag-overlay-faces-skips-faceless ()
+ "Boundary: an overlay without a `face' property is excluded."
+ (with-temp-buffer
+ (insert "xyz")
+ (let ((ov (make-overlay 1 3)))
+ (overlay-put ov 'help-echo "no face here")
+ (should-not (cj/--face-diag-overlay-faces 1)))))
+
+;;; cj/--face-diag-active-remaps
+
+(ert-deftest test-face-diag-active-remaps-matches-stack ()
+ "Normal: a remap of a stack face is returned; an unrelated remap is not."
+ (with-temp-buffer
+ (setq face-remapping-alist '((default :background "#111111")
+ (link :foreground "#222222")))
+ (let ((remaps (cj/--face-diag-active-remaps '(default))))
+ (should (assq 'default remaps))
+ (should-not (assq 'link remaps)))))
+
+(ert-deftest test-face-diag-active-remaps-empty ()
+ "Boundary: no remapping alist yields no entries."
+ (with-temp-buffer
+ (setq face-remapping-alist nil)
+ (should-not (cj/--face-diag-active-remaps '(default)))))
+
+;;; cj/--face-diag-stack
+
+(ert-deftest test-face-diag-stack-assembles-sources ()
+ "Normal: the stack carries text-property, overlay, remap, and default sources."
+ (with-temp-buffer
+ (insert (propertize "x" 'face 'bold))
+ (setq face-remapping-alist '((default :background "#111111")))
+ (let ((ov (make-overlay 1 2)))
+ (overlay-put ov 'face 'region)
+ (let ((stack (cj/--face-diag-stack 1)))
+ (should (equal (plist-get stack :text-property) '(bold)))
+ (should (eq (plist-get (car (plist-get stack :overlays)) :face) 'region))
+ (should (assq 'default (plist-get stack :remaps)))
+ (should (eq (plist-get stack :default) 'default))))))
+
+;;; cj/--face-diagnosis-at
+
+(ert-deftest test-face-diagnosis-at-shape ()
+ "Normal: the assembled core returns classification, char, and stack."
+ (with-temp-buffer
+ (fundamental-mode)
+ (insert (propertize "A" 'face 'bold))
+ (let ((diag (cj/--face-diagnosis-at (point-min))))
+ (should (eq (plist-get diag :classification) 'theme-faced))
+ (should (= (plist-get (plist-get diag :char) :char) ?A))
+ (should (equal (plist-get (plist-get diag :stack) :text-property) '(bold))))))
+
+(ert-deftest test-face-diagnosis-at-eob-char-nil ()
+ "Boundary: at end of an empty buffer the char group is nil, stack still present."
+ (with-temp-buffer
+ (fundamental-mode)
+ (let ((diag (cj/--face-diagnosis-at (point-max))))
+ (should-not (plist-get diag :char))
+ (should (eq (plist-get (plist-get diag :stack) :default) 'default)))))
+
+;;; cj/--face-diag-merged-attributes
+
+(ert-deftest test-face-diag-merged-explicit-text-prop ()
+ "Normal: an explicit text-property attribute is the winning merged value."
+ (with-temp-buffer
+ (insert (propertize "x" 'face '(:foreground "#abcdef" :weight bold)))
+ (let ((attrs (cj/--face-diag-merged-attributes (point-min))))
+ (should (equal (plist-get attrs :foreground) "#abcdef"))
+ (should (eq (plist-get attrs :weight) 'bold)))))
+
+(ert-deftest test-face-diag-merged-overlay-wins-over-text-prop ()
+ "Normal: a higher-priority overlay attribute beats the text-property face."
+ (with-temp-buffer
+ (insert (propertize "x" 'face '(:foreground "blue")))
+ (let ((ov (make-overlay 1 2)))
+ (overlay-put ov 'face '(:foreground "red"))
+ (overlay-put ov 'priority 10)
+ (should (equal (plist-get (cj/--face-diag-merged-attributes 1) :foreground)
+ "red")))))
+
+(ert-deftest test-face-diag-merged-applies-default-remap ()
+ "Normal: a remap of the default face shows up in the merged attributes."
+ (with-temp-buffer
+ (insert "x")
+ (setq face-remapping-alist '((default :foreground "#123456")))
+ (should (equal (plist-get (cj/--face-diag-merged-attributes 1) :foreground)
+ "#123456"))))
+
+(ert-deftest test-face-diag-merged-bold-face-symbol ()
+ "Boundary: a face symbol in the stack contributes its set attributes."
+ (with-temp-buffer
+ (insert (propertize "x" 'face 'bold))
+ (should (eq (plist-get (cj/--face-diag-merged-attributes 1) :weight) 'bold))))
+
+;;; cj/--face-diag-real-font
+
+(ert-deftest test-face-diag-real-font-unavailable-in-batch ()
+ "Boundary: font-at is nil under batch, so the real font reads \"unavailable\"."
+ (with-temp-buffer
+ (insert "x")
+ (let ((font (cj/--face-diag-real-font 1)))
+ (should (equal (plist-get font :font) "unavailable"))
+ (should-not (plist-get font :family)))))
+
+;;; cj/--face-diagnosis-at (groups 0-4)
+
+(ert-deftest test-face-diagnosis-at-includes-attributes-and-font ()
+ "Normal: the assembled core carries the merged attributes and font groups."
+ (with-temp-buffer
+ (fundamental-mode)
+ (insert (propertize "x" 'face '(:foreground "#abcdef")))
+ (let ((diag (cj/--face-diagnosis-at (point-min))))
+ (should (equal (plist-get (plist-get diag :attributes) :foreground) "#abcdef"))
+ (should (equal (plist-get (plist-get diag :font) :font) "unavailable")))))
+
+;;; provenance accessors
+
+(ert-deftest test-face-diag-face-themes ()
+ "Normal: theme names come from the face's theme-face property, newest first."
+ (make-face 'fd-test-themed)
+ (put 'fd-test-themed 'theme-face '((user spec1) (dupre spec2)))
+ (should (equal (cj/--face-diag-face-themes 'fd-test-themed) '(user dupre))))
+
+(ert-deftest test-face-diag-config-source ()
+ "Normal/Boundary: saved-face -> saved, customized-face -> customized, else nil."
+ (make-face 'fd-test-saved)
+ (put 'fd-test-saved 'saved-face '(spec))
+ (make-face 'fd-test-cust)
+ (put 'fd-test-cust 'customized-face '(spec))
+ (make-face 'fd-test-plain)
+ (should (eq (cj/--face-diag-config-source 'fd-test-saved) 'saved))
+ (should (eq (cj/--face-diag-config-source 'fd-test-cust) 'customized))
+ (should-not (cj/--face-diag-config-source 'fd-test-plain)))
+
+(ert-deftest test-face-diag-inherit-chain ()
+ "Normal: a single-symbol :inherit produces a nearest-first chain."
+ (make-face 'fd-test-parent)
+ (make-face 'fd-test-child)
+ (set-face-attribute 'fd-test-child nil :inherit 'fd-test-parent)
+ (should (equal (cj/--face-diag-inherit-chain 'fd-test-child) '(fd-test-parent))))
+
+(ert-deftest test-face-diag-inherit-chain-none ()
+ "Boundary: a face with no :inherit has an empty chain."
+ (make-face 'fd-test-noinherit)
+ (should-not (cj/--face-diag-inherit-chain 'fd-test-noinherit)))
+
+(ert-deftest test-face-diag-unspecified-attrs ()
+ "Normal: a bare face leaves attributes unspecified, so they fall to default."
+ (make-face 'fd-test-bare)
+ (should (memq :foreground (cj/--face-diag-unspecified-attrs 'fd-test-bare))))
+
+(ert-deftest test-face-diag-provenance-covers-stack-and-default ()
+ "Normal: provenance covers the stack's named faces and always the default."
+ (with-temp-buffer
+ (insert (propertize "x" 'face 'bold))
+ (let ((faces (mapcar (lambda (p) (plist-get p :face))
+ (cj/--face-diag-provenance (point-min)))))
+ (should (memq 'bold faces))
+ (should (memq 'default faces)))))
+
+(ert-deftest test-face-diagnosis-at-includes-provenance ()
+ "Normal: the assembled core carries the provenance group for stack faces."
+ (with-temp-buffer
+ (fundamental-mode)
+ (insert (propertize "x" 'face 'bold))
+ (let ((prov (plist-get (cj/--face-diagnosis-at (point-min)) :provenance)))
+ (should (cl-some (lambda (p) (eq (plist-get p :face) 'bold)) prov)))))
+
+;;; cj/--face-diag-render
+
+(ert-deftest test-face-diag-render-has-all-groups ()
+ "Normal: the rendered report names every group and the stack's face."
+ (with-temp-buffer
+ (fundamental-mode)
+ (insert (propertize "A" 'face 'bold))
+ (let ((report (cj/--face-diag-render (cj/--face-diagnosis-at (point-min)))))
+ (should (string-match-p "Character:" report))
+ (should (string-match-p "Face stack" report))
+ (should (string-match-p "bold" report))
+ (should (string-match-p "Effective attributes" report))
+ (should (string-match-p "Real font" report))
+ (should (string-match-p "Provenance" report)))))
+
+(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)))
+ (should (equal (cj/--face-diag-render-banner 'theme-faced) "")))
+
+(ert-deftest test-face-diag-render-no-char ()
+ "Boundary: a nil char group renders the no-character notice."
+ (should (string-match-p "none at point" (cj/--face-diag-render-char nil))))
+
+(ert-deftest test-face-diag-render-region-covers-runs ()
+ "Normal: region rendering emits a position header per distinct face-run."
+ (with-temp-buffer
+ (insert (propertize "aa" 'face 'bold))
+ (insert (propertize "bb" 'face 'italic))
+ (let ((report (cj/--face-diag-render-region (point-min) (point-max))))
+ (should (string-match-p "=== position 1 ===" report))
+ (should (string-match-p "=== position 3 ===" report)))))
+
+;;; cj/describe-face-at-point (smoke)
+
+(ert-deftest test-face-diag-command-creates-buffer ()
+ "Normal: the command renders into the read-only *Face Diagnosis* buffer."
+ (with-temp-buffer
+ (insert (propertize "A" 'face 'bold))
+ (goto-char (point-min))
+ (cj/describe-face-at-point)
+ (let ((buf (get-buffer "*Face Diagnosis*")))
+ (unwind-protect
+ (progn
+ (should buf)
+ (with-current-buffer buf
+ (should (eq major-mode 'cj/face-diagnostic-mode))
+ (should buffer-read-only)
+ (should (string-match-p "Face stack" (buffer-string)))))
+ (when (buffer-live-p buf) (kill-buffer buf))))))
+
+;;; keybinding
+
+(ert-deftest test-face-diag-bound-on-c-h-F ()
+ "Normal: loading the module binds C-h F to the diagnostic command."
+ (should (eq (keymap-lookup help-map "F") 'cj/describe-face-at-point)))
+
+(provide 'test-face-diagnostic)
+;;; test-face-diagnostic.el ends here
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-gptel-tools-web-fetch.el b/tests/test-gptel-tools-web-fetch.el
index b6dbefccb..10abe6eba 100644
--- a/tests/test-gptel-tools-web-fetch.el
+++ b/tests/test-gptel-tools-web-fetch.el
@@ -106,13 +106,13 @@
(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)))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) 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")))
+ (lambda (program &rest _) (and (equal program "pandoc") "/bin/pandoc")))
((symbol-function 'call-process-region)
(lambda (&rest _args) 9)))
(should-error (cj/gptel-web-fetch--html-to-text "<p>x</p>"))))
@@ -121,7 +121,7 @@
"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")))
+ (lambda (program &rest _) (and (equal program "w3m") "/bin/w3m")))
((symbol-function 'call-process-region)
(lambda (start end program delete output display &rest _args)
(setq called-program program)
diff --git a/tests/test-help-config.el b/tests/test-help-config.el
new file mode 100644
index 000000000..0ba95c410
--- /dev/null
+++ b/tests/test-help-config.el
@@ -0,0 +1,32 @@
+;;; test-help-config.el --- Tests for the Info-open decision logic -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/open-with-info-mode opens the current .info buffer in Info, prompting to
+;; save first if the buffer is modified. The save/cancel/open decision is
+;; factored into the pure helper `cj/--info-open-plan' so it's testable without
+;; driving find-file, Info, or the save prompt. Declining the prompt must yield
+;; `cancel' -- the original cl-return-from inside a plain defun signalled
+;; "No catch for tag" instead of cancelling.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'help-config)
+
+(ert-deftest test-info-open-plan-unmodified-opens ()
+ "Normal: an unmodified buffer opens in Info directly."
+ (should (eq (cj/--info-open-plan nil nil) 'open)))
+
+(ert-deftest test-info-open-plan-modified-confirmed-saves-then-opens ()
+ "Normal: a modified buffer whose save is confirmed saves, then opens."
+ (should (eq (cj/--info-open-plan t t) 'save-then-open)))
+
+(ert-deftest test-info-open-plan-modified-declined-cancels ()
+ "Error/edge: a modified buffer whose save is declined cancels -- the path that
+used to signal \"No catch for tag\" via cl-return-from in a plain defun."
+ (should (eq (cj/--info-open-plan t nil) 'cancel)))
+
+(provide 'test-help-config)
+;;; test-help-config.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 2680a19c9..a5b331f4d 100644
--- a/tests/test-init-module-headers.el
+++ b/tests/test-init-module-headers.el
@@ -2,7 +2,7 @@
;;; Commentary:
;; Enforces the module load-graph header standard from
-;; docs/design/init-load-graph.org against every module that has been
+;; docs/specs/init-load-graph-spec-doing.org against every module that has been
;; classified so far. Classification proceeds in batches; a module joins
;; `test-init-header--classified-modules' once its header declares the
;; contract. When that list reaches parity with the modules required by
@@ -113,7 +113,7 @@
"jumper"
"latex-config"
;; Batch 9 — Remaining domain / integration / optional modules (Layer 2-4)
- "linear-config"
+ "pearl-config"
"local-repository"
"lorem-optimum"
"mail-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-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-refile-folder.el b/tests/test-mail-config-refile-folder.el
new file mode 100644
index 000000000..e2d224eb6
--- /dev/null
+++ b/tests/test-mail-config-refile-folder.el
@@ -0,0 +1,40 @@
+;;; test-mail-config-refile-folder.el --- Tests for refile-folder dispatch -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; ERT tests for `cj/mu4e--refile-folder-for-maildir', the per-message refile
+;; (archive) target dispatch. cmail has a real synced Archive folder; the
+;; Gmail-backed accounts (gmail, dmail) have none, so refiling them must signal
+;; rather than move mail into an unsynced, phantom folder (silent mail loss).
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'mail-config)
+
+(ert-deftest test-mail-config-refile-cmail-returns-archive ()
+ "Normal: a cmail message refiles into the synced /cmail/Archive folder."
+ (should (string= (cj/mu4e--refile-folder-for-maildir "/cmail/INBOX")
+ "/cmail/Archive"))
+ (should (string= (cj/mu4e--refile-folder-for-maildir "/cmail/Sent")
+ "/cmail/Archive")))
+
+(ert-deftest test-mail-config-refile-gmail-signals ()
+ "Error: gmail has no synced archive folder, so refile signals rather than
+moving mail into a phantom folder."
+ (should-error (cj/mu4e--refile-folder-for-maildir "/gmail/INBOX")
+ :type 'user-error))
+
+(ert-deftest test-mail-config-refile-dmail-signals ()
+ "Error: dmail (Gmail-backed) has no synced archive folder; refile signals."
+ (should-error (cj/mu4e--refile-folder-for-maildir "/dmail/INBOX")
+ :type 'user-error))
+
+(ert-deftest test-mail-config-refile-nil-maildir-signals ()
+ "Boundary: a message with no maildir cannot be refiled; signal."
+ (should-error (cj/mu4e--refile-folder-for-maildir nil)
+ :type 'user-error))
+
+(provide 'test-mail-config-refile-folder)
+;;; test-mail-config-refile-folder.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-markdown-config.el b/tests/test-markdown-config.el
index 45e1a6018..edb20d357 100644
--- a/tests/test-markdown-config.el
+++ b/tests/test-markdown-config.el
@@ -9,6 +9,7 @@
;;; Code:
(require 'ert)
+(require 'cl-lib)
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
@@ -50,5 +51,14 @@
(should (string-match-p "<xmp" (buffer-string))))
(kill-buffer src))))
+;;; cj/markdown-preview (guard: refuse when the httpd listener is down)
+
+(ert-deftest test-markdown-preview-errors-when-server-down ()
+ "Error: `cj/markdown-preview' signals a user-error when the simple-httpd
+listener is not running, rather than opening a preview against a dead server.
+Also pins the rename off the bare `markdown-preview' that markdown-mode shadows."
+ (cl-letf (((symbol-function 'httpd-running-p) (lambda () nil)))
+ (should-error (cj/markdown-preview) :type 'user-error)))
+
(provide 'test-markdown-config)
;;; test-markdown-config.el ends here
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-flycheck-segment.el b/tests/test-modeline-config-flycheck-segment.el
index 208deaa72..2ae2f5de1 100644
--- a/tests/test-modeline-config-flycheck-segment.el
+++ b/tests/test-modeline-config-flycheck-segment.el
@@ -5,7 +5,7 @@
;; a guarded reference to `flycheck-mode-line-status-text', and that
;; the guard requires both `mode-line-window-selected-p' and
;; `bound-and-true-p flycheck-mode'. See
-;; docs/design/flycheck-modeline-customization.org for the design.
+;; docs/specs/flycheck-modeline-customization-spec-implemented.org for the design.
;;; Code:
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-modeline-config-vc-cache-key.el b/tests/test-modeline-config-vc-cache-key.el
index ae869f4b8..6ba7985c2 100644
--- a/tests/test-modeline-config-vc-cache-key.el
+++ b/tests/test-modeline-config-vc-cache-key.el
@@ -1,56 +1,36 @@
;;; test-modeline-config-vc-cache-key.el --- Tests for VC modeline cache key -*- lexical-binding: t; -*-
;;; Commentary:
-;; The VC modeline cache keys on the file. A symlink whose target moves to a
-;; different VC tree must invalidate the cache, so the key includes the
-;; resolved `file-truename', not just the symlink path.
+;; The VC modeline cache keys on the file path and the `cj/modeline-vc-show-remote'
+;; flag only. `file-truename' is deliberately NOT in the key: it would run on
+;; every redisplay (the mode-line rebuilds the key each render to check validity),
+;; and a moved symlink target is picked up at the next TTL refresh anyway, since
+;; `vc-backend' resolves the link fresh. The per-render stat isn't worth it.
;;; Code:
(require 'ert)
-(require 'cl-lib)
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'modeline-config)
-;;; Normal Cases
-
-(ert-deftest test-modeline-vc-cache-key-includes-truename ()
- "Normal: the cache key includes the resolved truename of the file."
- (let ((f (make-temp-file "cj-mlkey-")))
- (unwind-protect
- (should (member (file-truename f) (cj/modeline-vc-cache-key f)))
- (delete-file f))))
-
-;;; Boundary Cases
-
-(ert-deftest test-modeline-vc-cache-key-changes-when-symlink-target-moves ()
- "Boundary: re-pointing a symlink to a new target changes the cache key.
-The symlink path is identical both times; only its truename differs, so a
-key that ignored the truename would serve a stale VC backend."
- (let* ((dir (make-temp-file "cj-mlkey-dir-" t))
- (target-a (expand-file-name "a" dir))
- (target-b (expand-file-name "b" dir))
- (link (expand-file-name "link" dir)))
- (unwind-protect
- (progn
- (write-region "" nil target-a)
- (write-region "" nil target-b)
- (make-symbolic-link target-a link)
- (let ((key-a (cj/modeline-vc-cache-key link)))
- (delete-file link)
- (make-symbolic-link target-b link)
- (let ((key-b (cj/modeline-vc-cache-key link)))
- (should-not (equal key-a key-b)))))
- (delete-directory dir t))))
+(ert-deftest test-modeline-vc-cache-key-is-file-and-show-remote ()
+ "Normal: the key is (FILE SHOW-REMOTE), with no per-render file-truename stat."
+ (let ((cj/modeline-vc-show-remote nil))
+ (should (equal (cj/modeline-vc-cache-key "/x/y.el") '("/x/y.el" nil)))))
+
+(ert-deftest test-modeline-vc-cache-key-tracks-show-remote ()
+ "Boundary: toggling show-remote yields a different key (separate cache entry)."
+ (should-not (equal (let ((cj/modeline-vc-show-remote nil))
+ (cj/modeline-vc-cache-key "/x/y.el"))
+ (let ((cj/modeline-vc-show-remote t))
+ (cj/modeline-vc-cache-key "/x/y.el")))))
(ert-deftest test-modeline-vc-cache-key-stable-for-same-file ()
- "Boundary: the key is stable across calls for an unchanged file."
- (let ((f (make-temp-file "cj-mlkey-stable-")))
- (unwind-protect
- (should (equal (cj/modeline-vc-cache-key f)
- (cj/modeline-vc-cache-key f)))
- (delete-file f))))
+ "Boundary: the key is stable across calls for an unchanged file + show-remote."
+ (let ((cj/modeline-vc-show-remote nil))
+ (should (equal (cj/modeline-vc-cache-key "/x/y.el")
+ (cj/modeline-vc-cache-key "/x/y.el")))))
(provide 'test-modeline-config-vc-cache-key)
;;; test-modeline-config-vc-cache-key.el ends here
diff --git a/tests/test-modeline-config-vc-cache.el b/tests/test-modeline-config-vc-cache.el
index b6aafbfbe..dab755442 100644
--- a/tests/test-modeline-config-vc-cache.el
+++ b/tests/test-modeline-config-vc-cache.el
@@ -98,5 +98,12 @@
(should (text-property-any 0 (length rendered)
'mouse-face 'mode-line-highlight rendered)))))
+(ert-deftest test-modeline-config-vc-fetch-swallows-vc-errors ()
+ "Error: a signal from the VC backend is swallowed (returns nil) rather than
+propagating into the mode-line redisplay path, where it would break all redisplay."
+ (cl-letf (((symbol-function 'file-remote-p) (lambda (&rest _) nil))
+ ((symbol-function 'vc-backend) (lambda (&rest _) (error "git boom"))))
+ (should (null (cj/modeline-vc-fetch "/tmp/project/file.el")))))
+
(provide 'test-modeline-config-vc-cache)
;;; test-modeline-config-vc-cache.el ends here
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-org-agenda-config--base-files.el b/tests/test-org-agenda-config--base-files.el
new file mode 100644
index 000000000..c6939b4d7
--- /dev/null
+++ b/tests/test-org-agenda-config--base-files.el
@@ -0,0 +1,36 @@
+;;; 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. The path vars are special (defvar'd in
+;; user-constants), so they can be dynamically bound here.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-agenda-config)
+
+(ert-deftest test-org-agenda-base-files-returns-fixed-list-in-order ()
+ "Normal: returns inbox, schedule, gcal, pcal, dcal in that order."
+ (let ((inbox-file "/i")
+ (schedule-file "/s")
+ (gcal-file "/g")
+ (pcal-file "/p")
+ (dcal-file "/d"))
+ (should (equal (cj/--org-agenda-base-files)
+ '("/i" "/s" "/g" "/p" "/d")))))
+
+(ert-deftest test-org-agenda-base-files-reflects-current-values ()
+ "Boundary: the helper reads the vars at call time (not a captured snapshot)."
+ (let ((inbox-file "first")
+ (schedule-file "x") (gcal-file "x") (pcal-file "x") (dcal-file "x"))
+ (should (equal (car (cj/--org-agenda-base-files)) "first"))
+ (setq inbox-file "second")
+ (should (equal (car (cj/--org-agenda-base-files)) "second"))
+ (should (= (length (cj/--org-agenda-base-files)) 5))))
+
+(provide 'test-org-agenda-config--base-files)
+;;; test-org-agenda-config--base-files.el ends here
diff --git a/tests/test-org-agenda-config-commands.el b/tests/test-org-agenda-config-commands.el
index e29871b79..76407439d 100644
--- a/tests/test-org-agenda-config-commands.el
+++ b/tests/test-org-agenda-config-commands.el
@@ -145,6 +145,24 @@ calling `org-agenda'."
(should build-called)
(should (equal agenda-args '("a" "d")))))
+;;; org-agenda-custom-commands "d" daily structure
+
+(defun test-org-agenda--daily-blocks ()
+ "Return the block list of the \"d\" daily agenda command."
+ (nth 2 (assoc "d" org-agenda-custom-commands)))
+
+(ert-deftest test-org-agenda-daily-schedule-block-is-first ()
+ "Normal: the schedule (calendar) block leads the daily agenda."
+ (should (eq (car (nth 0 (test-org-agenda--daily-blocks))) 'agenda)))
+
+(ert-deftest test-org-agenda-daily-has-no-overdue-block ()
+ "Normal: no overdue block. It duplicated the past-due
+scheduled/deadline items the schedule block already surfaces on
+today's line (org-scheduled-past-days/org-deadline-past-days are
+large), so the standalone OVERDUE section was redundant."
+ (let ((flat (flatten-tree (test-org-agenda--daily-blocks))))
+ (should-not (memq 'cj/org-agenda-skip-subtree-if-not-overdue flat))))
+
;;; cj/add-timestamp-to-org-entry
(ert-deftest test-org-agenda-add-timestamp-inserts-on-next-line ()
diff --git a/tests/test-org-agenda-config-skip-functions.el b/tests/test-org-agenda-config-skip-functions.el
index aec1e71be..b8290da21 100644
--- a/tests/test-org-agenda-config-skip-functions.el
+++ b/tests/test-org-agenda-config-skip-functions.el
@@ -145,76 +145,6 @@ Suppresses org-mode hooks to avoid loading packages not available in batch."
(test-org-agenda--with-org-buffer "* DONE Finished task\n"
(should (integerp (cj/org-skip-subtree-if-keyword '("TODO" "DONE" "CANCELLED"))))))
-;;; ---------- cj/org-agenda-skip-subtree-if-not-overdue ----------
-
-;;; Normal Cases
-
-(ert-deftest test-org-agenda-config-skip-overdue-normal-past-scheduled-keeps ()
- "Entry scheduled in the past with TODO keyword is overdue — keep it."
- (test-org-agenda--with-org-buffer
- (concat "* TODO Overdue task\n"
- "SCHEDULED: " (test-org-timestamp-days-ago 7) "\n")
- (should (null (cj/org-agenda-skip-subtree-if-not-overdue)))))
-
-(ert-deftest test-org-agenda-config-skip-overdue-normal-future-scheduled-skips ()
- "Entry scheduled in the future is not overdue — skip it."
- (test-org-agenda--with-org-buffer
- (concat "* TODO Future task\n"
- "SCHEDULED: " (test-org-timestamp-days-ahead 7) "\n")
- (should (integerp (cj/org-agenda-skip-subtree-if-not-overdue)))))
-
-(ert-deftest test-org-agenda-config-skip-overdue-normal-past-deadline-keeps ()
- "Entry with past deadline and TODO keyword is overdue — keep it."
- (test-org-agenda--with-org-buffer
- (concat "* TODO Missed deadline\n"
- "DEADLINE: " (test-org-timestamp-days-ago 3) "\n")
- (should (null (cj/org-agenda-skip-subtree-if-not-overdue)))))
-
-(ert-deftest test-org-agenda-config-skip-overdue-normal-done-task-skips ()
- "Done task should be skipped even if overdue."
- (test-org-agenda--with-org-buffer
- (concat "* DONE Completed task\n"
- "SCHEDULED: " (test-org-timestamp-days-ago 7) "\n")
- (should (integerp (cj/org-agenda-skip-subtree-if-not-overdue)))))
-
-(ert-deftest test-org-agenda-config-skip-overdue-normal-habit-skips ()
- "Habit should be skipped even if overdue."
- (test-org-agenda--with-org-buffer
- (concat "* TODO Daily habit\n"
- "SCHEDULED: " (test-org-timestamp-days-ago 7) "\n"
- ":PROPERTIES:\n"
- ":STYLE: habit\n"
- ":END:\n")
- (should (integerp (cj/org-agenda-skip-subtree-if-not-overdue)))))
-
-(ert-deftest test-org-agenda-config-skip-overdue-normal-no-todo-keyword-skips ()
- "Entry without a TODO keyword should be skipped."
- (test-org-agenda--with-org-buffer
- (concat "* Just a heading\n"
- "SCHEDULED: " (test-org-timestamp-days-ago 7) "\n")
- (should (integerp (cj/org-agenda-skip-subtree-if-not-overdue)))))
-
-;;; Boundary Cases
-
-(ert-deftest test-org-agenda-config-skip-overdue-boundary-today-scheduled-skips ()
- "Entry scheduled today is NOT overdue (not strictly before today) — skip."
- (test-org-agenda--with-org-buffer
- (concat "* TODO Today task\n"
- "SCHEDULED: " (test-org-timestamp-today) "\n")
- (should (integerp (cj/org-agenda-skip-subtree-if-not-overdue)))))
-
-(ert-deftest test-org-agenda-config-skip-overdue-boundary-no-date-skips ()
- "Entry with TODO but no scheduled/deadline date — not overdue, skip."
- (test-org-agenda--with-org-buffer "* TODO Undated task\n"
- (should (integerp (cj/org-agenda-skip-subtree-if-not-overdue)))))
-
-(ert-deftest test-org-agenda-config-skip-overdue-boundary-future-deadline-skips ()
- "Entry with future deadline is not overdue — skip."
- (test-org-agenda--with-org-buffer
- (concat "* TODO Future deadline\n"
- "DEADLINE: " (test-org-timestamp-days-ahead 14) "\n")
- (should (integerp (cj/org-agenda-skip-subtree-if-not-overdue)))))
-
;;; ---------- "d" command SCHEDULE block: CANCELLED skip ----------
;;; Normal Cases
@@ -268,17 +198,18 @@ regression where one block diverges from the others on the format."
;;; Normal Cases
-(ert-deftest test-org-agenda-config-d-command-has-six-blocks-in-expected-order ()
- "Normal: the \"d\" command runs six blocks in the expected order --
-OVERDUE -> HIGH PRIORITY -> VERIFICATION -> SCHEDULE -> IN-PROGRESS -> PRIORITY B."
+(ert-deftest test-org-agenda-config-d-command-has-five-blocks-in-expected-order ()
+ "Normal: the \"d\" command runs five blocks in the expected order --
+SCHEDULE -> HIGH PRIORITY -> VERIFICATION -> IN-PROGRESS -> PRIORITY B.
+The schedule (calendar) leads; the former OVERDUE block was dropped
+because it duplicated the past-due items the schedule already shows."
(let* ((entry (assoc "d" org-agenda-custom-commands))
(blocks (nth 2 entry))
(shapes (mapcar (lambda (b) (list (car b) (cadr b))) blocks)))
(should (equal shapes
- '((alltodo "")
+ '((agenda "")
(tags "PRIORITY=\"A\"")
(todo "VERIFY")
- (agenda "")
(todo "DOING")
(alltodo ""))))))
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
new file mode 100644
index 000000000..671d55ab9
--- /dev/null
+++ b/tests/test-org-capture-config-popup-window.el
@@ -0,0 +1,195 @@
+;;; test-org-capture-config-popup-window.el --- Quick-capture popup tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the Hyprland Super+Shift+N quick-capture popup. The popup opens an
+;; emacsclient frame named "org-capture" and runs `cj/quick-capture', which
+;; captures a single Task into the global inbox with no template menu. Covered
+;; here: the sole-window predicate and display action (the CAPTURE-* buffer
+;; fills the frame), the single-Task template builder, frame discovery and focus
+;; (the emacsclient focus race), and frame cleanup on every exit path.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'org)
+(require 'org-capture) ; makes `org-capture-templates' a real special var
+(require 'user-constants)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-capture-config)
+
+;;; cj/org-capture--popup-sole-window-p
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-select-menu ()
+ "Normal: the *Org Select* menu in the popup frame wants the sole window."
+ (should (cj/org-capture--popup-sole-window-p "org-capture" "*Org Select*")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-capture-buffer ()
+ "Normal: a CAPTURE-* buffer in the popup frame wants the sole window."
+ (should (cj/org-capture--popup-sole-window-p "org-capture" "CAPTURE-todo.org")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-capture-prefix-only ()
+ "Boundary: the bare \"CAPTURE-\" prefix still matches."
+ (should (cj/org-capture--popup-sole-window-p "org-capture" "CAPTURE-")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-other-frame ()
+ "Boundary: the same menu in a normal frame is left alone."
+ (should-not (cj/org-capture--popup-sole-window-p "emacs" "*Org Select*"))
+ (should-not (cj/org-capture--popup-sole-window-p nil "CAPTURE-todo.org")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-other-buffer ()
+ "Boundary: an unrelated buffer in the popup frame is left alone."
+ (should-not (cj/org-capture--popup-sole-window-p "org-capture" "todo.org"))
+ (should-not (cj/org-capture--popup-sole-window-p "org-capture" "*scratch*")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-nil-buffer ()
+ "Error: a nil or non-string buffer name returns nil without raising."
+ (should-not (cj/org-capture--popup-sole-window-p "org-capture" nil))
+ (should-not (cj/org-capture--popup-sole-window-p "org-capture" 42)))
+
+;;; Integration: the display-buffer-alist entry routes to a sole window
+
+(ert-deftest test-integration-org-capture-popup-display-sole-window ()
+ "Integration: in an \"org-capture\"-named frame, displaying a CAPTURE-*
+buffer fills the frame's sole window via the registered display-buffer-alist
+entry, instead of splitting.
+
+Components integrated:
+- cj/org-capture--popup-display-condition (real)
+- cj/org-capture--display-sole-window (real)
+- display-buffer / display-buffer-alist (real)
+
+Validates the popup frame ends with one window showing the CAPTURE buffer."
+ (let ((buf (get-buffer-create "CAPTURE-itest")))
+ (unwind-protect
+ (progn
+ (set-frame-parameter nil 'name "org-capture")
+ (delete-other-windows)
+ (display-buffer buf)
+ (should (= (length (window-list)) 1))
+ (should (eq (window-buffer (selected-window)) buf)))
+ (set-frame-parameter nil 'name nil)
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+;;; cj/--quick-capture-template (single Task into the inbox)
+
+(ert-deftest test-org-capture-config-quick-capture-template ()
+ "Normal: the quick-capture template is a single Task into INBOX's Inbox."
+ (let* ((tmpl (cj/--quick-capture-template "/inbox.org"))
+ (task (assoc "t" tmpl)))
+ (should (equal (mapcar #'car tmpl) '("t")))
+ (should (equal (nth 1 task) "Task"))
+ (should (eq (nth 2 task) 'entry))
+ (should (equal (nth 3 task) '(file+headline "/inbox.org" "Inbox")))
+ (should (equal (nth 4 task) "* TODO %?"))
+ (should (memq :prepend task))))
+
+;;; cj/quick-capture (single Task; stubbed org-capture)
+
+(ert-deftest test-integration-org-capture-quick-capture-binds-task-only ()
+ "Integration: cj/quick-capture runs org-capture with a single Task template
+targeting the inbox, dispatched by key.
+
+Components integrated:
+- cj/quick-capture (real)
+- cj/--quick-capture-template (real)
+- org-capture (MOCKED — records the bound templates and dispatch key)"
+ (let (captured key)
+ (cl-letf (((symbol-function 'org-capture)
+ (lambda (&optional _goto k) (setq captured org-capture-templates key k))))
+ (cj/quick-capture))
+ (should (equal (mapcar #'car captured) '("t")))
+ (should (equal (nth 3 (assoc "t" captured)) (list 'file+headline inbox-file "Inbox")))
+ (should (equal (nth 4 (assoc "t" captured)) "* TODO %?"))
+ (should (equal key "t"))))
+
+(ert-deftest test-integration-org-capture-quick-capture-closes-frame-on-abort ()
+ "Integration: when capture aborts (org-capture signals), cj/quick-capture
+deletes the popup frame instead of leaving it orphaned.
+
+Components integrated:
+- cj/quick-capture (real)
+- org-capture (MOCKED — signals user-error \"Abort\")
+- cj/org-capture--delete-popup-frame (MOCKED — records the call)"
+ (let ((deleted 0))
+ (cl-letf (((symbol-function 'org-capture)
+ (lambda (&rest _) (user-error "Abort")))
+ ((symbol-function 'cj/org-capture--delete-popup-frame)
+ (lambda () (cl-incf deleted))))
+ (cj/quick-capture))
+ (should (= deleted 1))))
+
+(ert-deftest test-integration-org-capture-quick-capture-closes-frame-on-quit ()
+ "Integration: a C-g (quit) during capture also closes the popup frame."
+ (let ((deleted 0))
+ (cl-letf (((symbol-function 'org-capture)
+ (lambda (&rest _) (signal 'quit nil)))
+ ((symbol-function 'cj/org-capture--delete-popup-frame)
+ (lambda () (cl-incf deleted))))
+ (cj/quick-capture))
+ (should (= deleted 1))))
+
+(ert-deftest test-integration-org-capture-quick-capture-keeps-frame-on-success ()
+ "Integration: a successful capture (no signal) does NOT delete the frame —
+the finalize hook owns that."
+ (let ((deleted 0))
+ (cl-letf (((symbol-function 'org-capture) (lambda (&rest _) nil))
+ ((symbol-function 'cj/org-capture--delete-popup-frame)
+ (lambda () (cl-incf deleted))))
+ (cj/quick-capture))
+ (should (= deleted 0))))
+
+;;; cj/org-capture--popup-frame-p
+
+(ert-deftest test-org-capture-config-popup-frame-p ()
+ "Normal/Boundary: true only when the selected frame is named \"org-capture\"."
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (&rest _) "org-capture")))
+ (should (cj/org-capture--popup-frame-p)))
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (&rest _) "emacs")))
+ (should-not (cj/org-capture--popup-frame-p))))
+
+;;; cj/org-capture--popup-frame (find the popup frame by name)
+
+(ert-deftest test-org-capture-config-popup-frame-found ()
+ "Normal: returns the live frame whose name is \"org-capture\"."
+ (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) "org-capture" "other"))))
+ (should (eq (cj/org-capture--popup-frame) 'fb))))
+
+(ert-deftest test-org-capture-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/org-capture--popup-frame))))
+
+;;; cj/quick-capture targets the popup frame
+
+(ert-deftest test-integration-org-capture-quick-capture-selects-named-frame ()
+ "Integration: cj/quick-capture selects the \"org-capture\" frame found by name,
+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 &rest _) (setq focused f)))
+ ((symbol-function 'org-capture) (lambda (&rest _) nil)))
+ (cj/quick-capture))
+ (should (eq focused 'popup-frame))))
+
+(ert-deftest test-integration-org-capture-quick-capture-no-frame-still-captures ()
+ "Integration: when no popup frame is found, cj/quick-capture skips the focus
+call and still runs the capture (no error)."
+ (let ((focused 'unset)
+ (captured nil))
+ (cl-letf (((symbol-function 'cj/org-capture--popup-frame) (lambda () nil))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f &rest _) (setq focused f)))
+ ((symbol-function 'org-capture) (lambda (&rest _) (setq captured t))))
+ (cj/quick-capture))
+ (should (eq focused 'unset))
+ (should captured)))
+
+(provide 'test-org-capture-config-popup-window)
+;;; test-org-capture-config-popup-window.el ends here
diff --git a/tests/test-org-capture-config-project-target.el b/tests/test-org-capture-config-project-target.el
new file mode 100644
index 000000000..c9091c912
--- /dev/null
+++ b/tests/test-org-capture-config-project-target.el
@@ -0,0 +1,174 @@
+;;; test-org-capture-config-project-target.el --- Project-aware capture tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the project-aware capture target shared by C-c c t (Task) and
+;; C-c c b (Bug): the pure project-name and target-decision helpers, the
+;; find-or-create "Open Work" / "Inbox" heading helpers, the function-target
+;; wiring, and the two template registrations.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'org)
+(require 'org-capture)
+(require 'user-constants)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-capture-config)
+
+;;; cj/--org-capture-project-name
+
+(ert-deftest test-org-capture-project-name-normal ()
+ "Normal: basename, first letter upcased; trailing slash ignored."
+ (should (equal (cj/--org-capture-project-name "/home/cj/code/duet/") "Duet"))
+ (should (equal (cj/--org-capture-project-name "/home/cj/code/duet") "Duet")))
+
+(ert-deftest test-org-capture-project-name-strips-leading-dot ()
+ "Boundary: a single leading dot is stripped before upcasing."
+ (should (equal (cj/--org-capture-project-name "/home/cj/.emacs.d/") "Emacs.d")))
+
+(ert-deftest test-org-capture-project-name-nil-and-empty ()
+ "Error: nil or empty root yields nil."
+ (should-not (cj/--org-capture-project-name nil))
+ (should-not (cj/--org-capture-project-name "")))
+
+;;; cj/--org-capture-project-target
+
+(ert-deftest test-org-capture-target-project-with-todo ()
+ "Normal: a projectile root whose todo.org exists targets that file's Open Work."
+ (let ((root (make-temp-file "captest-" t)))
+ (unwind-protect
+ (progn
+ (with-temp-file (expand-file-name "todo.org" root)
+ (insert "* X Open Work\n"))
+ (let ((plan (cj/--org-capture-project-target root "/tmp/inbox.org")))
+ (should (string= (plist-get plan :file)
+ (expand-file-name "todo.org" root)))
+ (should (plist-get plan :open-work))
+ (should-not (plist-get plan :warn))))
+ (delete-directory root t))))
+
+(ert-deftest test-org-capture-target-project-without-todo ()
+ "Boundary: a projectile root with no todo.org falls back to inbox and warns."
+ (let ((root (make-temp-file "captest-" t)))
+ (unwind-protect
+ (let ((plan (cj/--org-capture-project-target root "/tmp/inbox.org")))
+ (should (string= (plist-get plan :file) "/tmp/inbox.org"))
+ (should-not (plist-get plan :open-work))
+ (should (stringp (plist-get plan :warn)))
+ (should (string-match-p (regexp-quote (cj/--org-capture-project-name root))
+ (plist-get plan :warn))))
+ (delete-directory root t))))
+
+(ert-deftest test-org-capture-target-no-project ()
+ "Boundary: nil root targets the inbox with no warning."
+ (let ((plan (cj/--org-capture-project-target nil "/tmp/inbox.org")))
+ (should (string= (plist-get plan :file) "/tmp/inbox.org"))
+ (should-not (plist-get plan :open-work))
+ (should-not (plist-get plan :warn))))
+
+;;; cj/--org-capture-goto-open-work
+
+(ert-deftest test-org-capture-goto-open-work-finds-existing ()
+ "Normal: an existing top-level \"... Open Work\" heading is reused, not duplicated."
+ (with-temp-buffer
+ (org-mode)
+ (insert "* Emacs Open Work\n** TODO a\n* Emacs Resolved\n")
+ (cj/--org-capture-goto-open-work "Ignored")
+ (should (string= (org-get-heading t t t t) "Emacs Open Work"))
+ (should-not (string-match-p "Ignored" (buffer-string)))))
+
+(ert-deftest test-org-capture-goto-open-work-matches-tagged-heading ()
+ "Boundary: a tagged \"... Open Work\" heading still matches and is not duplicated."
+ (with-temp-buffer
+ (org-mode)
+ (insert "* Foo Open Work :stuff:\n")
+ (cj/--org-capture-goto-open-work "Bar")
+ (should (string-match-p "Open Work" (org-get-heading t t t t)))
+ (should-not (string-match-p "Bar Open Work" (buffer-string)))))
+
+(ert-deftest test-org-capture-goto-open-work-creates-when-absent ()
+ "Boundary: with no Open Work heading, create \"* NAME Open Work\" at end."
+ (with-temp-buffer
+ (org-mode)
+ (insert "* Something Else\n")
+ (cj/--org-capture-goto-open-work "Duet")
+ (should (string-match-p "^\\* Duet Open Work$" (buffer-string)))
+ (should (string= (org-get-heading t t t t) "Duet Open Work"))))
+
+;;; cj/--org-capture-goto-exact-headline
+
+(ert-deftest test-org-capture-goto-exact-headline-finds ()
+ "Normal: an existing Inbox heading is found."
+ (with-temp-buffer
+ (org-mode)
+ (insert "* Inbox\n** TODO x\n")
+ (cj/--org-capture-goto-exact-headline "Inbox")
+ (should (string= (org-get-heading t t t t) "Inbox"))))
+
+(ert-deftest test-org-capture-goto-exact-headline-creates ()
+ "Boundary: a missing Inbox heading is created at end of buffer."
+ (with-temp-buffer
+ (org-mode)
+ (insert "* Other\n")
+ (cj/--org-capture-goto-exact-headline "Inbox")
+ (should (string-match-p "^\\* Inbox$" (buffer-string)))))
+
+;;; cj/--org-capture-project-location (function-target wiring)
+
+(ert-deftest test-org-capture-location-files-into-project-open-work ()
+ "Integration: in a project with a todo.org, the location function visits that
+file and lands point on its Open Work heading."
+ (let* ((root (make-temp-file "captest-" t))
+ (todo (expand-file-name "todo.org" root))
+ (org-capture-plist nil)
+ visited)
+ (unwind-protect
+ (progn
+ (with-temp-file todo (insert "* Captest Open Work\n** TODO old\n"))
+ (cl-letf (((symbol-function 'projectile-project-root)
+ (lambda (&optional _d) root)))
+ (cj/--org-capture-project-location)
+ (setq visited (current-buffer))
+ (should (string= (buffer-file-name) todo))
+ (should (string-match-p "Open Work" (org-get-heading t t t t)))))
+ (when (buffer-live-p visited) (kill-buffer visited))
+ (delete-directory root t))))
+
+(ert-deftest test-org-capture-location-falls-back-to-inbox-without-project ()
+ "Integration: with no project, the location function visits the inbox file
+under its Inbox heading."
+ (let* ((inbox (make-temp-file "captest-inbox-" nil ".org" "* Inbox\n"))
+ (inbox-file inbox)
+ (org-capture-plist nil)
+ visited)
+ (unwind-protect
+ (cl-letf (((symbol-function 'projectile-project-root)
+ (lambda (&optional _d) nil)))
+ (cj/--org-capture-project-location)
+ (setq visited (current-buffer))
+ (should (string= (buffer-file-name) inbox))
+ (should (string= (org-get-heading t t t t) "Inbox")))
+ (when (buffer-live-p visited) (kill-buffer visited))
+ (delete-file inbox))))
+
+;;; templates
+
+(ert-deftest test-org-capture-task-template-is-project-aware ()
+ "Normal: the Task template (t) targets the project-aware function."
+ (let ((entry (assoc "t" org-capture-templates)))
+ (should entry)
+ (should (equal (nth 3 entry)
+ '(function cj/--org-capture-project-location)))))
+
+(ert-deftest test-org-capture-bug-template-registered ()
+ "Normal: the Bug template (b) exists, targets the project-aware function, and
+defaults to the [#C] priority."
+ (let ((entry (assoc "b" org-capture-templates)))
+ (should entry)
+ (should (equal (nth 3 entry)
+ '(function cj/--org-capture-project-location)))
+ (should (string-match-p "\\[#C\\]" (nth 4 entry)))))
+
+(provide 'test-org-capture-config-project-target)
+;;; test-org-capture-config-project-target.el ends here
diff --git a/tests/test-org-config-table-header.el b/tests/test-org-config-table-header.el
new file mode 100644
index 000000000..38e73b483
--- /dev/null
+++ b/tests/test-org-config-table-header.el
@@ -0,0 +1,115 @@
+;;; test-org-config-table-header.el --- In-buffer org table header fontify -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Org has no in-buffer header-row face -- the whole table uses `org-table'.
+;; cj/--org-table-header-row-p, cj/--org-table-first-hline-position, and the
+;; font-lock matcher cj/--org-fontify-table-header-matcher (org-config.el) add
+;; one: they identify a table's header rows (the non-hline rows above its first
+;; hline) so font-lock can prepend `org-table-header' there. These exercise the
+;; detection logic directly against fixture tables, matching the tag-alignment
+;; test's pure-logic style.
+
+;;; Code:
+
+(require 'ert)
+(require 'org)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-config)
+
+(defmacro test-org-th--in (content &rest body)
+ "Run BODY in a temp org buffer holding CONTENT, hooks suppressed."
+ (declare (indent 1))
+ `(let ((org-mode-hook nil))
+ (with-temp-buffer
+ (insert ,content)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body)))
+
+(defun test-org-th--goto (substring)
+ "Move point to the beginning of the line containing SUBSTRING."
+ (goto-char (point-min))
+ (search-forward substring)
+ (beginning-of-line))
+
+;; ----- cj/--org-table-header-row-p -----
+
+(ert-deftest test-org-table-header-row-p-header-above-hline ()
+ "Normal: a non-hline row above the first hline is a header row."
+ (test-org-th--in "| Name | Age |\n|------+-----|\n| Bob | 3 |\n"
+ (test-org-th--goto "Name")
+ (should (cj/--org-table-header-row-p))))
+
+(ert-deftest test-org-table-header-row-p-body-row-not-header ()
+ "Normal: a row below the first hline is not a header row."
+ (test-org-th--in "| Name | Age |\n|------+-----|\n| Bob | 3 |\n"
+ (test-org-th--goto "Bob")
+ (should-not (cj/--org-table-header-row-p))))
+
+(ert-deftest test-org-table-header-row-p-hline-not-header ()
+ "Boundary: the hline itself is not a header row."
+ (test-org-th--in "| Name | Age |\n|------+-----|\n| Bob | 3 |\n"
+ (test-org-th--goto "----")
+ (should-not (cj/--org-table-header-row-p))))
+
+(ert-deftest test-org-table-header-row-p-no-hline-no-header ()
+ "Boundary: a table with no hline has no header rows."
+ (test-org-th--in "| A | B |\n| x | y |\n"
+ (test-org-th--goto "A |")
+ (should-not (cj/--org-table-header-row-p))))
+
+(ert-deftest test-org-table-header-row-p-multi-row-header ()
+ "Boundary: every non-hline row above the first hline is a header row."
+ (test-org-th--in "| A | B |\n| C | D |\n|---+---|\n| x | y |\n"
+ (test-org-th--goto "A |")
+ (should (cj/--org-table-header-row-p))
+ (test-org-th--goto "C |")
+ (should (cj/--org-table-header-row-p))))
+
+(ert-deftest test-org-table-header-row-p-key-value-first-row-only ()
+ "Boundary: hline-after-every-row table -- only the first row is header."
+ (test-org-th--in "| Status | draft |\n|--------+-------|\n| Owner | cj |\n|--------+-------|\n"
+ (test-org-th--goto "Status")
+ (should (cj/--org-table-header-row-p))
+ (test-org-th--goto "Owner")
+ (should-not (cj/--org-table-header-row-p))))
+
+(ert-deftest test-org-table-header-row-p-non-table-line ()
+ "Error: a line that is not in a table is never a header row."
+ (test-org-th--in "Just some prose.\n"
+ (test-org-th--goto "prose")
+ (should-not (cj/--org-table-header-row-p))))
+
+;; ----- cj/--org-table-first-hline-position -----
+
+(ert-deftest test-org-table-first-hline-position-found ()
+ "Normal: returns the bol of the first hline in the table."
+ (test-org-th--in "| Name | Age |\n|------+-----|\n| Bob | 3 |\n"
+ (test-org-th--goto "Name")
+ (let ((expected (save-excursion (goto-char (point-min))
+ (forward-line 1)
+ (line-beginning-position))))
+ (should (equal (cj/--org-table-first-hline-position) expected)))))
+
+(ert-deftest test-org-table-first-hline-position-none ()
+ "Boundary: a table with no hline returns nil."
+ (test-org-th--in "| A | B |\n| x | y |\n"
+ (test-org-th--goto "A |")
+ (should-not (cj/--org-table-first-hline-position))))
+
+;; ----- cj/--org-fontify-table-header-matcher -----
+
+(ert-deftest test-org-fontify-table-header-matcher-matches-header-only ()
+ "Normal: the matcher sets match data to the header row, then stops."
+ (test-org-th--in "| Name | Age |\n|------+-----|\n| Bob | 3 |\n"
+ (should (cj/--org-fontify-table-header-matcher (point-max)))
+ (should (equal (match-string 0) "| Name | Age |"))
+ (should-not (cj/--org-fontify-table-header-matcher (point-max)))))
+
+(ert-deftest test-org-fontify-table-header-matcher-no-header ()
+ "Boundary: a table with no hline yields no matches."
+ (test-org-th--in "| A | B |\n| x | y |\n"
+ (should-not (cj/--org-fontify-table-header-matcher (point-max)))))
+
+(provide 'test-org-config-table-header)
+;;; test-org-config-table-header.el ends here
diff --git a/tests/test-org-drill-config-commands.el b/tests/test-org-drill-config-commands.el
index 7d1976164..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))))
@@ -71,21 +71,50 @@
;;; cj/drill-refile
-(ert-deftest test-org-drill-refile-sets-targets-and-delegates ()
- "Normal: drill-refile narrows `org-refile-targets' to current buffer +
-`drill-dir', then dispatches to `org-refile' via `call-interactively'."
- (let (seen-targets called-fn)
- (cl-letf (((symbol-function 'call-interactively)
- (lambda (fn)
+(ert-deftest test-org-drill-refile-targets-from-validated-helper ()
+ "Normal: drill-refile builds its drill targets from the shared
+`cj/--drill-files-or-error' helper, expanded against `drill-dir' — not from
+a raw `directory-files' call (so it inherits the helper's dot-file exclusion
+and validation)."
+ (let ((drill-dir "/tmp/cj-drill/")
+ seen-targets called-fn)
+ (cl-letf (((symbol-function 'cj/--drill-files-or-error)
+ (lambda (_dir) '("a.org" "b.org")))
+ ;; If the old raw path were still in use it would call
+ ;; `directory-files'; a sentinel here keeps it from masquerading.
+ ((symbol-function 'directory-files)
+ (lambda (&rest _) '("/WRONG/raw.org")))
+ ((symbol-function 'call-interactively)
+ (lambda (fn &rest _)
(setq called-fn fn
seen-targets org-refile-targets))))
(cj/drill-refile))
(should (eq called-fn 'org-refile))
- (should seen-targets)
- ;; Two entries: (nil :maxlevel . 1) and (drill-dir :maxlevel . 1).
(should (= 2 (length seen-targets)))
(should (assoc nil seen-targets))
- (should (assoc 'drill-dir seen-targets))))
+ (should (equal (car (nth 1 seen-targets))
+ '("/tmp/cj-drill/a.org" "/tmp/cj-drill/b.org")))))
+
+(ert-deftest test-org-drill-refile-does-not-clobber-global-targets ()
+ "Error: drill-refile let-binds `org-refile-targets'; the session-wide value
+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 &rest _) nil)))
+ (cj/drill-refile))
+ (should (equal org-refile-targets '((sentinel :maxlevel . 9))))))
+
+(ert-deftest test-org-drill-refile-errors-on-missing-drill-dir ()
+ "Error: a missing or unreadable drill dir signals a clear `user-error' via
+the shared validated helper, instead of a low-level error, and never reaches
+`org-refile'."
+ (let ((drill-dir (expand-file-name "cj-drill-nonexistent-XYZ/"
+ temporary-file-directory))
+ (called nil))
+ (cl-letf (((symbol-function 'call-interactively) (lambda (_fn &rest _) (setq called t))))
+ (should-error (cj/drill-refile) :type 'user-error))
+ (should-not called)))
(provide 'test-org-drill-config-commands)
;;; test-org-drill-config-commands.el ends here
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-faces-config.el b/tests/test-org-faces-config.el
new file mode 100644
index 000000000..8e7da3309
--- /dev/null
+++ b/tests/test-org-faces-config.el
@@ -0,0 +1,54 @@
+;;; test-org-faces-config.el --- Tests for org-faces-config -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Verifies the custom agenda header-row faces exist and that the keyword and
+;; priority maps wire each keyword / priority to its org-faces-* face. org is
+;; required first so the `with-eval-after-load' wiring in org-faces-config fires
+;; on load.
+
+;;; Code:
+
+(require 'ert)
+(require 'org)
+(require 'org-faces-config)
+
+(ert-deftest test-org-faces-config-base-faces-exist ()
+ "Normal: every base keyword and priority face is defined."
+ (dolist (f '(org-faces-todo org-faces-project org-faces-doing org-faces-waiting
+ org-faces-verify org-faces-stalled org-faces-delegated org-faces-failed
+ org-faces-done org-faces-cancelled
+ org-faces-priority-a org-faces-priority-b org-faces-priority-c org-faces-priority-d))
+ (should (facep f))))
+
+(ert-deftest test-org-faces-config-dim-faces-exist ()
+ "Normal: every dim variant is defined (auto-dim remaps onto these)."
+ (dolist (f '(org-faces-todo-dim org-faces-project-dim org-faces-doing-dim org-faces-waiting-dim
+ org-faces-verify-dim org-faces-stalled-dim org-faces-delegated-dim org-faces-failed-dim
+ org-faces-done-dim org-faces-cancelled-dim
+ org-faces-priority-a-dim org-faces-priority-b-dim org-faces-priority-c-dim org-faces-priority-d-dim))
+ (should (facep f))))
+
+(ert-deftest test-org-faces-config-keyword-map ()
+ "Normal: representative keywords map to their org-faces-* face."
+ (should (eq (cdr (assoc "TODO" org-todo-keyword-faces)) 'org-faces-todo))
+ (should (eq (cdr (assoc "VERIFY" org-todo-keyword-faces)) 'org-faces-verify))
+ (should (eq (cdr (assoc "CANCELLED" org-todo-keyword-faces)) 'org-faces-cancelled))
+ (should (eq (cdr (assoc "DELEGATED" org-todo-keyword-faces)) 'org-faces-delegated)))
+
+(ert-deftest test-org-faces-config-keyword-coverage ()
+ "Boundary: all ten keywords are mapped, each to a real face."
+ (dolist (kw '("TODO" "PROJECT" "DOING" "WAITING" "VERIFY" "STALLED"
+ "DELEGATED" "FAILED" "DONE" "CANCELLED"))
+ (let ((face (cdr (assoc kw org-todo-keyword-faces))))
+ (should face)
+ (should (facep face)))))
+
+(ert-deftest test-org-faces-config-priority-map ()
+ "Normal: each priority A-D maps to its org-faces-priority-* face."
+ (should (eq (cdr (assq ?A org-priority-faces)) 'org-faces-priority-a))
+ (should (eq (cdr (assq ?B org-priority-faces)) 'org-faces-priority-b))
+ (should (eq (cdr (assq ?C org-priority-faces)) 'org-faces-priority-c))
+ (should (eq (cdr (assq ?D org-priority-faces)) 'org-faces-priority-d)))
+
+(provide 'test-org-faces-config)
+;;; test-org-faces-config.el ends here
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-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-roam-config-dailies-head.el b/tests/test-org-roam-config-dailies-head.el
new file mode 100644
index 000000000..631f017c3
--- /dev/null
+++ b/tests/test-org-roam-config-dailies-head.el
@@ -0,0 +1,29 @@
+;;; test-org-roam-config-dailies-head.el --- Tests for the dailies template head -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/--org-roam-dailies-head' is the head inserted into a new org-roam
+;; daily file. #+FILETAGS and #+TITLE must sit on separate lines, or Org
+;; never parses the #+TITLE keyword and the FILETAGS value swallows the
+;; rest of the line.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-roam-config)
+
+(ert-deftest test-org-roam-config-dailies-head-separates-filetags-and-title ()
+ "Boundary: #+FILETAGS and #+TITLE sit on separate lines."
+ (should (string-match-p "#\\+FILETAGS: Journal\n#\\+TITLE:"
+ cj/--org-roam-dailies-head))
+ ;; And never run together on one line.
+ (should-not (string-match-p "Journal #\\+TITLE:" cj/--org-roam-dailies-head)))
+
+(ert-deftest test-org-roam-config-dailies-head-ends-with-newline ()
+ "Boundary: the head ends with a newline so the capture body starts clean."
+ (should (string-suffix-p "\n" cj/--org-roam-dailies-head)))
+
+(provide 'test-org-roam-config-dailies-head)
+;;; test-org-roam-config-dailies-head.el ends here
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 eef2d9102..33c503377 100644
--- a/tests/test-prog-c-mode-settings.el
+++ b/tests/test-prog-c-mode-settings.el
@@ -16,9 +16,9 @@
"Normal: cj/c-mode-settings applies the documented buffer-local values."
(with-temp-buffer
(cl-letf (((symbol-function 'auto-fill-mode) (lambda (&rest _) nil))
- ((symbol-function 'electric-pair-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))
@@ -31,9 +31,9 @@
(let ((lsp-calls 0))
(with-temp-buffer
(cl-letf (((symbol-function 'auto-fill-mode) (lambda (&rest _) nil))
- ((symbol-function 'electric-pair-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))))
@@ -42,9 +42,9 @@
(let ((lsp-calls 0))
(with-temp-buffer
(cl-letf (((symbol-function 'auto-fill-mode) (lambda (&rest _) nil))
- ((symbol-function 'electric-pair-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--electric-pair-angle.el b/tests/test-prog-general--electric-pair-angle.el
new file mode 100644
index 000000000..cb33725a2
--- /dev/null
+++ b/tests/test-prog-general--electric-pair-angle.el
@@ -0,0 +1,54 @@
+;;; test-prog-general--electric-pair-angle.el --- Angle-bracket pairing inhibit -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/--electric-pair-inhibit-angle, which stops electric-pair from
+;; pairing "<" into "<>". Craig's yasnippet keys start with "<" (e.g. <cj);
+;; auto-pairing the "<" strands a ">" after the expanded snippet, which broke
+;; the cj-comment close fence into "#+end_src>".
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'elec-pair)
+(require 'org)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'prog-general)
+
+;;; cj/--electric-pair-inhibit-angle
+
+(ert-deftest test-prog-general-electric-pair-inhibit-angle-open ()
+ "Normal: the open angle bracket is inhibited."
+ (should (cj/--electric-pair-inhibit-angle ?<)))
+
+(ert-deftest test-prog-general-electric-pair-inhibit-angle-delegates ()
+ "Boundary: any other character defers to electric-pair-default-inhibit."
+ (cl-letf (((symbol-function 'electric-pair-default-inhibit)
+ (lambda (_c) 'delegated)))
+ (should (eq (cj/--electric-pair-inhibit-angle ?a) 'delegated))
+ (should (eq (cj/--electric-pair-inhibit-angle ?\() 'delegated))))
+
+(ert-deftest test-prog-general-electric-pair-predicate-installed ()
+ "Normal: prog-general installs the predicate as the global value."
+ (should (eq electric-pair-inhibit-predicate #'cj/--electric-pair-inhibit-angle)))
+
+;;; Integration — the actual pairing behavior
+
+(ert-deftest test-integration-prog-general-angle-not-paired-in-org ()
+ "Integration: in an org buffer (where < has paren syntax), typing < with the
+inhibit predicate active inserts just <, not <>.
+
+Components integrated:
+- cj/--electric-pair-inhibit-angle (real)
+- electric-pair-local-mode / self-insert-command (real)
+- org-mode syntax table (real — gives < paren syntax)"
+ (with-temp-buffer
+ (org-mode)
+ (electric-pair-local-mode 1)
+ (setq-local electric-pair-inhibit-predicate #'cj/--electric-pair-inhibit-angle)
+ (let ((last-command-event ?<))
+ (call-interactively #'self-insert-command))
+ (should (equal (buffer-substring-no-properties (point-min) (point-max)) "<"))))
+
+(provide 'test-prog-general--electric-pair-angle)
+;;; test-prog-general--electric-pair-angle.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 6947f358b..6e6998348 100644
--- a/tests/test-prog-go-commands.el
+++ b/tests/test-prog-go-commands.el
@@ -27,19 +27,19 @@
"Normal: tab-width 4, standard-indent 4, indent-tabs-mode t (Go convention)."
(with-temp-buffer
(cl-letf (((symbol-function 'company-mode) #'ignore)
- ((symbol-function 'electric-pair-mode) #'ignore))
+ ((symbol-function 'electric-pair-local-mode) #'ignore))
(cj/go-setup)
(should (= tab-width 4))
(should (= standard-indent 4))
(should indent-tabs-mode))))
(ert-deftest test-prog-go-setup-enables-mode-helpers ()
- "Normal: company-mode and electric-pair-mode are both called."
+ "Normal: company-mode and electric-pair-local-mode are both called."
(with-temp-buffer
(let ((called nil))
(cl-letf (((symbol-function 'company-mode)
(lambda (&rest _) (push 'company called)))
- ((symbol-function 'electric-pair-mode)
+ ((symbol-function 'electric-pair-local-mode)
(lambda (arg) (push (cons 'pair arg) called))))
(cj/go-setup))
(should (memq 'company called))
@@ -50,11 +50,11 @@
(with-temp-buffer
(let ((started nil))
(cl-letf (((symbol-function 'company-mode) #'ignore)
- ((symbol-function 'electric-pair-mode) #'ignore)
+ ((symbol-function 'electric-pair-local-mode) #'ignore)
((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))))
@@ -63,10 +63,10 @@
(with-temp-buffer
(let ((started nil))
(cl-letf (((symbol-function 'company-mode) #'ignore)
- ((symbol-function 'electric-pair-mode) #'ignore)
+ ((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-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-reconcile--find-git-repos.el b/tests/test-reconcile--find-git-repos.el
index e065fca90..c6a190a17 100644
--- a/tests/test-reconcile--find-git-repos.el
+++ b/tests/test-reconcile--find-git-repos.el
@@ -81,6 +81,15 @@
(should (= (length repos) 1))
(should (string-suffix-p "visible-repo" (car repos))))))
+(ert-deftest test-find-git-repos-boundary-dotted-repo-name-found ()
+ "Boundary: a repo whose directory name contains a dot (e.g. mcp.el) is
+discovered. Regression for the `^[^.]+$' filter that matched only dot-free
+names and silently skipped dotted repos like mcp.el / capture.el."
+ (reconcile-test-with-temp-dirs
+ ("mcp.el/.git/" "capture.el/.git/" "plain-repo/.git/")
+ (let ((repos (cj/find-git-repos test-root)))
+ (should (= (length repos) 3)))))
+
(ert-deftest test-find-git-repos-boundary-prunes-heavy-directories ()
"Skips generated/heavy directories while discovering repos."
(reconcile-test-with-temp-dirs
diff --git a/tests/test-selection-framework--consult-line-or-repeat.el b/tests/test-selection-framework--consult-line-or-repeat.el
index fcaddcfd0..66f5b1724 100644
--- a/tests/test-selection-framework--consult-line-or-repeat.el
+++ b/tests/test-selection-framework--consult-line-or-repeat.el
@@ -64,5 +64,11 @@
"Normal: `cj/consult-line-or-repeat' is an interactive command."
(should (commandp #'cj/consult-line-or-repeat)))
+(ert-deftest test-selection-framework-vertico-repeat-save-on-minibuffer-setup ()
+ "Normal: loading the module registers `vertico-repeat-save' on
+`minibuffer-setup-hook'. Without it `vertico-repeat' has no saved session
+and the second C-s signals \"No Vertico session\"."
+ (should (memq 'vertico-repeat-save minibuffer-setup-hook)))
+
(provide 'test-selection-framework--consult-line-or-repeat)
;;; test-selection-framework--consult-line-or-repeat.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-signal-config-notify.el b/tests/test-signal-config-notify.el
new file mode 100644
index 000000000..1a7722893
--- /dev/null
+++ b/tests/test-signal-config-notify.el
@@ -0,0 +1,150 @@
+;;; test-signal-config-notify.el --- Tests for the signal-config notification slice -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; ERT tests for the notification slice of `signal-config': the pure
+;; body formatter (whitespace collapse + truncation to
+;; `cj/signal--notify-body-max') and `cj/signel--notify' routing (the
+;; suppression gate, the notify-script path with the sound flag, and
+;; the `notifications-notify' fallback). Spec: the "Notification
+;; slice" addendum in docs/specs/signal-client-spec-doing.org. No signal-cli or
+;; linked account needed.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+;; signel is the fork at ~/code/signel; signal-config wires it via
+;; use-package but these tests need the symbols available directly.
+(eval-and-compile
+ (add-to-list 'load-path (expand-file-name "~/code/signel")))
+(require 'signel)
+
+(require 'signal-config)
+
+;;; cj/signal--format-notify-body
+
+(ert-deftest test-signal-config-format-notify-body-passthrough ()
+ "Normal: short single-line text passes through unchanged."
+ (should (equal (cj/signal--format-notify-body "lunch at noon?")
+ "lunch at noon?")))
+
+(ert-deftest test-signal-config-format-notify-body-collapses-whitespace ()
+ "Normal: newlines and whitespace runs collapse to single spaces."
+ (should (equal (cj/signal--format-notify-body "two\nlines\n\nhere")
+ "two lines here"))
+ (should (equal (cj/signal--format-notify-body "tabs\t\tand spaces")
+ "tabs and spaces")))
+
+(ert-deftest test-signal-config-format-notify-body-trims ()
+ "Boundary: leading and trailing whitespace is trimmed."
+ (should (equal (cj/signal--format-notify-body " hi ") "hi")))
+
+(ert-deftest test-signal-config-format-notify-body-empty ()
+ "Boundary: the empty string stays empty."
+ (should (equal (cj/signal--format-notify-body "") "")))
+
+(ert-deftest test-signal-config-format-notify-body-exact-limit ()
+ "Boundary: a body exactly at the limit is untouched."
+ (let ((s (make-string cj/signal--notify-body-max ?x)))
+ (should (equal (cj/signal--format-notify-body s) s))))
+
+(ert-deftest test-signal-config-format-notify-body-truncates-over-limit ()
+ "Boundary: over-limit text truncates to the limit, ending in an ellipsis."
+ (let* ((s (make-string (1+ cj/signal--notify-body-max) ?x))
+ (out (cj/signal--format-notify-body s)))
+ (should (= (length out) cj/signal--notify-body-max))
+ (should (string-suffix-p "…" out))))
+
+(ert-deftest test-signal-config-format-notify-body-unicode ()
+ "Boundary: multibyte text truncates by characters, not bytes."
+ (let* ((s (make-string (+ cj/signal--notify-body-max 10) ?é))
+ (out (cj/signal--format-notify-body s)))
+ (should (= (length out) cj/signal--notify-body-max))
+ (should (string-suffix-p "…" out))))
+
+;;; cj/signel--notify routing
+
+(ert-deftest test-signal-config-notify-suppressed-when-viewing ()
+ "Normal: nothing fires when the suppression predicate says no."
+ (let (script-calls fallback-calls)
+ (cl-letf (((symbol-function 'cj/signal--should-notify-p)
+ (lambda (_chat-id) nil))
+ ((symbol-function 'start-process)
+ (lambda (&rest args) (push args script-calls) nil))
+ ((symbol-function 'notifications-notify)
+ (lambda (&rest args) (push args fallback-calls) nil)))
+ (cj/signel--notify "+15551234567" "Alice" "hi"))
+ (should-not script-calls)
+ (should-not fallback-calls)))
+
+(ert-deftest test-signal-config-notify-script-silent-by-default ()
+ "Normal: with the script present and sound off, runs notify info --silent."
+ (let (script-calls)
+ (cl-letf (((symbol-function 'cj/signal--should-notify-p)
+ (lambda (_chat-id) t))
+ ((symbol-function 'executable-find)
+ (lambda (p &optional _remote)
+ (when (equal p "notify") "/usr/bin/notify")))
+ ((symbol-function 'start-process)
+ (lambda (&rest args) (push args script-calls) nil))
+ ((symbol-function 'notifications-notify)
+ (lambda (&rest _)
+ (error "Fallback must not fire when the script is present"))))
+ (let ((cj/signel-notify-sound nil))
+ (cj/signel--notify "+15551234567" "Alice" "hi")))
+ (should (= (length script-calls) 1))
+ ;; start-process args: (NAME BUFFER PROGRAM &rest PROGRAM-ARGS);
+ ;; PROGRAM is the path executable-find resolved, not the bare name.
+ (should (equal (nthcdr 2 (car script-calls))
+ '("/usr/bin/notify" "info" "Signal: Alice" "hi" "--silent")))))
+
+(ert-deftest test-signal-config-notify-sound-enabled-drops-silent ()
+ "Normal: with `cj/signel-notify-sound' non-nil, --silent is omitted."
+ (let (script-calls)
+ (cl-letf (((symbol-function 'cj/signal--should-notify-p)
+ (lambda (_chat-id) t))
+ ((symbol-function 'executable-find)
+ (lambda (p &optional _remote)
+ (when (equal p "notify") "/usr/bin/notify")))
+ ((symbol-function 'start-process)
+ (lambda (&rest args) (push args script-calls) nil)))
+ (let ((cj/signel-notify-sound t))
+ (cj/signel--notify "+15551234567" "Alice" "hi")))
+ (should (equal (nthcdr 2 (car script-calls))
+ '("/usr/bin/notify" "info" "Signal: Alice" "hi")))))
+
+(ert-deftest test-signal-config-notify-fallback-when-script-missing ()
+ "Error: without the script on PATH, falls back to notifications-notify."
+ (let (script-calls fallback-calls)
+ (cl-letf (((symbol-function 'cj/signal--should-notify-p)
+ (lambda (_chat-id) t))
+ ((symbol-function 'executable-find)
+ (lambda (_p &optional _remote) nil))
+ ((symbol-function 'start-process)
+ (lambda (&rest args) (push args script-calls) nil))
+ ((symbol-function 'notifications-notify)
+ (lambda (&rest args) (push args fallback-calls) nil)))
+ (cj/signel--notify "+15551234567" "Alice" "hi"))
+ (should-not script-calls)
+ (should (= (length fallback-calls) 1))
+ (let ((args (car fallback-calls)))
+ (should (equal (plist-get args :title) "Signal: Alice"))
+ (should (equal (plist-get args :body) "hi")))))
+
+(ert-deftest test-signal-config-notify-formats-body-before-send ()
+ "Normal: the body runs through the formatter before reaching the script."
+ (let (script-calls)
+ (cl-letf (((symbol-function 'cj/signal--should-notify-p)
+ (lambda (_chat-id) t))
+ ((symbol-function 'executable-find)
+ (lambda (p &optional _remote)
+ (when (equal p "notify") "/usr/bin/notify")))
+ ((symbol-function 'start-process)
+ (lambda (&rest args) (push args script-calls) nil)))
+ (let ((cj/signel-notify-sound nil))
+ (cj/signel--notify "+15551234567" "Alice" "first line\nsecond line")))
+ (should (equal (nth 5 (car script-calls)) "first line second line"))))
+
+(provide 'test-signal-config-notify)
+;;; test-signal-config-notify.el ends here
diff --git a/tests/test-signel-notify-function.el b/tests/test-signel-notify-function.el
new file mode 100644
index 000000000..e3d97af51
--- /dev/null
+++ b/tests/test-signel-notify-function.el
@@ -0,0 +1,89 @@
+;;; test-signel-notify-function.el --- Tests for signel's notify-function dispatch -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; signel's receive handler (signel.el in the fork at ~/code/signel)
+;; raised notifications through a hardwired `notifications-notify'
+;; call. The notification slice (docs/specs/signal-client-spec-doing.org,
+;; "Notification slice" addendum) replaces that with
+;; `signel-notify-function', a customization point called with
+;; CHAT-ID, SENDER, and BODY so a config layer can add suppression or
+;; route through an external notifier. These tests cover the
+;; dispatch: text, sticker, and attachment bodies reach the function
+;; with the right arguments, and the default preserves the plain
+;; `notifications-notify' behavior.
+;;
+;; `signel--handle-receive' is exercised directly with synthetic
+;; envelope alists; buffer/dashboard side effects are stubbed. No
+;; live process needed.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(eval-and-compile
+ (add-to-list 'load-path (expand-file-name "~/code/signel")))
+(require 'signel)
+
+(defun test-signel-notify--receive (envelope)
+ "Run `signel--handle-receive' on ENVELOPE, capturing notify calls.
+Returns the list of (CHAT-ID SENDER BODY) argument lists the handler
+passed to `signel-notify-function', oldest first. Buffer and
+dashboard side effects are stubbed out."
+ (let (calls)
+ (cl-letf (((symbol-function 'signel--insert-msg) (lambda (&rest _) nil))
+ ((symbol-function 'signel--dashboard-refresh) (lambda () nil))
+ ((symbol-function 'signel--get-buffer)
+ (lambda (_) (current-buffer))))
+ (let ((signel-notify-function
+ (lambda (chat-id sender body)
+ (push (list chat-id sender body) calls)))
+ (signel-auto-open-buffer nil))
+ (signel--handle-receive `((envelope . ,envelope)))))
+ (nreverse calls)))
+
+(ert-deftest test-signel-notify-function-text-message ()
+ "Normal: a text dataMessage calls the function with chat-id, sender, text."
+ (should (equal (test-signel-notify--receive
+ '((sourceNumber . "+15551234567")
+ (sourceName . "Alice")
+ (dataMessage . ((message . "hi there")))))
+ '(("+15551234567" "Alice" "hi there")))))
+
+(ert-deftest test-signel-notify-function-sticker-placeholder ()
+ "Boundary: a sticker with no text gets the [Sticker] placeholder body."
+ (should (equal (test-signel-notify--receive
+ '((sourceNumber . "+15551234567")
+ (sourceName . "Alice")
+ (dataMessage . ((sticker . ((packId . "p1")))))))
+ '(("+15551234567" "Alice" "[Sticker]")))))
+
+(ert-deftest test-signel-notify-function-attachment-placeholder ()
+ "Boundary: an attachment with no text gets the [Attachment] placeholder."
+ (should (equal (test-signel-notify--receive
+ '((sourceNumber . "+15551234567")
+ (sourceName . "Alice")
+ (dataMessage . ((attachments . [((id . "a1"))])))))
+ '(("+15551234567" "Alice" "[Attachment]")))))
+
+(ert-deftest test-signel-notify-function-no-data-no-call ()
+ "Boundary: an envelope with no dataMessage never calls the function."
+ (should-not (test-signel-notify--receive
+ '((sourceNumber . "+15551234567")
+ (sourceName . "Alice")
+ (typingMessage . ((action . "STARTED")))))))
+
+(ert-deftest test-signel-notify-function-default-preserves-behavior ()
+ "Normal: the default value raises a plain notifications-notify toast."
+ (should (eq signel-notify-function #'signel--notify-default))
+ (let (calls)
+ (cl-letf (((symbol-function 'notifications-notify)
+ (lambda (&rest args) (push args calls) nil)))
+ (signel--notify-default "+15551234567" "Alice" "hi"))
+ (should (= (length calls) 1))
+ (let ((args (car calls)))
+ (should (equal (plist-get args :title) "Signel: Alice"))
+ (should (equal (plist-get args :body) "hi")))))
+
+(provide 'test-signel-notify-function)
+;;; test-signel-notify-function.el ends here
diff --git a/tests/test-slack-config-close-all.el b/tests/test-slack-config-close-all.el
new file mode 100644
index 000000000..a7f5423b8
--- /dev/null
+++ b/tests/test-slack-config-close-all.el
@@ -0,0 +1,32 @@
+;;; test-slack-config-close-all.el --- cj/slack-close-all-buffers guard -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/slack-close-all-buffers iterates every buffer. It must not signal
+;; void-variable when `slack-current-buffer' has no binding in a buffer (slack
+;; not loaded), and must kill only buffers where it is set non-nil. The original
+;; read it with `buffer-local-value' (which errors on buffers without the local
+;; binding) instead of guarding like its sibling cj/slack-mark-read-and-bury.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'slack-config)
+
+(ert-deftest test-slack-close-all-buffers-skips-unbound-kills-slack ()
+ "Error/Normal: no signal on buffers without `slack-current-buffer'; only
+buffers that have it set non-nil are killed."
+ (let ((plain (generate-new-buffer " *plain*"))
+ (slackish (generate-new-buffer " *slackish*")))
+ (with-current-buffer slackish (setq-local slack-current-buffer t))
+ (unwind-protect
+ (progn
+ (cj/slack-close-all-buffers)
+ (should (buffer-live-p plain))
+ (should-not (buffer-live-p slackish)))
+ (when (buffer-live-p plain) (kill-buffer plain))
+ (when (buffer-live-p slackish) (kill-buffer slackish)))))
+
+(provide 'test-slack-config-close-all)
+;;; test-slack-config-close-all.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 3c5e59777..f653e1fbb 100644
--- a/tests/test-system-defaults.el
+++ b/tests/test-system-defaults.el
@@ -24,7 +24,10 @@
"Normal: custom-file points at a throwaway temp file, never the repo.
This is what stops accidental Customize writes from landing in tracked init."
(test-system-defaults--with-load-environment
- (let ((custom-file nil))
+ ;; noninteractive is t under ERT batch; bind it nil so the interactive
+ ;; redirect runs (the module guards the redirect to interactive sessions).
+ (let ((custom-file nil)
+ (noninteractive nil))
(test-system-defaults--load)
(should (stringp custom-file))
(should (string-prefix-p (file-name-as-directory
@@ -35,6 +38,15 @@ This is what stops accidental Customize writes from landing in tracked init."
(should-not (string-prefix-p (expand-file-name user-emacs-directory)
(expand-file-name custom-file))))))
+(ert-deftest test-system-defaults-custom-file-not-littered-in-batch ()
+ "Boundary: a noninteractive (batch) load does not create a trashbin custom-file.
+Guards make validate-modules / byte-compile from dropping a temp file per run."
+ (test-system-defaults--with-load-environment
+ (let ((custom-file nil)
+ (noninteractive t))
+ (test-system-defaults--load)
+ (should-not custom-file))))
+
;;; backup directory
(ert-deftest test-system-defaults-backups-redirected-under-user-emacs-dir ()
@@ -51,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-confirm-strong.el b/tests/test-system-lib-confirm-strong.el
new file mode 100644
index 000000000..26c008228
--- /dev/null
+++ b/tests/test-system-lib-confirm-strong.el
@@ -0,0 +1,37 @@
+;;; test-system-lib-confirm-strong.el --- Tests for cj/confirm-strong -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; ERT tests for `cj/confirm-strong', the typed-"yes" confirmation used for
+;; irreversible actions. The behavior under test is the long-form guarantee:
+;; the prompt demands a typed yes/no even when the global single-key default
+;; (`use-short-answers') is in effect.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'system-lib)
+
+(ert-deftest test-system-lib-confirm-strong-returns-t-on-yes ()
+ "Normal: passes a t answer through from `yes-or-no-p'."
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t)))
+ (should (eq (cj/confirm-strong "Really? ") t))))
+
+(ert-deftest test-system-lib-confirm-strong-returns-nil-on-no ()
+ "Normal: passes a nil answer through from `yes-or-no-p'."
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) nil)))
+ (should (eq (cj/confirm-strong "Really? ") nil))))
+
+(ert-deftest test-system-lib-confirm-strong-forces-long-form ()
+ "Boundary: binds `use-short-answers' to nil for the call even when it is
+globally t, so the irreversible prompt requires a typed yes/no regardless of
+the single-key default."
+ (let ((use-short-answers t)
+ (seen 'unset))
+ (cl-letf (((symbol-function 'yes-or-no-p)
+ (lambda (&rest _) (setq seen use-short-answers) t)))
+ (cj/confirm-strong "Really? ")
+ (should (eq seen nil)))))
+
+(provide 'test-system-lib-confirm-strong)
+;;; test-system-lib-confirm-strong.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-term-config--f8-in-term.el b/tests/test-term-config--f8-in-term.el
new file mode 100644
index 000000000..6cee4ff46
--- /dev/null
+++ b/tests/test-term-config--f8-in-term.el
@@ -0,0 +1,42 @@
+;;; test-term-config--f8-in-term.el --- F8 reaches Emacs from inside a ghostel buffer -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; <f8> is a global binding (`cj/main-agenda-display', set in org-agenda-config).
+;; ghostel's semi-char mode forwards every key NOT in `ghostel-keymap-exceptions'
+;; to the terminal program, so a plain <f8> typed while point is in a ghostel
+;; buffer would be sent to the program instead of opening the agenda. Unlike the
+;; F9 family, F8 is NOT re-bound in `ghostel-mode-map' -- it simply falls through
+;; to the global map once the semi-char map stops forwarding it, so the only
+;; wiring term-config.el adds is the keymap-exceptions entry plus the rebuild.
+;; These tests require ghostel (so term-config's `with-eval-after-load' fires)
+;; BEFORE term-config, then confirm the exception landed and the rebuilt
+;; semi-char map no longer forwards <f8>. `(require 'ghostel)' does not load the
+;; native module, so this stays light.
+
+;;; Code:
+
+(require 'ert)
+(require 'package)
+
+(setq package-user-dir (expand-file-name "elpa" user-emacs-directory))
+(package-initialize)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ghostel)
+(require 'term-config)
+
+(ert-deftest test-term-config-f8-in-keymap-exceptions ()
+ "Regression: <f8> is in `ghostel-keymap-exceptions' so semi-char mode lets it
+reach Emacs instead of forwarding it to the terminal program. This is what lets
+the global agenda binding work from inside a ghostel buffer."
+ (should (member "<f8>" ghostel-keymap-exceptions)))
+
+(ert-deftest test-term-config-f8-not-forwarded-by-semi-char-map ()
+ "Regression: the rebuilt semi-char map must no longer forward <f8> to the pty.
+`add-to-list' updates the exceptions list but not the already-built map -- only
+`ghostel--rebuild-semi-char-keymap' (run in term-config's :init) drops the
+forwarding binding so <f8> falls through to the global agenda command."
+ (should-not (eq (keymap-lookup ghostel-semi-char-mode-map "<f8>")
+ 'ghostel--send-event)))
+
+(provide 'test-term-config--f8-in-term)
+;;; test-term-config--f8-in-term.el ends here
diff --git a/tests/test-term-tmux-history.el b/tests/test-term-tmux-history.el
index 51e9725c4..4ad7fb79d 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)))
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-ui-buffer-status-colors.el b/tests/test-ui-buffer-status-colors.el
deleted file mode 100644
index bb905ad4d..000000000
--- a/tests/test-ui-buffer-status-colors.el
+++ /dev/null
@@ -1,221 +0,0 @@
-;;; test-ui-buffer-status-colors.el --- Tests for buffer status colors -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; Unit tests for buffer status color system.
-;; Tests the state detection logic used by both cursor color and modeline.
-
-;;; Code:
-
-(require 'ert)
-(require 'user-constants)
-(require 'ui-config)
-(require 'modeline-config)
-
-;;; Color Constant Tests
-
-(ert-deftest test-buffer-status-colors-has-all-states ()
- "Test that all required states are defined in color alist."
- (should (alist-get 'read-only cj/buffer-status-colors))
- (should (alist-get 'overwrite cj/buffer-status-colors))
- (should (alist-get 'modified cj/buffer-status-colors))
- (should (alist-get 'unmodified cj/buffer-status-colors)))
-
-(ert-deftest test-buffer-status-colors-values-are-strings ()
- "Test that all color values are strings (hex colors)."
- (dolist (entry cj/buffer-status-colors)
- (should (stringp (cdr entry)))
- ;; Check if it looks like a hex color
- (should (string-match-p "^#[0-9a-fA-F]\\{6\\}$" (cdr entry)))))
-
-;;; Cursor Color State Detection Tests
-
-(ert-deftest test-cursor-color-state-read-only-buffer ()
- "Test state detection for read-only buffer."
- (with-temp-buffer
- (setq buffer-read-only t)
- (let* ((state (cond
- (buffer-read-only 'read-only)
- (overwrite-mode 'overwrite)
- ((buffer-modified-p) 'modified)
- (t 'unmodified))))
- (should (eq state 'read-only)))))
-
-(ert-deftest test-cursor-color-state-overwrite-mode ()
- "Test state detection for overwrite mode."
- (with-temp-buffer
- (setq buffer-read-only nil)
- (overwrite-mode 1)
- (let* ((state (cond
- (buffer-read-only 'read-only)
- (overwrite-mode 'overwrite)
- ((buffer-modified-p) 'modified)
- (t 'unmodified))))
- (should (eq state 'overwrite)))))
-
-(ert-deftest test-cursor-color-state-modified-buffer ()
- "Test state detection for modified buffer."
- (with-temp-buffer
- (setq buffer-read-only nil)
- (insert "test")
- (set-buffer-modified-p t)
- (let* ((state (cond
- (buffer-read-only 'read-only)
- (overwrite-mode 'overwrite)
- ((buffer-modified-p) 'modified)
- (t 'unmodified))))
- (should (eq state 'modified)))))
-
-(ert-deftest test-cursor-color-state-unmodified-buffer ()
- "Test state detection for unmodified buffer."
- (with-temp-buffer
- (setq buffer-read-only nil)
- (set-buffer-modified-p nil)
- (let* ((state (cond
- (buffer-read-only 'read-only)
- (overwrite-mode 'overwrite)
- ((buffer-modified-p) 'modified)
- (t 'unmodified))))
- (should (eq state 'unmodified)))))
-
-(ert-deftest test-cursor-color-state-priority-read-only-over-modified ()
- "Test that read-only state takes priority over modified state."
- (with-temp-buffer
- (insert "test")
- (set-buffer-modified-p t)
- (setq buffer-read-only t)
- (let* ((state (cond
- (buffer-read-only 'read-only)
- (overwrite-mode 'overwrite)
- ((buffer-modified-p) 'modified)
- (t 'unmodified))))
- (should (eq state 'read-only)))))
-
-(ert-deftest test-cursor-color-state-priority-overwrite-over-modified ()
- "Test that overwrite mode takes priority over modified state."
- (with-temp-buffer
- (insert "test")
- (set-buffer-modified-p t)
- (overwrite-mode 1)
- (let* ((state (cond
- (buffer-read-only 'read-only)
- (overwrite-mode 'overwrite)
- ((buffer-modified-p) 'modified)
- (t 'unmodified))))
- (should (eq state 'overwrite)))))
-
-;;; Integration Tests - Cursor Color Function
-
-(ert-deftest test-cursor-color-function-exists ()
- "Test that cursor color function is defined."
- (should (fboundp 'cj/set-cursor-color-according-to-mode)))
-
-(ert-deftest test-cursor-color-returns-correct-color-for-read-only ()
- "Test cursor color function returns red for read-only buffer."
- (with-temp-buffer
- (setq buffer-read-only t)
- (let* ((state (cond
- (buffer-read-only 'read-only)
- (overwrite-mode 'overwrite)
- ((buffer-modified-p) 'modified)
- (t 'unmodified)))
- (color (alist-get state cj/buffer-status-colors)))
- (should (equal color "#f06a3f")))))
-
-(ert-deftest test-cursor-color-returns-correct-color-for-overwrite ()
- "Test cursor color function returns gold for overwrite mode."
- (with-temp-buffer
- (overwrite-mode 1)
- (let* ((state (cond
- (buffer-read-only 'read-only)
- (overwrite-mode 'overwrite)
- ((buffer-modified-p) 'modified)
- (t 'unmodified)))
- (color (alist-get state cj/buffer-status-colors)))
- (should (equal color "#c48702")))))
-
-(ert-deftest test-cursor-color-returns-correct-color-for-modified ()
- "Test cursor color function returns green for modified buffer."
- (with-temp-buffer
- (insert "test")
- (set-buffer-modified-p t)
- (let* ((state (cond
- (buffer-read-only 'read-only)
- (overwrite-mode 'overwrite)
- ((buffer-modified-p) 'modified)
- (t 'unmodified)))
- (color (alist-get state cj/buffer-status-colors)))
- (should (equal color "#64aa0f")))))
-
-(ert-deftest test-cursor-color-returns-correct-color-for-unmodified ()
- "Test cursor color function returns white for unmodified buffer."
- (with-temp-buffer
- (set-buffer-modified-p nil)
- (let* ((state (cond
- (buffer-read-only 'read-only)
- (overwrite-mode 'overwrite)
- ((buffer-modified-p) 'modified)
- (t 'unmodified)))
- (color (alist-get state cj/buffer-status-colors)))
- (should (equal color "#ffffff")))))
-
-;;; Modeline Integration Tests
-
-(ert-deftest test-modeline-buffer-name-variable-exists ()
- "Test that modeline buffer name variable is defined."
- (should (boundp 'cj/modeline-buffer-name)))
-
-(ert-deftest test-modeline-buffer-name-is-mode-line-construct ()
- "Test that modeline buffer name is a valid mode-line construct."
- (should (listp cj/modeline-buffer-name))
- (should (eq (car cj/modeline-buffer-name) :eval)))
-
-;;; Edge Cases
-
-(ert-deftest test-buffer-status-new-buffer-starts-unmodified ()
- "Test that new buffer starts in unmodified state."
- (with-temp-buffer
- (let* ((state (cond
- (buffer-read-only 'read-only)
- (overwrite-mode 'overwrite)
- ((buffer-modified-p) 'modified)
- (t 'unmodified))))
- (should (eq state 'unmodified)))))
-
-(ert-deftest test-buffer-status-insert-makes-modified ()
- "Test that inserting text changes state to modified."
- (with-temp-buffer
- ;; Initially unmodified
- (set-buffer-modified-p nil)
- (let ((state1 (cond
- (buffer-read-only 'read-only)
- (overwrite-mode 'overwrite)
- ((buffer-modified-p) 'modified)
- (t 'unmodified))))
- (should (eq state1 'unmodified)))
-
- ;; Insert text
- (insert "test")
- (let ((state2 (cond
- (buffer-read-only 'read-only)
- (overwrite-mode 'overwrite)
- ((buffer-modified-p) 'modified)
- (t 'unmodified))))
- (should (eq state2 'modified)))))
-
-(ert-deftest test-buffer-status-explicit-unmodify ()
- "Test that explicitly setting unmodified works."
- (with-temp-buffer
- (insert "test")
- (should (buffer-modified-p))
-
- ;; Explicitly set unmodified
- (set-buffer-modified-p nil)
- (let ((state (cond
- (buffer-read-only 'read-only)
- (overwrite-mode 'overwrite)
- ((buffer-modified-p) 'modified)
- (t 'unmodified))))
- (should (eq state 'unmodified)))))
-
-(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 852865869..000000000
--- a/tests/test-ui-config--buffer-cursor-state.el
+++ /dev/null
@@ -1,96 +0,0 @@
-;;; test-ui-config--buffer-cursor-state.el --- Tests for cursor-state classification -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; `cj/--buffer-cursor-state' picks the buffer-state symbol that
-;; `cj/set-cursor-color-according-to-mode' maps to a cursor color via
-;; `cj/buffer-status-colors'. 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-cursor-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-cursor-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-cursor-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-cursor-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-cursor-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-cursor-state) 'read-only)))
- (when (buffer-live-p buf) (kill-buffer buf)))))
-
-(ert-deftest test-ui-config-set-cursor-color-live-ghostel-not-orange ()
- "Normal: in a live ghostel terminal the cursor-color hook picks a writeable
-color, not the read-only orange -- even though the buffer is read-only.
-`display-graphic-p' is stubbed t so the function reaches its work body in
-batch mode (the live function no-ops on TTY frames by design)."
- (let ((buf (cj/test--make-fake-ghostel-buffer "*test-ghostel-cursor-color*"))
- (applied 'unset))
- (unwind-protect
- (with-current-buffer buf
- (setq buffer-read-only t)
- (setq-local ghostel--input-mode 'semi-char)
- (let ((cj/-cursor-last-color nil)
- (cj/-cursor-last-buffer nil))
- (cl-letf (((symbol-function 'display-graphic-p) (lambda () t))
- ((symbol-function 'set-cursor-color)
- (lambda (c) (setq applied c))))
- (cj/set-cursor-color-according-to-mode)))
- (should (stringp applied))
- (should-not (equal applied
- (alist-get 'read-only cj/buffer-status-colors))))
- (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-cursor-color-integration.el b/tests/test-ui-cursor-color-integration.el
deleted file mode 100644
index c28bde923..000000000
--- a/tests/test-ui-cursor-color-integration.el
+++ /dev/null
@@ -1,175 +0,0 @@
-;;; test-ui-cursor-color-integration.el --- Integration tests for cursor color -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; Integration tests for cursor color hook behavior.
-;; Tests that cursor color actually updates when switching buffers,
-;; modifying files, etc.
-
-;;; Code:
-
-(require 'ert)
-(require 'user-constants)
-
-;; `cj/set-cursor-color-according-to-mode' and the `post-command-hook'
-;; install both gate on `display-graphic-p' -- a TTY / batch run is a
-;; no-op for cursor coloring by design. These integration tests
-;; exercise the work body, so we pretend we're in a graphical session
-;; for the whole file. Stubbing the symbol BEFORE loading ui-config
-;; matters because the hook install reads `display-graphic-p' at load
-;; time.
-(advice-add 'display-graphic-p :around
- (lambda (orig &rest args) (or (apply orig args) t)))
-
-(require 'ui-config)
-
-;;; Hook Integration Tests
-
-(ert-deftest test-cursor-color-integration-post-command-hook-installed ()
- "Test that post-command-hook is installed."
- (should (member 'cj/set-cursor-color-according-to-mode post-command-hook)))
-
-(ert-deftest test-cursor-color-integration-function-runs-without-error ()
- "Test that cursor color function runs without error in various buffers."
- (with-temp-buffer
- (should-not (condition-case err
- (progn
- (cj/set-cursor-color-according-to-mode)
- nil)
- (error err))))
-
- (with-temp-buffer
- (setq buffer-read-only t)
- (should-not (condition-case err
- (progn
- (cj/set-cursor-color-according-to-mode)
- nil)
- (error err)))))
-
-(ert-deftest test-cursor-color-integration-internal-buffers-ignored ()
- "Test that internal buffers (starting with space) are ignored."
- (let ((internal-buf (get-buffer-create " *test-internal*"))
- (cj/-cursor-last-color nil)
- (cj/-cursor-last-buffer nil))
- (unwind-protect
- (with-current-buffer internal-buf
- (cj/set-cursor-color-according-to-mode)
- ;; Cursor state should not have been updated
- (should-not cj/-cursor-last-buffer))
- (kill-buffer internal-buf))))
-
-(ert-deftest test-cursor-color-integration-normal-buffers-processed ()
- "Test that normal buffers (not starting with space) are processed."
- (let ((normal-buf (get-buffer-create "test-normal"))
- (cj/-cursor-last-color nil)
- (cj/-cursor-last-buffer nil))
- (unwind-protect
- (with-current-buffer normal-buf
- (cj/set-cursor-color-according-to-mode)
- ;; Cursor state should have been updated
- (should (equal cj/-cursor-last-buffer "test-normal")))
- (kill-buffer normal-buf))))
-
-(ert-deftest test-cursor-color-integration-cache-prevents-redundant-updates ()
- "Test that cache prevents redundant cursor color updates."
- (let* ((normal-buf (generate-new-buffer "test-cache"))
- (call-count 0)
- (advice-fn (lambda (&rest _) (setq call-count (1+ call-count)))))
- (unwind-protect
- (progn
- (advice-add 'set-cursor-color :before advice-fn)
- (with-current-buffer normal-buf
- ;; First call - cache matches, no update
- (let ((cj/-cursor-last-color "#ffffff")
- (cj/-cursor-last-buffer (buffer-name)))
- (cj/set-cursor-color-according-to-mode)
- (should (= call-count 0))) ; Cached, no update needed
-
- ;; Modify buffer and clear cache - should update
- (insert "test")
- (let ((cj/-cursor-last-buffer nil)) ; Force update
- (cj/set-cursor-color-according-to-mode)
- (should (= call-count 1))))) ; New state, should update
- (advice-remove 'set-cursor-color advice-fn)
- (kill-buffer normal-buf))))
-
-(ert-deftest test-cursor-color-integration-different-buffers-different-colors ()
- "Test that switching between buffers with different states updates cursor."
- (let ((buf1 (generate-new-buffer "test1"))
- (buf2 (generate-new-buffer "test2"))
- (cj/-cursor-last-color nil)
- (cj/-cursor-last-buffer nil))
- (unwind-protect
- (progn
- ;; Set buf1 to read-only
- (with-current-buffer buf1
- (setq buffer-read-only t)
- (cj/set-cursor-color-according-to-mode)
- (should (equal cj/-cursor-last-color "#f06a3f"))) ; Red
-
- ;; Set buf2 to normal
- (with-current-buffer buf2
- (setq buffer-read-only nil)
- (set-buffer-modified-p nil)
- (cj/set-cursor-color-according-to-mode)
- (should (equal cj/-cursor-last-color "#ffffff")))) ; White
- (kill-buffer buf1)
- (kill-buffer buf2))))
-
-(ert-deftest test-cursor-color-integration-buffer-modification-changes-color ()
- "Test that modifying a buffer changes cursor from white to green."
- (let ((normal-buf (generate-new-buffer "test-mod"))
- (cj/-cursor-last-color nil)
- (cj/-cursor-last-buffer nil))
- (unwind-protect
- (with-current-buffer normal-buf
- ;; Start unmodified
- (set-buffer-modified-p nil)
- (cj/set-cursor-color-according-to-mode)
- (should (equal cj/-cursor-last-color "#ffffff")) ; White
-
- ;; Modify buffer
- (insert "test")
- (should (buffer-modified-p))
- ;; Reset last buffer to force update
- (setq cj/-cursor-last-buffer nil)
- (cj/set-cursor-color-according-to-mode)
- (should (equal cj/-cursor-last-color "#64aa0f"))) ; Green
- (kill-buffer normal-buf))))
-
-(ert-deftest test-cursor-color-integration-save-changes-color-back ()
- "Test that saving a modified buffer changes cursor from green to white."
- (let ((test-file (make-temp-file "test-cursor-"))
- (cj/-cursor-last-color nil)
- (cj/-cursor-last-buffer nil))
- (unwind-protect
- (progn
- ;; Create and modify file
- (with-current-buffer (find-file-noselect test-file)
- (insert "test")
- (should (buffer-modified-p))
- (cj/set-cursor-color-according-to-mode)
- (should (equal cj/-cursor-last-color "#64aa0f")) ; Green
-
- ;; Save file
- (save-buffer)
- (should-not (buffer-modified-p))
- (cj/set-cursor-color-according-to-mode)
- (should (equal cj/-cursor-last-color "#ffffff")) ; White
- (kill-buffer)))
- (delete-file test-file))))
-
-;;; Performance Tests
-
-(ert-deftest test-cursor-color-integration-multiple-calls-efficient ()
- "Test that multiple rapid calls don't cause performance issues."
- (with-temp-buffer
- (let ((start-time (current-time)))
- ;; Call 1000 times
- (dotimes (_ 1000)
- (cj/set-cursor-color-according-to-mode))
- (let ((elapsed (float-time (time-subtract (current-time) start-time))))
- ;; Should complete in less than 1 second (cache makes this very fast)
- (should (< elapsed 1.0))))))
-
-(provide 'test-ui-cursor-color-integration)
-;;; test-ui-cursor-color-integration.el ends here
diff --git a/tests/test-ui-navigation--split-dashboard.el b/tests/test-ui-navigation--split-dashboard.el
new file mode 100644
index 000000000..407335f80
--- /dev/null
+++ b/tests/test-ui-navigation--split-dashboard.el
@@ -0,0 +1,90 @@
+;;; test-ui-navigation--split-dashboard.el --- Tests for split-with-dashboard -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; C-x 2 / C-x 3 split and show the *dashboard* in the new window while point
+;; stays in the original. cj/--split-show-buffer does the placement;
+;; cj/split-below/right-with-dashboard wire it to the two split directions.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ui-navigation)
+
+(ert-deftest test-ui-navigation-split-dashboard-keybindings ()
+ "Normal: C-x 2 / C-x 3 are bound to the dashboard-split commands."
+ (should (eq (key-binding (kbd "C-x 2")) #'cj/split-below-with-dashboard))
+ (should (eq (key-binding (kbd "C-x 3")) #'cj/split-right-with-dashboard)))
+
+(ert-deftest test-ui-navigation-split-show-buffer-displays-and-keeps-point ()
+ "Normal: the new window shows the buffer; the original stays selected."
+ (let ((buf (get-buffer-create " *split-dash-test*"))
+ (config (current-window-configuration)))
+ (unwind-protect
+ (progn
+ (delete-other-windows)
+ (let* ((orig (selected-window))
+ (new (cj/--split-show-buffer #'split-window-below buf)))
+ (should (window-live-p new))
+ (should (not (eq new orig)))
+ (should (eq (window-buffer new) buf))
+ (should (eq (selected-window) orig)))) ; point stays put
+ (set-window-configuration config)
+ (kill-buffer buf))))
+
+(ert-deftest test-ui-navigation-split-below-routes-to-split-window-below ()
+ "Normal: cj/split-below-with-dashboard splits below with the dashboard buffer."
+ (let (captured)
+ (cl-letf (((symbol-function 'cj/--dashboard-buffer) (lambda () 'dashboard))
+ ((symbol-function 'cj/--split-show-buffer)
+ (lambda (fn buf) (setq captured (list fn buf)) nil)))
+ (cj/split-below-with-dashboard))
+ (should (eq (car captured) #'split-window-below))
+ (should (eq (cadr captured) 'dashboard))))
+
+(ert-deftest test-ui-navigation-split-right-routes-to-split-window-right ()
+ "Normal: cj/split-right-with-dashboard splits right with the dashboard buffer."
+ (let (captured)
+ (cl-letf (((symbol-function 'cj/--dashboard-buffer) (lambda () 'dashboard))
+ ((symbol-function 'cj/--split-show-buffer)
+ (lambda (fn buf) (setq captured (list fn buf)) nil)))
+ (cj/split-right-with-dashboard))
+ (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*"))
+ (opened nil))
+ (unwind-protect
+ (cl-letf (((symbol-function 'dashboard-open)
+ (lambda (&rest _) (setq opened t))))
+ (should (eq (cj/--dashboard-buffer) db))
+ (should-not opened))
+ (kill-buffer db))))
+
+(provide 'test-ui-navigation--split-dashboard)
+;;; test-ui-navigation--split-dashboard.el ends here
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 74c1e2fc1..35ed7a020 100644
--- a/tests/test-ui-navigation-split-follow-undo-kill.el
+++ b/tests/test-ui-navigation-split-follow-undo-kill.el
@@ -54,8 +54,9 @@
;;; cj/undo-kill-buffer
-(ert-deftest test-ui-navigation-undo-kill-buffer-opens-most-recent ()
- "Normal: with no arg, opens the head of recentf-list that isn't currently visited."
+(ert-deftest test-ui-navigation-undo-kill-buffer-no-prefix-opens-most-recent ()
+ "Normal: no prefix (arg=1, the value `\"p\"' yields) opens the most-recent
+non-visited entry, not the second."
(let ((opened nil)
(recentf-mode t)
(recentf-list '("/tmp/dead.org" "/tmp/alive.txt")))
@@ -69,14 +70,37 @@
(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 0)
+ (cj/undo-kill-buffer 1)
(when (get-buffer "*test-alive*") (kill-buffer "*test-alive*"))))
(should (equal opened "/tmp/dead.org"))))
-(ert-deftest test-ui-navigation-undo-kill-buffer-honors-numeric-arg ()
- "Normal: with N=1, opens the second non-visited entry from recentf-list."
+(ert-deftest test-ui-navigation-undo-kill-buffer-skips-open-file-at-head ()
+ "Boundary: an open file at the head of the list is skipped (equal, not eq).
+The previous delq compared expand-file-name strings by identity, so a
+currently-open most-recent file was never skipped."
+ (let ((opened nil)
+ (recentf-mode t)
+ ;; The open file is FIRST — only an equal-based filter removes it.
+ (recentf-list '("/tmp/alive.txt" "/tmp/dead.org")))
+ (cl-letf (((symbol-function 'require) (lambda (&rest _) t))
+ ((symbol-function 'recentf-mode) (lambda (&rest _) t))
+ ((symbol-function 'buffer-list)
+ (lambda (&rest _)
+ (list (let ((b (get-buffer-create "*test-alive*")))
+ (with-current-buffer b
+ (setq buffer-file-name "/tmp/alive.txt"))
+ b))))
+ ((symbol-function 'find-file)
+ (lambda (f &rest _) (setq opened f))))
+ (unwind-protect
+ (cj/undo-kill-buffer 1)
+ (when (get-buffer "*test-alive*") (kill-buffer "*test-alive*"))))
+ (should (equal opened "/tmp/dead.org"))))
+
+(ert-deftest test-ui-navigation-undo-kill-buffer-numeric-arg-is-one-based ()
+ "Normal: a numeric prefix is 1-based — N=2 opens the second non-visited entry."
(let ((opened nil)
(recentf-mode t)
(recentf-list '("/tmp/a.org" "/tmp/b.org" "/tmp/c.org")))
@@ -84,11 +108,8 @@
((symbol-function 'recentf-mode) (lambda (&rest _) t))
((symbol-function 'buffer-list) (lambda (&rest _) nil))
((symbol-function 'find-file)
- (lambda (f) (setq opened f))))
- ;; cj/undo-kill-buffer takes a prefix `arg' and indexes into the list
- ;; with `(nth arg ...)` when arg is non-nil. Passing 1 grabs the 2nd
- ;; entry.
- (cj/undo-kill-buffer 1))
+ (lambda (f &rest _) (setq opened f))))
+ (cj/undo-kill-buffer 2))
(should (equal opened "/tmp/b.org"))))
(ert-deftest test-ui-navigation-undo-kill-buffer-no-op-when-list-empty ()
@@ -100,9 +121,22 @@
((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)))
+(ert-deftest test-ui-navigation-undo-kill-buffer-out-of-range-arg-errors ()
+ "Error: a prefix larger than the killed-file list signals a clear user-error,
+not a wrong-type-argument from find-file on nil."
+ (let ((opened nil)
+ (recentf-mode t)
+ (recentf-list '("/tmp/a.org")))
+ (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 &rest _) (setq opened f))))
+ (should-error (cj/undo-kill-buffer 5) :type 'user-error))
+ (should-not opened)))
+
(provide 'test-ui-navigation-split-follow-undo-kill)
;;; test-ui-navigation-split-follow-undo-kill.el ends here
diff --git a/tests/test-ui-theme-commands.el b/tests/test-ui-theme-commands.el
index 55facc17e..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:
@@ -36,13 +35,11 @@
;;; fallback-theme-name default
-(ert-deftest test-ui-theme-default-fallback-is-bundled-dupre ()
- "Normal: the default fallback theme is dupre, the config's bundled theme.
-modus-vivendi ships with Emacs but has no chosen dimming colors; dupre is
-bundled in themes/, so it is available on every machine that loads this
-config and is the right default fallback. Its loadability is covered by
-test-dupre-theme.el."
- (should (equal "dupre" (default-value 'fallback-theme-name))))
+(ert-deftest test-ui-theme-default-fallback-is-builtin-modus ()
+ "Normal: the default fallback theme is modus-vivendi.
+The fallback has no further fallback, so it must be present everywhere this
+config loads. modus-vivendi ships with Emacs, so it always resolves."
+ (should (equal "modus-vivendi" (default-value 'fallback-theme-name))))
;;; cj/save-theme-to-file
@@ -70,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-ui-theme-persistence.el b/tests/test-ui-theme-persistence.el
index 31e0e6cc8..02bb105a6 100644
--- a/tests/test-ui-theme-persistence.el
+++ b/tests/test-ui-theme-persistence.el
@@ -46,12 +46,12 @@
(lambda (&rest _args)
(setq write-file-called t)
(error "write-file should not be used"))))
- (should (cj/theme-write-file-contents "dupre" file)))
+ (should (cj/theme-write-file-contents "modus-vivendi" file)))
(delete-file file))
(should (equal (list (car write-region-args)
(cadr write-region-args)
(nth 2 write-region-args))
- (list "dupre" nil file)))
+ (list "modus-vivendi" nil file)))
(should-not write-file-called)))
(ert-deftest test-ui-theme-load-valid-persisted-theme ()
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)