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-gptel-commands.el5
-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--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--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.el293
-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-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-coverage-core--changed-lines.el101
-rw-r--r--tests/test-coverage-core--project-root.el37
-rw-r--r--tests/test-custom-buffer-file-keymap-bindings.el30
-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-dirvish-config-dired-line-directory.el56
-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-public-wrappers.el19
-rw-r--r--tests/test-dupre-theme.el261
-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-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-font-config--frame-lifecycle.el75
-rw-r--r--tests/test-help-config.el32
-rw-r--r--tests/test-host-environment--detect-system-timezone.el25
-rw-r--r--tests/test-init-module-headers.el2
-rw-r--r--tests/test-jumper--location-candidates.el52
-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-markdown-config.el10
-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-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.el152
-rw-r--r--tests/test-org-config-table-header.el115
-rw-r--r--tests/test-org-faces-config.el54
-rw-r--r--tests/test-prog-c-mode-settings.el6
-rw-r--r--tests/test-prog-general--deadgrep.el44
-rw-r--r--tests/test-prog-general--find-project-root-file.el49
-rw-r--r--tests/test-prog-go-commands.el10
-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.el2
-rw-r--r--tests/test-signel-notify-function.el2
-rw-r--r--tests/test-slack-config-close-all.el32
-rw-r--r--tests/test-system-defaults.el14
-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-toggle--display.el37
-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-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.el23
-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
94 files changed, 3559 insertions, 1268 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-gptel-commands.el b/tests/test-ai-config-gptel-commands.el
index b87c4975e..371a75cc8 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 ()
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--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--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
index 87b17e0a4..8793da73a 100644
--- a/tests/test-build-theme.el
+++ b/tests/test-build-theme.el
@@ -1,4 +1,4 @@
-;;; test-build-theme.el --- Tests for the theme.json -> dupre-*.el converter -*- lexical-binding: t -*-
+;;; test-build-theme.el --- Tests for the theme.json -> deftheme converter -*- lexical-binding: t -*-
;;; Commentary:
@@ -34,12 +34,14 @@
"{
\"name\": \"dupre-fixture\",
\"palette\": [[\"#000000\",\"ground\"],[\"#7a9abe\",\"blue\"],[\"#84b068\",\"green\"]],
- \"assignments\": {
- \"bg\":\"#000000\", \"p\":\"#cdced1\",
- \"kw\":\"#7a9abe\", \"str\":\"#84b068\", \"cm\":\"#838d97\", \"dec\":\"#e8bd30\"
+ \"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}
},
- \"bold\": [\"kw\"],
- \"italic\": [\"cm\"],
\"ui\": {
\"region\": {\"fg\":null, \"bg\":\"#264364\"},
\"mode-line\": {\"fg\":\"#cdced1\", \"bg\":\"#2f343a\"}
@@ -54,8 +56,10 @@
}"
"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). Owned by the test so it can't drift
-the way Craig's downloaded exports under scripts/theme-studio/ can.")
+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."
@@ -70,7 +74,7 @@ the way Craig's downloaded exports under scripts/theme-studio/ can.")
(unwind-protect (progn ,@body)
(delete-directory ,var t))))
-;; --- WCAG contrast helpers (mirror of the dupre-theme test helpers) ---
+;; --- WCAG contrast helpers ---
(defun test-build-theme--channel-luminance (c)
"Linearize an 8-bit channel value C (0-255) per the WCAG formula."
@@ -91,43 +95,175 @@ the way Craig's downloaded exports under scripts/theme-studio/ can.")
;;; ---------------------------------------------------------------------------
;;; build-theme/--attrs (the core attribute builder)
+;;
+;; `--attrs' takes one face-spec alist and emits a face-attribute plist. It
+;; reads the full attribute model and tolerates the legacy boolean
+;; bold/italic/underline/strike fields that older theme.json exports carry.
-(ert-deftest test-build-theme-attrs-fg-and-bold ()
- "Normal: a foreground plus bold yields :foreground and :weight bold."
- (should (equal (build-theme/--attrs nil "#67809c" nil t nil nil nil nil)
+;; --- Legacy boolean fields still work (back-compat with committed presets) ---
+
+(ert-deftest test-build-theme-attrs-legacy-fg-and-bold ()
+ "Normal: legacy bold flag yields :weight bold."
+ (should (equal (build-theme/--attrs '((fg . "#67809c") (bold . t)))
'(:foreground "#67809c" :weight bold))))
-(ert-deftest test-build-theme-attrs-full-ordering ()
- "Normal: every attribute present, in canonical order."
- (should (equal (build-theme/--attrs 'org-level-1 "#e8bd30" "#1a1714" t t t t 1.3)
- '(:inherit org-level-1 :foreground "#e8bd30" :background "#1a1714"
- :weight bold :slant italic :underline t :strike-through t :height 1.3))))
-
-(ert-deftest test-build-theme-attrs-underline-and-strike ()
- "Normal: underline and strike yield :underline t and :strike-through t."
- (should (equal (build-theme/--attrs nil "#67809c" nil nil nil t t nil)
- '(:foreground "#67809c" :underline t :strike-through t)))
- ;; either alone
- (should (equal (build-theme/--attrs nil nil nil nil nil t nil nil)
- '(:underline t)))
- (should (equal (build-theme/--attrs nil nil nil nil nil nil t nil)
- '(:strike-through t))))
+(ert-deftest test-build-theme-attrs-legacy-italic-underline-strike ()
+ "Normal: legacy italic/underline/strike booleans map to their attributes."
+ (should (equal (build-theme/--attrs '((italic . t))) '(:slant italic)))
+ (should (equal (build-theme/--attrs '((underline . t))) '(:underline t)))
+ (should (equal (build-theme/--attrs '((strike . t))) '(:strike-through t))))
(ert-deftest test-build-theme-attrs-empty-is-nil ()
- "Boundary: a fully-cleared face (all nil) yields an empty plist."
- (should (equal (build-theme/--attrs nil nil nil nil nil nil nil nil) '())))
+ "Boundary: a blank face (empty alist, or all-nil fields) yields an empty plist."
+ (should (equal (build-theme/--attrs '()) '()))
+ (should (equal (build-theme/--attrs '((fg) (bg) (bold) (italic) (underline) (strike))) '())))
(ert-deftest test-build-theme-attrs-bold-false-omits-weight ()
- "Boundary: bold false produces no :weight key (only overrides are written)."
- (should (equal (build-theme/--attrs nil "#cdced1" nil nil nil nil nil nil)
- '(:foreground "#cdced1"))))
+ "Boundary: bold false (or absent) writes no :weight -- only overrides appear."
+ (should (equal (build-theme/--attrs '((fg . "#cdced1") (bold . nil)))
+ '(:foreground "#cdced1")))
+ (should (equal (build-theme/--attrs '((fg . "#cdced1"))) '(:foreground "#cdced1"))))
(ert-deftest test-build-theme-attrs-height-one-omitted ()
- "Boundary: a height of exactly 1.0 is omitted (the default multiplier)."
- (should (equal (build-theme/--attrs nil "#cdced1" nil nil nil nil nil 1.0)
- '(:foreground "#cdced1")))
- (should (equal (build-theme/--attrs nil "#cdced1" nil nil nil nil nil 1)
- '(:foreground "#cdced1"))))
+ "Boundary: a height of exactly 1.0 (or integer 1) is omitted as the default."
+ (should (equal (build-theme/--attrs '((fg . "#cdced1") (height . 1.0))) '(:foreground "#cdced1")))
+ (should (equal (build-theme/--attrs '((fg . "#cdced1") (height . 1))) '(:foreground "#cdced1")))
+ (should (equal (build-theme/--attrs '((height . 1.2))) '(:height 1.2))))
+
+;; --- New attributes ---
+
+(ert-deftest test-build-theme-attrs-family ()
+ "Normal/Boundary: a non-empty family string emits :family; empty is omitted."
+ (should (equal (build-theme/--attrs '((family . "Iosevka"))) '(:family "Iosevka")))
+ (should (equal (build-theme/--attrs '((family . ""))) '()))
+ (should (equal (build-theme/--attrs '((family . nil))) '())))
+
+(ert-deftest test-build-theme-attrs-distant-foreground ()
+ "Normal: distant-fg emits :distant-foreground."
+ (should (equal (build-theme/--attrs '((distant-fg . "#ffffff")))
+ '(:distant-foreground "#ffffff"))))
+
+(ert-deftest test-build-theme-attrs-weight-range ()
+ "Normal: an explicit weight string emits that weight symbol."
+ (should (equal (build-theme/--attrs '((weight . "light"))) '(:weight light)))
+ (should (equal (build-theme/--attrs '((weight . "semibold"))) '(:weight semibold)))
+ (should (equal (build-theme/--attrs '((weight . "heavy"))) '(:weight heavy))))
+
+(ert-deftest test-build-theme-attrs-weight-overrides-legacy-bold ()
+ "Boundary: an explicit weight wins over a legacy bold flag on the same face."
+ (should (equal (build-theme/--attrs '((weight . "light") (bold . t)))
+ '(:weight light))))
+
+(ert-deftest test-build-theme-attrs-slant-range ()
+ "Normal: an explicit slant string emits that slant; it wins over legacy italic."
+ (should (equal (build-theme/--attrs '((slant . "oblique"))) '(:slant oblique)))
+ (should (equal (build-theme/--attrs '((slant . "normal"))) '(:slant normal)))
+ (should (equal (build-theme/--attrs '((slant . "oblique") (italic . t))) '(:slant oblique))))
+
+(ert-deftest test-build-theme-attrs-underline-object ()
+ "Normal/Boundary: the structured underline form covers line/wave and color."
+ ;; plain line in the face color collapses to t
+ (should (equal (build-theme/--attrs '((underline . ((style . "line") (color . nil)))))
+ '(:underline t)))
+ ;; wave alone -> a :style plist
+ (should (equal (build-theme/--attrs '((underline . ((style . "wave") (color . nil)))))
+ '(:underline (:style wave))))
+ ;; colored line -> a :color plist
+ (should (equal (build-theme/--attrs '((underline . ((style . "line") (color . "#cb6b4d")))))
+ '(:underline (:color "#cb6b4d"))))
+ ;; colored wave -> both
+ (should (equal (build-theme/--attrs '((underline . ((style . "wave") (color . "#cb6b4d")))))
+ '(:underline (:color "#cb6b4d" :style wave)))))
+
+(ert-deftest test-build-theme-attrs-strike-object ()
+ "Normal: structured strike emits t for no color, or the color string."
+ (should (equal (build-theme/--attrs '((strike . ((color . nil))))) '(:strike-through t)))
+ (should (equal (build-theme/--attrs '((strike . ((color . "#cb6b4d")))))
+ '(:strike-through "#cb6b4d"))))
+
+(ert-deftest test-build-theme-attrs-migrated-shapes-match-legacy ()
+ "Boundary: the shapes the import migration produces emit identically to the
+legacy booleans they replace, so the cutover keeps generated themes byte-identical.
+Mirrors migrateLegacyFace (app-core.js) / migrate_legacy (face_specs.py)."
+ (should (equal (build-theme/--attrs '((weight . "bold")))
+ (build-theme/--attrs '((bold . t)))))
+ (should (equal (build-theme/--attrs '((slant . "italic")))
+ (build-theme/--attrs '((italic . t)))))
+ (should (equal (build-theme/--attrs '((underline . ((style . "line") (color . nil)))))
+ (build-theme/--attrs '((underline . t)))))
+ (should (equal (build-theme/--attrs '((strike . ((color . nil)))))
+ (build-theme/--attrs '((strike . t))))))
+
+(ert-deftest test-build-theme-attrs-overline ()
+ "Normal/Boundary: overline emits t for no color, the color otherwise, nil when unset."
+ (should (equal (build-theme/--attrs '((overline . ((color . nil))))) '(:overline t)))
+ (should (equal (build-theme/--attrs '((overline . ((color . "#a9b2bb")))))
+ '(:overline "#a9b2bb")))
+ (should (equal (build-theme/--attrs '((overline . nil))) '())))
+
+(ert-deftest test-build-theme-attrs-inverse-and-extend ()
+ "Normal/Boundary: inverse and extend emit t when set, nothing when nil."
+ (should (equal (build-theme/--attrs '((inverse . t))) '(:inverse-video t)))
+ (should (equal (build-theme/--attrs '((extend . t))) '(:extend t)))
+ (should (equal (build-theme/--attrs '((inverse . t) (extend . t)))
+ '(:inverse-video t :extend t)))
+ (should (equal (build-theme/--attrs '((inverse . nil) (extend . nil))) '())))
+
+(ert-deftest test-build-theme-attrs-inherit-any-tier ()
+ "Normal: inherit coerces a face-name string to a symbol (now allowed on every tier)."
+ (should (equal (build-theme/--attrs '((inherit . "shadow"))) '(:inherit shadow)))
+ (should (equal (build-theme/--attrs '((inherit . shadow))) '(:inherit shadow)))
+ (should (equal (build-theme/--attrs '((inherit . nil))) '())))
+
+(ert-deftest test-build-theme-attrs-full-ordering ()
+ "Normal: every attribute present, emitted in canonical order."
+ (should (equal (build-theme/--attrs
+ '((inherit . "org-level-1") (family . "Iosevka")
+ (fg . "#e8bd30") (bg . "#1a1714") (distant-fg . "#ffffff")
+ (weight . "semibold") (slant . "italic") (height . 1.3)
+ (underline . ((style . "wave") (color . "#cb6b4d")))
+ (overline . ((color . "#a9b2bb")))
+ (strike . ((color . nil)))
+ (box . ((style . "line") (color . "#67809c")))
+ (inverse . t) (extend . t)))
+ '(:inherit org-level-1 :family "Iosevka"
+ :foreground "#e8bd30" :background "#1a1714" :distant-foreground "#ffffff"
+ :weight semibold :slant italic :height 1.3
+ :underline (:color "#cb6b4d" :style wave) :overline "#a9b2bb"
+ :strike-through t :box (:line-width 1 :color "#67809c")
+ :inverse-video t :extend t))))
+
+;; --- Attribute-helper edge cases (the coercion functions in isolation) ---
+
+(ert-deftest test-build-theme-weight-helper ()
+ "Boundary: weight prefers explicit string, falls back to bold, else nil."
+ (should (eq (build-theme/--weight '((weight . "bold"))) 'bold))
+ (should (eq (build-theme/--weight '((weight . "light") (bold . t))) 'light))
+ (should (eq (build-theme/--weight '((bold . t))) 'bold))
+ (should (null (build-theme/--weight '((weight . "") (bold . nil)))))
+ (should (null (build-theme/--weight '()))))
+
+(ert-deftest test-build-theme-slant-helper ()
+ "Boundary: slant prefers explicit string, falls back to italic, else nil."
+ (should (eq (build-theme/--slant '((slant . "oblique"))) 'oblique))
+ (should (eq (build-theme/--slant '((italic . t))) 'italic))
+ (should (null (build-theme/--slant '((slant . "")))))
+ (should (null (build-theme/--slant '()))))
+
+(ert-deftest test-build-theme-underline-helper ()
+ "Boundary: underline coercion across nil / legacy t / structured forms."
+ (should (null (build-theme/--underline '((underline . nil)))))
+ (should (eq (build-theme/--underline '((underline . t))) t))
+ (should (eq (build-theme/--underline '((underline . ((style . "line") (color . nil))))) t))
+ (should (equal (build-theme/--underline '((underline . ((style . "wave"))))) '(:style wave)))
+ (should (equal (build-theme/--underline '((underline . ((color . "#aa0000"))))) '(:color "#aa0000"))))
+
+(ert-deftest test-build-theme-line-attr-helper ()
+ "Boundary: the overline/strike coercion: nil / t / {color} forms."
+ (should (null (build-theme/--line-attr nil)))
+ (should (eq (build-theme/--line-attr t) t))
+ (should (eq (build-theme/--line-attr '((color . nil))) t))
+ (should (equal (build-theme/--line-attr '((color . "#abcdef"))) "#abcdef")))
;;; ---------------------------------------------------------------------------
;;; build-theme/--face-spec (skips empty faces)
@@ -145,9 +281,11 @@ the way Craig's downloaded exports under scripts/theme-studio/ can.")
;;; Syntax tier
(ert-deftest test-build-theme-syntax-keyword-bold ()
- "Normal: kw maps to font-lock-keyword-face and picks up the bold set."
- (let* ((assignments '((kw . "#7a9abe") (str . "#84b068")))
- (specs (build-theme/--syntax-face-specs assignments '(kw) '())))
+ "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"))))
@@ -155,7 +293,7 @@ the way Craig's downloaded exports under scripts/theme-studio/ can.")
(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 . "#a9b2bb")) '() '())))
+ (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)))))
@@ -164,12 +302,12 @@ the way Craig's downloaded exports under scripts/theme-studio/ can.")
"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 . "#e8bd30")) '() '())))
+ (let ((specs (build-theme/--syntax-face-specs '((dec . ((fg . "#e8bd30")))))))
(should (null specs))))
(ert-deftest test-build-theme-syntax-comment-italic ()
- "Normal: cm in the italic set yields :slant italic on the comment face."
- (let ((specs (build-theme/--syntax-face-specs '((cm . "#a9b2bb")) '() '(cm))))
+ "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))))
@@ -177,8 +315,9 @@ mapping dec would clobber the type color."
;;; Default face
(ert-deftest test-build-theme-default-face ()
- "Normal: default takes background from bg and foreground from p."
- (should (equal (build-theme/--default-spec '((bg . "#000000") (p . "#cdced1")))
+ "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")))))))
;;; ---------------------------------------------------------------------------
@@ -294,7 +433,7 @@ including an inherit+height package face."
(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\"]],\"assignments\":{\"bg\":\"#000000\",\"p\":\"#ffffff\",\"kw\":\"#67809c\"},\"bold\":[\"kw\"],\"italic\":[],\"ui\":{}}")
+ (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)))
@@ -313,6 +452,25 @@ including an inherit+height package face."
(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
@@ -329,5 +487,46 @@ parse -> spec -> file -> face pipeline preserves the designed contrast."
(should (>= (test-build-theme--contrast fg bg) 4.5))))
(disable-theme 'dupre-fixture))))))
+(ert-deftest test-build-theme-convert-file-new-attributes-round-trip ()
+ "Integration: the new attribute model survives parse -> spec -> file -> face.
+Components integrated:
+- build-theme/convert-file (entry point, real)
+- json parsing of the inline fixture (real)
+- custom-theme-set-faces / load-theme / face-attribute (real)
+Exercises extend, structured underline (wave + color), overline, inverse-video,
+distant-foreground, family, and the weight/slant ranges across the UI and
+package tiers."
+ (test-build-theme--with-sandbox out
+ (let* ((json "{\"name\":\"newattrs\",\"palette\":[[\"#000000\",\"ground\"]],
+ \"syntax\":{\"bg\":{\"fg\":\"#000000\"},\"p\":{\"fg\":\"#ffffff\"}},
+ \"ui\":{
+ \"region\":{\"bg\":\"#264364\",\"extend\":true},
+ \"highlight\":{\"fg\":\"#eddba7\",\"underline\":{\"style\":\"wave\",\"color\":\"#cb6b4d\"},\"overline\":{\"color\":\"#a9b2bb\"}},
+ \"secondary-selection\":{\"bg\":\"#333333\",\"inverse\":true,\"distant-fg\":\"#ffffff\"}
+ },
+ \"packages\":{
+ \"misc\":{
+ \"shadow\":{\"fg\":\"#cdced1\",\"family\":\"Iosevka\",\"weight\":\"light\",\"slant\":\"oblique\",\"source\":\"user\"}
+ }
+ }}")
+ (in (expand-file-name "newattrs.json" out)))
+ (with-temp-file in (insert json))
+ (build-theme/convert-file in out)
+ (let ((custom-theme-load-path (cons out custom-theme-load-path))
+ (load-path (cons out load-path)))
+ (unwind-protect
+ (progn
+ (load-theme 'newattrs t)
+ (should (eq (face-attribute 'region :extend nil t) t))
+ (should (equal (face-attribute 'highlight :underline nil t)
+ '(:color "#cb6b4d" :style wave)))
+ (should (string= (face-attribute 'highlight :overline nil t) "#a9b2bb"))
+ (should (eq (face-attribute 'secondary-selection :inverse-video nil t) t))
+ (should (string= (face-attribute 'secondary-selection :distant-foreground nil t) "#ffffff"))
+ (should (string= (face-attribute 'shadow :family nil t) "Iosevka"))
+ (should (eq (face-attribute 'shadow :weight nil t) 'light))
+ (should (eq (face-attribute 'shadow :slant nil t) 'oblique)))
+ (disable-theme 'newattrs))))))
+
(provide 'test-build-theme)
;;; test-build-theme.el ends here
diff --git a/tests/test-calendar-sync--apply-single-exception.el b/tests/test-calendar-sync--apply-single-exception.el
index 2fcf7c718..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-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-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-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-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-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-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-public-wrappers.el b/tests/test-dirvish-config-public-wrappers.el
index 0a9998646..cec979e4a 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
diff --git a/tests/test-dupre-theme.el b/tests/test-dupre-theme.el
deleted file mode 100644
index 4d0e786cb..000000000
--- a/tests/test-dupre-theme.el
+++ /dev/null
@@ -1,261 +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")))
-
-;;; Diff face legibility (WCAG contrast)
-
-(defun dupre-test--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 dupre-test--relative-luminance (hex)
- "WCAG relative luminance of HEX color \"#rrggbb\"."
- (+ (* 0.2126 (dupre-test--channel-luminance (string-to-number (substring hex 1 3) 16)))
- (* 0.7152 (dupre-test--channel-luminance (string-to-number (substring hex 3 5) 16)))
- (* 0.0722 (dupre-test--channel-luminance (string-to-number (substring hex 5 7) 16)))))
-
-(defun dupre-test--contrast (fg bg)
- "WCAG contrast ratio between hex colors FG and BG."
- (let ((l1 (dupre-test--relative-luminance fg))
- (l2 (dupre-test--relative-luminance bg)))
- (/ (+ (max l1 l2) 0.05) (+ (min l1 l2) 0.05))))
-
-(ert-deftest dupre-diff-changed-faces-meet-wcag-aa ()
- "Error/Regression: diff-changed and diff-refine-changed must stay legible as
-standalone backgrounds (WCAG AA, >= 4.5:1 for normal text). Guards the bug
-where diff-refine-changed rendered the default fg (#f0fef0) on the bright-gold
-yellow-1 (#ffd700) at 1.35:1 -- unreadable wherever the face is used as a plain
-background, not just inside diff-mode's own foreground overlay."
- (require 'diff-mode)
- (load-theme 'dupre t)
- (dolist (face '(diff-changed diff-refine-changed))
- (let ((fg (face-attribute face :foreground nil t))
- (bg (face-attribute face :background nil t)))
- (should (string-match-p "^#[0-9a-fA-F]\\{6\\}$" fg))
- (should (string-match-p "^#[0-9a-fA-F]\\{6\\}$" bg))
- (should (>= (dupre-test--contrast fg bg) 4.5)))))
-
-(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-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-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-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..1b5e61081 100644
--- a/tests/test-host-environment--detect-system-timezone.el
+++ b/tests/test-host-environment--detect-system-timezone.el
@@ -74,5 +74,30 @@ contents primitives."
((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 (_) nil))
+ ((symbol-function 'file-exists-p) (lambda (_) nil))
+ ((symbol-function 'file-symlink-p)
+ (lambda (path) (string= path "/etc/localtime")))
+ ((symbol-function 'file-truename)
+ (lambda (_) "/usr/share/zoneinfo/America/Denver")))
+ (should (equal (cj/detect-system-timezone) "America/Denver"))))
+
+(ert-deftest test-host-environment-detect-tz-symlink-without-zoneinfo-is-nil ()
+ "Error: a symlink target with no /zoneinfo/ segment yields nil."
+ (cl-letf (((symbol-function 'cj/match-localtime-to-zoneinfo)
+ (lambda () nil))
+ ((symbol-function 'getenv) (lambda (_) nil))
+ ((symbol-function 'file-exists-p) (lambda (_) nil))
+ ((symbol-function 'file-symlink-p)
+ (lambda (path) (string= path "/etc/localtime")))
+ ((symbol-function 'file-truename)
+ (lambda (_) "/var/lib/elsewhere/localtime")))
+ (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-init-module-headers.el b/tests/test-init-module-headers.el
index bbda23887..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
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-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-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-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-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
index 34f67b36e..d308fc2b7 100644
--- a/tests/test-org-capture-config-popup-window.el
+++ b/tests/test-org-capture-config-popup-window.el
@@ -1,13 +1,12 @@
-;;; test-org-capture-config-popup-window.el --- Quick-capture popup single-window tests -*- lexical-binding: t; -*-
+;;; test-org-capture-config-popup-window.el --- Quick-capture popup tests -*- lexical-binding: t; -*-
;;; Commentary:
-;; Tests for the pure predicate behind the quick-capture popup single-window
-;; fix. The Hyprland Super+Shift+N popup opens an emacsclient frame named
-;; "org-capture"; in that frame the *Org Select* template menu and the
-;; CAPTURE-* buffer must fill the frame's sole window instead of splitting it.
-;; `cj/org-capture--popup-sole-window-p' is the frame+buffer decision; the
-;; display-buffer action that acts on it is exercised by hand (window ops),
-;; not here.
+;; 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:
@@ -19,18 +18,6 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'org-capture-config)
-(defconst test-org-capture-popup--sample-templates
- '(("t" "Task" entry (function cj/--org-capture-project-location)
- "* TODO %?" :prepend t)
- ("b" "Bug" entry (function cj/--org-capture-project-location)
- "* TODO [#C] %?" :prepend t)
- ("e" "Event" entry (file+headline schedule-file "Scheduled Events")
- "* %?" :prepend t :prepare-finalize cj/org-capture-format-event-headline)
- ("m" "Mu4e Email" entry (file+headline inbox-file "Inbox") "* TODO %?" :prepend t)
- ("L" "Link" entry (file+headline inbox-file "Inbox") "* %?" :immediate-finish t)
- ("d" "Drill Question" entry (file ignore) "* Item :drill:\n%?" :prepend t))
- "A representative org-capture-templates list for popup-subset tests.")
-
;;; cj/org-capture--popup-sole-window-p
(ert-deftest test-org-capture-config-popup-sole-window-p-select-menu ()
@@ -73,9 +60,6 @@ Components integrated:
- display-buffer / display-buffer-alist (real)
Validates the popup frame ends with one window showing the CAPTURE buffer."
- ;; The batch frame is auto-named (\"F1\"), which cannot be restored by name
- ;; (\"F<num> usurped by Emacs\"); reset to nil to return it to auto-naming,
- ;; keeping the test independent of execution order.
(let ((buf (get-buffer-create "CAPTURE-itest")))
(unwind-protect
(progn
@@ -87,76 +71,47 @@ Validates the popup frame ends with one window showing the CAPTURE buffer."
(set-frame-parameter nil 'name nil)
(when (buffer-live-p buf) (kill-buffer buf)))))
-;;; cj/--org-capture-popup-templates (pure subset/retarget)
-
-(ert-deftest test-org-capture-config-popup-templates-keeps-tbe ()
- "Normal: only Task, Bug, Event survive, preserving order."
- (should (equal (mapcar #'car (cj/--org-capture-popup-templates
- test-org-capture-popup--sample-templates "/inbox.org"))
- '("t" "b" "e"))))
+;;; cj/--quick-capture-template (single Task into the inbox)
-(ert-deftest test-org-capture-config-popup-templates-retargets-task-bug ()
- "Normal: Task and Bug retarget to the inbox \"Inbox\" headline; body + props kept."
- (let* ((result (cj/--org-capture-popup-templates
- test-org-capture-popup--sample-templates "/inbox.org"))
- (task (assoc "t" result))
- (bug (assoc "b" result)))
+(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 3 bug) '(file+headline "/inbox.org" "Inbox")))
(should (equal (nth 4 task) "* TODO %?"))
- (should (equal (nth 4 bug) "* TODO [#C] %?"))
(should (memq :prepend task))))
-(ert-deftest test-org-capture-config-popup-templates-event-unchanged ()
- "Boundary: Event passes through untouched, schedule-file target and props intact."
- (let ((event (assoc "e" (cj/--org-capture-popup-templates
- test-org-capture-popup--sample-templates "/inbox.org"))))
- (should (equal (nth 3 event) '(file+headline schedule-file "Scheduled Events")))
- (should (memq :prepare-finalize event))))
-
-(ert-deftest test-org-capture-config-popup-templates-drops-context-templates ()
- "Boundary: context-dependent templates (mu4e, link, drill) are dropped."
- (let ((result (cj/--org-capture-popup-templates
- test-org-capture-popup--sample-templates "/inbox.org")))
- (should-not (assoc "m" result))
- (should-not (assoc "L" result))
- (should-not (assoc "d" result))))
-
-(ert-deftest test-org-capture-config-popup-templates-empty ()
- "Error/Boundary: empty or all-dropped input yields nil without raising."
- (should-not (cj/--org-capture-popup-templates nil "/inbox.org"))
- (should-not (cj/--org-capture-popup-templates
- '(("L" "Link" entry (file+headline f "Inbox") "* %?")) "/inbox.org")))
-
-;;; cj/quick-capture (binds the subset; integration with a stubbed org-capture)
-
-(ert-deftest test-integration-org-capture-quick-capture-binds-subset ()
- "Integration: cj/quick-capture runs org-capture with only Task/Bug/Event,
-Task and Bug retargeted to the inbox.
+;;; 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/--org-capture-popup-templates (real)
-- org-capture (MOCKED — records the dynamically-bound templates)"
- (let ((org-capture-templates test-org-capture-popup--sample-templates)
- captured)
+- 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 (&rest _) (setq captured org-capture-templates))))
+ (lambda (&optional _goto k) (setq captured org-capture-templates key k))))
(cj/quick-capture))
- (should (equal (mapcar #'car captured) '("t" "b" "e")))
+ (should (equal (mapcar #'car captured) '("t")))
(should (equal (nth 3 (assoc "t" captured)) (list 'file+headline inbox-file "Inbox")))
- (should (equal (nth 3 (assoc "b" 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 selection aborts (org-capture signals), cj/quick-capture
+ "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 ((org-capture-templates test-org-capture-popup--sample-templates)
- (deleted 0))
+ (let ((deleted 0))
(cl-letf (((symbol-function 'org-capture)
(lambda (&rest _) (user-error "Abort")))
((symbol-function 'cj/org-capture--delete-popup-frame)
@@ -166,8 +121,7 @@ Components integrated:
(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 ((org-capture-templates test-org-capture-popup--sample-templates)
- (deleted 0))
+ (let ((deleted 0))
(cl-letf (((symbol-function 'org-capture)
(lambda (&rest _) (signal 'quit nil)))
((symbol-function 'cj/org-capture--delete-popup-frame)
@@ -178,31 +132,13 @@ Components integrated:
(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 ((org-capture-templates test-org-capture-popup--sample-templates)
- (deleted 0))
+ (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-strip-specials (drop the Customize menu entry)
-
-(ert-deftest test-org-capture-config-popup-strip-specials-removes-customize ()
- "Normal: the \"C\" Customize entry is removed, \"q\" Abort kept, order intact."
- (should (equal (cj/--org-capture-popup-strip-specials
- '(("C" "Customize org-capture-templates") ("q" "Abort")))
- '(("q" "Abort")))))
-
-(ert-deftest test-org-capture-config-popup-strip-specials-no-customize ()
- "Boundary: specials without a \"C\" entry pass through unchanged."
- (should (equal (cj/--org-capture-popup-strip-specials '(("q" "Abort")))
- '(("q" "Abort")))))
-
-(ert-deftest test-org-capture-config-popup-strip-specials-empty ()
- "Error/Boundary: nil specials yields nil without raising."
- (should-not (cj/--org-capture-popup-strip-specials nil)))
-
;;; cj/org-capture--popup-frame-p
(ert-deftest test-org-capture-config-popup-frame-p ()
@@ -212,26 +148,6 @@ the finalize hook owns that."
(cl-letf (((symbol-function 'frame-parameter) (lambda (&rest _) "emacs")))
(should-not (cj/org-capture--popup-frame-p))))
-;;; cj/org-capture--popup-mks-advice (frame-gated specials stripping)
-
-(ert-deftest test-org-capture-config-popup-mks-advice-strips-in-popup ()
- "Integration: in the popup frame, org-mks receives specials without \"C\"."
- (let (seen)
- (cl-letf (((symbol-function 'cj/org-capture--popup-frame-p) (lambda () t)))
- (cj/org-capture--popup-mks-advice
- (lambda (_table _title _prompt specials) (setq seen specials))
- nil nil nil '(("C" "Customize org-capture-templates") ("q" "Abort"))))
- (should (equal seen '(("q" "Abort"))))))
-
-(ert-deftest test-org-capture-config-popup-mks-advice-keeps-elsewhere ()
- "Integration: in a normal frame, org-mks receives the specials untouched."
- (let (seen)
- (cl-letf (((symbol-function 'cj/org-capture--popup-frame-p) (lambda () nil)))
- (cj/org-capture--popup-mks-advice
- (lambda (_table _title _prompt specials) (setq seen specials))
- nil nil nil '(("C" "Customize org-capture-templates") ("q" "Abort"))))
- (should (equal seen '(("C" "Customize org-capture-templates") ("q" "Abort"))))))
-
;;; cj/org-capture--popup-frame (find the popup frame by name)
(ert-deftest test-org-capture-config-popup-frame-found ()
@@ -254,8 +170,7 @@ the finalize hook owns that."
(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 ((org-capture-templates test-org-capture-popup--sample-templates)
- (focused nil))
+ (let ((focused nil))
(cl-letf (((symbol-function 'cj/org-capture--popup-frame) (lambda () 'popup-frame))
((symbol-function 'select-frame-set-input-focus)
(lambda (f) (setq focused f)))
@@ -266,8 +181,7 @@ not whatever frame happens to be selected (the emacsclient -c focus race)."
(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 ((org-capture-templates test-org-capture-popup--sample-templates)
- (focused 'unset)
+ (let ((focused 'unset)
(captured nil))
(cl-letf (((symbol-function 'cj/org-capture--popup-frame) (lambda () nil))
((symbol-function 'select-frame-set-input-focus)
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-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-prog-c-mode-settings.el b/tests/test-prog-c-mode-settings.el
index eef2d9102..37a77a213 100644
--- a/tests/test-prog-c-mode-settings.el
+++ b/tests/test-prog-c-mode-settings.el
@@ -16,7 +16,7 @@
"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)))
(cj/c-mode-settings))
@@ -31,7 +31,7 @@
(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")))
(cj/c-mode-settings)))
@@ -42,7 +42,7 @@
(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)))
(cj/c-mode-settings)))
diff --git a/tests/test-prog-general--deadgrep.el b/tests/test-prog-general--deadgrep.el
new file mode 100644
index 000000000..21223105d
--- /dev/null
+++ b/tests/test-prog-general--deadgrep.el
@@ -0,0 +1,44 @@
+;;; test-prog-general--deadgrep.el --- Tests for the deadgrep helpers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/deadgrep--initial-term (region text or symbol at point) and cj/--deadgrep-run
+;; (the normalize-root + read-term + invoke tail shared by cj/deadgrep-here and
+;; cj/deadgrep-in-dir) were lifted out of the deadgrep use-package :config.
+;; deadgrep is mocked at the boundary.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'prog-general)
+
+(ert-deftest test-prg-deadgrep-initial-term-symbol-at-point ()
+ "Normal: with no region, the symbol at point seeds the search."
+ (with-temp-buffer
+ (insert "hello world")
+ (goto-char (point-min))
+ (should (equal (cj/deadgrep--initial-term) "hello"))))
+
+(ert-deftest test-prg-deadgrep-initial-term-region ()
+ "Normal: an active region's text seeds the search."
+ (with-temp-buffer
+ (insert "needle")
+ (transient-mark-mode 1)
+ (set-mark (point-min))
+ (goto-char (point-max))
+ (activate-mark)
+ (should (equal (cj/deadgrep--initial-term) "needle"))))
+
+(ert-deftest test-prg-deadgrep-run-normalizes-root-and-passes-term ()
+ "Normal: ROOT is normalized to a directory and TERM is passed through."
+ (let (got-term got-root)
+ (cl-letf (((symbol-function 'deadgrep)
+ (lambda (term root) (setq got-term term got-root root))))
+ (cj/--deadgrep-run "/tmp/foo" "needle"))
+ (should (equal got-term "needle"))
+ (should (equal got-root "/tmp/foo/"))))
+
+(provide 'test-prog-general--deadgrep)
+;;; test-prog-general--deadgrep.el ends here
diff --git a/tests/test-prog-general--find-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-go-commands.el b/tests/test-prog-go-commands.el
index 6947f358b..a2fc0625f 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,7 +50,7 @@
(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)
@@ -63,7 +63,7 @@
(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)))
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
index c4067a663..1a7722893 100644
--- a/tests/test-signal-config-notify.el
+++ b/tests/test-signal-config-notify.el
@@ -6,7 +6,7 @@
;; `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/design/signal-client.org. No signal-cli or
+;; slice" addendum in docs/specs/signal-client-spec-doing.org. No signal-cli or
;; linked account needed.
;;; Code:
diff --git a/tests/test-signel-notify-function.el b/tests/test-signel-notify-function.el
index cff7f7394..e3d97af51 100644
--- a/tests/test-signel-notify-function.el
+++ b/tests/test-signel-notify-function.el
@@ -3,7 +3,7 @@
;;; 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/design/signal-client.org,
+;; 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
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-system-defaults.el b/tests/test-system-defaults.el
index 3c5e59777..928124f56 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 ()
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-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-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-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 8e3900743..f6981a36a 100644
--- a/tests/test-ui-navigation-split-follow-undo-kill.el
+++ b/tests/test-ui-navigation-split-follow-undo-kill.el
@@ -76,6 +76,29 @@ non-visited entry, not the second."
(when (get-buffer "*test-alive*") (kill-buffer "*test-alive*"))))
(should (equal opened "/tmp/dead.org"))))
+(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) (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)
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