aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test-ai-term--close.el31
-rw-r--r--tests/test-build-theme.el333
-rw-r--r--tests/test-calibredb-epub-config--bookmark-name.el87
-rw-r--r--tests/test-calibredb-epub-config--menu.el52
-rw-r--r--tests/test-custom-buffer-file-keymap-bindings.el30
-rw-r--r--tests/test-dashboard-config-recentf-exclude.el33
-rw-r--r--tests/test-dupre-theme.el34
-rw-r--r--tests/test-dwim-shell-config-command-fixes.el33
-rw-r--r--tests/test-help-config.el32
-rw-r--r--tests/test-init-module-headers.el2
-rw-r--r--tests/test-mail-config-refile-folder.el40
-rw-r--r--tests/test-markdown-config.el10
-rw-r--r--tests/test-org-capture-config-popup-window.el281
-rw-r--r--tests/test-org-capture-config-project-target.el174
-rw-r--r--tests/test-org-drill-config-commands.el45
-rw-r--r--tests/test-org-roam-config-dailies-head.el29
-rw-r--r--tests/test-prog-general--electric-pair-angle.el54
-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-signal-config-notify.el150
-rw-r--r--tests/test-signal-config.el10
-rw-r--r--tests/test-signel-notify-function.el89
-rw-r--r--tests/test-system-lib-confirm-strong.el37
-rw-r--r--tests/test-term-config--f8-in-term.el42
-rw-r--r--tests/test-term-tmux-history.el34
-rw-r--r--tests/test-ui-navigation-split-follow-undo-kill.el29
26 files changed, 1681 insertions, 25 deletions
diff --git a/tests/test-ai-term--close.el b/tests/test-ai-term--close.el
index 654e85f0..4098c091 100644
--- a/tests/test-ai-term--close.el
+++ b/tests/test-ai-term--close.el
@@ -13,7 +13,9 @@
(require 'cl-lib)
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
(require 'ai-term)
+(require 'testutil-ghostel-buffers)
(ert-deftest test-ai-term--kill-tmux-session-runs-kill-session ()
"Normal: invokes `tmux kill-session -t <session>'."
@@ -58,6 +60,35 @@
(should (buffer-live-p buf)))
(when (buffer-live-p buf) (kill-buffer buf)))))
+(ert-deftest test-ai-term--close-buffer-keeps-window-split ()
+ "Regression: closing an agent in a split keeps its window in the layout,
+showing a non-agent buffer, instead of deleting the split. Craig's M-F9
+annoyance -- a close must not tear down the window arrangement (the F9 hide
+toggle is what collapses the split; close should not)."
+ (cj/test--kill-agent-buffers)
+ (let ((work (get-buffer-create "*test-close-keep-work*"))
+ (agent (get-buffer-create "agent [close-keep]")))
+ (with-current-buffer agent (setq-local default-directory "/tmp/close-keep/"))
+ (unwind-protect
+ (save-window-excursion
+ (delete-other-windows)
+ (set-window-buffer (selected-window) work)
+ (let ((agent-win (split-window (selected-window) nil 'below)))
+ (set-window-buffer agent-win agent)
+ (should-not (one-window-p))
+ (cl-letf (((symbol-function 'cj/--ai-term-kill-tmux-session)
+ (lambda (_s) 0)))
+ (cj/--ai-term-close-buffer agent))
+ ;; The window survives the close ...
+ (should (window-live-p agent-win))
+ (should-not (one-window-p))
+ ;; ... now showing a non-agent buffer ...
+ (should-not (cj/--ai-term-buffer-p (window-buffer agent-win)))
+ ;; ... and the agent buffer itself is gone.
+ (should-not (buffer-live-p agent))))
+ (when (get-buffer "*test-close-keep-work*") (kill-buffer "*test-close-keep-work*"))
+ (cj/test--kill-agent-buffers))))
+
(ert-deftest test-ai-term--close-target-current-agent-buffer ()
"Normal: returns the current buffer when it is an agent buffer."
(let ((buf (get-buffer-create "agent [cur]")))
diff --git a/tests/test-build-theme.el b/tests/test-build-theme.el
new file mode 100644
index 00000000..87b17e0a
--- /dev/null
+++ b/tests/test-build-theme.el
@@ -0,0 +1,333 @@
+;;; test-build-theme.el --- Tests for the theme.json -> dupre-*.el converter -*- lexical-binding: t -*-
+
+;;; Commentary:
+
+;; ERT tests for scripts/theme-studio/build-theme.el, the converter that
+;; turns a theme.json exported by the theme-studio into a loadable Emacs
+;; deftheme file. This is the correctness-sensitive end of the pipeline, so
+;; it is covered Normal / Boundary / Error per category.
+
+;;; Code:
+
+(require 'ert)
+(require 'json)
+
+;; The converter lives under scripts/, not on the normal load-path. Add it at
+;; compile time too (the validate hook byte-compiles this file in isolation and
+;; only -L's the project, modules, tests, and themes dirs).
+(eval-and-compile
+ (add-to-list 'load-path
+ (expand-file-name
+ "../scripts/theme-studio"
+ (file-name-directory
+ (or load-file-name
+ (bound-and-true-p byte-compile-current-file)
+ buffer-file-name
+ default-directory)))))
+
+(require 'build-theme)
+
+;;; ---------------------------------------------------------------------------
+;;; Fixtures
+
+(defconst test-build-theme--fixture-json
+ "{
+ \"name\": \"dupre-fixture\",
+ \"palette\": [[\"#000000\",\"ground\"],[\"#7a9abe\",\"blue\"],[\"#84b068\",\"green\"]],
+ \"assignments\": {
+ \"bg\":\"#000000\", \"p\":\"#cdced1\",
+ \"kw\":\"#7a9abe\", \"str\":\"#84b068\", \"cm\":\"#838d97\", \"dec\":\"#e8bd30\"
+ },
+ \"bold\": [\"kw\"],
+ \"italic\": [\"cm\"],
+ \"ui\": {
+ \"region\": {\"fg\":null, \"bg\":\"#264364\"},
+ \"mode-line\": {\"fg\":\"#cdced1\", \"bg\":\"#2f343a\"}
+ },
+ \"packages\": {
+ \"org-mode\": {
+ \"org-level-1\": {\"fg\":\"#67809c\",\"bg\":null,\"bold\":true,\"italic\":false,\"inherit\":null,\"source\":\"default\"},
+ \"org-level-2\": {\"fg\":\"#e8bd30\",\"bg\":null,\"bold\":false,\"italic\":false,\"inherit\":\"org-level-1\",\"height\":1.2,\"source\":\"user\"},
+ \"org-tag\": {\"fg\":null,\"bg\":null,\"bold\":false,\"italic\":false,\"inherit\":null,\"source\":\"cleared\"}
+ }
+ }
+}"
+ "A self-contained theme.json exercising every tier: default, syntax (bold +
+italic + the unmappable dec key), UI, and packages (a plain face, an
+inherit+height face, and a cleared face). Owned by the test so it can't drift
+the way Craig's downloaded exports under scripts/theme-studio/ can.")
+
+(defun test-build-theme--write-fixture (dir)
+ "Write the fixture JSON into DIR and return its path."
+ (let ((path (expand-file-name "dupre-fixture.json" dir)))
+ (with-temp-file path (insert test-build-theme--fixture-json))
+ path))
+
+(defmacro test-build-theme--with-sandbox (var &rest body)
+ "Bind VAR to a fresh temp directory, run BODY, then delete it."
+ (declare (indent 1))
+ `(let ((,var (make-temp-file "build-theme-test-" t)))
+ (unwind-protect (progn ,@body)
+ (delete-directory ,var t))))
+
+;; --- WCAG contrast helpers (mirror of the dupre-theme test helpers) ---
+
+(defun test-build-theme--channel-luminance (c)
+ "Linearize an 8-bit channel value C (0-255) per the WCAG formula."
+ (let ((x (/ c 255.0)))
+ (if (<= x 0.03928) (/ x 12.92) (expt (/ (+ x 0.055) 1.055) 2.4))))
+
+(defun test-build-theme--relative-luminance (hex)
+ "WCAG relative luminance of HEX color \"#rrggbb\"."
+ (+ (* 0.2126 (test-build-theme--channel-luminance (string-to-number (substring hex 1 3) 16)))
+ (* 0.7152 (test-build-theme--channel-luminance (string-to-number (substring hex 3 5) 16)))
+ (* 0.0722 (test-build-theme--channel-luminance (string-to-number (substring hex 5 7) 16)))))
+
+(defun test-build-theme--contrast (fg bg)
+ "WCAG contrast ratio between hex colors FG and BG."
+ (let ((l1 (test-build-theme--relative-luminance fg))
+ (l2 (test-build-theme--relative-luminance bg)))
+ (/ (+ (max l1 l2) 0.05) (+ (min l1 l2) 0.05))))
+
+;;; ---------------------------------------------------------------------------
+;;; build-theme/--attrs (the core attribute builder)
+
+(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)
+ '(: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-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) '())))
+
+(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"))))
+
+(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"))))
+
+;;; ---------------------------------------------------------------------------
+;;; build-theme/--face-spec (skips empty faces)
+
+(ert-deftest test-build-theme-face-spec-normal ()
+ "Normal: a face with attrs becomes a custom-theme-set-faces spec."
+ (should (equal (build-theme/--face-spec 'font-lock-string-face '(:foreground "#84b068"))
+ '(font-lock-string-face ((t (:foreground "#84b068")))))))
+
+(ert-deftest test-build-theme-face-spec-empty-skipped ()
+ "Boundary: a face with no attributes (cleared) yields nil, not an empty spec."
+ (should (null (build-theme/--face-spec 'whatever '()))))
+
+;;; ---------------------------------------------------------------------------
+;;; Syntax tier
+
+(ert-deftest test-build-theme-syntax-keyword-bold ()
+ "Normal: kw maps to font-lock-keyword-face and picks up the bold set."
+ (let* ((assignments '((kw . "#7a9abe") (str . "#84b068")))
+ (specs (build-theme/--syntax-face-specs assignments '(kw) '())))
+ (should (member '(font-lock-keyword-face ((t (:foreground "#7a9abe" :weight bold))))
+ specs))
+ (should (member '(font-lock-string-face ((t (:foreground "#84b068"))))
+ specs))))
+
+(ert-deftest test-build-theme-syntax-one-to-many ()
+ "Normal: punc fans out to every punctuation/bracket/delimiter face."
+ (let ((specs (build-theme/--syntax-face-specs '((punc . "#a9b2bb")) '() '())))
+ (dolist (face '(font-lock-punctuation-face font-lock-bracket-face
+ font-lock-delimiter-face font-lock-misc-punctuation-face))
+ (should (member `(,face ((t (:foreground "#a9b2bb")))) specs)))))
+
+(ert-deftest test-build-theme-syntax-decorator-omitted ()
+ "Boundary: dec has no independent Emacs face, so it maps to nothing.
+Emacs renders decorators with font-lock-type-face, which ty already owns;
+mapping dec would clobber the type color."
+ (let ((specs (build-theme/--syntax-face-specs '((dec . "#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))))
+ (should (member '(font-lock-comment-face ((t (:foreground "#a9b2bb" :slant italic))))
+ specs))))
+
+;;; ---------------------------------------------------------------------------
+;;; Default face
+
+(ert-deftest test-build-theme-default-face ()
+ "Normal: default takes background from bg and foreground from p."
+ (should (equal (build-theme/--default-spec '((bg . "#000000") (p . "#cdced1")))
+ '(default ((t (:foreground "#cdced1" :background "#000000")))))))
+
+;;; ---------------------------------------------------------------------------
+;;; UI tier
+
+(ert-deftest test-build-theme-ui-passthrough ()
+ "Normal: a ui face passes fg/bg straight through."
+ (let ((specs (build-theme/--ui-face-specs
+ '((region . ((fg . nil) (bg . "#264364")))
+ (mode-line . ((fg . "#cdced1") (bg . "#2f343a")))))))
+ (should (member '(region ((t (:background "#264364")))) specs))
+ (should (member '(mode-line ((t (:foreground "#cdced1" :background "#2f343a")))) specs))))
+
+(ert-deftest test-build-theme-box-styles ()
+ "Normal/Boundary: a face box spec converts to the right Emacs :box value."
+ (should (equal (build-theme/--box '((style . "released") (width . 1)))
+ '(:line-width 1 :style released-button)))
+ (should (equal (build-theme/--box '((style . "pressed") (width . 2)))
+ '(:line-width 2 :style pressed-button)))
+ (should (equal (build-theme/--box '((style . "line") (color . "#67809c")))
+ '(:line-width 1 :color "#67809c")))
+ (should (equal (build-theme/--box '((style . "line"))) '(:line-width 1)))
+ (should (null (build-theme/--box nil)))
+ (should (null (build-theme/--box '((style . "none"))))))
+
+(ert-deftest test-build-theme-ui-face-emits-box ()
+ "Normal: a ui face with a box exports a :box attribute."
+ (let ((specs (build-theme/--ui-face-specs
+ '((mode-line . ((fg . "#cdced1") (bg . "#2f343a")
+ (box . ((style . "released") (width . 1)))))))))
+ (should (member '(mode-line ((t (:foreground "#cdced1" :background "#2f343a"
+ :box (:line-width 1 :style released-button)))))
+ specs))))
+
+;;; ---------------------------------------------------------------------------
+;;; Package tier
+
+(ert-deftest test-build-theme-package-inherit-and-height ()
+ "Normal: a package face writes :inherit plus overrides plus :height."
+ (let ((specs (build-theme/--package-face-specs
+ '((org-mode . ((org-level-2 . ((fg . "#e8bd30") (bg . nil)
+ (bold . nil) (italic . nil)
+ (inherit . "org-level-1") (height . 1.2)
+ (source . "user")))))))))
+ (should (member '(org-level-2 ((t (:inherit org-level-1 :foreground "#e8bd30" :height 1.2))))
+ specs))))
+
+(ert-deftest test-build-theme-package-underline-and-strike ()
+ "Normal: a package face writes :underline and :strike-through from the flags."
+ (let ((specs (build-theme/--package-face-specs
+ '((shr . ((shr-link . ((fg . "#67809c") (bg . nil) (bold . nil) (italic . nil)
+ (underline . t) (strike . nil) (inherit . nil) (source . "default")))
+ (shr-strike-through . ((fg . "#5e6770") (bg . nil) (bold . nil) (italic . nil)
+ (underline . nil) (strike . t) (inherit . nil) (source . "default")))))))))
+ (should (member '(shr-link ((t (:foreground "#67809c" :underline t)))) specs))
+ (should (member '(shr-strike-through ((t (:foreground "#5e6770" :strike-through t)))) specs))))
+
+(ert-deftest test-build-theme-package-cleared-skipped ()
+ "Boundary: a cleared package face (no renderable attrs) is not emitted."
+ (let ((specs (build-theme/--package-face-specs
+ '((org-mode . ((org-tag . ((fg . nil) (bg . nil) (bold . nil)
+ (italic . nil) (inherit . nil)
+ (height . nil) (source . "cleared")))))))))
+ (should (null specs))))
+
+;;; ---------------------------------------------------------------------------
+;;; Hex validation
+
+(ert-deftest test-build-theme-hex-p ()
+ "Normal/Error: only #rrggbb strings validate."
+ (should (build-theme/--hex-p "#0d0b0a"))
+ (should (build-theme/--hex-p "#FFFFFF"))
+ (should-not (build-theme/--hex-p "0d0b0a"))
+ (should-not (build-theme/--hex-p "#fff"))
+ (should-not (build-theme/--hex-p nil)))
+
+;;; ---------------------------------------------------------------------------
+;;; End-to-end: convert a file and load the result
+
+(ert-deftest test-build-theme-convert-file-writes-loadable-theme ()
+ "Integration: converting the fixture produces a theme Emacs can load.
+Components integrated:
+- build-theme/convert-file (entry point, real)
+- json parsing of the inline fixture (real)
+- custom-theme-set-faces / load-theme (real)
+Validates the syntax, default, UI, and package tiers all reach real faces,
+including an inherit+height package face."
+ (require 'org)
+ (test-build-theme--with-sandbox out
+ (let* ((in (test-build-theme--write-fixture out))
+ (path (build-theme/convert-file in out)))
+ (should (file-exists-p path))
+ (should (string-suffix-p "dupre-fixture-theme.el" path))
+ (let ((custom-theme-load-path (cons out custom-theme-load-path))
+ (load-path (cons out load-path)))
+ (unwind-protect
+ (progn
+ (load-theme 'dupre-fixture t)
+ ;; default tier
+ (should (string= (face-attribute 'default :background nil t) "#000000"))
+ (should (string= (face-attribute 'default :foreground nil t) "#cdced1"))
+ ;; syntax tier (kw is blue + bold in the fixture)
+ (should (string= (face-attribute 'font-lock-keyword-face :foreground nil t) "#7a9abe"))
+ (should (eq (face-attribute 'font-lock-keyword-face :weight nil t) 'bold))
+ ;; ui tier
+ (should (string= (face-attribute 'region :background nil t) "#264364"))
+ ;; package tier — plain face and an inherit+height face
+ (should (string= (face-attribute 'org-level-1 :foreground nil t) "#67809c"))
+ (should (eq (face-attribute 'org-level-2 :inherit nil t) 'org-level-1))
+ (should (= (face-attribute 'org-level-2 :height nil t) 1.2)))
+ (disable-theme 'dupre-fixture))))))
+
+(ert-deftest test-build-theme-convert-file-old-json-without-packages ()
+ "Boundary: a theme.json with no packages key still converts and loads."
+ (test-build-theme--with-sandbox out
+ (let* ((json "{\"name\":\"noformat\",\"palette\":[[\"#000000\",\"ground\"]],\"assignments\":{\"bg\":\"#000000\",\"p\":\"#ffffff\",\"kw\":\"#67809c\"},\"bold\":[\"kw\"],\"italic\":[],\"ui\":{}}")
+ (in (expand-file-name "noformat.json" out)))
+ (with-temp-file in (insert json))
+ (let ((path (build-theme/convert-file in out)))
+ (should (file-exists-p path))
+ (let ((custom-theme-load-path (cons out custom-theme-load-path))
+ (load-path (cons out load-path)))
+ (unwind-protect
+ (progn
+ (load-theme 'noformat t)
+ (should (string= (face-attribute 'default :background nil t) "#000000"))
+ (should (string= (face-attribute 'font-lock-keyword-face :foreground nil t) "#67809c")))
+ (disable-theme 'noformat)))))))
+
+(ert-deftest test-build-theme-convert-file-missing-input-errors ()
+ "Error: a missing input file signals rather than writing garbage."
+ (test-build-theme--with-sandbox out
+ (should-error (build-theme/convert-file (expand-file-name "does-not-exist.json" out) out))))
+
+(ert-deftest test-build-theme-generated-default-meets-wcag-aa ()
+ "Error/Regression: the generated default face stays legible.
+A WCAG-AA (>= 4.5:1) assertion on the round-tripped result -- proves the whole
+parse -> spec -> file -> face pipeline preserves the designed contrast."
+ (test-build-theme--with-sandbox out
+ (let ((path (build-theme/convert-file (test-build-theme--write-fixture out) out)))
+ (let ((custom-theme-load-path (cons out custom-theme-load-path))
+ (load-path (cons out load-path)))
+ (unwind-protect
+ (progn
+ (load-theme 'dupre-fixture t)
+ (let ((fg (face-attribute 'default :foreground nil t))
+ (bg (face-attribute 'default :background nil t)))
+ (should (>= (test-build-theme--contrast fg bg) 4.5))))
+ (disable-theme 'dupre-fixture))))))
+
+(provide 'test-build-theme)
+;;; test-build-theme.el ends here
diff --git a/tests/test-calibredb-epub-config--bookmark-name.el b/tests/test-calibredb-epub-config--bookmark-name.el
new file mode 100644
index 00000000..2e1d253e
--- /dev/null
+++ b/tests/test-calibredb-epub-config--bookmark-name.el
@@ -0,0 +1,87 @@
+;;; test-calibredb-epub-config--bookmark-name.el --- Nov bookmark naming tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the clean "Author, Title" bookmark naming that replaces nov.el's
+;; filename-based default. The name is parsed from the EPUB filename (Calibre's
+;; "<Title> - <Author>.epub" convention), restoring colons that Calibre
+;; sanitized to underscores and reordering to "Author, Title".
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'calibredb-epub-config)
+
+;;; cj/--nov-clean-title
+
+(ert-deftest test-nov-clean-title-passthrough ()
+ "Normal: a clean string is returned unchanged."
+ (should (equal (cj/--nov-clean-title "Agatha Christie") "Agatha Christie"))
+ (should (equal (cj/--nov-clean-title "The A.B.C. Murders") "The A.B.C. Murders")))
+
+(ert-deftest test-nov-clean-title-restores-colon ()
+ "Boundary: Calibre's \"_ \" colon substitution is restored to \": \"."
+ (should (equal (cj/--nov-clean-title "Frege_ A Guide for the Perplexed")
+ "Frege: A Guide for the Perplexed"))
+ (should (equal (cj/--nov-clean-title "The Fool's Progress_ An Honest Novel")
+ "The Fool's Progress: An Honest Novel")))
+
+(ert-deftest test-nov-clean-title-stray-underscore-and-whitespace ()
+ "Boundary: a non-colon underscore becomes a space; whitespace collapses."
+ (should (equal (cj/--nov-clean-title "a_b") "a b"))
+ (should (equal (cj/--nov-clean-title " x y ") "x y")))
+
+(ert-deftest test-nov-clean-title-rejects-blank-and-nonstring ()
+ "Error: nil, empty, all-whitespace, or non-string yields nil."
+ (should-not (cj/--nov-clean-title nil))
+ (should-not (cj/--nov-clean-title ""))
+ (should-not (cj/--nov-clean-title " "))
+ (should-not (cj/--nov-clean-title 42)))
+
+;;; cj/--nov-bookmark-name-from-file
+
+(ert-deftest test-nov-bookmark-name-real-examples ()
+ "Normal: real Calibre filenames become \"Author, Title\" with colons restored."
+ (should (equal (cj/--nov-bookmark-name-from-file
+ "/books/Frege_ A Guide for the Perplexed - Edward Kanterian.epub")
+ "Edward Kanterian, Frege: A Guide for the Perplexed"))
+ (should (equal (cj/--nov-bookmark-name-from-file
+ "/books/The A.B.C. Murders - Agatha Christie.epub")
+ "Agatha Christie, The A.B.C. Murders"))
+ (should (equal (cj/--nov-bookmark-name-from-file
+ "/books/The Fool's Progress_ An Honest Novel - Edward Abbey.epub")
+ "Edward Abbey, The Fool's Progress: An Honest Novel")))
+
+(ert-deftest test-nov-bookmark-name-splits-on-last-separator ()
+ "Boundary: a title containing \" - \" splits on the LAST separator."
+ (should (equal (cj/--nov-bookmark-name-from-file "/b/Title - Part Two - Some Author.epub")
+ "Some Author, Title - Part Two")))
+
+(ert-deftest test-nov-bookmark-name-no-separator ()
+ "Boundary: a filename with no \" - \" falls back to the cleaned whole name."
+ (should (equal (cj/--nov-bookmark-name-from-file "/b/Untitled_ Draft.epub")
+ "Untitled: Draft")))
+
+(ert-deftest test-nov-bookmark-name-nil-and-empty ()
+ "Error: nil or empty path yields nil."
+ (should-not (cj/--nov-bookmark-name-from-file nil))
+ (should-not (cj/--nov-bookmark-name-from-file "")))
+
+;;; cj/--nov-bookmark-rename-record
+
+(ert-deftest test-nov-bookmark-rename-record-replaces-name ()
+ "Normal: the record's name is rebuilt from its filename; the alist is kept."
+ (let* ((record (cons "The A.B.C. Murders - Agatha Christie.epub"
+ '((filename . "/b/The A.B.C. Murders - Agatha Christie.epub")
+ (index . 0))))
+ (out (cj/--nov-bookmark-rename-record record)))
+ (should (equal (car out) "Agatha Christie, The A.B.C. Murders"))
+ (should (equal (cdr out) (cdr record)))))
+
+(ert-deftest test-nov-bookmark-rename-record-keeps-original-without-filename ()
+ "Boundary: a record with no usable filename is returned unchanged."
+ (let ((record (cons "whatever" '((index . 0)))))
+ (should (equal (cj/--nov-bookmark-rename-record record) record))))
+
+(provide 'test-calibredb-epub-config--bookmark-name)
+;;; test-calibredb-epub-config--bookmark-name.el ends here
diff --git a/tests/test-calibredb-epub-config--menu.el b/tests/test-calibredb-epub-config--menu.el
new file mode 100644
index 00000000..4860efc3
--- /dev/null
+++ b/tests/test-calibredb-epub-config--menu.el
@@ -0,0 +1,52 @@
+;;; test-calibredb-epub-config--menu.el --- calibredb curated-menu tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the docked book-description command bound into the curated calibredb
+;; menu. The transient itself, its `?'/`H' keybindings, and the
+;; display-buffer-alist dock live in calibredb's deferred `use-package' config
+;; (they need the elpa transient, which batch does not load) and are verified
+;; live in the daemon; here we cover the describe command, which has no transient
+;; dependency.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'calibredb-epub-config)
+
+;; calibredb vars (defvar'd here so the tests' `let' bindings are dynamic; the
+;; module's bare defvars are file-local to its own compilation unit).
+(defvar calibredb-sort-by)
+(defvar calibredb-search-filter)
+(defvar calibredb-format-filter-p)
+
+(ert-deftest test-calibredb-describe-at-point-shows-entry-without-switch ()
+ "Normal: describe calls `calibredb-show-entry' on the entry at point with no
+switch argument, so the entry lands in the docked window with focus (q quits)."
+ (let (call)
+ (cl-letf (((symbol-function 'calibredb-find-candidate-at-point)
+ (lambda () '(the-entry extra)))
+ ((symbol-function 'calibredb-show-entry)
+ (lambda (&rest args) (setq call args))))
+ (cj/calibredb-describe-at-point)
+ ;; one argument only -- the entry -- and switch is therefore nil
+ (should (equal call '(the-entry))))))
+
+(ert-deftest test-calibredb-sort-preserving-filter-keeps-filter ()
+ "Normal: the filter-preserving sort sets the field and refreshes via
+`calibredb-search-refresh-or-resume' without touching the active filter."
+ (let ((calibredb-sort-by 'id)
+ (calibredb-search-filter "epub")
+ (calibredb-format-filter-p t)
+ (refreshed nil))
+ (cl-letf (((symbol-function 'calibredb-search-refresh-or-resume)
+ (lambda (&rest _) (setq refreshed t))))
+ (cj/--calibredb-sort-preserving-filter 'author)
+ (should (eq calibredb-sort-by 'author)) ; field updated
+ (should refreshed) ; refreshed
+ (should (equal calibredb-search-filter "epub")) ; filter kept
+ (should calibredb-format-filter-p)))) ; filter flag kept
+
+(provide 'test-calibredb-epub-config--menu)
+;;; test-calibredb-epub-config--menu.el ends here
diff --git a/tests/test-custom-buffer-file-keymap-bindings.el b/tests/test-custom-buffer-file-keymap-bindings.el
new file mode 100644
index 00000000..ea9ceb26
--- /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-dashboard-config-recentf-exclude.el b/tests/test-dashboard-config-recentf-exclude.el
new file mode 100644
index 00000000..f35b3eda
--- /dev/null
+++ b/tests/test-dashboard-config-recentf-exclude.el
@@ -0,0 +1,33 @@
+;;; test-dashboard-config-recentf-exclude.el --- recentf-exclude is not clobbered -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/--dashboard-exclude-emms-from-recentf' adds the EMMS history pattern
+;; to `recentf-exclude'. It must ADD to the list, not replace it, or it
+;; wipes the exclusions system-defaults.el set earlier in init order
+;; (emacs_bookmarks, elpa, recentf, ElfeedDB, airootfs).
+
+;;; Code:
+
+(require 'ert)
+(require 'recentf) ; makes `recentf-exclude' special so the let below is dynamic
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'testutil-general)
+(require 'dashboard-config)
+
+(ert-deftest test-dashboard-config-exclude-emms-preserves-existing-entries ()
+ "Error: excluding the EMMS history preserves prior recentf-exclude entries."
+ (let ((recentf-exclude (list "emacs_bookmarks" "airootfs")))
+ (cj/--dashboard-exclude-emms-from-recentf)
+ (should (member "/emms/history" recentf-exclude))
+ (should (member "emacs_bookmarks" recentf-exclude))
+ (should (member "airootfs" recentf-exclude))))
+
+(ert-deftest test-dashboard-config-exclude-emms-adds-the-pattern ()
+ "Normal: the EMMS history pattern is present after the call."
+ (let ((recentf-exclude nil))
+ (cj/--dashboard-exclude-emms-from-recentf)
+ (should (member "/emms/history" recentf-exclude))))
+
+(provide 'test-dashboard-config-recentf-exclude)
+;;; test-dashboard-config-recentf-exclude.el ends here
diff --git a/tests/test-dupre-theme.el b/tests/test-dupre-theme.el
index dec648d1..4d0e786c 100644
--- a/tests/test-dupre-theme.el
+++ b/tests/test-dupre-theme.el
@@ -223,5 +223,39 @@ The defface registration in dupre-faces.el is what makes direct use work."
(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 00000000..2f49a868
--- /dev/null
+++ b/tests/test-dwim-shell-config-command-fixes.el
@@ -0,0 +1,33 @@
+;;; 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))))
+
+(provide 'test-dwim-shell-config-command-fixes)
+;;; test-dwim-shell-config-command-fixes.el ends here
diff --git a/tests/test-help-config.el b/tests/test-help-config.el
new file mode 100644
index 00000000..0ba95c41
--- /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-init-module-headers.el b/tests/test-init-module-headers.el
index 2680a19c..bbda2388 100644
--- a/tests/test-init-module-headers.el
+++ b/tests/test-init-module-headers.el
@@ -113,7 +113,7 @@
"jumper"
"latex-config"
;; Batch 9 — Remaining domain / integration / optional modules (Layer 2-4)
- "linear-config"
+ "pearl-config"
"local-repository"
"lorem-optimum"
"mail-config"
diff --git a/tests/test-mail-config-refile-folder.el b/tests/test-mail-config-refile-folder.el
new file mode 100644
index 00000000..e2d224eb
--- /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 45e1a601..edb20d35 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-org-capture-config-popup-window.el b/tests/test-org-capture-config-popup-window.el
new file mode 100644
index 00000000..34f67b36
--- /dev/null
+++ b/tests/test-org-capture-config-popup-window.el
@@ -0,0 +1,281 @@
+;;; test-org-capture-config-popup-window.el --- Quick-capture popup single-window 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.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'org)
+(require 'org-capture) ; makes `org-capture-templates' a real special var
+(require 'user-constants)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-capture-config)
+
+(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 ()
+ "Normal: the *Org Select* menu in the popup frame wants the sole window."
+ (should (cj/org-capture--popup-sole-window-p "org-capture" "*Org Select*")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-capture-buffer ()
+ "Normal: a CAPTURE-* buffer in the popup frame wants the sole window."
+ (should (cj/org-capture--popup-sole-window-p "org-capture" "CAPTURE-todo.org")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-capture-prefix-only ()
+ "Boundary: the bare \"CAPTURE-\" prefix still matches."
+ (should (cj/org-capture--popup-sole-window-p "org-capture" "CAPTURE-")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-other-frame ()
+ "Boundary: the same menu in a normal frame is left alone."
+ (should-not (cj/org-capture--popup-sole-window-p "emacs" "*Org Select*"))
+ (should-not (cj/org-capture--popup-sole-window-p nil "CAPTURE-todo.org")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-other-buffer ()
+ "Boundary: an unrelated buffer in the popup frame is left alone."
+ (should-not (cj/org-capture--popup-sole-window-p "org-capture" "todo.org"))
+ (should-not (cj/org-capture--popup-sole-window-p "org-capture" "*scratch*")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-nil-buffer ()
+ "Error: a nil or non-string buffer name returns nil without raising."
+ (should-not (cj/org-capture--popup-sole-window-p "org-capture" nil))
+ (should-not (cj/org-capture--popup-sole-window-p "org-capture" 42)))
+
+;;; Integration: the display-buffer-alist entry routes to a sole window
+
+(ert-deftest test-integration-org-capture-popup-display-sole-window ()
+ "Integration: in an \"org-capture\"-named frame, displaying a CAPTURE-*
+buffer fills the frame's sole window via the registered display-buffer-alist
+entry, instead of splitting.
+
+Components integrated:
+- cj/org-capture--popup-display-condition (real)
+- cj/org-capture--display-sole-window (real)
+- display-buffer / display-buffer-alist (real)
+
+Validates the popup frame ends with one window showing the CAPTURE buffer."
+ ;; 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
+ (set-frame-parameter nil 'name "org-capture")
+ (delete-other-windows)
+ (display-buffer buf)
+ (should (= (length (window-list)) 1))
+ (should (eq (window-buffer (selected-window)) buf)))
+ (set-frame-parameter nil 'name nil)
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+;;; cj/--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"))))
+
+(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)))
+ (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.
+
+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)
+ (cl-letf (((symbol-function 'org-capture)
+ (lambda (&rest _) (setq captured org-capture-templates))))
+ (cj/quick-capture))
+ (should (equal (mapcar #'car captured) '("t" "b" "e")))
+ (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")))))
+
+(ert-deftest test-integration-org-capture-quick-capture-closes-frame-on-abort ()
+ "Integration: when selection 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))
+ (cl-letf (((symbol-function 'org-capture)
+ (lambda (&rest _) (user-error "Abort")))
+ ((symbol-function 'cj/org-capture--delete-popup-frame)
+ (lambda () (cl-incf deleted))))
+ (cj/quick-capture))
+ (should (= deleted 1))))
+
+(ert-deftest test-integration-org-capture-quick-capture-closes-frame-on-quit ()
+ "Integration: a C-g (quit) during capture also closes the popup frame."
+ (let ((org-capture-templates test-org-capture-popup--sample-templates)
+ (deleted 0))
+ (cl-letf (((symbol-function 'org-capture)
+ (lambda (&rest _) (signal 'quit nil)))
+ ((symbol-function 'cj/org-capture--delete-popup-frame)
+ (lambda () (cl-incf deleted))))
+ (cj/quick-capture))
+ (should (= deleted 1))))
+
+(ert-deftest test-integration-org-capture-quick-capture-keeps-frame-on-success ()
+ "Integration: a successful capture (no signal) does NOT delete the frame —
+the finalize hook owns that."
+ (let ((org-capture-templates test-org-capture-popup--sample-templates)
+ (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 ()
+ "Normal/Boundary: true only when the selected frame is named \"org-capture\"."
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (&rest _) "org-capture")))
+ (should (cj/org-capture--popup-frame-p)))
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (&rest _) "emacs")))
+ (should-not (cj/org-capture--popup-frame-p))))
+
+;;; cj/org-capture--popup-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 ()
+ "Normal: returns the live frame whose name is \"org-capture\"."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fb fc)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter)
+ (lambda (f _p) (if (eq f 'fb) "org-capture" "other"))))
+ (should (eq (cj/org-capture--popup-frame) 'fb))))
+
+(ert-deftest test-org-capture-config-popup-frame-none ()
+ "Boundary: no popup frame present yields nil."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fc)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter) (lambda (_f _p) "other")))
+ (should-not (cj/org-capture--popup-frame))))
+
+;;; cj/quick-capture targets the popup frame
+
+(ert-deftest test-integration-org-capture-quick-capture-selects-named-frame ()
+ "Integration: cj/quick-capture selects the \"org-capture\" frame found by name,
+not whatever frame happens to be selected (the emacsclient -c focus race)."
+ (let ((org-capture-templates test-org-capture-popup--sample-templates)
+ (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)))
+ ((symbol-function 'org-capture) (lambda (&rest _) nil)))
+ (cj/quick-capture))
+ (should (eq focused 'popup-frame))))
+
+(ert-deftest test-integration-org-capture-quick-capture-no-frame-still-captures ()
+ "Integration: when no popup frame is found, cj/quick-capture skips the focus
+call and still runs the capture (no error)."
+ (let ((org-capture-templates test-org-capture-popup--sample-templates)
+ (focused 'unset)
+ (captured nil))
+ (cl-letf (((symbol-function 'cj/org-capture--popup-frame) (lambda () nil))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f) (setq focused f)))
+ ((symbol-function 'org-capture) (lambda (&rest _) (setq captured t))))
+ (cj/quick-capture))
+ (should (eq focused 'unset))
+ (should captured)))
+
+(provide 'test-org-capture-config-popup-window)
+;;; test-org-capture-config-popup-window.el ends here
diff --git a/tests/test-org-capture-config-project-target.el b/tests/test-org-capture-config-project-target.el
new file mode 100644
index 00000000..c9091c91
--- /dev/null
+++ b/tests/test-org-capture-config-project-target.el
@@ -0,0 +1,174 @@
+;;; test-org-capture-config-project-target.el --- Project-aware capture tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the project-aware capture target shared by C-c c t (Task) and
+;; C-c c b (Bug): the pure project-name and target-decision helpers, the
+;; find-or-create "Open Work" / "Inbox" heading helpers, the function-target
+;; wiring, and the two template registrations.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'org)
+(require 'org-capture)
+(require 'user-constants)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-capture-config)
+
+;;; cj/--org-capture-project-name
+
+(ert-deftest test-org-capture-project-name-normal ()
+ "Normal: basename, first letter upcased; trailing slash ignored."
+ (should (equal (cj/--org-capture-project-name "/home/cj/code/duet/") "Duet"))
+ (should (equal (cj/--org-capture-project-name "/home/cj/code/duet") "Duet")))
+
+(ert-deftest test-org-capture-project-name-strips-leading-dot ()
+ "Boundary: a single leading dot is stripped before upcasing."
+ (should (equal (cj/--org-capture-project-name "/home/cj/.emacs.d/") "Emacs.d")))
+
+(ert-deftest test-org-capture-project-name-nil-and-empty ()
+ "Error: nil or empty root yields nil."
+ (should-not (cj/--org-capture-project-name nil))
+ (should-not (cj/--org-capture-project-name "")))
+
+;;; cj/--org-capture-project-target
+
+(ert-deftest test-org-capture-target-project-with-todo ()
+ "Normal: a projectile root whose todo.org exists targets that file's Open Work."
+ (let ((root (make-temp-file "captest-" t)))
+ (unwind-protect
+ (progn
+ (with-temp-file (expand-file-name "todo.org" root)
+ (insert "* X Open Work\n"))
+ (let ((plan (cj/--org-capture-project-target root "/tmp/inbox.org")))
+ (should (string= (plist-get plan :file)
+ (expand-file-name "todo.org" root)))
+ (should (plist-get plan :open-work))
+ (should-not (plist-get plan :warn))))
+ (delete-directory root t))))
+
+(ert-deftest test-org-capture-target-project-without-todo ()
+ "Boundary: a projectile root with no todo.org falls back to inbox and warns."
+ (let ((root (make-temp-file "captest-" t)))
+ (unwind-protect
+ (let ((plan (cj/--org-capture-project-target root "/tmp/inbox.org")))
+ (should (string= (plist-get plan :file) "/tmp/inbox.org"))
+ (should-not (plist-get plan :open-work))
+ (should (stringp (plist-get plan :warn)))
+ (should (string-match-p (regexp-quote (cj/--org-capture-project-name root))
+ (plist-get plan :warn))))
+ (delete-directory root t))))
+
+(ert-deftest test-org-capture-target-no-project ()
+ "Boundary: nil root targets the inbox with no warning."
+ (let ((plan (cj/--org-capture-project-target nil "/tmp/inbox.org")))
+ (should (string= (plist-get plan :file) "/tmp/inbox.org"))
+ (should-not (plist-get plan :open-work))
+ (should-not (plist-get plan :warn))))
+
+;;; cj/--org-capture-goto-open-work
+
+(ert-deftest test-org-capture-goto-open-work-finds-existing ()
+ "Normal: an existing top-level \"... Open Work\" heading is reused, not duplicated."
+ (with-temp-buffer
+ (org-mode)
+ (insert "* Emacs Open Work\n** TODO a\n* Emacs Resolved\n")
+ (cj/--org-capture-goto-open-work "Ignored")
+ (should (string= (org-get-heading t t t t) "Emacs Open Work"))
+ (should-not (string-match-p "Ignored" (buffer-string)))))
+
+(ert-deftest test-org-capture-goto-open-work-matches-tagged-heading ()
+ "Boundary: a tagged \"... Open Work\" heading still matches and is not duplicated."
+ (with-temp-buffer
+ (org-mode)
+ (insert "* Foo Open Work :stuff:\n")
+ (cj/--org-capture-goto-open-work "Bar")
+ (should (string-match-p "Open Work" (org-get-heading t t t t)))
+ (should-not (string-match-p "Bar Open Work" (buffer-string)))))
+
+(ert-deftest test-org-capture-goto-open-work-creates-when-absent ()
+ "Boundary: with no Open Work heading, create \"* NAME Open Work\" at end."
+ (with-temp-buffer
+ (org-mode)
+ (insert "* Something Else\n")
+ (cj/--org-capture-goto-open-work "Duet")
+ (should (string-match-p "^\\* Duet Open Work$" (buffer-string)))
+ (should (string= (org-get-heading t t t t) "Duet Open Work"))))
+
+;;; cj/--org-capture-goto-exact-headline
+
+(ert-deftest test-org-capture-goto-exact-headline-finds ()
+ "Normal: an existing Inbox heading is found."
+ (with-temp-buffer
+ (org-mode)
+ (insert "* Inbox\n** TODO x\n")
+ (cj/--org-capture-goto-exact-headline "Inbox")
+ (should (string= (org-get-heading t t t t) "Inbox"))))
+
+(ert-deftest test-org-capture-goto-exact-headline-creates ()
+ "Boundary: a missing Inbox heading is created at end of buffer."
+ (with-temp-buffer
+ (org-mode)
+ (insert "* Other\n")
+ (cj/--org-capture-goto-exact-headline "Inbox")
+ (should (string-match-p "^\\* Inbox$" (buffer-string)))))
+
+;;; cj/--org-capture-project-location (function-target wiring)
+
+(ert-deftest test-org-capture-location-files-into-project-open-work ()
+ "Integration: in a project with a todo.org, the location function visits that
+file and lands point on its Open Work heading."
+ (let* ((root (make-temp-file "captest-" t))
+ (todo (expand-file-name "todo.org" root))
+ (org-capture-plist nil)
+ visited)
+ (unwind-protect
+ (progn
+ (with-temp-file todo (insert "* Captest Open Work\n** TODO old\n"))
+ (cl-letf (((symbol-function 'projectile-project-root)
+ (lambda (&optional _d) root)))
+ (cj/--org-capture-project-location)
+ (setq visited (current-buffer))
+ (should (string= (buffer-file-name) todo))
+ (should (string-match-p "Open Work" (org-get-heading t t t t)))))
+ (when (buffer-live-p visited) (kill-buffer visited))
+ (delete-directory root t))))
+
+(ert-deftest test-org-capture-location-falls-back-to-inbox-without-project ()
+ "Integration: with no project, the location function visits the inbox file
+under its Inbox heading."
+ (let* ((inbox (make-temp-file "captest-inbox-" nil ".org" "* Inbox\n"))
+ (inbox-file inbox)
+ (org-capture-plist nil)
+ visited)
+ (unwind-protect
+ (cl-letf (((symbol-function 'projectile-project-root)
+ (lambda (&optional _d) nil)))
+ (cj/--org-capture-project-location)
+ (setq visited (current-buffer))
+ (should (string= (buffer-file-name) inbox))
+ (should (string= (org-get-heading t t t t) "Inbox")))
+ (when (buffer-live-p visited) (kill-buffer visited))
+ (delete-file inbox))))
+
+;;; templates
+
+(ert-deftest test-org-capture-task-template-is-project-aware ()
+ "Normal: the Task template (t) targets the project-aware function."
+ (let ((entry (assoc "t" org-capture-templates)))
+ (should entry)
+ (should (equal (nth 3 entry)
+ '(function cj/--org-capture-project-location)))))
+
+(ert-deftest test-org-capture-bug-template-registered ()
+ "Normal: the Bug template (b) exists, targets the project-aware function, and
+defaults to the [#C] priority."
+ (let ((entry (assoc "b" org-capture-templates)))
+ (should entry)
+ (should (equal (nth 3 entry)
+ '(function cj/--org-capture-project-location)))
+ (should (string-match-p "\\[#C\\]" (nth 4 entry)))))
+
+(provide 'test-org-capture-config-project-target)
+;;; test-org-capture-config-project-target.el ends here
diff --git a/tests/test-org-drill-config-commands.el b/tests/test-org-drill-config-commands.el
index 7d197616..c35bd6cd 100644
--- a/tests/test-org-drill-config-commands.el
+++ b/tests/test-org-drill-config-commands.el
@@ -71,21 +71,50 @@
;;; cj/drill-refile
-(ert-deftest test-org-drill-refile-sets-targets-and-delegates ()
- "Normal: drill-refile narrows `org-refile-targets' to current buffer +
-`drill-dir', then dispatches to `org-refile' via `call-interactively'."
- (let (seen-targets called-fn)
- (cl-letf (((symbol-function 'call-interactively)
+(ert-deftest test-org-drill-refile-targets-from-validated-helper ()
+ "Normal: drill-refile builds its drill targets from the shared
+`cj/--drill-files-or-error' helper, expanded against `drill-dir' — not from
+a raw `directory-files' call (so it inherits the helper's dot-file exclusion
+and validation)."
+ (let ((drill-dir "/tmp/cj-drill/")
+ seen-targets called-fn)
+ (cl-letf (((symbol-function 'cj/--drill-files-or-error)
+ (lambda (_dir) '("a.org" "b.org")))
+ ;; If the old raw path were still in use it would call
+ ;; `directory-files'; a sentinel here keeps it from masquerading.
+ ((symbol-function 'directory-files)
+ (lambda (&rest _) '("/WRONG/raw.org")))
+ ((symbol-function 'call-interactively)
(lambda (fn)
(setq called-fn fn
seen-targets org-refile-targets))))
(cj/drill-refile))
(should (eq called-fn 'org-refile))
- (should seen-targets)
- ;; Two entries: (nil :maxlevel . 1) and (drill-dir :maxlevel . 1).
(should (= 2 (length seen-targets)))
(should (assoc nil seen-targets))
- (should (assoc 'drill-dir seen-targets))))
+ (should (equal (car (nth 1 seen-targets))
+ '("/tmp/cj-drill/a.org" "/tmp/cj-drill/b.org")))))
+
+(ert-deftest test-org-drill-refile-does-not-clobber-global-targets ()
+ "Error: drill-refile let-binds `org-refile-targets'; the session-wide value
+survives the call instead of being permanently replaced."
+ (let ((drill-dir "/tmp/cj-drill/")
+ (org-refile-targets '((sentinel :maxlevel . 9))))
+ (cl-letf (((symbol-function 'cj/--drill-files-or-error) (lambda (_dir) '("a.org")))
+ ((symbol-function 'call-interactively) (lambda (_fn) nil)))
+ (cj/drill-refile))
+ (should (equal org-refile-targets '((sentinel :maxlevel . 9))))))
+
+(ert-deftest test-org-drill-refile-errors-on-missing-drill-dir ()
+ "Error: a missing or unreadable drill dir signals a clear `user-error' via
+the shared validated helper, instead of a low-level error, and never reaches
+`org-refile'."
+ (let ((drill-dir (expand-file-name "cj-drill-nonexistent-XYZ/"
+ temporary-file-directory))
+ (called nil))
+ (cl-letf (((symbol-function 'call-interactively) (lambda (_fn) (setq called t))))
+ (should-error (cj/drill-refile) :type 'user-error))
+ (should-not called)))
(provide 'test-org-drill-config-commands)
;;; test-org-drill-config-commands.el ends here
diff --git a/tests/test-org-roam-config-dailies-head.el b/tests/test-org-roam-config-dailies-head.el
new file mode 100644
index 00000000..631f017c
--- /dev/null
+++ b/tests/test-org-roam-config-dailies-head.el
@@ -0,0 +1,29 @@
+;;; test-org-roam-config-dailies-head.el --- Tests for the dailies template head -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/--org-roam-dailies-head' is the head inserted into a new org-roam
+;; daily file. #+FILETAGS and #+TITLE must sit on separate lines, or Org
+;; never parses the #+TITLE keyword and the FILETAGS value swallows the
+;; rest of the line.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-roam-config)
+
+(ert-deftest test-org-roam-config-dailies-head-separates-filetags-and-title ()
+ "Boundary: #+FILETAGS and #+TITLE sit on separate lines."
+ (should (string-match-p "#\\+FILETAGS: Journal\n#\\+TITLE:"
+ cj/--org-roam-dailies-head))
+ ;; And never run together on one line.
+ (should-not (string-match-p "Journal #\\+TITLE:" cj/--org-roam-dailies-head)))
+
+(ert-deftest test-org-roam-config-dailies-head-ends-with-newline ()
+ "Boundary: the head ends with a newline so the capture body starts clean."
+ (should (string-suffix-p "\n" cj/--org-roam-dailies-head)))
+
+(provide 'test-org-roam-config-dailies-head)
+;;; test-org-roam-config-dailies-head.el ends here
diff --git a/tests/test-prog-general--electric-pair-angle.el b/tests/test-prog-general--electric-pair-angle.el
new file mode 100644
index 00000000..cb33725a
--- /dev/null
+++ b/tests/test-prog-general--electric-pair-angle.el
@@ -0,0 +1,54 @@
+;;; test-prog-general--electric-pair-angle.el --- Angle-bracket pairing inhibit -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/--electric-pair-inhibit-angle, which stops electric-pair from
+;; pairing "<" into "<>". Craig's yasnippet keys start with "<" (e.g. <cj);
+;; auto-pairing the "<" strands a ">" after the expanded snippet, which broke
+;; the cj-comment close fence into "#+end_src>".
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'elec-pair)
+(require 'org)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'prog-general)
+
+;;; cj/--electric-pair-inhibit-angle
+
+(ert-deftest test-prog-general-electric-pair-inhibit-angle-open ()
+ "Normal: the open angle bracket is inhibited."
+ (should (cj/--electric-pair-inhibit-angle ?<)))
+
+(ert-deftest test-prog-general-electric-pair-inhibit-angle-delegates ()
+ "Boundary: any other character defers to electric-pair-default-inhibit."
+ (cl-letf (((symbol-function 'electric-pair-default-inhibit)
+ (lambda (_c) 'delegated)))
+ (should (eq (cj/--electric-pair-inhibit-angle ?a) 'delegated))
+ (should (eq (cj/--electric-pair-inhibit-angle ?\() 'delegated))))
+
+(ert-deftest test-prog-general-electric-pair-predicate-installed ()
+ "Normal: prog-general installs the predicate as the global value."
+ (should (eq electric-pair-inhibit-predicate #'cj/--electric-pair-inhibit-angle)))
+
+;;; Integration — the actual pairing behavior
+
+(ert-deftest test-integration-prog-general-angle-not-paired-in-org ()
+ "Integration: in an org buffer (where < has paren syntax), typing < with the
+inhibit predicate active inserts just <, not <>.
+
+Components integrated:
+- cj/--electric-pair-inhibit-angle (real)
+- electric-pair-local-mode / self-insert-command (real)
+- org-mode syntax table (real — gives < paren syntax)"
+ (with-temp-buffer
+ (org-mode)
+ (electric-pair-local-mode 1)
+ (setq-local electric-pair-inhibit-predicate #'cj/--electric-pair-inhibit-angle)
+ (let ((last-command-event ?<))
+ (call-interactively #'self-insert-command))
+ (should (equal (buffer-substring-no-properties (point-min) (point-max)) "<"))))
+
+(provide 'test-prog-general--electric-pair-angle)
+;;; test-prog-general--electric-pair-angle.el ends here
diff --git a/tests/test-reconcile--find-git-repos.el b/tests/test-reconcile--find-git-repos.el
index e065fca9..c6a190a1 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 fcaddcfd..66f5b172 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-signal-config-notify.el b/tests/test-signal-config-notify.el
new file mode 100644
index 00000000..c4067a66
--- /dev/null
+++ b/tests/test-signal-config-notify.el
@@ -0,0 +1,150 @@
+;;; test-signal-config-notify.el --- Tests for the signal-config notification slice -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; ERT tests for the notification slice of `signal-config': the pure
+;; body formatter (whitespace collapse + truncation to
+;; `cj/signal--notify-body-max') and `cj/signel--notify' routing (the
+;; suppression gate, the notify-script path with the sound flag, and
+;; the `notifications-notify' fallback). Spec: the "Notification
+;; slice" addendum in docs/design/signal-client.org. No signal-cli or
+;; linked account needed.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+;; signel is the fork at ~/code/signel; signal-config wires it via
+;; use-package but these tests need the symbols available directly.
+(eval-and-compile
+ (add-to-list 'load-path (expand-file-name "~/code/signel")))
+(require 'signel)
+
+(require 'signal-config)
+
+;;; cj/signal--format-notify-body
+
+(ert-deftest test-signal-config-format-notify-body-passthrough ()
+ "Normal: short single-line text passes through unchanged."
+ (should (equal (cj/signal--format-notify-body "lunch at noon?")
+ "lunch at noon?")))
+
+(ert-deftest test-signal-config-format-notify-body-collapses-whitespace ()
+ "Normal: newlines and whitespace runs collapse to single spaces."
+ (should (equal (cj/signal--format-notify-body "two\nlines\n\nhere")
+ "two lines here"))
+ (should (equal (cj/signal--format-notify-body "tabs\t\tand spaces")
+ "tabs and spaces")))
+
+(ert-deftest test-signal-config-format-notify-body-trims ()
+ "Boundary: leading and trailing whitespace is trimmed."
+ (should (equal (cj/signal--format-notify-body " hi ") "hi")))
+
+(ert-deftest test-signal-config-format-notify-body-empty ()
+ "Boundary: the empty string stays empty."
+ (should (equal (cj/signal--format-notify-body "") "")))
+
+(ert-deftest test-signal-config-format-notify-body-exact-limit ()
+ "Boundary: a body exactly at the limit is untouched."
+ (let ((s (make-string cj/signal--notify-body-max ?x)))
+ (should (equal (cj/signal--format-notify-body s) s))))
+
+(ert-deftest test-signal-config-format-notify-body-truncates-over-limit ()
+ "Boundary: over-limit text truncates to the limit, ending in an ellipsis."
+ (let* ((s (make-string (1+ cj/signal--notify-body-max) ?x))
+ (out (cj/signal--format-notify-body s)))
+ (should (= (length out) cj/signal--notify-body-max))
+ (should (string-suffix-p "…" out))))
+
+(ert-deftest test-signal-config-format-notify-body-unicode ()
+ "Boundary: multibyte text truncates by characters, not bytes."
+ (let* ((s (make-string (+ cj/signal--notify-body-max 10) ?é))
+ (out (cj/signal--format-notify-body s)))
+ (should (= (length out) cj/signal--notify-body-max))
+ (should (string-suffix-p "…" out))))
+
+;;; cj/signel--notify routing
+
+(ert-deftest test-signal-config-notify-suppressed-when-viewing ()
+ "Normal: nothing fires when the suppression predicate says no."
+ (let (script-calls fallback-calls)
+ (cl-letf (((symbol-function 'cj/signal--should-notify-p)
+ (lambda (_chat-id) nil))
+ ((symbol-function 'start-process)
+ (lambda (&rest args) (push args script-calls) nil))
+ ((symbol-function 'notifications-notify)
+ (lambda (&rest args) (push args fallback-calls) nil)))
+ (cj/signel--notify "+15551234567" "Alice" "hi"))
+ (should-not script-calls)
+ (should-not fallback-calls)))
+
+(ert-deftest test-signal-config-notify-script-silent-by-default ()
+ "Normal: with the script present and sound off, runs notify info --silent."
+ (let (script-calls)
+ (cl-letf (((symbol-function 'cj/signal--should-notify-p)
+ (lambda (_chat-id) t))
+ ((symbol-function 'executable-find)
+ (lambda (p &optional _remote)
+ (when (equal p "notify") "/usr/bin/notify")))
+ ((symbol-function 'start-process)
+ (lambda (&rest args) (push args script-calls) nil))
+ ((symbol-function 'notifications-notify)
+ (lambda (&rest _)
+ (error "Fallback must not fire when the script is present"))))
+ (let ((cj/signel-notify-sound nil))
+ (cj/signel--notify "+15551234567" "Alice" "hi")))
+ (should (= (length script-calls) 1))
+ ;; start-process args: (NAME BUFFER PROGRAM &rest PROGRAM-ARGS);
+ ;; PROGRAM is the path executable-find resolved, not the bare name.
+ (should (equal (nthcdr 2 (car script-calls))
+ '("/usr/bin/notify" "info" "Signal: Alice" "hi" "--silent")))))
+
+(ert-deftest test-signal-config-notify-sound-enabled-drops-silent ()
+ "Normal: with `cj/signel-notify-sound' non-nil, --silent is omitted."
+ (let (script-calls)
+ (cl-letf (((symbol-function 'cj/signal--should-notify-p)
+ (lambda (_chat-id) t))
+ ((symbol-function 'executable-find)
+ (lambda (p &optional _remote)
+ (when (equal p "notify") "/usr/bin/notify")))
+ ((symbol-function 'start-process)
+ (lambda (&rest args) (push args script-calls) nil)))
+ (let ((cj/signel-notify-sound t))
+ (cj/signel--notify "+15551234567" "Alice" "hi")))
+ (should (equal (nthcdr 2 (car script-calls))
+ '("/usr/bin/notify" "info" "Signal: Alice" "hi")))))
+
+(ert-deftest test-signal-config-notify-fallback-when-script-missing ()
+ "Error: without the script on PATH, falls back to notifications-notify."
+ (let (script-calls fallback-calls)
+ (cl-letf (((symbol-function 'cj/signal--should-notify-p)
+ (lambda (_chat-id) t))
+ ((symbol-function 'executable-find)
+ (lambda (_p &optional _remote) nil))
+ ((symbol-function 'start-process)
+ (lambda (&rest args) (push args script-calls) nil))
+ ((symbol-function 'notifications-notify)
+ (lambda (&rest args) (push args fallback-calls) nil)))
+ (cj/signel--notify "+15551234567" "Alice" "hi"))
+ (should-not script-calls)
+ (should (= (length fallback-calls) 1))
+ (let ((args (car fallback-calls)))
+ (should (equal (plist-get args :title) "Signal: Alice"))
+ (should (equal (plist-get args :body) "hi")))))
+
+(ert-deftest test-signal-config-notify-formats-body-before-send ()
+ "Normal: the body runs through the formatter before reaching the script."
+ (let (script-calls)
+ (cl-letf (((symbol-function 'cj/signal--should-notify-p)
+ (lambda (_chat-id) t))
+ ((symbol-function 'executable-find)
+ (lambda (p &optional _remote)
+ (when (equal p "notify") "/usr/bin/notify")))
+ ((symbol-function 'start-process)
+ (lambda (&rest args) (push args script-calls) nil)))
+ (let ((cj/signel-notify-sound nil))
+ (cj/signel--notify "+15551234567" "Alice" "first line\nsecond line")))
+ (should (equal (nth 5 (car script-calls)) "first line second line"))))
+
+(provide 'test-signal-config-notify)
+;;; test-signal-config-notify.el ends here
diff --git a/tests/test-signal-config.el b/tests/test-signal-config.el
index 3be63362..7556efdb 100644
--- a/tests/test-signal-config.el
+++ b/tests/test-signal-config.el
@@ -368,6 +368,16 @@ commands the workflow spec names."
(should (eq (keymap-lookup cj/signel-prefix-map "SPC")
#'cj/signel-connect)))
+(ert-deftest test-signal-config-prefix-map-registered-under-c-semi-m ()
+ "Normal: loading signal-config registers `cj/signel-prefix-map' under
+`M' in `cj/custom-keymap', so C-; M reaches the signel prefix. Guards
+the wiring contract that the load-order bug broke: signal-config must
+register through `cj/register-prefix-map', not a boundp-guarded direct
+mutation that silently no-ops when keybindings loaded in a different
+order."
+ (require 'keybindings)
+ (should (eq (keymap-lookup cj/custom-keymap "M") cj/signel-prefix-map)))
+
;;; display-buffer-alist entry for *Signel: ...* chat buffers
(ert-deftest test-signal-config-chat-buffer-display-rule-uses-bottom-30 ()
diff --git a/tests/test-signel-notify-function.el b/tests/test-signel-notify-function.el
new file mode 100644
index 00000000..cff7f739
--- /dev/null
+++ b/tests/test-signel-notify-function.el
@@ -0,0 +1,89 @@
+;;; test-signel-notify-function.el --- Tests for signel's notify-function dispatch -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; signel's receive handler (signel.el in the fork at ~/code/signel)
+;; raised notifications through a hardwired `notifications-notify'
+;; call. The notification slice (docs/design/signal-client.org,
+;; "Notification slice" addendum) replaces that with
+;; `signel-notify-function', a customization point called with
+;; CHAT-ID, SENDER, and BODY so a config layer can add suppression or
+;; route through an external notifier. These tests cover the
+;; dispatch: text, sticker, and attachment bodies reach the function
+;; with the right arguments, and the default preserves the plain
+;; `notifications-notify' behavior.
+;;
+;; `signel--handle-receive' is exercised directly with synthetic
+;; envelope alists; buffer/dashboard side effects are stubbed. No
+;; live process needed.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(eval-and-compile
+ (add-to-list 'load-path (expand-file-name "~/code/signel")))
+(require 'signel)
+
+(defun test-signel-notify--receive (envelope)
+ "Run `signel--handle-receive' on ENVELOPE, capturing notify calls.
+Returns the list of (CHAT-ID SENDER BODY) argument lists the handler
+passed to `signel-notify-function', oldest first. Buffer and
+dashboard side effects are stubbed out."
+ (let (calls)
+ (cl-letf (((symbol-function 'signel--insert-msg) (lambda (&rest _) nil))
+ ((symbol-function 'signel--dashboard-refresh) (lambda () nil))
+ ((symbol-function 'signel--get-buffer)
+ (lambda (_) (current-buffer))))
+ (let ((signel-notify-function
+ (lambda (chat-id sender body)
+ (push (list chat-id sender body) calls)))
+ (signel-auto-open-buffer nil))
+ (signel--handle-receive `((envelope . ,envelope)))))
+ (nreverse calls)))
+
+(ert-deftest test-signel-notify-function-text-message ()
+ "Normal: a text dataMessage calls the function with chat-id, sender, text."
+ (should (equal (test-signel-notify--receive
+ '((sourceNumber . "+15551234567")
+ (sourceName . "Alice")
+ (dataMessage . ((message . "hi there")))))
+ '(("+15551234567" "Alice" "hi there")))))
+
+(ert-deftest test-signel-notify-function-sticker-placeholder ()
+ "Boundary: a sticker with no text gets the [Sticker] placeholder body."
+ (should (equal (test-signel-notify--receive
+ '((sourceNumber . "+15551234567")
+ (sourceName . "Alice")
+ (dataMessage . ((sticker . ((packId . "p1")))))))
+ '(("+15551234567" "Alice" "[Sticker]")))))
+
+(ert-deftest test-signel-notify-function-attachment-placeholder ()
+ "Boundary: an attachment with no text gets the [Attachment] placeholder."
+ (should (equal (test-signel-notify--receive
+ '((sourceNumber . "+15551234567")
+ (sourceName . "Alice")
+ (dataMessage . ((attachments . [((id . "a1"))])))))
+ '(("+15551234567" "Alice" "[Attachment]")))))
+
+(ert-deftest test-signel-notify-function-no-data-no-call ()
+ "Boundary: an envelope with no dataMessage never calls the function."
+ (should-not (test-signel-notify--receive
+ '((sourceNumber . "+15551234567")
+ (sourceName . "Alice")
+ (typingMessage . ((action . "STARTED")))))))
+
+(ert-deftest test-signel-notify-function-default-preserves-behavior ()
+ "Normal: the default value raises a plain notifications-notify toast."
+ (should (eq signel-notify-function #'signel--notify-default))
+ (let (calls)
+ (cl-letf (((symbol-function 'notifications-notify)
+ (lambda (&rest args) (push args calls) nil)))
+ (signel--notify-default "+15551234567" "Alice" "hi"))
+ (should (= (length calls) 1))
+ (let ((args (car calls)))
+ (should (equal (plist-get args :title) "Signel: Alice"))
+ (should (equal (plist-get args :body) "hi")))))
+
+(provide 'test-signel-notify-function)
+;;; test-signel-notify-function.el ends here
diff --git a/tests/test-system-lib-confirm-strong.el b/tests/test-system-lib-confirm-strong.el
new file mode 100644
index 00000000..26c00822
--- /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-term-config--f8-in-term.el b/tests/test-term-config--f8-in-term.el
new file mode 100644
index 00000000..6cee4ff4
--- /dev/null
+++ b/tests/test-term-config--f8-in-term.el
@@ -0,0 +1,42 @@
+;;; test-term-config--f8-in-term.el --- F8 reaches Emacs from inside a ghostel buffer -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; <f8> is a global binding (`cj/main-agenda-display', set in org-agenda-config).
+;; ghostel's semi-char mode forwards every key NOT in `ghostel-keymap-exceptions'
+;; to the terminal program, so a plain <f8> typed while point is in a ghostel
+;; buffer would be sent to the program instead of opening the agenda. Unlike the
+;; F9 family, F8 is NOT re-bound in `ghostel-mode-map' -- it simply falls through
+;; to the global map once the semi-char map stops forwarding it, so the only
+;; wiring term-config.el adds is the keymap-exceptions entry plus the rebuild.
+;; These tests require ghostel (so term-config's `with-eval-after-load' fires)
+;; BEFORE term-config, then confirm the exception landed and the rebuilt
+;; semi-char map no longer forwards <f8>. `(require 'ghostel)' does not load the
+;; native module, so this stays light.
+
+;;; Code:
+
+(require 'ert)
+(require 'package)
+
+(setq package-user-dir (expand-file-name "elpa" user-emacs-directory))
+(package-initialize)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ghostel)
+(require 'term-config)
+
+(ert-deftest test-term-config-f8-in-keymap-exceptions ()
+ "Regression: <f8> is in `ghostel-keymap-exceptions' so semi-char mode lets it
+reach Emacs instead of forwarding it to the terminal program. This is what lets
+the global agenda binding work from inside a ghostel buffer."
+ (should (member "<f8>" ghostel-keymap-exceptions)))
+
+(ert-deftest test-term-config-f8-not-forwarded-by-semi-char-map ()
+ "Regression: the rebuilt semi-char map must no longer forward <f8> to the pty.
+`add-to-list' updates the exceptions list but not the already-built map -- only
+`ghostel--rebuild-semi-char-keymap' (run in term-config's :init) drops the
+forwarding binding so <f8> falls through to the global agenda command."
+ (should-not (eq (keymap-lookup ghostel-semi-char-mode-map "<f8>")
+ 'ghostel--send-event)))
+
+(provide 'test-term-config--f8-in-term)
+;;; test-term-config--f8-in-term.el ends here
diff --git a/tests/test-term-tmux-history.el b/tests/test-term-tmux-history.el
index 1bb7e73b..51e9725c 100644
--- a/tests/test-term-tmux-history.el
+++ b/tests/test-term-tmux-history.el
@@ -260,8 +260,11 @@ ghostel-mode terminal."
(should-not tmux-called)))))
(ert-deftest test-term-copy-mode-dwim-sends-tmux-prefix-when-attached ()
- "Normal: with tmux attached, dwim writes C-b [ into the pty so tmux enters
-its own copy-mode against the full pane history."
+ "Normal: with tmux attached, dwim writes C-b [ then C-a into the pty so
+tmux enters its own copy-mode and lands the cursor at the start of the
+line. Without the trailing C-a the cursor inherits the live column (far
+right after a prompt) and scrolling up runs up the right edge; start-of-line
+puts it at column 0 so it runs up the left."
(let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]"))
(sent nil)
(copy-mode-called nil))
@@ -279,16 +282,20 @@ its own copy-mode against the full pane history."
'((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0
"/dev/pts/8\t%8\n"))
(cj/term-copy-mode-dwim)
- (should (equal sent '("\C-b[")))
+ (should (equal sent '("\C-b[\C-a")))
(should-not copy-mode-called))))
(when (buffer-live-p agent)
(kill-buffer agent)))))
(ert-deftest test-term-copy-mode-dwim-falls-back-without-tmux ()
- "Boundary: without tmux, dwim calls `ghostel-copy-mode' and sends nothing."
+ "Boundary: without tmux, dwim calls `ghostel-copy-mode' then moves point
+to the start of the line and sends nothing to the pty. The
+`beginning-of-line' must run after `ghostel-copy-mode' so it repositions
+inside the copy view; column 0 keeps the cursor on the left edge while
+scrolling, parity with the tmux branch's trailing C-a."
(let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]"))
(sent nil)
- (copy-mode-called nil))
+ (dwim-order nil))
(unwind-protect
(with-current-buffer agent
(cl-letf (((symbol-function 'get-buffer-process)
@@ -298,13 +305,15 @@ its own copy-mode against the full pane history."
((symbol-function 'ghostel-send-string)
(lambda (s) (push s sent)))
((symbol-function 'ghostel-copy-mode)
- (lambda () (setq copy-mode-called t))))
+ (lambda () (push 'copy-mode dwim-order)))
+ ((symbol-function 'beginning-of-line)
+ (lambda (&optional _n) (push 'beginning-of-line dwim-order))))
(test-term-tmux-history--with-tmux-mock
'((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 1
"no server running"))
(cj/term-copy-mode-dwim)
(should-not sent)
- (should copy-mode-called))))
+ (should (equal (reverse dwim-order) '(copy-mode beginning-of-line))))))
(when (buffer-live-p agent)
(kill-buffer agent)))))
@@ -327,6 +336,17 @@ instead of being forwarded to the terminal program."
(should-not (eq (keymap-lookup ghostel-semi-char-mode-map "C-M-<left>")
'ghostel--send-event)))
+(ert-deftest test-term-f10-music-and-shutdown-in-keymap-exceptions ()
+ "Regression: F10 (music playlist toggle) and C-F10 (server shutdown) are in
+`ghostel-keymap-exceptions' so they reach Emacs from inside a ghostel buffer
+instead of being forwarded to the terminal program. Both are global bindings,
+so dropping them from the semi-char map lets the lookup fall through to the
+global map."
+ (dolist (key '("<f10>" "C-<f10>"))
+ (should (member key ghostel-keymap-exceptions)))
+ (should-not (eq (keymap-lookup ghostel-semi-char-mode-map "<f10>")
+ 'ghostel--send-event)))
+
(ert-deftest test-term-c-spc-forwarded-not-set-mark ()
"Regression: C-SPC is forwarded to the terminal, not bound to the global
`set-mark-command'. ghostel only forwards the `C-@' event, so without this an
diff --git a/tests/test-ui-navigation-split-follow-undo-kill.el b/tests/test-ui-navigation-split-follow-undo-kill.el
index 74c1e2fc..8e390074 100644
--- a/tests/test-ui-navigation-split-follow-undo-kill.el
+++ b/tests/test-ui-navigation-split-follow-undo-kill.el
@@ -54,8 +54,9 @@
;;; cj/undo-kill-buffer
-(ert-deftest test-ui-navigation-undo-kill-buffer-opens-most-recent ()
- "Normal: with no arg, opens the head of recentf-list that isn't currently visited."
+(ert-deftest test-ui-navigation-undo-kill-buffer-no-prefix-opens-most-recent ()
+ "Normal: no prefix (arg=1, the value `\"p\"' yields) opens the most-recent
+non-visited entry, not the second."
(let ((opened nil)
(recentf-mode t)
(recentf-list '("/tmp/dead.org" "/tmp/alive.txt")))
@@ -71,12 +72,12 @@
((symbol-function 'find-file)
(lambda (f) (setq opened f))))
(unwind-protect
- (cj/undo-kill-buffer 0)
+ (cj/undo-kill-buffer 1)
(when (get-buffer "*test-alive*") (kill-buffer "*test-alive*"))))
(should (equal opened "/tmp/dead.org"))))
-(ert-deftest test-ui-navigation-undo-kill-buffer-honors-numeric-arg ()
- "Normal: with N=1, opens the second non-visited entry from recentf-list."
+(ert-deftest test-ui-navigation-undo-kill-buffer-numeric-arg-is-one-based ()
+ "Normal: a numeric prefix is 1-based — N=2 opens the second non-visited entry."
(let ((opened nil)
(recentf-mode t)
(recentf-list '("/tmp/a.org" "/tmp/b.org" "/tmp/c.org")))
@@ -85,10 +86,7 @@
((symbol-function 'buffer-list) (lambda (&rest _) nil))
((symbol-function 'find-file)
(lambda (f) (setq opened f))))
- ;; cj/undo-kill-buffer takes a prefix `arg' and indexes into the list
- ;; with `(nth arg ...)` when arg is non-nil. Passing 1 grabs the 2nd
- ;; entry.
- (cj/undo-kill-buffer 1))
+ (cj/undo-kill-buffer 2))
(should (equal opened "/tmp/b.org"))))
(ert-deftest test-ui-navigation-undo-kill-buffer-no-op-when-list-empty ()
@@ -104,5 +102,18 @@
(cj/undo-kill-buffer 0))
(should-not opened)))
+(ert-deftest test-ui-navigation-undo-kill-buffer-out-of-range-arg-errors ()
+ "Error: a prefix larger than the killed-file list signals a clear user-error,
+not a wrong-type-argument from find-file on nil."
+ (let ((opened nil)
+ (recentf-mode t)
+ (recentf-list '("/tmp/a.org")))
+ (cl-letf (((symbol-function 'require) (lambda (&rest _) t))
+ ((symbol-function 'recentf-mode) (lambda (&rest _) t))
+ ((symbol-function 'buffer-list) (lambda (&rest _) nil))
+ ((symbol-function 'find-file) (lambda (f) (setq opened f))))
+ (should-error (cj/undo-kill-buffer 5) :type 'user-error))
+ (should-not opened)))
+
(provide 'test-ui-navigation-split-follow-undo-kill)
;;; test-ui-navigation-split-follow-undo-kill.el ends here