aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xgithooks/pre-commit25
-rw-r--r--modules/auto-dim-config.el6
-rw-r--r--modules/browser-config.el22
-rw-r--r--modules/calendar-sync.el32
-rw-r--r--modules/calibredb-epub-config.el24
-rw-r--r--modules/dashboard-config.el15
-rw-r--r--modules/help-utils.el42
-rw-r--r--modules/hugo-config.el21
-rw-r--r--modules/media-utils.el38
-rw-r--r--modules/music-config.el24
-rw-r--r--modules/org-agenda-frame.el64
-rw-r--r--modules/org-refile-config.el41
-rw-r--r--modules/system-defaults.el29
-rw-r--r--modules/video-audio-recording-capture.el4
-rw-r--r--modules/video-audio-recording-devices.el48
-rw-r--r--modules/wrap-up.el6
-rwxr-xr-xscripts/remote-repository-reset.sh19
-rw-r--r--tests/test-auto-dim-config.el7
-rw-r--r--tests/test-browser-config--preferred-default.el89
-rw-r--r--tests/test-calendar-sync--syncing-p.el116
-rw-r--r--tests/test-calendar-sync.el45
-rw-r--r--tests/test-calibredb-epub-config--epub-mode.el70
-rw-r--r--tests/test-calibredb-epub-config.el51
-rw-r--r--tests/test-help-utils--arch-wiki-search.el124
-rw-r--r--tests/test-hugo-config--keymap.el71
-rw-r--r--tests/test-integration-org-agenda-frame-load-order.el80
-rw-r--r--tests/test-integration-recording-device-workflow.el178
-rw-r--r--tests/test-media-utils--yt-dl-message.el64
-rw-r--r--tests/test-music-config--header-text.el17
-rw-r--r--tests/test-music-config-create-radio-station.el7
-rw-r--r--tests/test-org-agenda-frame.el41
-rw-r--r--tests/test-org-refile-config--advice-helpers.el87
-rw-r--r--tests/test-pre-commit-hook.bats126
-rw-r--r--tests/test-system-defaults-functions.el31
-rw-r--r--tests/test-validate-el-hook.bats97
-rw-r--r--tests/test-video-audio-recording-group-devices-by-hardware.el194
-rw-r--r--tests/test-wrap-up--bury-buffers.el96
37 files changed, 1414 insertions, 637 deletions
diff --git a/githooks/pre-commit b/githooks/pre-commit
index 27f280c3..a87bedf8 100755
--- a/githooks/pre-commit
+++ b/githooks/pre-commit
@@ -5,7 +5,7 @@
set -u
REPO_ROOT="$(git rev-parse --show-toplevel)"
-cd "$REPO_ROOT"
+cd "$REPO_ROOT" || exit 1
# --- 1. Secret scan ---
# Patterns for common credentials. Scans only added lines in the staged diff.
@@ -18,8 +18,18 @@ cd "$REPO_ROOT"
SECRET_PATTERNS_CS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----)'
SECRET_PATTERNS_CI='(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"']'
-added_lines="$(git diff --cached -U0 --diff-filter=AM \
- | grep '^+' | grep -v '^+++' || true)"
+# Read the diff on its own so a git failure is distinguishable from "grep
+# matched nothing". Both end in a non-zero status, but only one of them means
+# there is nothing to scan; piping them together and swallowing the result with
+# `|| true` made a broken git look like a clean commit — the scan searched an
+# empty string, found nothing, and the secret went in.
+if ! staged_diff="$(git diff --cached -U0 --diff-filter=AM)"; then
+ echo "pre-commit: cannot read the staged diff — refusing to skip the secret scan" >&2
+ exit 1
+fi
+
+# The greps keep their `|| true`: exiting 1 on no match is their normal result.
+added_lines="$(printf '%s\n' "$staged_diff" | grep '^+' | grep -v '^+++' || true)"
cs_hits="$(printf '%s\n' "$added_lines" | grep -nE "$SECRET_PATTERNS_CS" || true)"
ci_hits="$(printf '%s\n' "$added_lines" | grep -niE "$SECRET_PATTERNS_CI" || true)"
@@ -37,7 +47,14 @@ if [ -n "$secret_hits" ]; then
fi
# --- 2. Paren check on staged .el files ---
-staged_el="$(git diff --cached --name-only --diff-filter=AM | grep '\.el$' || true)"
+# Same split as the secret scan above: a git failure must not read as "no files
+# staged", which would skip the language check silently.
+if ! staged_names="$(git diff --cached --name-only --diff-filter=AM)"; then
+ echo "pre-commit: cannot read the staged file list — refusing to skip the check" >&2
+ exit 1
+fi
+
+staged_el="$(printf '%s\n' "$staged_names" | grep '\.el$' || true)"
if [ -n "$staged_el" ]; then
paren_fail=""
diff --git a/modules/auto-dim-config.el b/modules/auto-dim-config.el
index faa4101c..980d301f 100644
--- a/modules/auto-dim-config.el
+++ b/modules/auto-dim-config.el
@@ -58,7 +58,11 @@ focus cue on a split-displayed dashboard, accepted as a fair trade."
;; Emacs loses focus -- on Hyprland focus moves to other apps constantly,
;; and the ai-term agents live in their own windows.
(auto-dim-other-buffers-dim-on-focus-out nil)
- (auto-dim-other-buffers-dim-on-switch-to-minibuffer t)
+ ;; Entering the minibuffer leaves dimming exactly as it was -- a dim window
+ ;; stays dim, a lit one stays lit. With this at t, the window being worked
+ ;; in went dark on every minibuffer prompt, since selecting the minibuffer
+ ;; deselects it and the dim follows selection.
+ (auto-dim-other-buffers-dim-on-switch-to-minibuffer nil)
:config
;; Remap these faces to auto-dim-other-buffers (pure-black background +
;; faded gray foreground, defined in the theme) in non-selected windows.
diff --git a/modules/browser-config.el b/modules/browser-config.el
index 564e7a27..4571c1d9 100644
--- a/modules/browser-config.el
+++ b/modules/browser-config.el
@@ -143,7 +143,23 @@ Persists the choice for future sessions."
('save-failed (message "Failed to save browser choice"))
('invalid-plist (message "Invalid browser configuration"))))))))
-;; Initialize: Load saved choice or use first available browser
+(defun cj/--preferred-default-browser (browsers)
+ "Return the browser plist to adopt as the first-run default from BROWSERS.
+
+Prefers the first entry with a non-nil :executable -- a real external
+browser -- and falls back to the first entry overall when none is
+installed. Built-in browsers carry a nil :executable and so are always
+\"available\", which put EWW at the head of `cj/discover-browsers' on
+every machine. Taking the head therefore opened every link in the text
+browser on a fresh checkout even with Chrome installed, until the user
+happened to run `cj/choose-browser'. EWW stays reachable as the
+deliberate fallback when nothing external is on PATH.
+
+Returns nil for an empty BROWSERS list."
+ (or (seq-find (lambda (b) (plist-get b :executable)) browsers)
+ (car browsers)))
+
+;; Initialize: Load saved choice or use the preferred available browser
(defun cj/--do-initialize-browser ()
"Initialize browser configuration.
Returns: (cons \\='loaded browser-plist) if saved choice was loaded,
@@ -153,10 +169,10 @@ Returns: (cons \\='loaded browser-plist) if saved choice was loaded,
(let ((saved-choice (cj/load-browser-choice)))
(if saved-choice
(cons 'loaded saved-choice)
- ;; No saved choice - try to set first available browser
+ ;; No saved choice - adopt the preferred available browser
(let ((browsers (cj/discover-browsers)))
(if browsers
- (cons 'first-available (car browsers))
+ (cons 'first-available (cj/--preferred-default-browser browsers))
(cons 'no-browsers nil))))))
(defun cj/initialize-browser ()
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index 804d71fa..d504f246 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -300,16 +300,28 @@ When called non-interactively with nil, syncs all calendars."
;;; Timer management
(defun calendar-sync--sync-timer-function ()
- "Function called by sync timer.
-Checks for timezone changes and triggers re-sync if detected."
- (when (calendar-sync--timezone-changed-p)
- (let ((old-tz (calendar-sync--format-timezone-offset
- calendar-sync--last-timezone-offset))
- (new-tz (calendar-sync--format-timezone-offset
- (calendar-sync--current-timezone-offset))))
- (message "calendar-sync: Timezone change detected (%s → %s), re-syncing..."
- old-tz new-tz)))
- (calendar-sync--sync-all-calendars))
+ "Function called by the hourly sync timer.
+Checks for timezone changes and triggers re-sync if detected.
+
+The body is wrapped so a signal — from the timezone check or the sync
+fan-out — is caught and logged rather than propagated: this runs from a
+`run-at-time' timer, and an unguarded error would repeat on every tick,
+once an hour, indefinitely. The timezone-change notice goes to the silent
+log, not the echo area, since an hourly timer must not spam `message'."
+ (condition-case err
+ (progn
+ (when (calendar-sync--timezone-changed-p)
+ (let ((old-tz (calendar-sync--format-timezone-offset
+ calendar-sync--last-timezone-offset))
+ (new-tz (calendar-sync--format-timezone-offset
+ (calendar-sync--current-timezone-offset))))
+ (calendar-sync--log-silently
+ "calendar-sync: Timezone change detected (%s → %s), re-syncing..."
+ old-tz new-tz)))
+ (calendar-sync--sync-all-calendars))
+ (error
+ (calendar-sync--log-silently
+ "calendar-sync: sync timer error: %s" (error-message-string err)))))
;;;###autoload
(defun calendar-sync-start ()
diff --git a/modules/calibredb-epub-config.el b/modules/calibredb-epub-config.el
index e0a82245..27fa6369 100644
--- a/modules/calibredb-epub-config.el
+++ b/modules/calibredb-epub-config.el
@@ -205,22 +205,14 @@ Adjust it live with `cj/nov-widen-text' and `cj/nov-narrow-text'.")
(defvar cj/nov-margin-step 2
"Percentage points each `cj/nov-widen-text'/`cj/nov-narrow-text' press changes.")
-;; Prevent magic-fallback-mode-alist from opening epub as archive-mode
-;; Advise set-auto-mode to force nov-mode for .epub files before magic-fallback runs
-(defun cj/force-nov-mode-for-epub (orig-fun &rest args)
- "Force nov-mode for .epub files, bypassing archive-mode detection."
- (if (and buffer-file-name
- (string-match-p "\\.epub\\'" buffer-file-name))
- (progn
- (unless (featurep 'nov)
- (require 'nov nil t))
- ;; Call nov-mode if available, otherwise fallback to default behavior
- (if (fboundp 'nov-mode)
- (nov-mode)
- (apply orig-fun args)))
- (apply orig-fun args)))
-
-(advice-add 'set-auto-mode :around #'cj/force-nov-mode-for-epub)
+;; .epub reaches nov-mode through auto-mode-alist -- nov's use-package :mode
+;; below registers "\\.epub\\'" there, and `set-auto-mode' consults
+;; auto-mode-alist before magic-fallback-mode-alist, so the zip container never
+;; reaches the archive-mode fallback. An :around advice on `set-auto-mode' used
+;; to force this and was pure overhead: set-auto-mode runs on every file visit,
+;; so it added a frame and a failure surface to every file of every type.
+;; Verified live before removal -- a real zip-format .epub opened in nov-mode
+;; both with the advice and without it.
;; Define helper functions before use-package so they're available for hooks
(defun cj/forward-paragraph-and-center ()
diff --git a/modules/dashboard-config.el b/modules/dashboard-config.el
index 8507344d..c7ff39dc 100644
--- a/modules/dashboard-config.el
+++ b/modules/dashboard-config.el
@@ -73,6 +73,7 @@
;; External package commands invoked by launchers.
(declare-function mu4e "mu4e")
(declare-function pearl-list-issues "pearl")
+(declare-function wttrin "wttrin")
;; ------------------------ Dashboard Bookmarks Override -----------------------
;; overrides the bookmark insertion from the dashboard package to provide an
@@ -84,13 +85,13 @@
(defvar dashboard-bookmarks-item-format "%s"
"Format to use when showing the base of the file name.")
-;; `el' is bound dynamically by dashboard's section-insertion machinery, which the
-;; override below plugs into. Declare it so the byte-compiler reads the
-;; references as that special variable rather than a free variable. The name is
-;; dashboard's, not ours, so the missing-prefix lint is suppressed rather than
-;; renamed (renaming would break the dynamic binding dashboard supplies).
-(with-suppressed-warnings ((lexical el))
- (defvar el))
+;; No `(defvar el)' here on purpose. `el' is the per-item variable that
+;; dashboard's `dashboard-insert-section' macro binds inside its own expansion;
+;; the override's forms below reference it within that binding. Declaring `el'
+;; special (as an earlier attempt did) is what CREATED a byte-compile warning --
+;; it turned the macro's ordinary lexical binding into one that "shadows the
+;; dynamic variable el". Left lexical, the references resolve inside the
+;; expansion and the compile is clean.
;; The override body uses the `dashboard-insert-section' MACRO, so it must be
;; known when this module byte-compiles or the call compiles as a plain
diff --git a/modules/help-utils.el b/modules/help-utils.el
index 9792841a..2709ac45 100644
--- a/modules/help-utils.el
+++ b/modules/help-utils.el
@@ -65,24 +65,40 @@
;; on Arch: yay (or whatever your AUR package manager is) -S arch-wiki-docs
;; browse the arch wiki topics offline
+(defvar cj/arch-wiki-html-dir "/usr/share/doc/arch-wiki/html/en"
+ "Directory holding the offline ArchWiki HTML copies.
+Populated by the arch-wiki-docs package on Arch systems.")
+
+(defun cj/--arch-wiki-topics (dir)
+ "Return an alist of (BASENAME . FULLPATH) for ArchWiki topics under DIR.
+
+Returns nil when DIR does not exist rather than signaling. This is the
+whole point of the helper: `directory-files' raises file-missing on an
+absent directory, and the caller's \"is arch-wiki-docs installed?\" hint
+sat below that call, so on the one machine state the hint was written for
+it could never be reached."
+ (when (file-directory-p dir)
+ (mapcar (lambda (f) (cons (file-name-base f) f))
+ (directory-files dir t "\\.html\\'"))))
+
(defun cj/local-arch-wiki-search ()
"Prompt for an ArchWiki topic and open its local HTML copy in EWW.
-Looks for “*.html” files under \"/usr/share/doc/arch-wiki/html/en\",
-lets you complete on their basenames, and displays the chosen file
-with `eww-browse-url'. If no file is found, reminds you to install
+Looks for “*.html” files under `cj/arch-wiki-html-dir', lets you complete
+on their basenames, and displays the chosen file with `eww-browse-url'.
+If the directory is missing or empty, reminds you to install
arch-wiki-docs."
(interactive)
- (let* ((dir "/usr/share/doc/arch-wiki/html/en")
- (full-filenames (directory-files dir t "\\.html\\'"))
- (basenames (mapcar 'file-name-base full-filenames))
- (chosen (completing-read "Choose an ArchWiki Topic: " basenames)))
- (if (member chosen basenames)
- (let* ((idx (cl-position chosen basenames :test 'equal))
- (fullname (nth idx full-filenames))
- (url (concat "file://" fullname)))
- (eww-browse-url url))
- (message "File not found! Is arch-wiki-docs installed?"))))
+ (let ((topics (cj/--arch-wiki-topics cj/arch-wiki-html-dir)))
+ (if (null topics)
+ (message "No ArchWiki topics in %s. Is arch-wiki-docs installed?"
+ cj/arch-wiki-html-dir)
+ (let* ((chosen (completing-read "Choose an ArchWiki Topic: "
+ (mapcar #'car topics)))
+ (fullname (cdr (assoc chosen topics))))
+ (if fullname
+ (eww-browse-url (concat "file://" fullname))
+ (message "No ArchWiki topic named %s" chosen))))))
(keymap-global-set "C-h A" #'cj/local-arch-wiki-search)
(provide 'help-utils)
diff --git a/modules/hugo-config.el b/modules/hugo-config.el
index b26398c6..36f9e07a 100644
--- a/modules/hugo-config.el
+++ b/modules/hugo-config.el
@@ -28,6 +28,7 @@
(require 'user-constants)
(require 'host-environment)
(require 'system-lib) ;; completion table + file annotator
+(require 'keybindings) ;; cj/register-prefix-map, cj/custom-keymap
;; --------------------------------- Constants ---------------------------------
@@ -247,14 +248,18 @@ to /var/www/cjennings/, so a successful push is the deploy."
;; -------------------------------- Keybindings --------------------------------
-(global-set-key (kbd "C-; h n") #'cj/hugo-new-post)
-(global-set-key (kbd "C-; h e") #'cj/hugo-export-post)
-(global-set-key (kbd "C-; h o") #'cj/hugo-open-blog-dir)
-(global-set-key (kbd "C-; h O") #'cj/hugo-open-blog-dir-external)
-(global-set-key (kbd "C-; h d") #'cj/hugo-open-draft)
-(global-set-key (kbd "C-; h D") #'cj/hugo-toggle-draft)
-(global-set-key (kbd "C-; h p") #'cj/hugo-preview)
-(global-set-key (kbd "C-; h P") #'cj/hugo-publish)
+(defvar-keymap cj/hugo-keymap
+ :doc "Keymap for Hugo blog commands"
+ "n" #'cj/hugo-new-post
+ "e" #'cj/hugo-export-post
+ "o" #'cj/hugo-open-blog-dir
+ "O" #'cj/hugo-open-blog-dir-external
+ "d" #'cj/hugo-open-draft
+ "D" #'cj/hugo-toggle-draft
+ "p" #'cj/hugo-preview
+ "P" #'cj/hugo-publish)
+
+(cj/register-prefix-map "h" cj/hugo-keymap)
(with-eval-after-load 'which-key
(which-key-add-key-based-replacements
diff --git a/modules/media-utils.el b/modules/media-utils.el
index 99b5dc37..7047411f 100644
--- a/modules/media-utils.el
+++ b/modules/media-utils.el
@@ -210,6 +210,32 @@ with a plain argv list -- no shell anywhere in the pipeline."
;; ------------------------- Media-Download Via yt-dlp -------------------------
+(defun cj/media--yt-dl-message (event url-display)
+ "Return the message for tsp EVENT on URL-DISPLAY, or nil when it reports nothing.
+
+Reports queueing, not completion, and the distinction is the point.
+`cj/yt-dl-it' launches \"tsp yt-dlp ...\", and tsp enqueues the job and
+exits immediately, so this sentinel fires on tsp's exit rather than
+yt-dlp's. A clean exit proves the job was accepted by the spooler and
+nothing more, so claiming the download finished would be a guess that is
+wrong whenever yt-dlp fails minutes later. Check the spooler with
+\"tsp\" for real download status."
+ (cond
+ ((string-match-p "finished" event)
+ (format "✓ Queued for download: %s" url-display))
+ ((string-match-p "exited abnormally" event)
+ (format "✗ Could not queue download: %s" url-display))))
+
+(defun cj/media--yt-dl-sentinel (url-display)
+ "A process sentinel reporting the queueing of URL-DISPLAY.
+Messages per `cj/media--yt-dl-message' and reaps the process buffer once
+tsp finishes or exits."
+ (lambda (proc event)
+ (when-let ((msg (cj/media--yt-dl-message event url-display)))
+ (message "%s" msg))
+ (when (string-match-p "finished\\|exited" event)
+ (kill-buffer (process-buffer proc)))))
+
(defun cj/yt-dl-it (url)
"Downloads the URL in an async shell."
(unless (executable-find "yt-dlp")
@@ -223,16 +249,8 @@ with a plain argv list -- no shell anywhere in the pipeline."
(process (start-process "yt-dlp" buffer-name
"tsp" "yt-dlp" "--add-metadata" "-ic"
"-o" output-template url)))
- (message "Started download: %s" url-display)
- (set-process-sentinel process
- (lambda (proc event)
- (cond
- ((string-match-p "finished" event)
- (message "✓ Finished downloading: %s" url-display))
- ((string-match-p "exited abnormally" event)
- (message "✗ Download failed: %s" url-display)))
- (when (string-match-p "finished\\|exited" event)
- (kill-buffer (process-buffer proc)))))))
+ (message "Queueing download: %s" url-display)
+ (set-process-sentinel process (cj/media--yt-dl-sentinel url-display))))
(provide 'media-utils)
;;; media-utils.el ends here.
diff --git a/modules/music-config.el b/modules/music-config.el
index d33c8c45..233bae72 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -374,6 +374,11 @@ point until it ends; the snap lands when the search exits."
(defvar-local cj/music--renumber-timer nil
"Pending idle timer for the playlist row renumber, or nil.")
+;; Forward declaration: the real `defvar-local' is a few defuns below, next to
+;; the highlight helper that owns it. Declared special here so the setq in this
+;; function compiles as a dynamic binding, not a free-variable warning.
+(defvar cj/music--current-number-overlay)
+
(defun cj/music--renumber-rows (&optional buffer)
"Number every playlist row in BUFFER (default: current buffer) via overlays.
Each non-blank line gets an \"NNN \" before-string so the cursor stays
@@ -789,6 +794,10 @@ ENTRIES. Nil when neither applies (the caller falls back to a timestamp)."
(plist-get (cdr (assoc (emms-track-name tr) entries))
:name)))))
+;; Forward declaration: the real `defvar' lives with the radio config block far
+;; below. Declared special here so this reference compiles clean.
+(defvar cj/music-radio-save-dir)
+
(defun cj/music--save-directory (tracks)
"Directory a saved playlist targets.
An all-stream queue is a radio playlist and saves into
@@ -1377,12 +1386,12 @@ The rule uses a resize-safe :align-to span, not a hardcoded character count."
(propertize "Mode " 'face 'cj/music-header-face)
(propertize " : " 'face 'cj/music-header-face)
(funcall mode-indicator "r" "repeat" (bound-and-true-p emms-repeat-playlist)) " "
- (funcall mode-indicator "s" "single" (bound-and-true-p emms-repeat-track)) " "
+ (funcall mode-indicator "1" "single" (bound-and-true-p emms-repeat-track)) " "
(funcall mode-indicator "z" "random" (bound-and-true-p emms-random-playlist)) " "
(funcall mode-indicator "x" "consume" cj/music-consume-mode) "\n"
(propertize "Keys " 'face 'cj/music-header-face)
(propertize " : " 'face 'cj/music-header-face)
- (propertize "a:add c:clear L:load v:save D:delete S:stop SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
+ (propertize "a:add c:clear L:load s:save D:delete SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
'face 'cj/music-keyhint-face) "\n"
(propertize "Radio " 'face 'cj/music-header-face)
(propertize " : " 'face 'cj/music-header-face)
@@ -1614,7 +1623,6 @@ unless fancy."
;; Playback
("p" . emms-playlist-mode-go)
("SPC" . emms-pause)
- ("s" . emms-stop)
("n" . cj/music-next)
(">" . cj/music-next)
("P" . cj/music-previous)
@@ -1640,7 +1648,6 @@ unless fancy."
("D" . cj/music-delete-playlist)
("E" . cj/music-playlist-edit)
("g" . cj/music-playlist-reload)
- ("v" . cj/music-playlist-save)
;; Track reordering
("S-<up>" . emms-playlist-mode-shift-track-up)
("S-<down>" . emms-playlist-mode-shift-track-down)
@@ -2094,14 +2101,15 @@ when there is nothing to fetch."
(message "Cleared music art cache: %s" cj/music-art-cache-dir))
;; Radio row in the playlist buffer: n = search by name, t = search by tag,
-;; m = enter a station by hand. This moves the "single" mode toggle off t to s
-;; and emms-stop off s to S (see the header's Mode/Keys/Radio rows).
+;; m = enter a station by hand. Single-track mode is on 1 and s saves the
+;; playlist; stop was dropped (SPC/pause covers it). These run after
+;; use-package's :map, so they win (see the header's Mode/Keys/Radio rows).
(with-eval-after-load 'emms
(keymap-set emms-playlist-mode-map "n" #'cj/music-radio-search-by-name)
(keymap-set emms-playlist-mode-map "t" #'cj/music-radio-search-by-tag)
(keymap-set emms-playlist-mode-map "m" #'cj/music-create-radio-station)
- (keymap-set emms-playlist-mode-map "s" #'emms-toggle-repeat-track)
- (keymap-set emms-playlist-mode-map "S" #'emms-stop))
+ (keymap-set emms-playlist-mode-map "1" #'emms-toggle-repeat-track)
+ (keymap-set emms-playlist-mode-map "s" #'cj/music-playlist-save))
(provide 'music-config)
;;; music-config.el ends here
diff --git a/modules/org-agenda-frame.el b/modules/org-agenda-frame.el
index 35e77912..f0de99ce 100644
--- a/modules/org-agenda-frame.el
+++ b/modules/org-agenda-frame.el
@@ -31,6 +31,7 @@
;; `with-eval-after-load' so a batch test-load runs no org-agenda side effects.
(defvar org-agenda-custom-commands)
(defvar org-agenda-finalize-hook)
+(defvar org-agenda-mode-map)
(defvar org-agenda-sticky)
(defvar org-agenda-window-setup)
(declare-function cj/build-org-agenda-list "org-agenda-config" (&optional force-rebuild))
@@ -44,10 +45,12 @@
(declare-function org-get-at-bol "org" (property))
(declare-function org-fold-show-context "org-fold" (&optional key))
(declare-function org-agenda "org-agenda" (&optional arg org-keys restriction))
-;; Forward references: defined later in this file (Phase 2 for -safe-redo,
-;; the lifecycle block for -delete).
-(declare-function cj/--agenda-frame-safe-redo "org-agenda-frame" ())
-(declare-function cj/--agenda-frame-delete "org-agenda-frame" ())
+;; No declare-function for -safe-redo / -delete: they are defined later in THIS
+;; file, and the byte-compiler resolves same-file forward references at end of
+;; compilation. A declare-function for a same-file function instead counts as a
+;; second definition ("defined multiple times") and, worse, its declared arglist
+;; overrides the real one for arg-count checking -- the empty () shadowed
+;; -safe-redo's actual (&optional frame), disabling that check.
(defconst cj/--agenda-frame-parameter 'cj/agenda-frame
"Frame parameter marking the dedicated agenda frame.
@@ -318,6 +321,53 @@ of contract."
:lighter " AgendaFrame"
:keymap cj/agenda-frame-mode-map)
+(defun cj/--agenda-frame-shadow-mutations (&optional source-map prefix)
+ "Deny every SOURCE-MAP key sequence not on the frame map's allowlist.
+Walk SOURCE-MAP (default `org-agenda-mode-map') recursively. For each
+sequence it binds to a command, if `cj/agenda-frame-mode-map' doesn't already
+bind that sequence to a command or manage it as a prefix, add an explicit
+read-only deny.
+
+This closes the default-deny hole: a keymap's `[t]' default never shadows an
+explicit binding in a lower-priority map, so a single `[t]' catch-all denies
+only keys that are unbound everywhere. Every key `org-agenda-mode-map' binds
+\(t, I, k, z, s, ., the C-c mutators, C-x C-s, ...) would otherwise sail
+through the catch-all and mutate source files from the read-only frame.
+Explicitly denying each non-allowlisted sequence makes the catch-all's intent
+actually hold.
+
+PREFIX is the accumulated key vector during recursion (internal). Idempotent:
+re-running rebinds the same denials. Runs from `with-eval-after-load' once
+`org-agenda-mode-map' exists."
+ (let ((source (or source-map org-agenda-mode-map))
+ (prefix (or prefix [])))
+ (map-keymap
+ (lambda (event binding)
+ (unless (or (eq event t) (eq event 'menu-bar) (eq event 'remap)
+ (consp event))
+ (let ((seq (vconcat prefix (vector event))))
+ (cond
+ ((keymapp binding)
+ (cj/--agenda-frame-shadow-mutations binding seq))
+ ((commandp binding)
+ (let ((ours (lookup-key cj/agenda-frame-mode-map seq)))
+ ;; A command we allowlisted or a prefix we manage: leave it.
+ ;; Anything else (only the `[t]' default, or unbound under a
+ ;; shared prefix) escapes to org's command -- deny it here.
+ (unless (or (commandp ours) (keymapp ours))
+ (define-key cj/agenda-frame-mode-map seq
+ #'cj/--agenda-frame-denied-readonly))))))))
+ source)))
+
+;; The shadow walk is installed at the END of this file, not here: it reads
+;; `commandp' on each allowlisted binding to decide whether to keep it, and the
+;; view/redo handlers (day-view, week-view, safe-redo) are defined further down.
+;; If org-agenda is already loaded when this file loads (the normal startup order,
+;; and every reload), `with-eval-after-load' fires immediately -- so the walk must
+;; not run until those defuns exist, or it reads them as undefined, fails the
+;; commandp guard, and denies the very keys the allowlist grants. See the bottom
+;; of the file.
+
(defun cj/--agenda-frame-maybe-enable-mode ()
"Re-enable `cj/agenda-frame-mode' after an agenda build in the agenda frame.
Added to `org-agenda-finalize-hook'. `org-agenda-redo' rebuilds through
@@ -787,5 +837,11 @@ force-refresh idea in the F8 family."
;; The public gesture appears only now that the feature is complete and live.
(cj/--agenda-frame-install-keys)
+;; Install the read-only shadow now that every allowlist handler above is
+;; defined, so the walk's `commandp' guard recognizes them and preserves the
+;; allowlist regardless of whether org-agenda loaded before or after this file.
+(with-eval-after-load 'org-agenda
+ (cj/--agenda-frame-shadow-mutations))
+
(provide 'org-agenda-frame)
;;; org-agenda-frame.el ends here
diff --git a/modules/org-refile-config.el b/modules/org-refile-config.el
index 5f826cac..d94e4965 100644
--- a/modules/org-refile-config.el
+++ b/modules/org-refile-config.el
@@ -185,6 +185,25 @@ ARG DEFAULT-BUFFER RFLOC and MSG parameters passed to org-refile."
;; --------------------------------- Org Refile --------------------------------
+(declare-function org-save-all-org-buffers "org")
+
+(defun cj/org-refile--save-all-buffers (&rest _)
+ "Save every open Org buffer. Installed as `:after' advice on `org-refile'.
+Named (not an anonymous lambda) so the :config reload can `advice-remove'
+it by reference and a test can assert its installation."
+ (org-save-all-org-buffers))
+
+(defun cj/org-refile--ensure-targets-in-org-mode (&rest _)
+ "Put every string-named refile target buffer into `org-mode' first.
+Installed as `:before' advice on `org-refile-get-targets'. Fixes targets
+opened before Org loaded getting stuck in `fundamental-mode'. A non-string
+target car (a function or symbol spec) is skipped. Named for the same
+remove-by-reference and testability reasons as the save helper above."
+ (dolist (target org-refile-targets)
+ (let ((file (car target)))
+ (when (stringp file)
+ (cj/org-refile-ensure-org-mode file)))))
+
(use-package org-refile
:ensure nil ;; built-in
:defer .5
@@ -193,20 +212,14 @@ ARG DEFAULT-BUFFER RFLOC and MSG parameters passed to org-refile."
("C-c C-w" . cj/org-refile)
("C-c w" . cj/org-refile-in-file))
:config
- ;; save all open org buffers after a refile is complete
- (advice-add 'org-refile :after
- (lambda (&rest _)
- (org-save-all-org-buffers)))
-
- ;; Ensure refile target buffers are in org-mode before processing
- ;; Fixes issue where buffers opened before org loaded get stuck in fundamental-mode
- (advice-add 'org-refile-get-targets :before
- (lambda (&rest _)
- "Ensure all refile target buffers are in org-mode."
- (dolist (target org-refile-targets)
- (let ((file (car target)))
- (when (stringp file)
- (cj/org-refile-ensure-org-mode file)))))))
+ ;; Install both advices by named-function reference with a remove-then-add
+ ;; guard. Anonymous lambdas here couldn't be `advice-remove'd (deleting the
+ ;; advice from source left a live daemon still running it) and couldn't be
+ ;; tested; the named helpers above are both.
+ (advice-remove 'org-refile #'cj/org-refile--save-all-buffers)
+ (advice-add 'org-refile :after #'cj/org-refile--save-all-buffers)
+ (advice-remove 'org-refile-get-targets #'cj/org-refile--ensure-targets-in-org-mode)
+ (advice-add 'org-refile-get-targets :before #'cj/org-refile--ensure-targets-in-org-mode))
(provide 'org-refile-config)
;;; org-refile-config.el ends here.
diff --git a/modules/system-defaults.el b/modules/system-defaults.el
index 7f369a5e..9b4652e8 100644
--- a/modules/system-defaults.el
+++ b/modules/system-defaults.el
@@ -55,6 +55,10 @@
(expand-file-name "comp-warnings.log" user-emacs-directory)
"File where native-comp warnings will be appended.")
+(defvar cj/comp-warnings-log-max-bytes (* 512 1024)
+ "Cap on `comp-warnings-log' size. Once it exceeds this, the log is reset
+before the next write, so native-comp warnings can't grow it without bound.")
+
(defun cj/log-comp-warning (type message &rest args)
"Log native-comp warnings of TYPE with MESSAGE & ARGS.
Log to buffer `comp-warnings-log'. Suppress warnings from appearing in the
@@ -62,13 +66,24 @@ Log to buffer `comp-warnings-log'. Suppress warnings from appearing in the
timestamp to the file specified by `comp-warnings-log'. Return non-nil to
indicate the warning was handled."
(when (memq 'comp (if (listp type) type (list type)))
- (with-temp-buffer
- (insert (format-time-string "[%Y-%m-%d %H:%M:%S] "))
- (insert (if (stringp message)
- (apply #'format message args)
- (format "%S %S" message args)))
- (insert "\n")
- (append-to-file (point-min) (point-max) comp-warnings-log))
+ ;; Reset the log if it has grown past the cap, so async comp warnings can't
+ ;; grow it without bound.
+ (when (ignore-errors
+ (> (or (file-attribute-size (file-attributes comp-warnings-log)) 0)
+ cj/comp-warnings-log-max-bytes))
+ (ignore-errors (delete-file comp-warnings-log)))
+ ;; Guard the write: this runs as `:before-until' advice on `display-warning',
+ ;; so a signal here (an unwritable log path) would propagate out and break
+ ;; warning display for every async comp notice. Swallow the failure; the
+ ;; warning stays suppressed either way.
+ (ignore-errors
+ (with-temp-buffer
+ (insert (format-time-string "[%Y-%m-%d %H:%M:%S] "))
+ (insert (if (stringp message)
+ (apply #'format message args)
+ (format "%S %S" message args)))
+ (insert "\n")
+ (append-to-file (point-min) (point-max) comp-warnings-log)))
;; Return non-nil to tell `display-warning' “we handled it.”
t))
diff --git a/modules/video-audio-recording-capture.el b/modules/video-audio-recording-capture.el
index 7b71d54f..a56a5906 100644
--- a/modules/video-audio-recording-capture.el
+++ b/modules/video-audio-recording-capture.el
@@ -56,6 +56,10 @@ Checks if process is actually alive, not just if variable is set."
;;; Process Lifecycle (Sentinel and Graceful Shutdown)
+;; Forward declaration: the real `defvar' is defined below with the other
+;; recording thresholds. Declared special here so this reference compiles clean.
+(defvar cj/recording-start-fail-threshold)
+
(defun cj/recording-process-sentinel (process event)
"Sentinel for recording processes — handles unexpected exits.
PROCESS is the ffmpeg shell process, EVENT describes what happened.
diff --git a/modules/video-audio-recording-devices.el b/modules/video-audio-recording-devices.el
index 375a81cf..8adcd347 100644
--- a/modules/video-audio-recording-devices.el
+++ b/modules/video-audio-recording-devices.el
@@ -272,54 +272,6 @@ Returns the selected device name, or signals user-error if cancelled."
(user-error "Device setup cancelled"))
device))
-(defun cj/recording-group-devices-by-hardware ()
- "Group audio sources by physical hardware device.
-Returns alist of (friendly-name . (mic-source . monitor-source)).
-Only includes devices that have BOTH a mic and a monitor source,
-since recording needs both to capture your voice and system audio."
- (let ((sources (cj/recording-parse-sources))
- (devices (make-hash-table :test 'equal))
- (result nil))
- ;; Group sources by base device name (hardware identifier)
- (dolist (source sources)
- (let* ((device (nth 0 source))
- ;; Extract hardware ID — the unique part identifying the physical device.
- ;; Different device types use different naming conventions in PulseAudio.
- (base-name (cond
- ;; USB devices: extract usb-XXXXX-XX part
- ((string-match "\\.\\(usb-[^.]+\\-[0-9]+\\)\\." device)
- (match-string 1 device))
- ;; Built-in (PCI) devices: extract pci-XXXXX part
- ((string-match "\\.\\(pci-[^.]+\\)\\." device)
- (match-string 1 device))
- ;; Bluetooth devices: extract and normalize MAC address
- ;; (input uses colons, output uses underscores)
- ((string-match "bluez_\\(?:input\\|output\\)\\.\\([^.]+\\)" device)
- (replace-regexp-in-string "_" ":" (match-string 1 device)))
- (t device)))
- (is-monitor (string-match-p "\\.monitor$" device))
- (device-entry (gethash base-name devices)))
- (unless device-entry
- (setf device-entry (cons nil nil))
- (puthash base-name device-entry devices))
- (if is-monitor
- (setcdr device-entry device)
- (setcar device-entry device))))
-
- ;; Convert hash table to alist with user-friendly names
- (maphash (lambda (base-name pair)
- (when (and (car pair) (cdr pair))
- (let ((friendly-name
- (cond
- ((string-match-p "usb.*[Jj]abra" base-name) "Jabra SPEAK 510 USB")
- ((string-match-p "^usb-" base-name) "USB Audio Device")
- ((string-match-p "^pci-" base-name) "Built-in Audio")
- ((string-match-p "^[0-9A-Fa-f:]+$" base-name) "Bluetooth Headset")
- (t base-name))))
- (push (cons friendly-name pair) result))))
- devices)
- (nreverse result)))
-
(defun cj/recording-select-device (prompt device-type)
"Interactively select an audio device.
PROMPT is shown to user. DEVICE-TYPE is \\='mic or \\='monitor for filtering.
diff --git a/modules/wrap-up.el b/modules/wrap-up.el
index e28ba845..6901901f 100644
--- a/modules/wrap-up.el
+++ b/modules/wrap-up.el
@@ -23,12 +23,12 @@
"Bury comint and compilation buffers."
(dolist (buf (buffer-list))
(with-current-buffer buf
+ ;; Byte-compilation output arrives in `emacs-lisp-compilation-mode',
+ ;; which derives from `compilation-mode' and so is covered by that clause.
(when (or (derived-mode-p 'comint-mode)
(derived-mode-p 'compilation-mode)
(derived-mode-p 'debugger-mode)
- (derived-mode-p 'elisp-compile-mode)
- (derived-mode-p 'messages-buffer-mode)
- ) ;; byte-compilations
+ (derived-mode-p 'messages-buffer-mode))
(bury-buffer)))))
(defun cj/bury-buffers-after-delay ()
diff --git a/scripts/remote-repository-reset.sh b/scripts/remote-repository-reset.sh
deleted file mode 100755
index e9a243a8..00000000
--- a/scripts/remote-repository-reset.sh
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/bin/sh
-# Craig Jennings
-# post archsetup step to reset remote upstream repositories on emacs
-# configuration and doftiles.
-
-cd ~/emacs.d/
-git remote remove origin
-git remote add github git@github.com:cjennings/dotemacs.git
-git remote add origin git@cjennings.net:dotemacs.git
-git branch -M main
-git push -u origin main
-
-
-cd ~/.dotfiles/
-git remote remove origin
-git remote add github git@github.com:cjennings/dotfiles.git
-git remote add origin git@cjennings.net:dotfiles.git
-git branch -M main
-git push -u origin main
diff --git a/tests/test-auto-dim-config.el b/tests/test-auto-dim-config.el
index 12435fa0..dcab7eff 100644
--- a/tests/test-auto-dim-config.el
+++ b/tests/test-auto-dim-config.el
@@ -30,7 +30,12 @@
(progn
(should (bound-and-true-p auto-dim-other-buffers-mode))
(should (null auto-dim-other-buffers-dim-on-focus-out))
- (should (eq t auto-dim-other-buffers-dim-on-switch-to-minibuffer))
+ ;; Entering the minibuffer must not change what is dimmed: a dim window
+ ;; stays dim, a lit one stays lit. The fork's `adob--update' returns
+ ;; early when this is nil and the selected window is the minibuffer, so
+ ;; nil is what keeps a minibuffer prompt from re-dimming the window the
+ ;; user was just in.
+ (should (null auto-dim-other-buffers-dim-on-switch-to-minibuffer))
(should-not (assq 'fringe auto-dim-other-buffers-affected-faces)))
(when (fboundp 'auto-dim-other-buffers-mode)
(auto-dim-other-buffers-mode -1))))
diff --git a/tests/test-browser-config--preferred-default.el b/tests/test-browser-config--preferred-default.el
new file mode 100644
index 00000000..113ad540
--- /dev/null
+++ b/tests/test-browser-config--preferred-default.el
@@ -0,0 +1,89 @@
+;;; test-browser-config--preferred-default.el --- Tests for the first-run browser pick -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/--preferred-default-browser, the pure helper that picks
+;; the first-run default when no saved choice exists.
+;;
+;; The behavior it fixes: EWW is listed first in `cj/browser-definitions' and
+;; carries a nil executable, so `cj/discover-browsers' always reports it as
+;; available and it sorted first. A fresh machine therefore opened every org
+;; link in the Emacs text browser until the user found cj/choose-browser, even
+;; with Chrome or Firefox installed. The helper prefers a real external
+;; browser and keeps EWW as the genuine last resort.
+;;
+;; The helper takes the discovered list as an argument rather than calling
+;; `cj/discover-browsers' itself, so these tests drive real data structures
+;; and never stub executable-find.
+;;
+;; Test organization:
+;; - Normal Cases: external browser preferred over a leading built-in
+;; - Boundary Cases: only built-ins, only externals, single entry, empty list
+;; - Error Cases: entries missing the :executable key
+;;
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'browser-config)
+
+(defconst test-browser--eww
+ '(:executable nil :function eww-browse-url :name "EWW (Emacs Browser)"
+ :path nil :program-var nil))
+
+(defconst test-browser--chrome
+ '(:executable "google-chrome" :function browse-url-chrome :name "Google Chrome"
+ :path "/usr/bin/google-chrome" :program-var browse-url-chrome-program))
+
+(defconst test-browser--firefox
+ '(:executable "firefox" :function browse-url-firefox :name "Firefox"
+ :path "/usr/bin/firefox" :program-var browse-url-firefox-program))
+
+;;; Normal Cases
+
+(ert-deftest test-browser-config-preferred-default-skips-leading-builtin ()
+ "Normal: an installed external browser wins over a built-in listed first."
+ (should (equal (cj/--preferred-default-browser
+ (list test-browser--eww test-browser--chrome))
+ test-browser--chrome)))
+
+(ert-deftest test-browser-config-preferred-default-keeps-external-order ()
+ "Normal: the FIRST external in list order wins, not merely any external."
+ (should (equal (cj/--preferred-default-browser
+ (list test-browser--eww test-browser--chrome test-browser--firefox))
+ test-browser--chrome)))
+
+;;; Boundary Cases
+
+(ert-deftest test-browser-config-preferred-default-builtin-only-falls-back ()
+ "Boundary: with no external installed, the built-in is still chosen."
+ (should (equal (cj/--preferred-default-browser (list test-browser--eww))
+ test-browser--eww)))
+
+(ert-deftest test-browser-config-preferred-default-external-only ()
+ "Boundary: a list of externals returns the first one."
+ (should (equal (cj/--preferred-default-browser
+ (list test-browser--firefox test-browser--chrome))
+ test-browser--firefox)))
+
+(ert-deftest test-browser-config-preferred-default-empty-list-is-nil ()
+ "Boundary: an empty discovery result yields nil, never an error."
+ (should (null (cj/--preferred-default-browser '()))))
+
+(ert-deftest test-browser-config-preferred-default-single-builtin ()
+ "Boundary: a one-element built-in list returns that element."
+ (should (equal (cj/--preferred-default-browser (list test-browser--eww))
+ test-browser--eww)))
+
+;;; Error Cases
+
+(ert-deftest test-browser-config-preferred-default-missing-executable-key ()
+ "Error: a plist with no :executable key counts as built-in, not a crash."
+ (let ((malformed '(:name "Odd" :function ignore)))
+ (should (equal (cj/--preferred-default-browser
+ (list malformed test-browser--chrome))
+ test-browser--chrome))
+ (should (equal (cj/--preferred-default-browser (list malformed))
+ malformed))))
+
+(provide 'test-browser-config--preferred-default)
+;;; test-browser-config--preferred-default.el ends here
diff --git a/tests/test-calendar-sync--syncing-p.el b/tests/test-calendar-sync--syncing-p.el
index b346bf77..df8bcd52 100644
--- a/tests/test-calendar-sync--syncing-p.el
+++ b/tests/test-calendar-sync--syncing-p.el
@@ -4,81 +4,111 @@
;; Unit tests for `calendar-sync--syncing-p' (the per-calendar in-flight check
;; that lets the dispatcher skip an overlapping timer tick) and for the
;; load-state sanitize that clears a stale `syncing' status in a fresh process.
+;;
+;; Every test runs inside `test-cs-syncing--with-fresh-state', which let-binds
+;; a private state hash. These tests previously cleared the module's global
+;; hash on entry and left whatever they wrote in it on exit, which leaked:
+;; `...-sync-calendar-skips-when-in-flight' marks "proton" as syncing to
+;; exercise the guard, and `test-calendar-sync--sync-dispatch-normal-ics-fetcher'
+;; in the sibling dispatch file dispatches a calendar also named "proton".
+;; ERT runs them in that order, so the leftover in-flight status made the
+;; dispatch a no-op and the sibling failed -- but only when the calendar-sync
+;; files ran in one process. `make test' runs each file separately and the
+;; editor hook skipped this family for being over its file cap, so nothing
+;; caught it. Let-binding is what the sibling files already do
+;; (test-calendar-sync.el, test-calendar-sync-async-worker.el); this file was
+;; the odd one out.
;;; Code:
(require 'ert)
(require 'calendar-sync)
-(defun test-cs-syncing--reset ()
- "Clear the module's per-calendar state hash."
- (clrhash calendar-sync--calendar-states))
+(defmacro test-cs-syncing--with-fresh-state (&rest body)
+ "Run BODY with a private, empty per-calendar state hash.
+Let-bound rather than cleared in place, so nothing this test writes can
+reach a later test."
+ (declare (indent 0))
+ `(let ((calendar-sync--calendar-states (make-hash-table :test 'equal)))
+ ,@body))
;;; calendar-sync--syncing-p
(ert-deftest test-calendar-sync--syncing-p-normal-true-when-syncing ()
"Normal: a calendar whose status is `syncing' reads as in-flight."
- (test-cs-syncing--reset)
- (calendar-sync--set-calendar-state "google" '(:status syncing))
- (should (calendar-sync--syncing-p "google")))
+ (test-cs-syncing--with-fresh-state
+ (calendar-sync--set-calendar-state "google" '(:status syncing))
+ (should (calendar-sync--syncing-p "google"))))
(ert-deftest test-calendar-sync--syncing-p-boundary-nil-when-no-state ()
"Boundary: a calendar with no recorded state is not in-flight."
- (test-cs-syncing--reset)
- (should-not (calendar-sync--syncing-p "never-seen")))
+ (test-cs-syncing--with-fresh-state
+ (should-not (calendar-sync--syncing-p "never-seen"))))
(ert-deftest test-calendar-sync--syncing-p-error-nil-for-terminal-status ()
"Error: a terminal status (ok / error) is not in-flight."
- (test-cs-syncing--reset)
- (calendar-sync--set-calendar-state "google" '(:status ok))
- (should-not (calendar-sync--syncing-p "google"))
- (calendar-sync--set-calendar-state "proton" '(:status error))
- (should-not (calendar-sync--syncing-p "proton")))
+ (test-cs-syncing--with-fresh-state
+ (calendar-sync--set-calendar-state "google" '(:status ok))
+ (should-not (calendar-sync--syncing-p "google"))
+ (calendar-sync--set-calendar-state "proton" '(:status error))
+ (should-not (calendar-sync--syncing-p "proton"))))
;;; Dispatcher guard: an in-flight calendar skips both leaf syncers
(ert-deftest test-calendar-sync--sync-calendar-skips-when-in-flight ()
"Normal: `calendar-sync--sync-calendar' does not launch a second sync for a
calendar already marked syncing, so an overlapping timer tick is a no-op."
- (test-cs-syncing--reset)
- (let ((api-calls '()) (ics-calls '()))
- (cl-letf (((symbol-function 'calendar-sync--sync-calendar-api)
- (lambda (cal) (push cal api-calls)))
- ((symbol-function 'calendar-sync--sync-calendar-ics)
- (lambda (cal) (push cal ics-calls))))
- (calendar-sync--set-calendar-state "proton" '(:status syncing))
- (calendar-sync--sync-calendar '(:name "proton" :url "https://x/y.ics"
- :file "/tmp/c.org"))
- (should (null api-calls))
- (should (null ics-calls)))))
+ (test-cs-syncing--with-fresh-state
+ (let ((api-calls '()) (ics-calls '()))
+ (cl-letf (((symbol-function 'calendar-sync--sync-calendar-api)
+ (lambda (cal) (push cal api-calls)))
+ ((symbol-function 'calendar-sync--sync-calendar-ics)
+ (lambda (cal) (push cal ics-calls))))
+ (calendar-sync--set-calendar-state "proton" '(:status syncing))
+ (calendar-sync--sync-calendar '(:name "proton" :url "https://x/y.ics"
+ :file "/tmp/c.org"))
+ (should (null api-calls))
+ (should (null ics-calls))))))
(ert-deftest test-calendar-sync--sync-calendar-dispatches-when-idle ()
"Boundary: an idle calendar (no in-flight status) still dispatches normally."
- (test-cs-syncing--reset)
- (let ((ics-calls '()))
- (cl-letf (((symbol-function 'calendar-sync--sync-calendar-ics)
- (lambda (cal) (push cal ics-calls))))
- (calendar-sync--sync-calendar '(:name "proton" :url "https://x/y.ics"
- :file "/tmp/c.org"))
- (should (= 1 (length ics-calls))))))
+ (test-cs-syncing--with-fresh-state
+ (let ((ics-calls '()))
+ (cl-letf (((symbol-function 'calendar-sync--sync-calendar-ics)
+ (lambda (cal) (push cal ics-calls))))
+ (calendar-sync--sync-calendar '(:name "proton" :url "https://x/y.ics"
+ :file "/tmp/c.org"))
+ (should (= 1 (length ics-calls)))))))
+
+;;; Isolation guard
+
+(ert-deftest test-calendar-sync--syncing-state-does-not-leak ()
+ "Error: state written inside the macro is gone once it returns.
+Pins the isolation itself. Without it a test marking a calendar syncing
+leaves that status set for every later test in the same process, which is
+exactly what broke the sibling dispatch test."
+ (test-cs-syncing--with-fresh-state
+ (calendar-sync--set-calendar-state "leak-probe" '(:status syncing))
+ (should (calendar-sync--syncing-p "leak-probe")))
+ (should-not (calendar-sync--syncing-p "leak-probe")))
;;; load-state sanitize: a persisted `syncing' status is cleared on load
(ert-deftest test-calendar-sync--load-state-clears-stale-syncing ()
"Error: a `syncing' status persisted before a crash is reset on load, so the
in-flight guard cannot skip that calendar forever in the new session."
- (test-cs-syncing--reset)
- (let* ((dir (make-temp-file "cs-state-" t))
- (calendar-sync--state-file (expand-file-name "state.el" dir)))
- (unwind-protect
- (progn
- (with-temp-file calendar-sync--state-file
- (prin1 '((timezone-offset . nil)
- (calendar-states . (("google" . (:status syncing)))))
- (current-buffer)))
- (calendar-sync--load-state)
- (should-not (calendar-sync--syncing-p "google")))
- (delete-directory dir t))))
+ (test-cs-syncing--with-fresh-state
+ (let* ((dir (make-temp-file "cs-state-" t))
+ (calendar-sync--state-file (expand-file-name "state.el" dir)))
+ (unwind-protect
+ (progn
+ (with-temp-file calendar-sync--state-file
+ (prin1 '((timezone-offset . nil)
+ (calendar-states . (("google" . (:status syncing)))))
+ (current-buffer)))
+ (calendar-sync--load-state)
+ (should-not (calendar-sync--syncing-p "google")))
+ (delete-directory dir t)))))
(provide 'test-calendar-sync--syncing-p)
;;; test-calendar-sync--syncing-p.el ends here
diff --git a/tests/test-calendar-sync.el b/tests/test-calendar-sync.el
index f562cfc6..8a7c2549 100644
--- a/tests/test-calendar-sync.el
+++ b/tests/test-calendar-sync.el
@@ -713,5 +713,50 @@ Valid events should be parsed, invalid ones skipped."
(should-not (and org-content
(string-match-p "OutOfRangeEvent" org-content)))))
+;;; calendar-sync--sync-timer-function — hourly-timer body hygiene
+
+(ert-deftest test-calendar-sync-timer-function-does-not-propagate-a-signal ()
+ "Error: a signal in the timer body is caught, not propagated.
+The function runs from an hourly `run-at-time' timer. An unguarded signal
+in the timezone check or the sync fan-out would error on every tick — the
+same error, once an hour, forever. It must swallow-and-log instead."
+ (cl-letf (((symbol-function 'calendar-sync--timezone-changed-p)
+ (lambda (&rest _) (error "boom from the timezone check")))
+ ((symbol-function 'calendar-sync--sync-all-calendars) #'ignore)
+ ((symbol-function 'calendar-sync--log-silently) #'ignore))
+ ;; Must return normally rather than signal.
+ (should (progn (calendar-sync--sync-timer-function) t))))
+
+(ert-deftest test-calendar-sync-timer-function-signal-in-sync-is-caught ()
+ "Error: a signal from the sync fan-out is also caught, not propagated."
+ (cl-letf (((symbol-function 'calendar-sync--timezone-changed-p) #'ignore)
+ ((symbol-function 'calendar-sync--sync-all-calendars)
+ (lambda (&rest _) (error "boom from sync-all")))
+ ((symbol-function 'calendar-sync--log-silently) #'ignore))
+ (should (progn (calendar-sync--sync-timer-function) t))))
+
+(ert-deftest test-calendar-sync-timer-function-timezone-change-is-not-echoed ()
+ "Normal: a detected timezone change is logged silently, not echoed.
+An hourly timer that calls `message' spams the echo area; the notice belongs
+in the silent log like the module's other timer-path notices."
+ (let (silent-logged echoed)
+ (cl-letf (((symbol-function 'calendar-sync--timezone-changed-p)
+ (lambda (&rest _) t))
+ ((symbol-function 'calendar-sync--format-timezone-offset)
+ (lambda (&rest _) "UTC+0"))
+ ((symbol-function 'calendar-sync--current-timezone-offset)
+ (lambda (&rest _) 0))
+ ((symbol-function 'calendar-sync--sync-all-calendars) #'ignore)
+ ((symbol-function 'calendar-sync--log-silently)
+ (lambda (fmt &rest _) (when (string-match-p "Timezone" fmt)
+ (setq silent-logged t))))
+ ((symbol-function 'message)
+ (lambda (fmt &rest _) (when (and (stringp fmt)
+ (string-match-p "Timezone" fmt))
+ (setq echoed t)))))
+ (calendar-sync--sync-timer-function)
+ (should silent-logged)
+ (should-not echoed))))
+
(provide 'test-calendar-sync)
;;; test-calendar-sync.el ends here
diff --git a/tests/test-calibredb-epub-config--epub-mode.el b/tests/test-calibredb-epub-config--epub-mode.el
new file mode 100644
index 00000000..a65bdabf
--- /dev/null
+++ b/tests/test-calibredb-epub-config--epub-mode.el
@@ -0,0 +1,70 @@
+;;; test-calibredb-epub-config--epub-mode.el --- Tests for epub mode resolution -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests that .epub files reach nov-mode through `auto-mode-alist' alone, with
+;; no advice on `set-auto-mode'.
+;;
+;; Background: the module used to carry an :around advice on `set-auto-mode'
+;; forcing nov-mode for .epub, added to keep `magic-fallback-mode-alist' from
+;; opening the zip container in archive-mode. It was never needed.
+;; `set-auto-mode' consults `auto-mode-alist' before `magic-fallback-mode-alist',
+;; and nov's use-package :mode registers "\\.epub\\'" there, so the alist
+;; already won. Verified live on the daemon: a real zip-format .epub opened in
+;; nov-mode both with the advice and with it removed.
+;;
+;; The advice was not free. `set-auto-mode' runs on every file visit, so the
+;; advice put a redundant frame and an extra failure surface on the path for
+;; every file of every type.
+;;
+;; The second test is a regression guard: it fails if the advice is ever
+;; reinstated, which is the mistake this cleanup exists to prevent.
+;;
+;; Test organization:
+;; - Normal Cases: .epub resolves to nov-mode; no advice on set-auto-mode
+;; - Boundary Cases: a path merely containing "epub", and a bare "epub" name
+;; - Error Cases: an unrelated extension does not resolve to nov-mode
+;;
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'calibredb-epub-config)
+
+(defun test-epub-mode--resolve (filename)
+ "Return the major mode `auto-mode-alist' assigns to FILENAME."
+ (assoc-default filename auto-mode-alist 'string-match))
+
+;;; Normal Cases
+
+(ert-deftest test-calibredb-epub-config-epub-resolves-to-nov-mode ()
+ "Normal: auto-mode-alist maps a .epub file to nov-mode on its own."
+ (should (eq 'nov-mode (test-epub-mode--resolve "book.epub"))))
+
+(ert-deftest test-calibredb-epub-config-no-set-auto-mode-advice ()
+ "Normal: nothing advises set-auto-mode to force nov-mode.
+Regression guard. auto-mode-alist already wins over
+magic-fallback-mode-alist, so an advice here would be redundant work on
+every file visit."
+ (should-not (advice-member-p 'cj/force-nov-mode-for-epub 'set-auto-mode))
+ (should-not (fboundp 'cj/force-nov-mode-for-epub)))
+
+;;; Boundary Cases
+
+(ert-deftest test-calibredb-epub-config-epub-in-directory-name ()
+ "Boundary: the extension anchors at the end, so a directory named epub
+does not by itself select nov-mode."
+ (should-not (eq 'nov-mode (test-epub-mode--resolve "/home/user/epub/notes.txt"))))
+
+(ert-deftest test-calibredb-epub-config-epub-with-path ()
+ "Boundary: a full path with directories still resolves on the extension."
+ (should (eq 'nov-mode (test-epub-mode--resolve "/home/user/books/a b.epub"))))
+
+;;; Error Cases
+
+(ert-deftest test-calibredb-epub-config-other-extension-not-nov ()
+ "Error: an unrelated extension must not resolve to nov-mode."
+ (should-not (eq 'nov-mode (test-epub-mode--resolve "archive.zip")))
+ (should-not (eq 'nov-mode (test-epub-mode--resolve "notes.org"))))
+
+(provide 'test-calibredb-epub-config--epub-mode)
+;;; test-calibredb-epub-config--epub-mode.el ends here
diff --git a/tests/test-calibredb-epub-config.el b/tests/test-calibredb-epub-config.el
index 71581d4c..7afc58f3 100644
--- a/tests/test-calibredb-epub-config.el
+++ b/tests/test-calibredb-epub-config.el
@@ -285,57 +285,6 @@ so the search buffer rebuilds against the now-unfiltered set."
(cj/calibredb-clear-filters))
(should (equal "" passed))))
-;;; --------------------------- cj/force-nov-mode-for-epub ---------------------
-
-(ert-deftest test-calibredb-epub-force-nov-mode-on-epub-calls-nov-mode ()
- "Normal: a .epub buffer with nov-mode bound dispatches to `nov-mode' and
-does not fall through to the original mode dispatcher."
- (skip-unless (fboundp 'nov-mode))
- (let (orig-called nov-called)
- (cl-letf (((symbol-function 'nov-mode)
- (lambda () (setq nov-called t))))
- (with-temp-buffer
- (setq buffer-file-name "/tmp/sample.epub")
- (cj/force-nov-mode-for-epub
- (lambda (&rest _) (setq orig-called t)))))
- (should nov-called)
- (should-not orig-called)))
-
-(ert-deftest test-calibredb-epub-force-nov-mode-passes-through-non-epub ()
- "Boundary: a non-epub buffer falls through to the original mode dispatcher."
- (let (orig-called)
- (with-temp-buffer
- (setq buffer-file-name "/tmp/sample.txt")
- (cj/force-nov-mode-for-epub
- (lambda (&rest _) (setq orig-called t))))
- (should orig-called)))
-
-(ert-deftest test-calibredb-epub-force-nov-mode-passes-through-no-filename ()
- "Boundary: a buffer with no associated filename falls through to the
-original mode dispatcher."
- (let (orig-called)
- (with-temp-buffer
- (cj/force-nov-mode-for-epub
- (lambda (&rest _) (setq orig-called t))))
- (should orig-called)))
-
-(ert-deftest test-calibredb-epub-force-nov-mode-passes-through-when-nov-missing ()
- "Error: a .epub buffer falls through to the original dispatcher when nov-mode
-is not defined (the require failed and there is nothing to dispatch to)."
- (let ((saved (and (fboundp 'nov-mode) (symbol-function 'nov-mode)))
- orig-called)
- (when saved (fmakunbound 'nov-mode))
- (unwind-protect
- (cl-letf (((symbol-function 'require)
- ;; Pretend the (require 'nov nil t) call fails too.
- (lambda (&rest _) nil)))
- (with-temp-buffer
- (setq buffer-file-name "/tmp/sample.epub")
- (cj/force-nov-mode-for-epub
- (lambda (&rest _) (setq orig-called t)))))
- (when saved (fset 'nov-mode saved)))
- (should orig-called)))
-
;;; ---------------------------- cj/nov--metadata-get --------------------------
(ert-deftest test-calibredb-epub-metadata-get-symbol-key ()
diff --git a/tests/test-help-utils--arch-wiki-search.el b/tests/test-help-utils--arch-wiki-search.el
new file mode 100644
index 00000000..d02dd041
--- /dev/null
+++ b/tests/test-help-utils--arch-wiki-search.el
@@ -0,0 +1,124 @@
+;;; test-help-utils--arch-wiki-search.el --- Tests for the ArchWiki search guard -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/--arch-wiki-topics and the cj/local-arch-wiki-search command.
+;;
+;; The bug: the command read "/usr/share/doc/arch-wiki/html/en" with
+;; `directory-files' before checking the directory existed. On a machine
+;; without arch-wiki-docs -- the exact state the command's own error text is
+;; written for -- that signaled file-missing on the first line, so the friendly
+;; "Is arch-wiki-docs installed?" message below it was unreachable. The user
+;; got a raw Lisp error naming a path instead of the install hint.
+;;
+;; Two things had to change to make this testable. The directory was
+;; hardcoded inside the command, so a test could only ever exercise whatever
+;; the developer's own machine happened to have installed; it is now
+;; `cj/arch-wiki-html-dir'. And the directory read is now the pure helper
+;; cj/--arch-wiki-topics, which takes a directory and returns an alist, so the
+;; interesting cases are driven with real temporary directories instead of
+;; mocking `directory-files'.
+;;
+;; Test organization:
+;; - Normal Cases: topics found and returned; the command opens the choice
+;; - Boundary Cases: empty dir, single topic, a name matching no topic
+;; - Error Cases: missing dir returns nil and reports the install hint
+;;
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'help-utils)
+
+(defmacro test-arch-wiki--with-topics (dir-var topics &rest body)
+ "Bind DIR-VAR to a temp dir holding TOPICS (a list of html basenames).
+The directory is removed after BODY."
+ (declare (indent 2))
+ `(let ((,dir-var (make-temp-file "arch-wiki-test" t)))
+ (unwind-protect
+ (progn
+ (dolist (name ,topics)
+ (write-region "" nil (expand-file-name (concat name ".html") ,dir-var)))
+ ,@body)
+ (delete-directory ,dir-var t))))
+
+;;; Normal Cases — the pure helper
+
+(ert-deftest test-help-utils-arch-wiki-topics-lists-html-basenames ()
+ "Normal: each .html file becomes a (basename . fullpath) pair."
+ (test-arch-wiki--with-topics dir '("Systemd" "Pacman")
+ (let ((topics (cj/--arch-wiki-topics dir)))
+ (should (equal '("Pacman" "Systemd") (sort (mapcar #'car topics) #'string<)))
+ (should (string-suffix-p "Systemd.html" (cdr (assoc "Systemd" topics)))))))
+
+(ert-deftest test-help-utils-arch-wiki-topics-ignores-non-html ()
+ "Normal: files without the .html extension are not topics."
+ (test-arch-wiki--with-topics dir '("Systemd")
+ (write-region "" nil (expand-file-name "README.txt" dir))
+ (should (equal '("Systemd") (mapcar #'car (cj/--arch-wiki-topics dir))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-help-utils-arch-wiki-topics-empty-dir-is-nil ()
+ "Boundary: an existing but empty directory yields no topics."
+ (test-arch-wiki--with-topics dir '()
+ (should (null (cj/--arch-wiki-topics dir)))))
+
+(ert-deftest test-help-utils-arch-wiki-topics-single-topic ()
+ "Boundary: one topic returns a one-element alist."
+ (test-arch-wiki--with-topics dir '("Systemd")
+ (should (= 1 (length (cj/--arch-wiki-topics dir))))))
+
+;;; Error Cases — the missing-install path
+
+(ert-deftest test-help-utils-arch-wiki-topics-missing-dir-returns-nil ()
+ "Error: an absent directory returns nil rather than signaling file-missing."
+ (let ((missing (expand-file-name "definitely-absent-arch-wiki"
+ temporary-file-directory)))
+ (should-not (file-directory-p missing))
+ (should (null (cj/--arch-wiki-topics missing)))))
+
+(ert-deftest test-help-utils-arch-wiki-search-missing-dir-reports-hint ()
+ "Error: the command reports the install hint and opens nothing."
+ (let ((cj/arch-wiki-html-dir (expand-file-name "definitely-absent-arch-wiki"
+ temporary-file-directory))
+ (said nil)
+ (opened nil))
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (setq said (apply #'format fmt args)) nil))
+ ((symbol-function 'eww-browse-url)
+ (lambda (url &rest _) (setq opened url))))
+ ;; Must not signal: this is the case that used to raise file-missing.
+ (cj/local-arch-wiki-search))
+ (should-not opened)
+ (should (string-match-p "arch-wiki-docs" said))))
+
+;;; Normal Cases — the command
+
+(ert-deftest test-help-utils-arch-wiki-search-opens-chosen-topic ()
+ "Normal: the chosen topic is opened as a file URL in EWW."
+ (test-arch-wiki--with-topics dir '("Systemd")
+ (let ((cj/arch-wiki-html-dir dir)
+ (opened nil))
+ (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "Systemd"))
+ ((symbol-function 'eww-browse-url)
+ (lambda (url &rest _) (setq opened url))))
+ (cj/local-arch-wiki-search))
+ (should (string-prefix-p "file://" opened))
+ (should (string-suffix-p "Systemd.html" opened))
+ ;; The opened path is the one in the temp dir, not a system copy.
+ (should (string-match-p (regexp-quote dir) opened)))))
+
+(ert-deftest test-help-utils-arch-wiki-search-unknown-topic-opens-nothing ()
+ "Boundary: a name matching no topic reports rather than opening."
+ (test-arch-wiki--with-topics dir '("Systemd")
+ (let ((cj/arch-wiki-html-dir dir)
+ (opened nil))
+ (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "NotATopic"))
+ ((symbol-function 'message) (lambda (&rest _) nil))
+ ((symbol-function 'eww-browse-url)
+ (lambda (url &rest _) (setq opened url))))
+ (cj/local-arch-wiki-search))
+ (should-not opened))))
+
+(provide 'test-help-utils--arch-wiki-search)
+;;; test-help-utils--arch-wiki-search.el ends here
diff --git a/tests/test-hugo-config--keymap.el b/tests/test-hugo-config--keymap.el
new file mode 100644
index 00000000..0f8df257
--- /dev/null
+++ b/tests/test-hugo-config--keymap.el
@@ -0,0 +1,71 @@
+;;; test-hugo-config--keymap.el --- Tests for the Hugo prefix keymap -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Pins the eight Hugo commands reachable under the "C-; h" prefix.
+;;
+;; The module used to install these with eight raw `global-set-key' calls plus
+;; a hand-written which-key block, writing into the global map directly instead
+;; of going through `cj/register-prefix-map' the way its siblings
+;; (erc-config, custom-ordering, org-reveal-config) do. These tests were added
+;; alongside that conversion so the refactor is checkable: every key must still
+;; reach the same command afterward.
+;;
+;; Test organization:
+;; - Normal Cases: each of the eight keys resolves to its command
+;; - Boundary Cases: case-distinct pairs stay distinct; the map is a prefix map
+;; - Error Cases: an unbound key in the map resolves to nothing
+;;
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(provide 'ox-hugo)
+(require 'hugo-config)
+(require 'keybindings)
+
+(defconst test-hugo--expected-bindings
+ '(("n" . cj/hugo-new-post)
+ ("e" . cj/hugo-export-post)
+ ("o" . cj/hugo-open-blog-dir)
+ ("O" . cj/hugo-open-blog-dir-external)
+ ("d" . cj/hugo-open-draft)
+ ("D" . cj/hugo-toggle-draft)
+ ("p" . cj/hugo-preview)
+ ("P" . cj/hugo-publish))
+ "Every key the Hugo prefix map must carry, and the command it runs.")
+
+;;; Normal Cases
+
+(ert-deftest test-hugo-config-keymap-binds-every-command ()
+ "Normal: each Hugo key resolves to its command inside the prefix map."
+ (dolist (pair test-hugo--expected-bindings)
+ (should (eq (cdr pair) (keymap-lookup cj/hugo-keymap (car pair))))))
+
+(ert-deftest test-hugo-config-keymap-registered-under-custom-prefix ()
+ "Normal: the map is reachable at \"h\" within `cj/custom-keymap'."
+ (should (eq cj/hugo-keymap (keymap-lookup cj/custom-keymap "h"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-hugo-config-keymap-case-pairs-stay-distinct ()
+ "Boundary: the shifted variants run different commands than their lowercase
+counterparts, which a case-folding binding would silently collapse."
+ (should-not (eq (keymap-lookup cj/hugo-keymap "o")
+ (keymap-lookup cj/hugo-keymap "O")))
+ (should-not (eq (keymap-lookup cj/hugo-keymap "d")
+ (keymap-lookup cj/hugo-keymap "D")))
+ (should-not (eq (keymap-lookup cj/hugo-keymap "p")
+ (keymap-lookup cj/hugo-keymap "P"))))
+
+(ert-deftest test-hugo-config-keymap-is-a-keymap ()
+ "Boundary: the value registered as a prefix is an actual keymap."
+ (should (keymapp cj/hugo-keymap)))
+
+;;; Error Cases
+
+(ert-deftest test-hugo-config-keymap-unbound-key-is-nil ()
+ "Error: a key the map does not define resolves to nothing."
+ (should-not (keymap-lookup cj/hugo-keymap "z")))
+
+(provide 'test-hugo-config--keymap)
+;;; test-hugo-config--keymap.el ends here
diff --git a/tests/test-integration-org-agenda-frame-load-order.el b/tests/test-integration-org-agenda-frame-load-order.el
new file mode 100644
index 00000000..6541d250
--- /dev/null
+++ b/tests/test-integration-org-agenda-frame-load-order.el
@@ -0,0 +1,80 @@
+;;; test-integration-org-agenda-frame-load-order.el --- Frame allowlist survives load order -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Regression test for a load-order bug in the Full Agenda frame's read-only
+;; shadow.
+;;
+;; Components integrated:
+;; - org-agenda-frame (real, loaded in a subprocess)
+;; - org-agenda (real, loaded BEFORE the frame module to reproduce the bug)
+;;
+;; The bug: cj/--agenda-frame-shadow-mutations walks org-agenda-mode-map and
+;; keeps a key only when the frame map already binds it to a `commandp' value.
+;; The view/redo handlers (day-view, week-view, safe-redo) were defined LOWER in
+;; the file than the `with-eval-after-load' that ran the walk. When org-agenda
+;; was already loaded at frame-load time -- the normal startup order and every
+;; reload -- the walk fired before those defuns existed, read them as not-yet
+;; commands, and denied d/w/g/r, the very keys the allowlist grants. Moving the
+;; walk to the end of the file (after the defuns) fixed it.
+;;
+;; This test can't reproduce the ordering in-process (the module is already
+;; loaded), so it drives a fresh Emacs that requires org-agenda first, then the
+;; frame module, and inspects the resulting keymap.
+;;
+;; Validates:
+;; - d/w/g/r keep their allowlist commands in the org-first load order
+;; - a real mutation key (t) is still denied (the read-only guarantee holds)
+;;
+;;; Code:
+
+(require 'ert)
+
+(defconst test-oaf--repo-root
+ (file-name-directory (directory-file-name
+ (file-name-directory (or load-file-name buffer-file-name))))
+ "Repo root, one level up from tests/.")
+
+(defun test-oaf--lookup-in-subprocess (keys)
+ "Load org-agenda then org-agenda-frame in a fresh Emacs, return KEYS' bindings.
+Returns an alist of (KEY . BINDING-SYMBOL-NAME-OR-nil)."
+ (let* ((root test-oaf--repo-root)
+ (form
+ (prin1-to-string
+ `(progn
+ (setq load-prefer-newer t)
+ (package-initialize)
+ (require 'org-agenda) ; the bad order: org first
+ (require 'org-agenda-frame)
+ (princ (prin1-to-string
+ (mapcar
+ (lambda (k)
+ (cons k (let ((b (lookup-key cj/agenda-frame-mode-map (kbd k))))
+ (and (symbolp b) (symbol-name b)))))
+ ',keys))))))
+ (out (with-output-to-string
+ (with-current-buffer standard-output
+ (call-process
+ (expand-file-name invocation-name invocation-directory)
+ nil t nil
+ "--batch" "--no-site-file" "--no-site-lisp"
+ "-L" root
+ "-L" (expand-file-name "modules" root)
+ "-L" (expand-file-name "themes" root)
+ "--eval" form)))))
+ (car (read-from-string out))))
+
+(ert-deftest test-integration-org-agenda-frame-allowlist-survives-org-first-load ()
+ "Integration: with org-agenda loaded before the frame module, the allowlisted
+view/redo keys keep their commands and a mutation key stays denied."
+ (skip-unless (file-exists-p (expand-file-name "modules/org-agenda-frame.el"
+ test-oaf--repo-root)))
+ (let ((got (test-oaf--lookup-in-subprocess '("d" "w" "g" "r" "t"))))
+ (should (equal "cj/--agenda-frame-day-view" (cdr (assoc "d" got))))
+ (should (equal "cj/--agenda-frame-week-view" (cdr (assoc "w" got))))
+ (should (equal "cj/--agenda-frame-safe-redo" (cdr (assoc "g" got))))
+ (should (equal "cj/--agenda-frame-safe-redo" (cdr (assoc "r" got))))
+ ;; t is a real org mutation key; it must be denied, not allowlisted.
+ (should (equal "cj/--agenda-frame-denied-readonly" (cdr (assoc "t" got))))))
+
+(provide 'test-integration-org-agenda-frame-load-order)
+;;; test-integration-org-agenda-frame-load-order.el ends here
diff --git a/tests/test-integration-recording-device-workflow.el b/tests/test-integration-recording-device-workflow.el
index 3ef631f3..27ffac56 100644
--- a/tests/test-integration-recording-device-workflow.el
+++ b/tests/test-integration-recording-device-workflow.el
@@ -1,26 +1,24 @@
;;; test-integration-recording-device-workflow.el --- Integration tests for recording device workflow -*- lexical-binding: t; -*-
;;; Commentary:
-;; Integration tests covering the complete device detection and grouping workflow.
-;;
-;; This tests the full pipeline from raw pactl output through parsing, grouping,
-;; and friendly name assignment. The workflow enables users to select audio devices
-;; for recording calls/meetings.
+;; Integration test covering the device detection path that recording actually
+;; uses: raw pactl output through parsing and into friendly state names.
;;
;; Components integrated:
;; - cj/recording--parse-pactl-output (parse raw pactl output into structured data)
-;; - cj/recording-parse-sources (shell command wrapper)
-;; - cj/recording-group-devices-by-hardware (group inputs/monitors by device)
+;; - cj/recording-parse-sources (shell command wrapper, MOCKED at
+;; shell-command-to-string so no pactl runs)
;; - cj/recording-friendly-state (convert technical state names)
-;; - Bluetooth MAC address normalization (colons → underscores)
-;; - Device name pattern matching (USB, PCI, Bluetooth)
-;; - Friendly name assignment (user-facing device names)
;;
;; Critical integration points:
-;; - Parse output must produce data that group-devices can process
-;; - Bluetooth MAC normalization must work across parse→group boundary
-;; - Incomplete devices (only mic OR only monitor) must be filtered
-;; - Friendly names must correctly identify device types
+;; - Parse output must carry device state through to the friendly-name conversion
+;;
+;; This file once covered a parse-to-group pipeline as well. That half tested
+;; cj/recording-group-devices-by-hardware, a second device-grouping
+;; implementation nothing ever called -- cj/recording-select-device is the live
+;; selection path and reaches parse-sources directly. The function and its
+;; tests were removed rather than left as coverage that proved an unused code
+;; path worked.
;;; Code:
@@ -46,58 +44,6 @@
;;; Normal Cases - Complete Workflow
-(ert-deftest test-integration-recording-device-workflow-parse-to-group-all-devices ()
- "Test complete workflow from pactl output to grouped devices.
-
-When pactl output contains all three device types (built-in, USB, Bluetooth),
-the workflow should parse, group, and assign friendly names to all devices.
-
-Components integrated:
-- cj/recording--parse-pactl-output (parsing)
-- cj/recording-group-devices-by-hardware (grouping + MAC normalization)
-- Device pattern matching (USB/PCI/Bluetooth detection)
-- Friendly name assignment
-
-Validates:
-- All three device types are detected
-- Bluetooth MAC addresses normalized (colons → underscores)
-- Each device has both mic and monitor
-- Friendly names correctly assigned
-- Complete data flow: raw output → parsed list → grouped pairs"
- (let ((output (test-load-fixture "pactl-output-normal.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- ;; Test parse step
- (let ((parsed (cj/recording-parse-sources)))
- (should (= 6 (length parsed)))
-
- ;; Test group step (receives parsed data)
- (let ((grouped (cj/recording-group-devices-by-hardware)))
- (should (= 3 (length grouped)))
-
- ;; Validate built-in device
- (let ((built-in (assoc "Built-in Audio" grouped)))
- (should built-in)
- (should (string-prefix-p "alsa_input.pci" (cadr built-in)))
- (should (string-prefix-p "alsa_output.pci" (cddr built-in))))
-
- ;; Validate USB device
- (let ((usb (assoc "Jabra SPEAK 510 USB" grouped)))
- (should usb)
- (should (string-match-p "Jabra" (cadr usb)))
- (should (string-match-p "Jabra" (cddr usb))))
-
- ;; Validate Bluetooth device (CRITICAL: MAC normalization)
- (let ((bluetooth (assoc "Bluetooth Headset" grouped)))
- (should bluetooth)
- ;; Input has colons
- (should (string-match-p "00:1B:66:C0:91:6D" (cadr bluetooth)))
- ;; Output has underscores
- (should (string-match-p "00_1B_66_C0_91_6D" (cddr bluetooth)))
- ;; But they're grouped together!
- (should (equal "bluez_input.00:1B:66:C0:91:6D" (cadr bluetooth)))
- (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" (cddr bluetooth)))))))))
-
(ert-deftest test-integration-recording-device-workflow-friendly-states-in-list ()
"Test that friendly state names appear in device list output.
@@ -128,105 +74,5 @@ Validates:
;;; Boundary Cases - Incomplete Devices
-(ert-deftest test-integration-recording-device-workflow-incomplete-devices-filtered ()
- "Test that devices with only mic OR only monitor are filtered out.
-
-For call recording, we need BOTH mic and monitor from the same device.
-Incomplete devices should not appear in the grouped output.
-
-Components integrated:
-- cj/recording-parse-sources (parsing all devices)
-- cj/recording-group-devices-by-hardware (filtering incomplete pairs)
-
-Validates:
-- Device with only mic is filtered
-- Device with only monitor is filtered
-- Only complete devices (both mic and monitor) are returned
-- Filtering happens at group stage, not parse stage"
- (let ((output (concat
- ;; Complete device
- "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
- "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
- ;; Incomplete: USB mic with no monitor
- "100\talsa_input.usb-device.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n"
- ;; Incomplete: Bluetooth monitor with no mic
- "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- ;; Parse sees all 4 devices
- (let ((parsed (cj/recording-parse-sources)))
- (should (= 4 (length parsed)))
-
- ;; Group returns only 1 complete device
- (let ((grouped (cj/recording-group-devices-by-hardware)))
- (should (= 1 (length grouped)))
- (should (equal "Built-in Audio" (caar grouped))))))))
-
-;;; Edge Cases - Bluetooth MAC Normalization
-
-(ert-deftest test-integration-recording-device-workflow-bluetooth-mac-variations ()
- "Test Bluetooth MAC normalization with different formats.
-
-Bluetooth devices use colons in input names but underscores in output names.
-The grouping must normalize these to match devices correctly.
-
-Components integrated:
-- cj/recording-parse-sources (preserves original MAC format)
-- cj/recording-group-devices-by-hardware (normalizes MAC for matching)
-- Base name extraction (regex patterns)
-- MAC address transformation (underscores → colons)
-
-Validates:
-- Input with colons (bluez_input.AA:BB:CC:DD:EE:FF) parsed correctly
-- Output with underscores (bluez_output.AA_BB_CC_DD_EE_FF) parsed correctly
-- Normalization happens during grouping
-- Devices paired despite format difference
-- Original device names preserved (not mutated)"
- (let ((output (concat
- "79\tbluez_input.11:22:33:44:55:66\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n"
- "81\tbluez_output.11_22_33_44_55_66.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((parsed (cj/recording-parse-sources)))
- ;; Original formats preserved in parse
- (should (string-match-p "11:22:33" (caar parsed)))
- (should (string-match-p "11_22_33" (caadr parsed)))
-
- ;; But grouping matches them
- (let ((grouped (cj/recording-group-devices-by-hardware)))
- (should (= 1 (length grouped)))
- (should (equal "Bluetooth Headset" (caar grouped)))
- ;; Original names preserved
- (should (equal "bluez_input.11:22:33:44:55:66" (cadar grouped)))
- (should (equal "bluez_output.11_22_33_44_55_66.1.monitor" (cddar grouped))))))))
-
-;;; Error Cases - Malformed Data
-
-(ert-deftest test-integration-recording-device-workflow-malformed-output-handled ()
- "Test that malformed pactl output is handled gracefully.
-
-When pactl output is malformed or unparseable, the workflow should not crash.
-It should return empty results at appropriate stages.
-
-Components integrated:
-- cj/recording--parse-pactl-output (malformed line handling)
-- cj/recording-group-devices-by-hardware (empty input handling)
-
-Validates:
-- Malformed lines are silently skipped during parse
-- Empty parse results don't crash grouping
-- Workflow degrades gracefully
-- No exceptions thrown"
- (let ((output (test-load-fixture "pactl-output-malformed.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((parsed (cj/recording-parse-sources)))
- ;; Malformed output produces empty parse
- (should (null parsed))
-
- ;; Empty parse produces empty grouping (no crash)
- (let ((grouped (cj/recording-group-devices-by-hardware)))
- (should (null grouped)))))))
-
(provide 'test-integration-recording-device-workflow)
;;; test-integration-recording-device-workflow.el ends here
diff --git a/tests/test-media-utils--yt-dl-message.el b/tests/test-media-utils--yt-dl-message.el
new file mode 100644
index 00000000..491b64cf
--- /dev/null
+++ b/tests/test-media-utils--yt-dl-message.el
@@ -0,0 +1,64 @@
+;;; test-media-utils--yt-dl-message.el --- Tests for the yt-dl sentinel message -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/media--yt-dl-message, the pure helper behind
+;; cj/yt-dl-it's process sentinel.
+;;
+;; The behavior under test is a correctness fix, not cosmetics. cj/yt-dl-it
+;; launches "tsp yt-dlp ...", and tsp enqueues the job and exits immediately.
+;; The sentinel therefore fires on tsp's exit, not on yt-dlp's, so the old
+;; "Finished downloading" text claimed a completed download at the moment the
+;; download was merely queued -- and a yt-dlp failure minutes later was silent.
+;; The helper reports queueing, which is the only thing tsp's exit actually
+;; proves.
+;;
+;; Test organization:
+;; - Normal Cases: clean tsp exit reports queued; abnormal exit reports failure
+;; - Boundary Cases: unrelated events return nil; URL text passes through verbatim
+;; - Error Cases: empty event string returns nil
+;;
+;;; Code:
+
+(require 'ert)
+(require 'media-utils)
+
+;;; Normal Cases
+
+(ert-deftest test-media-utils--yt-dl-message-normal-finished-says-queued ()
+ "Normal: a clean tsp exit reports the job queued, never downloaded."
+ (let ((msg (cj/media--yt-dl-message "finished\n" "https://example.com/v")))
+ (should (string-match-p "[Qq]ueued" msg))
+ (should-not (string-match-p "[Ff]inished downloading" msg))))
+
+(ert-deftest test-media-utils--yt-dl-message-normal-abnormal-reports-failure ()
+ "Normal: an abnormal tsp exit reports that queueing failed."
+ (let ((msg (cj/media--yt-dl-message "exited abnormally with code 1\n"
+ "https://example.com/v")))
+ (should msg)
+ (should-not (string-match-p "[Qq]ueued for" msg))))
+
+;;; Boundary Cases
+
+(ert-deftest test-media-utils--yt-dl-message-boundary-unrelated-event-is-nil ()
+ "Boundary: an event that reports neither outcome produces no message."
+ (should (null (cj/media--yt-dl-message "run\n" "https://example.com/v")))
+ (should (null (cj/media--yt-dl-message "stopped\n" "https://example.com/v"))))
+
+(ert-deftest test-media-utils--yt-dl-message-boundary-url-passes-through ()
+ "Boundary: the URL text is carried into the message verbatim."
+ (let ((url "https://example.com/watch?v=a&b=c%20d"))
+ (should (string-match-p (regexp-quote url)
+ (cj/media--yt-dl-message "finished\n" url)))))
+
+(ert-deftest test-media-utils--yt-dl-message-boundary-empty-url ()
+ "Boundary: an empty URL still yields a message rather than signaling."
+ (should (stringp (cj/media--yt-dl-message "finished\n" ""))))
+
+;;; Error Cases
+
+(ert-deftest test-media-utils--yt-dl-message-error-empty-event-is-nil ()
+ "Error: an empty event string matches no outcome and returns nil."
+ (should (null (cj/media--yt-dl-message "" "https://example.com/v"))))
+
+(provide 'test-media-utils--yt-dl-message)
+;;; test-media-utils--yt-dl-message.el ends here
diff --git a/tests/test-music-config--header-text.el b/tests/test-music-config--header-text.el
index 8de97350..c860c6d4 100644
--- a/tests/test-music-config--header-text.el
+++ b/tests/test-music-config--header-text.el
@@ -139,6 +139,23 @@
(should (string-match-p "consume" plain))))
(test-header--teardown)))
+(ert-deftest test-music-config--header-text-boundary-key-hints-single-save-stop ()
+ "Header key hints: single is on 1, save is on s, and stop (S) is gone.
+SPC/pause covers stop, so the S:stop hint and the [s] single / v:save hints
+are retired."
+ (unwind-protect
+ (progn
+ (test-header--setup-playlist-buffer '("/music/a.mp3"))
+ (let* ((header (with-current-buffer cj/music-playlist-buffer-name
+ (cj/music--header-text)))
+ (plain (test-header--strip-properties header)))
+ (should (string-match-p "\\[1\\] single" plain))
+ (should (string-match-p "s:save" plain))
+ (should-not (string-match-p "\\[s\\] single" plain))
+ (should-not (string-match-p "v:save" plain))
+ (should-not (string-match-p "S:stop" plain))))
+ (test-header--teardown)))
+
;;; Error Cases
(ert-deftest test-music-config--header-text-error-empty-playlist-shows-zero-count ()
diff --git a/tests/test-music-config-create-radio-station.el b/tests/test-music-config-create-radio-station.el
index 7d9adbed..4f49f49b 100644
--- a/tests/test-music-config-create-radio-station.el
+++ b/tests/test-music-config-create-radio-station.el
@@ -106,15 +106,16 @@
(ert-deftest test-music-config-menu-map-lowercase-keys ()
"Normal: the menu's former uppercase keys live on lowercase homes, and the
-playlist buffer saves on v."
+playlist buffer saves on s (single on 1, old save key v unbound)."
(should (eq (lookup-key cj/music-map "v") 'cj/music-playlist-show))
(should (eq (lookup-key cj/music-map "u") 'emms-shuffle))
(should (eq (lookup-key cj/music-map "l") 'emms-toggle-repeat-playlist))
(should-not (lookup-key cj/music-map "R"))
(should-not (lookup-key cj/music-map "M"))
(should-not (lookup-key cj/music-map "Z"))
- (should (eq (lookup-key emms-playlist-mode-map "v") 'cj/music-playlist-save))
- (should-not (eq (lookup-key emms-playlist-mode-map "w") 'cj/music-playlist-save)))
+ (should (eq (lookup-key emms-playlist-mode-map "s") 'cj/music-playlist-save))
+ (should (eq (lookup-key emms-playlist-mode-map "1") 'emms-toggle-repeat-track))
+ (should-not (lookup-key emms-playlist-mode-map "v")))
;;; Error Cases
diff --git a/tests/test-org-agenda-frame.el b/tests/test-org-agenda-frame.el
index 92dd5ddc..3c56d361 100644
--- a/tests/test-org-agenda-frame.el
+++ b/tests/test-org-agenda-frame.el
@@ -315,6 +315,34 @@ d shrinks the frame to the current day, w restores the seven-day span."
(should (eq (lookup-key cj/agenda-frame-mode-map (kbd "w"))
'cj/--agenda-frame-week-view)))
+(ert-deftest test-org-agenda-frame-shadow-denies-org-mutations-preserves-allowlist ()
+ "Boundary: with the frame mode active over `org-agenda-mode-map', mutating
+org-agenda keys are denied and the allowlist still works.
+The `[t]' default cannot shadow org-agenda-mode-map's explicit bindings, so
+the shadow walk must add explicit denies for every non-allowlisted key --
+single keys (t/I/k/z/s/.), the C-c mutators (schedule/deadline/clock), and
+C-x (save-all) -- while leaving the allowlist (navigation, engage, refresh,
+d/w, C-c C-o) intact."
+ (require 'org-agenda)
+ (cj/--agenda-frame-shadow-mutations)
+ (with-temp-buffer
+ (use-local-map org-agenda-mode-map)
+ (cj/agenda-frame-mode 1)
+ ;; escaping mutators are now explicitly denied (not org's commands)
+ (dolist (chord '("t" "I" "k" "z" "s" "." "C-c C-s" "C-c C-d"
+ "C-c C-x C-i" "C-x C-s"))
+ (should (eq (key-binding (kbd chord))
+ 'cj/--agenda-frame-denied-readonly)))
+ ;; the allowlist survives the walk
+ (should (eq (key-binding (kbd "n")) 'org-agenda-next-line))
+ (should (eq (key-binding (kbd "p")) 'org-agenda-previous-line))
+ (should (eq (key-binding (kbd "RET")) 'cj/--agenda-frame-engage-open))
+ (should (eq (key-binding (kbd "g")) 'cj/--agenda-frame-safe-redo))
+ (should (eq (key-binding (kbd "d")) 'cj/--agenda-frame-day-view))
+ (should (eq (key-binding (kbd "w")) 'cj/--agenda-frame-week-view))
+ (should (eq (key-binding (kbd "C-c C-o")) 'cj/--agenda-frame-open-link))
+ (should (eq (key-binding (kbd "q")) 'cj/--agenda-frame-close))))
+
(ert-deftest test-org-agenda-frame-map-frame-controls-bound ()
"Normal: the frame's own controls work from inside the frame.
S-<f8> must close/toggle and C-M-<f8> must force-rescan; unbound, the
@@ -390,10 +418,15 @@ removed after a later success -- the failure banner would stick forever."
(should (= 0 (seq-count (lambda (o) (overlay-get o 'before-string))
(overlays-in (point-min) (point-max)))))))
-(ert-deftest test-org-agenda-frame-map-mutation-keys-not-explicitly-bound ()
- "Boundary: a mutation key (t = org-agenda-todo) is not explicitly bound, so the
-[t] catch-all denies it as read-only."
- (should (null (lookup-key cj/agenda-frame-mode-map (kbd "t")))))
+(ert-deftest test-org-agenda-frame-map-mutation-keys-denied ()
+ "Boundary: a mutation key (t = org-agenda-todo) is denied, never allowlisted.
+It is denied two ways depending on whether the shadow walk has run: the `[t]'
+catch-all handles it (lookup returns nil) before the walk, and the walk binds
+it explicitly to the deny handler once `org-agenda-mode-map' is present. Both
+are a read-only denial; the test asserts the outcome, not which path produced
+it, so it holds whether or not org-agenda is loaded in the test process."
+ (let ((b (lookup-key cj/agenda-frame-mode-map (kbd "t"))))
+ (should (or (null b) (eq b 'cj/--agenda-frame-denied-readonly)))))
;;; Default-deny policy — the minor mode + finalize re-enable
diff --git a/tests/test-org-refile-config--advice-helpers.el b/tests/test-org-refile-config--advice-helpers.el
new file mode 100644
index 00000000..0d9979d8
--- /dev/null
+++ b/tests/test-org-refile-config--advice-helpers.el
@@ -0,0 +1,87 @@
+;;; test-org-refile-config--advice-helpers.el --- Tests for the refile advice helpers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for the two named advice helpers extracted from anonymous lambdas
+;; in the org-refile use-package :config block:
+;;
+;; cj/org-refile--save-all-buffers (:after org-refile)
+;; cj/org-refile--ensure-targets-in-org-mode (:before org-refile-get-targets)
+;;
+;; They were anonymous `(lambda (&rest _) ...)' advices, which cannot be
+;; `advice-remove'd by reference and cannot be tested. Naming them makes both
+;; possible. The install-by-reference and removability are verified live in the
+;; daemon (the :config block doesn't run under batch make test); these tests
+;; pin the extracted logic.
+;;
+;; Test organization:
+;; - Normal Cases: ensure-targets visits each string-named target
+;; - Boundary Cases: empty targets, non-string cars, mixed list
+;; - Error Cases: a nil target list is a no-op
+;;
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-refile-config)
+
+;; The module's bare `(defvar org-refile-targets)' marks the symbol special only
+;; within its own file, so it isn't special here. Declare it (with a value) so
+;; the `let' bindings below bind it dynamically, the way the sibling
+;; test-org-refile-config-commands.el does.
+(defvar org-refile-targets nil)
+
+;;; cj/org-refile--ensure-targets-in-org-mode
+
+(ert-deftest test-org-refile-ensure-targets-visits-each-string-target ()
+ "Normal: every string-named target file is passed to the ensure helper."
+ (let ((org-refile-targets '(("/a.org" :maxlevel . 3)
+ ("/b.org" :maxlevel . 3)))
+ (seen '()))
+ (cl-letf (((symbol-function 'cj/org-refile-ensure-org-mode)
+ (lambda (f) (push f seen))))
+ (cj/org-refile--ensure-targets-in-org-mode))
+ (should (equal '("/a.org" "/b.org") (nreverse seen)))))
+
+;;; Boundary
+
+(ert-deftest test-org-refile-ensure-targets-skips-non-string-cars ()
+ "Boundary: a target whose car is not a string (a function/symbol spec) is
+skipped rather than passed to the ensure helper."
+ (let ((org-refile-targets `((,(lambda () '("/x.org")) :maxlevel . 2)
+ ("/real.org" :maxlevel . 2)
+ (org-agenda-files :maxlevel . 2)))
+ (seen '()))
+ (cl-letf (((symbol-function 'cj/org-refile-ensure-org-mode)
+ (lambda (f) (push f seen))))
+ (cj/org-refile--ensure-targets-in-org-mode))
+ (should (equal '("/real.org") seen))))
+
+(ert-deftest test-org-refile-ensure-targets-empty-list-is-noop ()
+ "Boundary: no targets means the ensure helper is never called."
+ (let ((org-refile-targets '())
+ (called nil))
+ (cl-letf (((symbol-function 'cj/org-refile-ensure-org-mode)
+ (lambda (_f) (setq called t))))
+ (cj/org-refile--ensure-targets-in-org-mode))
+ (should-not called)))
+
+;;; Error
+
+(ert-deftest test-org-refile-ensure-targets-nil-targets-does-not-signal ()
+ "Error: a nil `org-refile-targets' completes without signaling."
+ (let ((org-refile-targets nil))
+ (cl-letf (((symbol-function 'cj/org-refile-ensure-org-mode) #'ignore))
+ (should (progn (cj/org-refile--ensure-targets-in-org-mode) t)))))
+
+;;; cj/org-refile--save-all-buffers
+
+(ert-deftest test-org-refile-save-all-buffers-delegates ()
+ "Normal: the save helper calls `org-save-all-org-buffers'."
+ (let ((called nil))
+ (cl-letf (((symbol-function 'org-save-all-org-buffers)
+ (lambda (&rest _) (setq called t))))
+ (cj/org-refile--save-all-buffers))
+ (should called)))
+
+(provide 'test-org-refile-config--advice-helpers)
+;;; test-org-refile-config--advice-helpers.el ends here
diff --git a/tests/test-pre-commit-hook.bats b/tests/test-pre-commit-hook.bats
new file mode 100644
index 00000000..413c71d0
--- /dev/null
+++ b/tests/test-pre-commit-hook.bats
@@ -0,0 +1,126 @@
+#!/usr/bin/env bats
+# Tests for githooks/pre-commit — the secret scan and paren check.
+#
+# The scan reads its input through a pipeline:
+#
+# added_lines="$(git diff --cached ... | grep '^+' | grep -v '^+++' || true)"
+#
+# `grep` exits 1 when it matches nothing, which is the ordinary case, so the
+# `|| true` has to stay. But with no `pipefail` it also swallows a failure of
+# `git diff` itself, and an empty `added_lines` makes the scan search nothing,
+# find nothing, and report clean. A gate that passes without looking is the
+# failure this file exists to pin: the fail-open test drives a broken `git diff`
+# and asserts the hook refuses rather than exiting 0.
+#
+# Each test builds a throwaway git repo in BATS_TEST_TMPDIR, so nothing touches
+# the real repository or its hooks.
+
+setup() {
+ HOOK="${BATS_TEST_DIRNAME}/../githooks/pre-commit"
+ REPO="${BATS_TEST_TMPDIR}/repo"
+ mkdir -p "$REPO"
+ cd "$REPO" || return 1
+ git init -q .
+ git config user.email t@example.com
+ git config user.name Test
+ # Split so the fixtures never appear as credential-shaped literals here.
+ AWS_TAIL="IOSFODNN7EXAMPLE"
+ WORD_TAIL="word"
+}
+
+# Put a stub `git` ahead of the real one that fails for the staged-diff call
+# and delegates everything else, so only the pipeline under test breaks.
+break_staged_diff() {
+ mkdir -p "${BATS_TEST_TMPDIR}/bin"
+ cat > "${BATS_TEST_TMPDIR}/bin/git" <<'STUB'
+#!/usr/bin/env bash
+if [ "${1:-}" = "diff" ] && [ "${2:-}" = "--cached" ] && [ "${3:-}" = "-U0" ]; then
+ echo "simulated git failure" >&2
+ exit 128
+fi
+exec /usr/bin/git "$@"
+STUB
+ chmod +x "${BATS_TEST_TMPDIR}/bin/git"
+ PATH="${BATS_TEST_TMPDIR}/bin:$PATH"
+}
+
+# ------------------------------- Normal cases -------------------------------
+
+@test "secret scan: blocks a staged AWS key" {
+ # Assembled at runtime: a literal key-shaped string in this file would trip
+ # the very hook under test on every commit that touches it, and this repo
+ # mirrors to a public remote.
+ printf 'aws = "%s"\n' "AKIA${AWS_TAIL}" > creds.txt
+ git add creds.txt
+ run "$HOOK"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"potential secret"* ]]
+}
+
+@test "secret scan: blocks a staged keyword=value password" {
+ printf '%s = "%s"\n' "pass${WORD_TAIL}" "correcthorsebatterystaple" > conf.txt
+ git add conf.txt
+ run "$HOOK"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"potential secret"* ]]
+}
+
+@test "secret scan: allows an ordinary staged file" {
+ printf 'just some prose\n' > notes.txt
+ git add notes.txt
+ run "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+# ------------------------------ Boundary cases ------------------------------
+
+@test "secret scan: allows a commit with nothing staged" {
+ run "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+@test "paren check: blocks an unbalanced staged .el file" {
+ printf '(defun broken ()\n (message "no close"\n' > bad.el
+ git add bad.el
+ run "$HOOK"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"paren check failed"* ]]
+}
+
+@test "paren check: allows a balanced staged .el file" {
+ printf '(defun fine ()\n (message "ok"))\n' > good.el
+ git add good.el
+ run "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+# -------------------------------- Error cases -------------------------------
+
+@test "secret scan: refuses to pass when the staged diff cannot be read" {
+ # The scan must not report clean after searching nothing. Without a
+ # pipefail-aware guard the broken diff yields an empty added_lines and the
+ # hook exits 0, letting a real secret through unscanned.
+ printf 'aws = "%s"\n' "AKIA${AWS_TAIL}" > creds.txt
+ git add creds.txt
+ break_staged_diff
+ run "$HOOK"
+ [ "$status" -ne 0 ]
+}
+
+@test "paren check: refuses to pass when the staged file list cannot be read" {
+ printf '(defun broken ()\n (message "no close"\n' > bad.el
+ git add bad.el
+ mkdir -p "${BATS_TEST_TMPDIR}/bin2"
+ cat > "${BATS_TEST_TMPDIR}/bin2/git" <<'STUB'
+#!/usr/bin/env bash
+if [ "${1:-}" = "diff" ] && [ "${2:-}" = "--cached" ] && [ "${3:-}" = "--name-only" ]; then
+ echo "simulated git failure" >&2
+ exit 128
+fi
+exec /usr/bin/git "$@"
+STUB
+ chmod +x "${BATS_TEST_TMPDIR}/bin2/git"
+ PATH="${BATS_TEST_TMPDIR}/bin2:$PATH"
+ run "$HOOK"
+ [ "$status" -ne 0 ]
+}
diff --git a/tests/test-system-defaults-functions.el b/tests/test-system-defaults-functions.el
index c603fc7e..4b647166 100644
--- a/tests/test-system-defaults-functions.el
+++ b/tests/test-system-defaults-functions.el
@@ -162,5 +162,36 @@ and the rendered S-expression lands in the log."
(should (string-match-p ":slot" contents)))))
(delete-file comp-warnings-log))))
+(ert-deftest test-system-defaults-log-comp-warning-unwritable-log-does-not-signal ()
+ "Error: an unwritable log path must not signal.
+The function is `:before-until' advice on `display-warning'; a signal here
+propagates out of `display-warning' and breaks warning display for every
+async native-comp notice. It swallows the write failure and still returns
+t (the warning stays suppressed), rather than crashing."
+ (let ((comp-warnings-log "/proc/nonexistent-dir/cannot-write.log"))
+ (should (eq t (cj/log-comp-warning 'comp "boom")))))
+
+(ert-deftest test-system-defaults-log-comp-warning-caps-log-growth ()
+ "Boundary: the log is bounded — once it exceeds the cap, a further write
+resets it (deletes the old file, keeping only the new entry) rather than
+growing without limit. A hard reset, not a tail-trim: on overflow the old
+history is discarded, which is fine for a transient diagnostic log."
+ (let ((comp-warnings-log (make-temp-file "comp-warnings-" nil ".log")))
+ (unwind-protect
+ (progn
+ ;; Seed the file well over the cap.
+ (with-temp-file comp-warnings-log
+ (insert (make-string (1+ cj/comp-warnings-log-max-bytes) ?x)))
+ (should (> (file-attribute-size (file-attributes comp-warnings-log))
+ cj/comp-warnings-log-max-bytes))
+ (cj/log-comp-warning 'comp "after the cap")
+ (should (<= (file-attribute-size (file-attributes comp-warnings-log))
+ cj/comp-warnings-log-max-bytes))
+ ;; The newest entry survives the trim.
+ (with-temp-buffer
+ (insert-file-contents comp-warnings-log)
+ (should (string-match-p "after the cap" (buffer-string)))))
+ (delete-file comp-warnings-log))))
+
(provide 'test-system-defaults-functions)
;;; test-system-defaults-functions.el ends here
diff --git a/tests/test-validate-el-hook.bats b/tests/test-validate-el-hook.bats
new file mode 100644
index 00000000..43c3569c
--- /dev/null
+++ b/tests/test-validate-el-hook.bats
@@ -0,0 +1,97 @@
+#!/usr/bin/env bats
+# Tests for .claude/hooks/validate-el.sh — the auto-test runner.
+#
+# The runner used to skip entirely above MAX_AUTO_TEST_FILES=20, with no else
+# branch: nothing printed, exit 0, indistinguishable from a passing run. That
+# was live for the three largest families here (calendar-sync 63 test files,
+# music 45, ai-term 35), so every edit to those ran parens and byte-compile and
+# zero tests, silently.
+#
+# The cap was removed rather than made loud, because its premise did not hold.
+# Measured on this machine, running a whole family takes about a second:
+# ai-term 208 tests in 1.0s, music 403 in 1.7s, calendar-sync 633 in 0.9s. It
+# was also concealing a real cross-test pollution bug in calendar-sync that
+# only appears when that family runs in one process.
+#
+# These tests pin that no file count is skipped. Each builds a synthetic
+# project in BATS_TEST_TMPDIR and points CLAUDE_PROJECT_DIR at it, so nothing
+# runs against the real tree.
+
+setup() {
+ HOOK="${BATS_TEST_DIRNAME}/../.claude/hooks/validate-el.sh"
+ PROJ="${BATS_TEST_TMPDIR}/proj"
+ mkdir -p "$PROJ/modules" "$PROJ/tests"
+ export CLAUDE_PROJECT_DIR="$PROJ"
+ printf '(provide (quote widget))\n' > "$PROJ/modules/widget.el"
+}
+
+# N green test files matching the widget stem.
+make_tests() {
+ local n="$1" i
+ for ((i = 1; i <= n; i++)); do
+ printf '(require (quote ert))\n(ert-deftest test-widget-%d () (should t))\n' \
+ "$i" > "$PROJ/tests/test-widget-${i}.el"
+ done
+}
+
+# One failing test file, to prove the run is real rather than merely quiet.
+make_failing_test() {
+ printf '(require (quote ert))\n(ert-deftest test-widget-bad () (should nil))\n' \
+ > "$PROJ/tests/test-widget-bad.el"
+}
+
+hook_input() {
+ printf '{"tool_input":{"file_path":"%s"}}' "$PROJ/modules/widget.el"
+}
+
+run_hook() {
+ run bash -c "$(printf '%q' "$HOOK") <<< '$(hook_input)'"
+}
+
+# ------------------------------- Normal cases -------------------------------
+
+@test "a small family runs and passes quietly" {
+ make_tests 3
+ run_hook
+ [ "$status" -eq 0 ]
+}
+
+@test "a failing test blocks, so a quiet pass means the tests really ran" {
+ make_tests 3
+ make_failing_test
+ run_hook
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"TESTS FAILED"* ]]
+}
+
+# ------------------------------ Boundary cases ------------------------------
+
+@test "at the old cap of 20 files: runs" {
+ make_tests 20
+ run_hook
+ [ "$status" -eq 0 ]
+}
+
+@test "past the old cap: still runs, no longer skipped" {
+ make_tests 21
+ run_hook
+ [ "$status" -eq 0 ]
+ [[ "${output,,}" != *"skipped"* ]]
+}
+
+@test "well past the old cap: a failure in file 63 is still caught" {
+ # The regression this guards: at 63 files the runner used to skip, so a red
+ # test in a big family reported clean. calendar-sync is exactly this size.
+ make_tests 63
+ make_failing_test
+ run_hook
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"TESTS FAILED"* ]]
+}
+
+# -------------------------------- Error cases -------------------------------
+
+@test "no matching tests: exits clean without running anything" {
+ run_hook
+ [ "$status" -eq 0 ]
+}
diff --git a/tests/test-video-audio-recording-group-devices-by-hardware.el b/tests/test-video-audio-recording-group-devices-by-hardware.el
deleted file mode 100644
index 2be4982f..00000000
--- a/tests/test-video-audio-recording-group-devices-by-hardware.el
+++ /dev/null
@@ -1,194 +0,0 @@
-;;; test-video-audio-recording-group-devices-by-hardware.el --- Tests for cj/recording-group-devices-by-hardware -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; Unit tests for cj/recording-group-devices-by-hardware function.
-;; Tests grouping of audio sources by physical hardware device.
-;; Critical test: Bluetooth MAC address normalization (colons vs underscores).
-;;
-;; This function is used by the quick setup command to automatically pair
-;; microphone and monitor devices from the same hardware.
-
-;;; Code:
-
-(require 'ert)
-
-;; Stub dependencies before loading the module
-(defvar cj/custom-keymap (make-sparse-keymap)
- "Stub keymap for testing.")
-
-;; Now load the actual production module
-(require 'video-audio-recording)
-
-;;; Test Fixtures Helper
-
-(defun test-load-fixture (filename)
- "Load fixture file FILENAME from tests/fixtures directory."
- (let ((fixture-path (expand-file-name
- (concat "tests/fixtures/" filename)
- user-emacs-directory)))
- (with-temp-buffer
- (insert-file-contents fixture-path)
- (buffer-string))))
-
-;;; Normal Cases
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-all-types-grouped ()
- "Test grouping of all three device types (built-in, USB, Bluetooth).
-This is the key test validating the complete grouping logic."
- (let ((output (test-load-fixture "pactl-output-normal.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((result (cj/recording-group-devices-by-hardware)))
- (should (listp result))
- (should (= 3 (length result)))
- ;; Check that we have all three device types
- (let ((names (mapcar #'car result)))
- (should (member "Built-in Audio" names))
- (should (member "Bluetooth Headset" names))
- (should (member "Jabra SPEAK 510 USB" names)))
- ;; Verify each device has both mic and monitor
- (dolist (device result)
- (should (stringp (car device))) ; friendly name
- (should (stringp (cadr device))) ; mic device
- (should (stringp (cddr device))) ; monitor device
- (should-not (string-suffix-p ".monitor" (cadr device))) ; mic not monitor
- (should (string-suffix-p ".monitor" (cddr device)))))))) ; monitor has suffix
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-built-in-paired ()
- "Test that built-in laptop audio devices are correctly paired."
- (let ((output (test-load-fixture "pactl-output-normal.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let* ((result (cj/recording-group-devices-by-hardware))
- (built-in (assoc "Built-in Audio" result)))
- (should built-in)
- (should (string-match-p "pci-0000_00_1f" (cadr built-in)))
- (should (string-match-p "pci-0000_00_1f" (cddr built-in)))
- (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (cadr built-in)))
- (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" (cddr built-in)))))))
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-usb-paired ()
- "Test that USB devices (Jabra) are correctly paired."
- (let ((output (test-load-fixture "pactl-output-normal.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let* ((result (cj/recording-group-devices-by-hardware))
- (jabra (assoc "Jabra SPEAK 510 USB" result)))
- (should jabra)
- (should (string-match-p "Jabra" (cadr jabra)))
- (should (string-match-p "Jabra" (cddr jabra)))))))
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-bluetooth-paired ()
- "Test that Bluetooth devices are correctly paired.
-CRITICAL: Tests MAC address normalization (colons in input, underscores in output)."
- (let ((output (test-load-fixture "pactl-output-normal.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let* ((result (cj/recording-group-devices-by-hardware))
- (bluetooth (assoc "Bluetooth Headset" result)))
- (should bluetooth)
- ;; Input has colons: bluez_input.00:1B:66:C0:91:6D
- (should (equal "bluez_input.00:1B:66:C0:91:6D" (cadr bluetooth)))
- ;; Output has underscores: bluez_output.00_1B_66_C0_91_6D.1.monitor
- ;; But they should still be grouped together (MAC address normalized)
- (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" (cddr bluetooth)))))))
-
-;;; Boundary Cases
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-empty-returns-empty ()
- "Test that empty pactl output returns empty list."
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) "")))
- (let ((result (cj/recording-group-devices-by-hardware)))
- (should (listp result))
- (should (null result)))))
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-only-inputs-returns-empty ()
- "Test that only input devices (no monitors) returns empty list.
-Devices must have BOTH mic and monitor to be included."
- (let ((output (test-load-fixture "pactl-output-inputs-only.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((result (cj/recording-group-devices-by-hardware)))
- (should (listp result))
- (should (null result))))))
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-only-monitors-returns-empty ()
- "Test that only monitor devices (no inputs) returns empty list."
- (let ((output (test-load-fixture "pactl-output-monitors-only.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((result (cj/recording-group-devices-by-hardware)))
- (should (listp result))
- (should (null result))))))
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-single-complete-device ()
- "Test that single device with both mic and monitor is returned."
- (let ((output "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((result (cj/recording-group-devices-by-hardware)))
- (should (= 1 (length result)))
- (should (equal "Built-in Audio" (caar result)))))))
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-mixed-complete-incomplete ()
- "Test that only devices with BOTH mic and monitor are included.
-Incomplete devices (only mic or only monitor) are filtered out."
- (let ((output (concat
- ;; Complete device (built-in)
- "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
- "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
- ;; Incomplete: USB mic with no monitor
- "100\talsa_input.usb-device.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n"
- ;; Incomplete: Bluetooth monitor with no mic
- "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((result (cj/recording-group-devices-by-hardware)))
- ;; Only the complete built-in device should be returned
- (should (= 1 (length result)))
- (should (equal "Built-in Audio" (caar result)))))))
-
-;;; Error Cases
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-malformed-output-returns-empty ()
- "Test that malformed pactl output returns empty list."
- (let ((output (test-load-fixture "pactl-output-malformed.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((result (cj/recording-group-devices-by-hardware)))
- (should (listp result))
- (should (null result))))))
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-unknown-device-type ()
- "Test that unknown device types get generic 'USB Audio Device' name."
- (let ((output (concat
- "100\talsa_input.usb-unknown_device-00.analog-stereo\tPipeWire\ts16le 2ch 16000Hz\tSUSPENDED\n"
- "99\talsa_output.usb-unknown_device-00.analog-stereo.monitor\tPipeWire\ts16le 2ch 48000Hz\tSUSPENDED\n")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((result (cj/recording-group-devices-by-hardware)))
- (should (= 1 (length result)))
- ;; Should get generic USB name (not matching Jabra pattern)
- (should (equal "USB Audio Device" (caar result)))))))
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-bluetooth-mac-case-variations ()
- "Test that Bluetooth MAC addresses work with different formatting.
-Tests the normalization logic handles various MAC address formats."
- (let ((output (concat
- ;; Input with colons (typical)
- "79\tbluez_input.AA:BB:CC:DD:EE:FF\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n"
- ;; Output with underscores (typical)
- "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((result (cj/recording-group-devices-by-hardware)))
- (should (= 1 (length result)))
- (should (equal "Bluetooth Headset" (caar result)))
- ;; Verify both devices paired despite different MAC formats
- (let ((device (car result)))
- (should (string-match-p "AA:BB:CC" (cadr device)))
- (should (string-match-p "AA_BB_CC" (cddr device))))))))
-
-(provide 'test-video-audio-recording-group-devices-by-hardware)
-;;; test-video-audio-recording-group-devices-by-hardware.el ends here
diff --git a/tests/test-wrap-up--bury-buffers.el b/tests/test-wrap-up--bury-buffers.el
new file mode 100644
index 00000000..00df69c7
--- /dev/null
+++ b/tests/test-wrap-up--bury-buffers.el
@@ -0,0 +1,96 @@
+;;; test-wrap-up--bury-buffers.el --- Tests for cj/bury-buffers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Characterization tests for cj/bury-buffers, which buries the noisy
+;; compile-and-shell buffers at the end of startup.
+;;
+;; Written to pin the buried set while a dead clause was removed. The function
+;; tested `(derived-mode-p 'elisp-compile-mode)', and no such mode exists in
+;; Emacs -- the real one is `emacs-lisp-compilation-mode', which derives from
+;; `compilation-mode' and so was already matched by the clause above it. The
+;; clause could never be true, and removing it must not change which buffers
+;; get buried. These tests are what makes that claim checkable.
+;;
+;; Test organization:
+;; - Normal Cases: each buried mode is buried; byte-compilation output included
+;; - Boundary Cases: an ordinary buffer is left alone; an empty buffer list
+;; - Error Cases: a killed buffer in the list does not break the sweep
+;;
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'wrap-up)
+
+;; Required explicitly so each test stands alone. Without these the
+;; comint-mode test passed only because ERT runs tests in alphabetical order
+;; and an earlier test loaded `compile', which pulls in comint -- so renaming
+;; or running that test by itself made it fail with void-function comint-mode.
+(require 'comint)
+(require 'bytecomp)
+
+(defmacro test-wrap-up--with-mode-buffer (mode &rest body)
+ "Create a buffer in MODE, bind it to `buf', run BODY, then kill it."
+ (declare (indent 1))
+ `(let ((buf (generate-new-buffer "*test-bury*")))
+ (unwind-protect
+ (progn
+ (with-current-buffer buf (funcall ,mode))
+ ,@body)
+ (kill-buffer buf))))
+
+;;; Normal Cases
+
+(ert-deftest test-wrap-up-bury-buffers-buries-compilation ()
+ "Normal: a compilation-mode buffer is buried."
+ (test-wrap-up--with-mode-buffer #'compilation-mode
+ (switch-to-buffer buf)
+ (cj/bury-buffers)
+ (should-not (eq buf (car (buffer-list))))))
+
+(ert-deftest test-wrap-up-bury-buffers-buries-byte-compilation-output ()
+ "Normal: the real byte-compilation mode is buried.
+`emacs-lisp-compilation-mode' derives from `compilation-mode', which is
+why the never-matching elisp-compile-mode clause was redundant."
+ (should (eq 'compilation-mode
+ (get 'emacs-lisp-compilation-mode 'derived-mode-parent)))
+ (test-wrap-up--with-mode-buffer #'emacs-lisp-compilation-mode
+ (switch-to-buffer buf)
+ (cj/bury-buffers)
+ (should-not (eq buf (car (buffer-list))))))
+
+(ert-deftest test-wrap-up-bury-buffers-buries-comint ()
+ "Normal: a comint-mode buffer is buried."
+ (test-wrap-up--with-mode-buffer #'comint-mode
+ (switch-to-buffer buf)
+ (cj/bury-buffers)
+ (should-not (eq buf (car (buffer-list))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-wrap-up-bury-buffers-leaves-ordinary-buffer ()
+ "Boundary: a fundamental-mode buffer is not buried."
+ (test-wrap-up--with-mode-buffer #'fundamental-mode
+ (switch-to-buffer buf)
+ (cj/bury-buffers)
+ (should (eq buf (car (buffer-list))))))
+
+(ert-deftest test-wrap-up-bury-buffers-leaves-text-buffer ()
+ "Boundary: an ordinary text-mode buffer is not buried."
+ (test-wrap-up--with-mode-buffer #'text-mode
+ (switch-to-buffer buf)
+ (cj/bury-buffers)
+ (should (eq buf (car (buffer-list))))))
+
+;;; Error Cases
+
+(ert-deftest test-wrap-up-bury-buffers-survives-dead-mode-name ()
+ "Error: the sweep completes even though elisp-compile-mode does not exist.
+The removed clause named a mode Emacs has never defined; this pins that
+the function still runs cleanly with no such mode anywhere."
+ (should-not (fboundp 'elisp-compile-mode))
+ (should-not (get 'elisp-compile-mode 'derived-mode-parent))
+ (cj/bury-buffers))
+
+(provide 'test-wrap-up--bury-buffers)
+;;; test-wrap-up--bury-buffers.el ends here