aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test-ai-term--attached-agent-dirs.el58
-rw-r--r--tests/test-ai-term--buffer-name.el21
-rw-r--r--tests/test-ai-term--close.el16
-rw-r--r--tests/test-ai-term--keybindings.el16
-rw-r--r--tests/test-ai-term--quit.el16
-rw-r--r--tests/test-ai-term--runtime.el136
-rw-r--r--tests/test-ai-term--show-or-create.el19
-rw-r--r--tests/test-auto-dim-config.el204
-rw-r--r--tests/test-calendar-sync--apply-recurrence-exceptions.el31
-rw-r--r--tests/test-calendar-sync--expand-daily.el47
-rw-r--r--tests/test-calendar-sync--expand-monthly.el99
-rw-r--r--tests/test-calendar-sync--expand-weekly.el28
-rw-r--r--tests/test-calendar-sync--expand-yearly.el26
-rw-r--r--tests/test-calendar-sync--format-timestamp.el54
-rw-r--r--tests/test-calendar-sync--get-exdates.el29
-rw-r--r--tests/test-calendar-sync--nth-weekday-of-month.el67
-rw-r--r--tests/test-calendar-sync--parse-byday-entry.el41
-rw-r--r--tests/test-calendar-sync--parse-event.el32
-rw-r--r--tests/test-calendar-sync--parse-exception-event.el28
-rw-r--r--tests/test-calendar-sync--parse-rrule.el21
-rw-r--r--tests/test-calendar-sync-properties.el40
-rw-r--r--tests/test-calendar-sync-source-fetch-sentinel.el72
-rw-r--r--tests/test-config-utilities--recompile-emacs-home.el23
-rw-r--r--tests/test-custom-buffer-file--view-email-in-buffer.el20
-rw-r--r--tests/test-custom-buffer-file-copy-link-to-buffer-file.el11
-rw-r--r--tests/test-custom-case-title-case-region.el22
-rw-r--r--tests/test-custom-comments-comment-inline-border.el10
-rw-r--r--tests/test-custom-comments-comment-reformat.el11
-rw-r--r--tests/test-custom-datetime-all-methods.el5
-rw-r--r--tests/test-custom-line-paragraph-duplicate-line-or-region.el34
-rw-r--r--tests/test-custom-line-paragraph-join-line-or-region.el18
-rw-r--r--tests/test-custom-line-paragraph-jump-to-matching-paren.el24
-rw-r--r--tests/test-custom-ordering-number-lines.el5
-rw-r--r--tests/test-custom-ordering-reverse-lines.el6
-rw-r--r--tests/test-custom-text-enclose-indent.el29
-rw-r--r--tests/test-dashboard-config-launchers.el23
-rw-r--r--tests/test-dev-fkeys--f4-clean-rebuild-impl.el94
-rw-r--r--tests/test-dev-fkeys--f4-compile-and-run-impl.el101
-rw-r--r--tests/test-dev-fkeys--f4-make-once-hook.el15
-rw-r--r--tests/test-diff-config--ediff-options.el27
-rw-r--r--tests/test-dirvish-config--quantize-thumb-size.el60
-rw-r--r--tests/test-dirvish-config-runtime-requires.el24
-rw-r--r--tests/test-dwim-shell-config-runtime-requires.el24
-rw-r--r--tests/test-eat-config--xtwinops.el120
-rw-r--r--tests/test-external-open--open-with-argv.el59
-rw-r--r--tests/test-external-open-commands.el29
-rw-r--r--tests/test-flycheck-config-ledger-hook.el31
-rw-r--r--tests/test-flyspell-and-abbrev.el22
-rw-r--r--tests/test-font-config--frame-lifecycle.el75
-rw-r--r--tests/test-font-config.el300
-rw-r--r--tests/test-httpd-config--defer.el34
-rw-r--r--tests/test-integration-calendar-sync-timezone.el17
-rw-r--r--tests/test-integration-recording-toggle-workflow.el22
-rw-r--r--tests/test-integration-recurring-events.el113
-rw-r--r--tests/test-keyboard-compat-setup.el22
-rw-r--r--tests/test-ledger-config.el70
-rw-r--r--tests/test-local-repository--car-member.el58
-rw-r--r--tests/test-local-repository.el32
-rw-r--r--tests/test-lorem-optimum.el21
-rw-r--r--tests/test-mail-config--account-search-queries.el22
-rw-r--r--tests/test-mail-config-transport.el17
-rw-r--r--tests/test-media-utils--argv.el72
-rw-r--r--tests/test-media-utils.el106
-rw-r--r--tests/test-mu4e-attachments.el50
-rw-r--r--tests/test-music-config--art-cache-key.el69
-rw-r--r--tests/test-music-config--art-favicon-url.el69
-rw-r--r--tests/test-music-config--art-valid-image.el51
-rw-r--r--tests/test-music-config--bar-fill.el63
-rw-r--r--tests/test-music-config--bar-string.el50
-rw-r--r--tests/test-music-config--completion-table.el14
-rw-r--r--tests/test-music-config--delete-playlist-file.el135
-rw-r--r--tests/test-music-config--display-name.el139
-rw-r--r--tests/test-music-config--get-m3u-basenames.el21
-rw-r--r--tests/test-music-config--get-m3u-files.el25
-rw-r--r--tests/test-music-config--m3u-entries.el67
-rw-r--r--tests/test-music-config--m3u-file-tracks.el18
-rw-r--r--tests/test-music-config--m3u-labels.el62
-rw-r--r--tests/test-music-config--m3u-roots.el97
-rw-r--r--tests/test-music-config--m3u-text.el93
-rw-r--r--tests/test-music-config--music-files-recursive.el88
-rw-r--r--tests/test-music-config--pin-point.el78
-rw-r--r--tests/test-music-config--playlist-dock.el114
-rw-r--r--tests/test-music-config--playlist-open-position.el147
-rw-r--r--tests/test-music-config--playlist-side.el45
-rw-r--r--tests/test-music-config--radio-station-track.el135
-rw-r--r--tests/test-music-config--radio-tags.el79
-rw-r--r--tests/test-music-config--radio.el207
-rw-r--r--tests/test-music-config--renumber-rows.el232
-rw-r--r--tests/test-music-config--safe-filename.el97
-rw-r--r--tests/test-music-config--save-helpers.el90
-rw-r--r--tests/test-music-config--tidy-host.el47
-rw-r--r--tests/test-music-config--track-description.el181
-rw-r--r--tests/test-music-config-commands.el20
-rw-r--r--tests/test-music-config-create-radio-station.el219
-rw-r--r--tests/test-music-config-helpers-untested.el13
-rw-r--r--tests/test-music-config-more-commands.el149
-rw-r--r--tests/test-nov-reading--config-defaults.el67
-rw-r--r--tests/test-org-agenda-config-commands.el84
-rw-r--r--tests/test-org-agenda-frame.el999
-rw-r--r--tests/test-org-capture-config--neutralize.el136
-rw-r--r--tests/test-org-config-noop-state-log.el59
-rw-r--r--tests/test-org-contacts-config-find.el72
-rw-r--r--tests/test-org-drill-config-source.el31
-rw-r--r--tests/test-org-reveal-config-keymap.el38
-rw-r--r--tests/test-org-roam-config-tag-and-find.el11
-rw-r--r--tests/test-pdf-config--reading-palette.el87
-rw-r--r--tests/test-prog-c--tool-warnings.el34
-rw-r--r--tests/test-prog-general-lsp.el148
-rw-r--r--tests/test-prog-go--classic-mode-hooks.el46
-rw-r--r--tests/test-prog-lsp--add-file-watch-ignored-extras.el116
-rw-r--r--tests/test-prog-lsp.el66
-rw-r--r--tests/test-prog-python--lsp-guard.el64
-rw-r--r--tests/test-prog-shell--tool-warnings.el34
-rw-r--r--tests/test-prog-webdev--classic-and-web-mode-hooks.el78
-rw-r--r--tests/test-restclient-config--keymap.el29
-rw-r--r--tests/test-show-kill-ring--insert-item.el73
-rw-r--r--tests/test-signal-config-notify.el150
-rw-r--r--tests/test-signal-config.el407
-rw-r--r--tests/test-signel-cancel-input.el74
-rw-r--r--tests/test-signel-input-preservation.el68
-rw-r--r--tests/test-signel-notify-function.el89
-rw-r--r--tests/test-signel-rpc-dispatch.el94
-rw-r--r--tests/test-slack-config--notify.el113
-rw-r--r--tests/test-system-lib--ensure-marginalia-align.el57
-rw-r--r--tests/test-test-runner--nil-global-directory.el51
-rw-r--r--tests/test-test-runner.el19
-rw-r--r--tests/test-text-config.el13
-rw-r--r--tests/test-ui-theme-persistence.el13
-rw-r--r--tests/test-undead-buffers-kill-other-window.el9
-rw-r--r--tests/test-vc-config--git-clone.el135
-rw-r--r--tests/test-vc-config--gutter-hunk-candidates.el69
-rw-r--r--tests/test-vc-config--timemachine-commands.el36
-rw-r--r--tests/test-video-audio-recording--build-video-command.el8
-rw-r--r--tests/test-video-audio-recording--start-race.el56
-rw-r--r--tests/test-video-audio-recording-process-sentinel.el152
135 files changed, 7690 insertions, 2088 deletions
diff --git a/tests/test-ai-term--attached-agent-dirs.el b/tests/test-ai-term--attached-agent-dirs.el
new file mode 100644
index 00000000..bdd69544
--- /dev/null
+++ b/tests/test-ai-term--attached-agent-dirs.el
@@ -0,0 +1,58 @@
+;;; test-ai-term--attached-agent-dirs.el --- Tests for cj/--ai-term-attached-agent-dirs -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The queue `cj/ai-term-next-attached' (M-SPC) steps through: project dirs
+;; with a live agent BUFFER only -- attached sessions. Unlike
+;; `cj/--ai-term-active-agent-dirs', detached tmux sessions with no Emacs
+;; buffer are excluded; those are reachable only via `cj/ai-term-next'
+;; (M-S-SPC). Candidates / buffers / sessions are mocked so the enumeration
+;; logic is exercised without a real tmux server.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(ert-deftest test-ai-term--attached-agent-dirs-excludes-detached ()
+ "Normal: only dirs with a live buffer are attached; a detached session is
+excluded even though it is active."
+ (let ((buf (get-buffer-create (cj/--ai-term-buffer-name "/p/alpha"))))
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--ai-term-candidates)
+ (lambda (&rest _) '("/p/alpha" "/p/beta" "/p/gamma" "/p/delta")))
+ ((symbol-function 'cj/--ai-term-agent-buffers)
+ (lambda (&rest _) (list buf)))
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda (&rest _) (list (cj/--ai-term-tmux-session-name "/p/gamma")))))
+ ;; alpha attached (buffer), gamma detached (session): only alpha.
+ (should (equal '("/p/alpha") (cj/--ai-term-attached-agent-dirs))))
+ (kill-buffer buf))))
+
+(ert-deftest test-ai-term--attached-agent-dirs-multiple-sorted ()
+ "Normal: multiple attached dirs come back sorted by agent buffer name."
+ (let ((a (get-buffer-create (cj/--ai-term-buffer-name "/p/delta")))
+ (b (get-buffer-create (cj/--ai-term-buffer-name "/p/alpha"))))
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--ai-term-candidates)
+ (lambda (&rest _) '("/p/alpha" "/p/beta" "/p/delta")))
+ ((symbol-function 'cj/--ai-term-agent-buffers)
+ (lambda (&rest _) (list a b)))
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda (&rest _) nil)))
+ (should (equal '("/p/alpha" "/p/delta") (cj/--ai-term-attached-agent-dirs))))
+ (kill-buffer a)
+ (kill-buffer b))))
+
+(ert-deftest test-ai-term--attached-agent-dirs-empty-when-only-detached ()
+ "Boundary: sessions exist but no live buffers -> no attached dirs."
+ (cl-letf (((symbol-function 'cj/--ai-term-candidates) (lambda (&rest _) '("/p/solo")))
+ ((symbol-function 'cj/--ai-term-agent-buffers) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda (&rest _) (list (cj/--ai-term-tmux-session-name "/p/solo")))))
+ (should (null (cj/--ai-term-attached-agent-dirs)))))
+
+(provide 'test-ai-term--attached-agent-dirs)
+;;; test-ai-term--attached-agent-dirs.el ends here
diff --git a/tests/test-ai-term--buffer-name.el b/tests/test-ai-term--buffer-name.el
index b241977d..e728bc82 100644
--- a/tests/test-ai-term--buffer-name.el
+++ b/tests/test-ai-term--buffer-name.el
@@ -38,5 +38,26 @@
(should (equal (cj/--ai-term-buffer-name "/a/b/c/d/e/leaf")
"agent [leaf]")))
+;;; Basename extraction (inverse transform)
+
+(ert-deftest test-ai-term--buffer-basename-normal-round-trip ()
+ "Normal: extracts the basename embedded in an agent buffer's name."
+ (let ((buf (get-buffer-create "agent [proj]")))
+ (unwind-protect
+ (should (equal (cj/--ai-term-buffer-basename buf) "proj"))
+ (kill-buffer buf))))
+
+(ert-deftest test-ai-term--buffer-basename-boundary-dotted ()
+ "Boundary: dotted basenames (.emacs.d) survive extraction intact."
+ (let ((buf (get-buffer-create "agent [.emacs.d]")))
+ (unwind-protect
+ (should (equal (cj/--ai-term-buffer-basename buf) ".emacs.d"))
+ (kill-buffer buf))))
+
+(ert-deftest test-ai-term--buffer-basename-error-non-agent-nil ()
+ "Error: a non-agent buffer yields nil."
+ (with-temp-buffer
+ (should (null (cj/--ai-term-buffer-basename (current-buffer))))))
+
(provide 'test-ai-term--buffer-name)
;;; test-ai-term--buffer-name.el ends here
diff --git a/tests/test-ai-term--close.el b/tests/test-ai-term--close.el
index 242bfd74..8b028351 100644
--- a/tests/test-ai-term--close.el
+++ b/tests/test-ai-term--close.el
@@ -36,8 +36,22 @@
(lambda (&rest _) (error "no tmux"))))
(should (null (cj/--ai-term-kill-tmux-session "aiv-foo")))))
+(ert-deftest test-ai-term--close-buffer-session-from-name-after-cd ()
+ "Regression: the session name comes from the immutable buffer name.
+ghostel retargets `default-directory' via OSC 7 as the shell cds, so
+deriving from it after a cd kills the wrong aiv- session (or misses,
+orphaning the agent). The buffer name's basename never changes."
+ (let ((buf (get-buffer-create "agent [proj]"))
+ captured-session)
+ (with-current-buffer buf (setq-local default-directory "/tmp/elsewhere/"))
+ (cl-letf (((symbol-function 'cj/--ai-term-kill-tmux-session)
+ (lambda (s) (setq captured-session s) 0)))
+ (cj/--ai-term-close-buffer buf))
+ (should (equal captured-session "aiv-proj"))
+ (should-not (buffer-live-p buf))))
+
(ert-deftest test-ai-term--close-buffer-kills-session-and-buffer ()
- "Normal: derives the session from default-directory, kills it and the buffer."
+ "Normal: derives the session from the buffer name, kills it and the buffer."
(let ((buf (get-buffer-create "agent [foo]"))
captured-session)
(with-current-buffer buf (setq-local default-directory "/tmp/foo/"))
diff --git a/tests/test-ai-term--keybindings.el b/tests/test-ai-term--keybindings.el
index 6f7f53a5..8b1f8fa5 100644
--- a/tests/test-ai-term--keybindings.el
+++ b/tests/test-ai-term--keybindings.el
@@ -33,14 +33,18 @@
(should (eq (keymap-lookup cj/custom-keymap "a") cj/ai-term-keymap)))
(ert-deftest test-ai-term-next-bound-to-meta-space-globally ()
- "Normal: M-SPC runs `cj/ai-term-next' (the fast swap chord)."
- (should (eq (lookup-key (current-global-map) (kbd "M-SPC")) #'cj/ai-term-next)))
+ "Normal: M-SPC runs `cj/ai-term-next-attached' (cycle attached only) and
+M-S-SPC runs `cj/ai-term-next' (cycle all, including detached)."
+ (should (eq (lookup-key (current-global-map) (kbd "M-SPC")) #'cj/ai-term-next-attached))
+ (should (eq (lookup-key (current-global-map) (kbd "M-S-SPC")) #'cj/ai-term-next)))
(ert-deftest test-ai-term-meta-space-bound-in-eat-semi-char-mode-map ()
- "Normal: M-SPC is bound in `eat-semi-char-mode-map' so swap works inside an
-agent. EAT forwards unbound keys to the pty, so the bind is what lets it reach
-Emacs -- no ghostel-style exception list or rebuild is needed."
- (should (eq (keymap-lookup eat-semi-char-mode-map "M-SPC") #'cj/ai-term-next)))
+ "Normal: both swap chords are bound in `eat-semi-char-mode-map' so they work
+inside an agent. EAT forwards unbound keys to the pty, so the bind is what lets
+them reach Emacs -- no ghostel-style exception list or rebuild is needed. M-SPC
+cycles attached only; M-S-SPC cycles all."
+ (should (eq (keymap-lookup eat-semi-char-mode-map "M-SPC") #'cj/ai-term-next-attached))
+ (should (eq (keymap-lookup eat-semi-char-mode-map "M-S-SPC") #'cj/ai-term-next)))
(ert-deftest test-ai-term-f9-family-removed-globally ()
"Regression: the old F9 family no longer binds the ai-term commands globally."
diff --git a/tests/test-ai-term--quit.el b/tests/test-ai-term--quit.el
index 55ace81d..64b8a5d4 100644
--- a/tests/test-ai-term--quit.el
+++ b/tests/test-ai-term--quit.el
@@ -43,6 +43,22 @@
(should-not (buffer-live-p buf)))
(when (buffer-live-p buf) (kill-buffer buf)))))
+(ert-deftest test-ai-term-quit-nil-project-from-drifted-agent-buffer ()
+ "Regression: nil PROJECT inside an agent buffer keys off the buffer name.
+After a cd in the agent shell, ghostel's OSC 7 tracking moves the buffer's
+`default-directory' away from the project, so keying off it would kill the
+wrong session and miss the buffer."
+ (let ((buf (get-buffer-create "agent [realproj]"))
+ (calls nil))
+ (unwind-protect
+ (test-ai-term-quit--with-tmux calls
+ (with-current-buffer buf
+ (setq-local default-directory "/tmp/elsewhere/")
+ (cj/ai-term-quit))
+ (should (member '("kill-session" "-t" "aiv-realproj") calls))
+ (should-not (buffer-live-p buf)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
(ert-deftest test-ai-term-quit-idempotent-when-gone ()
"Error/Boundary: a second quit (session + buffer already gone) does not error."
(let ((calls nil))
diff --git a/tests/test-ai-term--runtime.el b/tests/test-ai-term--runtime.el
new file mode 100644
index 00000000..7644e468
--- /dev/null
+++ b/tests/test-ai-term--runtime.el
@@ -0,0 +1,136 @@
+;;; test-ai-term--runtime.el --- Tests for the ai-term runtime selection -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Multi-backend launch: a fresh agent session can run Claude, Codex, or a
+;; local model through codex --oss (ollama). The runtime names and launch
+;; strings mirror the rulesets bin/ai launcher so the two stay one mental
+;; model: "claude", "codex", and "local:<model>".
+;;
+;; Pure pieces tested here:
+;; - `cj/--ai-term-runtime-command' maps a runtime name to the full shell
+;; command (agent CLI + the shared opening prompt).
+;; - `cj/--ai-term-parse-runtime-lines' parses `ai --print-runtimes' output
+;; into (NAME . LABEL) choices.
+;; - `cj/--ai-term-runtime-choices' shells out to `ai' at its boundary
+;; (mocked here) and falls back to the static list when `ai' is absent.
+;; The interactive picker is a thin completing-read wrapper and is not
+;; tested (Interactive vs Internal split).
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+;;; ------------------------- runtime -> command ------------------------------
+
+(ert-deftest test-ai-term-runtime-command-claude-is-agent-command ()
+ "Normal: \"claude\" (and nil) return `cj/ai-term-agent-command' verbatim."
+ (let ((cj/ai-term-agent-command "claude \"do the thing\""))
+ (should (equal (cj/--ai-term-runtime-command "claude")
+ "claude \"do the thing\""))
+ (should (equal (cj/--ai-term-runtime-command nil)
+ "claude \"do the thing\""))))
+
+(ert-deftest test-ai-term-runtime-command-codex-composes-prompt ()
+ "Normal: \"codex\" is the codex CLI plus the shared opening prompt."
+ (let ((cj/ai-term-agent-prompt "read the protocols"))
+ (should (equal (cj/--ai-term-runtime-command "codex")
+ (concat "codex " (shell-quote-argument "read the protocols"))))))
+
+(ert-deftest test-ai-term-runtime-command-local-model ()
+ "Normal: \"local:<model>\" runs codex --oss against that ollama model.
+The model is shell-quoted, and POSIX `shell-quote-argument' backslash-escapes
+the colons, so the assertion matches the quoted form."
+ (let ((cj/ai-term-agent-prompt "read the protocols"))
+ (let ((cmd (cj/--ai-term-runtime-command "local:gpt-oss:120b")))
+ (should (string-prefix-p "codex --oss --local-provider=ollama -m " cmd))
+ (should (string-match-p
+ (regexp-quote (shell-quote-argument "gpt-oss:120b")) cmd))
+ (should (string-suffix-p (shell-quote-argument "read the protocols") cmd)))))
+
+(ert-deftest test-ai-term-runtime-command-boundary-empty-model ()
+ "Boundary: \"local:\" with no model is rejected, not launched half-formed."
+ (should-error (cj/--ai-term-runtime-command "local:") :type 'user-error))
+
+(ert-deftest test-ai-term-runtime-command-error-unknown-runtime ()
+ "Error: an unknown runtime name signals a `user-error' naming it."
+ (should-error (cj/--ai-term-runtime-command "gemini") :type 'user-error)
+ (condition-case err
+ (cj/--ai-term-runtime-command "gemini")
+ (user-error (should (string-match-p "gemini" (cadr err))))))
+
+;;; --------------------------- choice-list parsing ---------------------------
+
+(ert-deftest test-ai-term-parse-runtime-lines-normal ()
+ "Normal: `ai --print-runtimes' lines parse into (NAME . LABEL) pairs."
+ (should (equal (cj/--ai-term-parse-runtime-lines
+ "claude — Claude Code\ncodex — ChatGPT (Codex CLI)\nlocal:gpt-oss:120b — ollama\n")
+ '(("claude" . "Claude Code")
+ ("codex" . "ChatGPT (Codex CLI)")
+ ("local:gpt-oss:120b" . "ollama")))))
+
+(ert-deftest test-ai-term-parse-runtime-lines-boundary-junk ()
+ "Boundary: blank lines and lines without a separator are dropped."
+ (should (equal (cj/--ai-term-parse-runtime-lines
+ "\nclaude — Claude Code\nwarning: something\n\n")
+ '(("claude" . "Claude Code")))))
+
+(ert-deftest test-ai-term-parse-runtime-lines-boundary-empty ()
+ "Boundary: empty output parses to nil."
+ (should (null (cj/--ai-term-parse-runtime-lines ""))))
+
+;;; ------------------------------ choice list --------------------------------
+
+(ert-deftest test-ai-term-runtime-choices-uses-ai-launcher ()
+ "Normal: when the `ai' launcher exists, its runtime list is the choice list."
+ (cl-letf (((symbol-function 'executable-find)
+ (lambda (prog &rest _) (when (equal prog "ai") "/usr/bin/ai")))
+ ((symbol-function 'process-file)
+ (lambda (_prog _infile buffer _display &rest _args)
+ (with-current-buffer (cond ((eq buffer t) (current-buffer))
+ ((consp buffer) (car buffer))
+ (t buffer))
+ (insert "claude — Claude Code\nlocal:q — ollama\n"))
+ 0)))
+ (should (equal (cj/--ai-term-runtime-choices)
+ '(("claude" . "Claude Code") ("local:q" . "ollama"))))))
+
+(ert-deftest test-ai-term-runtime-choices-fallback-without-ai ()
+ "Boundary: with no `ai' launcher, the static claude-first list stands."
+ (cl-letf (((symbol-function 'executable-find) (lambda (&rest _) nil)))
+ (let ((choices (cj/--ai-term-runtime-choices)))
+ (should (equal (caar choices) "claude"))
+ (should (assoc "codex" choices)))))
+
+(ert-deftest test-ai-term-runtime-choices-error-ai-fails ()
+ "Error: a nonzero exit from `ai' falls back instead of erroring."
+ (cl-letf (((symbol-function 'executable-find)
+ (lambda (prog &rest _) (when (equal prog "ai") "/usr/bin/ai")))
+ ((symbol-function 'process-file) (lambda (&rest _) 1)))
+ (should (equal (caar (cj/--ai-term-runtime-choices)) "claude"))))
+
+;;; --------------------- launch command takes an override --------------------
+
+(ert-deftest test-ai-term-launch-command-runtime-override ()
+ "Normal: an explicit agent command is embedded instead of the default.
+The inner command is shell-quoted by the launch builder, so the assertion
+matches the quoted form."
+ (let ((cj/ai-term-agent-command "claude default"))
+ (let ((cmd (cj/--ai-term-launch-command "/tmp/proj" "codex prompted")))
+ (should (string-match-p
+ (regexp-quote (shell-quote-argument "codex prompted; exec bash"))
+ cmd))
+ (should-not (string-match-p "claude" cmd)))))
+
+(ert-deftest test-ai-term-launch-command-no-override-falls-back ()
+ "Boundary: without an override the configured agent command is used."
+ (let ((cj/ai-term-agent-command "claude default"))
+ (should (string-match-p
+ (regexp-quote (shell-quote-argument "claude default; exec bash"))
+ (cj/--ai-term-launch-command "/tmp/proj")))))
+
+(provide 'test-ai-term--runtime)
+;;; test-ai-term--runtime.el ends here
diff --git a/tests/test-ai-term--show-or-create.el b/tests/test-ai-term--show-or-create.el
index 9574b8a6..64e37653 100644
--- a/tests/test-ai-term--show-or-create.el
+++ b/tests/test-ai-term--show-or-create.el
@@ -122,7 +122,8 @@ dashboard put and letting the alist place agent into a fresh split.
This test stubs `eat' to mimic the same-window side-effect and asserts the
originally-selected window still shows its original buffer afterward."
(let ((agent-name "agent [preserve-window-test]")
- (orig-name "*test-original-buffer*"))
+ (orig-name "*test-original-buffer*")
+ (subprocess-calls nil))
(test-ai-term--cleanup agent-name)
(when (get-buffer orig-name) (kill-buffer orig-name))
(unwind-protect
@@ -138,9 +139,21 @@ originally-selected window still shows its original buffer afterward."
(set-window-buffer (selected-window) buf)
buf)))
((symbol-function 'cj/--ai-term-send-string)
- (lambda (_buf _s) nil)))
+ (lambda (_buf _s) nil))
+ ;; Same hermetic seams the shared macro mocks: no tmux
+ ;; subprocess for the fresh-session check, no /color timer.
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda () nil))
+ ((symbol-function 'cj/--ai-term-schedule-color)
+ (lambda (_buffer _color) nil))
+ ;; Leak guard: any subprocess attempt is recorded, not run.
+ ;; `cj/--ai-term-live-tmux-sessions' swallows errors, so a
+ ;; signaling barrier would pass silently -- record and assert.
+ ((symbol-function 'process-file)
+ (lambda (&rest _) (push 'process-file subprocess-calls) -1)))
(cj/--ai-term-show-or-create "/tmp/preserve" agent-name)
- (should (eq (window-buffer orig-win) orig-buf)))))
+ (should (eq (window-buffer orig-win) orig-buf))
+ (should-not subprocess-calls))))
(test-ai-term--cleanup agent-name)
(when (get-buffer orig-name) (kill-buffer orig-name)))))
diff --git a/tests/test-auto-dim-config.el b/tests/test-auto-dim-config.el
index 2686b88f..12435fa0 100644
--- a/tests/test-auto-dim-config.el
+++ b/tests/test-auto-dim-config.el
@@ -35,6 +35,210 @@
(when (fboundp 'auto-dim-other-buffers-mode)
(auto-dim-other-buffers-mode -1))))
+(defconst test-auto-dim--flat-dimmed-org-faces
+ (append (mapcar (lambda (n) (intern (format "org-level-%d" n)))
+ (number-sequence 1 8))
+ '(org-link org-tag
+ ;; Document header: #+TITLE:, #+AUTHOR:, #+ARCHIVE: and their values.
+ org-document-title org-document-info org-document-info-keyword
+ org-meta-line
+ ;; Inline markup and blocks.
+ org-code org-verbatim org-block-begin-line org-block-end-line
+ ;; Drawers, properties, planning lines.
+ org-drawer org-special-keyword org-property-value org-date
+ ;; Tables and the fold indicator.
+ org-table org-table-row org-ellipsis))
+ "Org faces that must flat-dim to the `auto-dim-other-buffers' face.
+These carry structure, not status: nothing about them needs to stay
+readable in a window the user is not looking at. Excluded on purpose are
+`org-todo' and `org-priority' (keyword class -- see the -dim variant test
+below) and `org-hide' (needs `auto-dim-other-buffers-hide' so folded text
+stays hidden).")
+
+(defconst test-auto-dim--flat-dimmed-link-faces
+ '(link link-visited)
+ "Built-in link faces that must flat-dim, distinct from `org-link'.
+They fontify links in help, info, and customize buffers. Both carry
+`:underline t', which survives the relative remap, so a dimmed link still
+reads as a link.")
+
+(defconst test-auto-dim--flat-dimmed-superstar-faces
+ '(org-superstar-header-bullet org-superstar-item org-superstar-first)
+ "org-superstar faces that must flat-dim.
+org-superstar puts its own face ahead of the org face beneath, so a heading
+star renders as (org-superstar-header-bullet org-level-1) and wins over the
+dimmed org-level-1. Without these, bullets stay lit in an unfocused window
+even though every face under them dims.
+`org-superstar-leading' is excluded on purpose -- see the test below.")
+
+(defconst test-auto-dim--hide-class-faces
+ '(org-hide org-superstar-leading org-indent)
+ "Faces whose foreground IS the background colour.
+That is what makes them invisible. They take `auto-dim-other-buffers-hide',
+never the flat dim, which would paint them visible grey.")
+
+(defconst test-auto-dim--no-foreground-faces
+ '(bold italic underline)
+ "Faces that carry no foreground, even through inheritance.
+They set weight, slant or underline only, so text wearing them takes its
+colour from `default', which is already remapped. They need no entry and
+must not gain one, or the alist grows entries that do nothing.")
+
+(defconst test-auto-dim--keyword-dim-variants
+ '((org-faces-todo . org-faces-todo-dim)
+ (org-faces-doing . org-faces-doing-dim)
+ (org-faces-priority-a . org-faces-priority-a-dim))
+ "Sample of keyword faces that must keep dedicated -dim variants.")
+
+(ert-deftest test-auto-dim-config-org-structure-faces-flat-dim ()
+ "Normal: org heading, link, and tag faces remap to the flat dim face."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (dolist (face test-auto-dim--flat-dimmed-org-faces)
+ (let ((entry (assq face auto-dim-other-buffers-affected-faces)))
+ (should entry)
+ (should (eq 'auto-dim-other-buffers (car (cdr entry))))
+ (should (null (cdr (cdr entry)))))))
+
+(ert-deftest test-auto-dim-config-link-faces-flat-dim ()
+ "Normal: the built-in `link' and `link-visited' faces flat-dim.
+Without these, links in help, info, and customize buffers stay lit while
+the rest of an unfocused window fades. `org-link' is a separate face and
+is covered above."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (dolist (face test-auto-dim--flat-dimmed-link-faces)
+ (let ((entry (assq face auto-dim-other-buffers-affected-faces)))
+ (should entry)
+ (should (eq 'auto-dim-other-buffers (car (cdr entry))))
+ (should (null (cdr (cdr entry)))))))
+
+(ert-deftest test-auto-dim-config-link-underline-survives-the-remap ()
+ "Boundary: the dim face sets no `:underline', so the link cue survives.
+A relative remap layers the dim face over the base face, so an underline
+the dim face does not specify falls through from `link'. If the theme ever
+gives `auto-dim-other-buffers' an `:underline', dimmed links stop looking
+like links and this test says so."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (should (eq 'unspecified
+ (face-attribute 'auto-dim-other-buffers :underline nil nil))))
+
+(ert-deftest test-auto-dim-config-superstar-bullets-flat-dim ()
+ "Normal: org-superstar's heading stars and list bullets flat-dim.
+They were the last thing left lit in an unfocused org window."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (dolist (face test-auto-dim--flat-dimmed-superstar-faces)
+ (let ((entry (assq face auto-dim-other-buffers-affected-faces)))
+ (should entry)
+ (should (eq 'auto-dim-other-buffers (car (cdr entry))))
+ (should (null (cdr (cdr entry)))))))
+
+(ert-deftest test-auto-dim-config-superstar-leading-uses-hide-face ()
+ "Error: `org-superstar-leading' takes the -hide face, never the flat dim.
+Its foreground is the background colour, which is what keeps hidden leading
+stars invisible. Flat-dimming it would give them the dim face's visible grey
+and reveal stars the user chose to hide. Same contract as `org-hide'."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (should-not (memq 'org-superstar-leading
+ test-auto-dim--flat-dimmed-superstar-faces))
+ (let ((entry (assq 'org-superstar-leading auto-dim-other-buffers-affected-faces)))
+ (should entry)
+ (should (eq 'auto-dim-other-buffers-hide (car (cdr entry))))))
+
+(ert-deftest test-auto-dim-config-hide-class-faces-use-hide-face ()
+ "Error: every background-coloured face takes the -hide face.
+`org-hide', `org-superstar-leading' and `org-indent' all resolve to the
+background colour, which is what keeps folded text, leading stars and indent
+prefixes invisible. Flat-dimming any of them reveals what the user hid."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (dolist (face test-auto-dim--hide-class-faces)
+ (let ((entry (assq face auto-dim-other-buffers-affected-faces)))
+ (should entry)
+ (should (eq 'auto-dim-other-buffers-hide (car (cdr entry)))))))
+
+(ert-deftest test-auto-dim-config-no-org-face-left-unmapped ()
+ "Boundary: a fontified org buffer uses no face we forgot to handle.
+Four rounds of this bug all had the same shape: a face nobody enumerated,
+sitting ahead of a mapped face in a face list and outranking it. This walks
+a representative buffer, collects every face it actually uses (including the
+`line-prefix' and `wrap-prefix' org-indent hangs its faces on), and fails on
+anything that is neither mapped nor deliberately excluded.
+
+Built-in org only. org-superstar and org-drill are elpa packages, and the
+test run has no `package-initialize', so their faces are pinned by name in
+the tests above instead."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (require 'org)
+ (let ((used (make-hash-table :test #'eq))
+ (allowed (append test-auto-dim--no-foreground-faces
+ ;; Keyword class: deliberately unmapped so status stays
+ ;; readable in an unfocused window. Pinned by
+ ;; test-auto-dim-config-todo-priority-faces-not-flat-dimmed.
+ '(org-todo org-priority)
+ (mapcar #'car auto-dim-other-buffers-affected-faces))))
+ (with-temp-buffer
+ (insert "#+TITLE: T\n#+AUTHOR: A\n\n* H1 :tag:\n** TODO [#A] task\n"
+ "DEADLINE: <2026-07-10 Fri>\n:PROPERTIES:\n:K: v\n:END:\n"
+ "Body ~verbatim~ =code= [[https://x.org][link]].\n"
+ "| a | b |\n|---+---|\n| 1 | 2 |\n"
+ "#+begin_src sh\necho hi\n#+end_src\n"
+ "- [X] done item\n")
+ (org-mode)
+ (font-lock-ensure)
+ (let ((p (point-min)))
+ (while (< p (point-max))
+ (dolist (f (let ((v (get-text-property p 'face)))
+ (if (listp v) v (list v))))
+ (when (and f (symbolp f)) (puthash f t used)))
+ (dolist (prop '(line-prefix wrap-prefix))
+ (let ((s (get-text-property p prop)))
+ (when (stringp s)
+ (dolist (f (let ((v (get-text-property 0 'face s)))
+ (if (listp v) v (list v))))
+ (when (and f (symbolp f)) (puthash f t used))))))
+ (setq p (1+ p)))))
+ (let (unmapped)
+ (maphash (lambda (face _v)
+ (unless (memq face allowed) (push face unmapped)))
+ used)
+ (should (equal nil (sort unmapped #'string<))))))
+
+(ert-deftest test-auto-dim-config-keyword-faces-keep-dim-variants ()
+ "Boundary: org TODO-keyword faces keep dedicated -dim variants, not flat dim.
+Keyword status is scanned across unfocused windows, so it earns a variant;
+heading colour does not. Guards the flat-dim change from over-reaching."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (dolist (pair test-auto-dim--keyword-dim-variants)
+ (let ((entry (assq (car pair) auto-dim-other-buffers-affected-faces)))
+ (should entry)
+ (should (eq (cdr pair) (car (cdr entry)))))))
+
+(ert-deftest test-auto-dim-config-todo-priority-faces-not-flat-dimmed ()
+ "Boundary: `org-todo' and `org-priority' are never flat-dimmed.
+They are keyword class. Dimming them would erase the status colour the
+-dim variants exist to preserve, so they stay out of the flat-dim set."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (dolist (face '(org-todo org-priority))
+ (should-not (memq face test-auto-dim--flat-dimmed-org-faces))
+ (let ((entry (assq face auto-dim-other-buffers-affected-faces)))
+ (should-not (and entry (eq 'auto-dim-other-buffers (car (cdr entry))))))))
+
+(ert-deftest test-auto-dim-config-org-hide-uses-hide-face ()
+ "Boundary: `org-hide' remaps to the -hide face, not the flat dim face.
+Flat-dimming it would give folded text a visible foreground."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (let ((entry (assq 'org-hide auto-dim-other-buffers-affected-faces)))
+ (should entry)
+ (should (eq 'auto-dim-other-buffers-hide (car (cdr entry))))))
+
(ert-deftest test-auto-dim-config-never-dim-dashboard-exempts-dashboard ()
"Normal: the *dashboard* buffer is exempt from dimming."
(skip-unless (file-directory-p test-auto-dim--fork))
diff --git a/tests/test-calendar-sync--apply-recurrence-exceptions.el b/tests/test-calendar-sync--apply-recurrence-exceptions.el
index 7711c5cb..999296fb 100644
--- a/tests/test-calendar-sync--apply-recurrence-exceptions.el
+++ b/tests/test-calendar-sync--apply-recurrence-exceptions.el
@@ -153,5 +153,36 @@ NEW-* values are the rescheduled time."
(and (= 1 (length result))
(= 9 (nth 3 (plist-get (car result) :start)))))))))
+;;; STATUS:CANCELLED Cases
+
+(ert-deftest test-calendar-sync--apply-recurrence-exceptions-normal-cancelled-removes ()
+ "Normal: a cancelled exception removes its occurrence instead of overriding it."
+ (let* ((occurrences (list (test-make-occurrence 2026 2 3 9 0 "Weekly Meeting")
+ (test-make-occurrence 2026 2 10 9 0 "Weekly Meeting")
+ (test-make-occurrence 2026 2 17 9 0 "Weekly Meeting")))
+ (exceptions (make-hash-table :test 'equal)))
+ ;; Feb 10 is cancelled.
+ (puthash "test-event@google.com"
+ (list (append (test-make-exception-data 2026 2 10 9 0 2026 2 10 9 0)
+ '(:cancelled t)))
+ exceptions)
+ (let ((result (calendar-sync--apply-recurrence-exceptions occurrences exceptions)))
+ (should (= 2 (length result)))
+ ;; Feb 3 and Feb 17 remain; Feb 10 is gone.
+ (should (equal '(3 17)
+ (mapcar (lambda (occ) (nth 2 (plist-get occ :start))) result))))))
+
+(ert-deftest test-calendar-sync--apply-recurrence-exceptions-boundary-cancelled-no-match-keeps-all ()
+ "Boundary: a cancelled exception that matches nothing removes nothing."
+ (let* ((occurrences (list (test-make-occurrence 2026 2 3 9 0 "Weekly Meeting")))
+ (exceptions (make-hash-table :test 'equal)))
+ ;; Cancelled exception targets a different date.
+ (puthash "test-event@google.com"
+ (list (append (test-make-exception-data 2026 3 3 9 0 2026 3 3 9 0)
+ '(:cancelled t)))
+ exceptions)
+ (let ((result (calendar-sync--apply-recurrence-exceptions occurrences exceptions)))
+ (should (= 1 (length result))))))
+
(provide 'test-calendar-sync--apply-recurrence-exceptions)
;;; test-calendar-sync--apply-recurrence-exceptions.el ends here
diff --git a/tests/test-calendar-sync--expand-daily.el b/tests/test-calendar-sync--expand-daily.el
index 43b93664..0db9f345 100644
--- a/tests/test-calendar-sync--expand-daily.el
+++ b/tests/test-calendar-sync--expand-daily.el
@@ -176,5 +176,52 @@
(occurrences (calendar-sync--expand-daily base-event rrule range)))
(should (= (length occurrences) 5))))
+;;; UNTIL is inclusive (RFC 5545 3.3.10)
+
+(ert-deftest test-calendar-sync--expand-daily-until-includes-the-until-date ()
+ "Boundary: an occurrence falling ON the UNTIL date is kept.
+RFC 5545 3.3.10: UNTIL bounds the recurrence \"in an inclusive manner\", and
+when it lines up with the recurrence that date \"becomes the last instance\".
+A strict before-comparison drops it, so the last meeting of every bounded
+series silently vanishes from the agenda."
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (until-date (test-calendar-sync-time-date-only 5))
+ (base-event (list :summary "Bounded Standup" :start start-date))
+ (rrule (list :freq 'daily :interval 1 :until until-date))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range))
+ (last-start (plist-get (car (last occurrences)) :start)))
+ ;; day+1 through day+5 inclusive = 5 occurrences.
+ (should (= (length occurrences) 5))
+ ;; The final occurrence is the UNTIL date itself.
+ (should (equal (seq-take last-start 3) until-date))))
+
+(ert-deftest test-calendar-sync--expand-daily-until-excludes-dates-after-it ()
+ "Boundary: inclusivity stops at UNTIL; the next day is not generated.
+Guards the fix against over-correcting into an off-by-one the other way."
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (until-date (test-calendar-sync-time-date-only 5))
+ (day-after (test-calendar-sync-time-date-only 6))
+ (base-event (list :summary "Bounded Standup" :start start-date))
+ (rrule (list :freq 'daily :interval 1 :until until-date))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range))
+ (dates (mapcar (lambda (o) (seq-take (plist-get o :start) 3)) occurrences)))
+ (should (member until-date dates))
+ (should-not (member day-after dates))))
+
+(ert-deftest test-calendar-sync--expand-daily-until-on-start-date-yields-one ()
+ "Boundary: UNTIL equal to the start date yields exactly that one occurrence.
+The degenerate single-instance series -- a strict comparison returns nothing
+at all here, which is the same defect at its smallest."
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (until-date (test-calendar-sync-time-date-only 1))
+ (base-event (list :summary "One Shot" :start start-date))
+ (rrule (list :freq 'daily :interval 1 :until until-date))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range)))
+ (should (= (length occurrences) 1))
+ (should (equal (seq-take (plist-get (car occurrences) :start) 3) until-date))))
+
(provide 'test-calendar-sync--expand-daily)
;;; test-calendar-sync--expand-daily.el ends here
diff --git a/tests/test-calendar-sync--expand-monthly.el b/tests/test-calendar-sync--expand-monthly.el
index 3dc1f2dc..d2d25a70 100644
--- a/tests/test-calendar-sync--expand-monthly.el
+++ b/tests/test-calendar-sync--expand-monthly.el
@@ -170,5 +170,104 @@
(occurrences (calendar-sync--expand-monthly base-event rrule range)))
(should (= (length occurrences) 3))))
+;;; BYDAY (nth weekday) Cases
+;;
+;; Fixed dates are deterministic here: the expansion range is an explicit
+;; parameter, not derived from the current time, so these never age out.
+
+(defun test-calendar-sync--expand-monthly-range-2026 ()
+ "Fixed expansion range covering calendar year 2026."
+ (list (encode-time 0 0 0 1 1 2026) (encode-time 0 0 0 31 12 2026)))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-second-wednesday ()
+ "Normal: BYDAY=2WE lands on the 2nd Wednesday of each month, not the
+day-of-month of DTSTART. This is the live Craig/Ryan series shape."
+ (let* ((base-event (list :summary "2nd Wednesday"
+ :start '(2026 1 14 10 0)
+ :end '(2026 1 14 11 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("2WE")))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (days (mapcar (lambda (occ)
+ (let ((s (plist-get occ :start)))
+ (list (nth 1 s) (nth 2 s))))
+ occurrences)))
+ (should (equal days '((1 14) (2 11) (3 11) (4 8) (5 13) (6 10)
+ (7 8) (8 12) (9 9) (10 14) (11 11) (12 9))))
+ ;; Every occurrence is a Wednesday (weekday 3), never a fixed day-of-month.
+ (dolist (occ occurrences)
+ (let ((s (plist-get occ :start)))
+ (should (= 3 (calendar-sync--date-weekday
+ (list (nth 0 s) (nth 1 s) (nth 2 s)))))))))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-last-tuesday ()
+ "Normal: BYDAY=-1TU lands on the last Tuesday of each month."
+ (let* ((base-event (list :summary "Last Tuesday"
+ :start '(2026 1 27 9 0)
+ :end '(2026 1 27 10 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("-1TU") :count 3))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (days (mapcar (lambda (occ)
+ (let ((s (plist-get occ :start)))
+ (list (nth 1 s) (nth 2 s))))
+ occurrences)))
+ (should (equal days '((1 27) (2 24) (3 31))))))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-bysetpos-second-sunday ()
+ "Normal: BYDAY=SU with BYSETPOS=2 lands on the 2nd Sunday (Proton shape)."
+ (let* ((base-event (list :summary "2nd Sunday"
+ :start '(2026 1 11 8 0)
+ :end '(2026 1 11 9 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("SU") :bysetpos 2 :count 3))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (days (mapcar (lambda (occ)
+ (let ((s (plist-get occ :start)))
+ (list (nth 1 s) (nth 2 s))))
+ occurrences)))
+ (should (equal days '((1 11) (2 8) (3 8))))))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-until-inclusive-and-reached ()
+ "Boundary: with BYDAY, UNTIL is inclusive and the series reaches it.
+Upper bound alone can't catch a dropped final occurrence -- assert both."
+ (let* ((base-event (list :summary "Bounded"
+ :start '(2026 1 14 10 0)
+ :end '(2026 1 14 11 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("2WE")
+ :until '(2026 5 13)))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (last-start (plist-get (car (last occurrences)) :start)))
+ (should (= (length occurrences) 5))
+ ;; Reach: the occurrence landing exactly on UNTIL is kept.
+ (should (equal (list (nth 0 last-start) (nth 1 last-start) (nth 2 last-start))
+ '(2026 5 13)))))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-count-limits ()
+ "Boundary: COUNT caps a BYDAY series."
+ (let* ((base-event (list :summary "Counted"
+ :start '(2026 1 14 10 0)
+ :end '(2026 1 14 11 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("2WE") :count 4))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range)))
+ (should (= (length occurrences) 4))))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-fifth-weekday-skips-short-months ()
+ "Boundary: BYDAY=5WE only lands in months that have a 5th Wednesday."
+ (let* ((base-event (list :summary "5th Wednesday"
+ :start '(2026 4 29 10 0)
+ :end '(2026 4 29 11 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("5WE")))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (days (mapcar (lambda (occ)
+ (let ((s (plist-get occ :start)))
+ (list (nth 1 s) (nth 2 s))))
+ occurrences)))
+ ;; 2026 months (Apr on) with a 5th Wednesday: Apr 29, Jul 29, Sep 30, Dec 30.
+ (should (equal days '((4 29) (7 29) (9 30) (12 30))))))
+
(provide 'test-calendar-sync--expand-monthly)
;;; test-calendar-sync--expand-monthly.el ends here
diff --git a/tests/test-calendar-sync--expand-weekly.el b/tests/test-calendar-sync--expand-weekly.el
index a6143bce..0639ca07 100644
--- a/tests/test-calendar-sync--expand-weekly.el
+++ b/tests/test-calendar-sync--expand-weekly.el
@@ -270,5 +270,33 @@
(should (= (length occurrences) 0)))
(test-calendar-sync--expand-weekly-teardown)))
+;;; UNTIL is inclusive (RFC 5545 3.3.10)
+
+(ert-deftest test-calendar-sync--expand-weekly-until-includes-the-until-date ()
+ "Boundary: a weekly occurrence landing ON the UNTIL date is kept.
+The weekly loop checks UNTIL in two places -- the outer week-stepping guard
+and the per-weekday check inside it -- so it needs its own guard rather than
+inheriting the daily one. Fixing the loop without this test left weekly
+silently unprotected: reverting the fix failed three daily tests and zero
+weekly ones."
+ (test-calendar-sync--expand-weekly-setup)
+ (unwind-protect
+ ;; Omitting :byday makes the series recur on the start date's own weekday,
+ ;; which anchors UNTIL exactly on an occurrence without hardcoding a date.
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 9 0))
+ (week-2 (test-calendar-sync-time-date-only 8))
+ (until-date (test-calendar-sync-time-date-only 15))
+ (week-4 (test-calendar-sync-time-date-only 22))
+ (base-event (list :summary "Bounded Weekly" :start start-date))
+ (rrule (list :freq 'weekly :interval 1 :until until-date))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range))
+ (dates (mapcar (lambda (o) (seq-take (plist-get o :start) 3)) occurrences)))
+ ;; day+1, +8, +15 are consecutive same-weekday dates; UNTIL is the third.
+ (should (equal dates (list (seq-take start-date 3) week-2 until-date)))
+ ;; Inclusivity stops at UNTIL -- the following week is not generated.
+ (should-not (member week-4 dates)))
+ (test-calendar-sync--expand-weekly-teardown)))
+
(provide 'test-calendar-sync--expand-weekly)
;;; test-calendar-sync--expand-weekly.el ends here
diff --git a/tests/test-calendar-sync--expand-yearly.el b/tests/test-calendar-sync--expand-yearly.el
index ad9b8f27..c636e54a 100644
--- a/tests/test-calendar-sync--expand-yearly.el
+++ b/tests/test-calendar-sync--expand-yearly.el
@@ -175,5 +175,31 @@
(occurrences (calendar-sync--expand-yearly base-event rrule range)))
(should (= (length occurrences) 2))))
+;;; BYMONTH + BYDAY (nth weekday) Cases
+;;
+;; Fixed dates are deterministic here: the expansion range is an explicit
+;; parameter, not derived from the current time.
+
+(ert-deftest test-calendar-sync--expand-yearly-bymonth-byday-nth-weekday ()
+ "Normal: FREQ=YEARLY;BYMONTH=3;BYDAY=2SU tracks the 2nd Sunday of March
+each year (the DST clock-change shape), not DTSTART's calendar date."
+ (let* ((base-event (list :summary "Clocks change"
+ :start '(2026 3 8 2 0)
+ :end '(2026 3 8 3 0)))
+ (rrule (list :freq 'yearly :interval 1 :bymonth 3 :byday '("2SU")))
+ (range (list (encode-time 0 0 0 1 1 2026)
+ (encode-time 0 0 0 31 12 2027)))
+ (occurrences (calendar-sync--expand-yearly base-event rrule range))
+ (days (mapcar (lambda (occ)
+ (let ((s (plist-get occ :start)))
+ (list (nth 0 s) (nth 1 s) (nth 2 s))))
+ occurrences)))
+ ;; 2nd Sunday of March: 2026-03-08, 2027-03-14 -- different day-of-month.
+ (should (equal days '((2026 3 8) (2027 3 14))))
+ (dolist (occ occurrences)
+ (let ((s (plist-get occ :start)))
+ (should (= 7 (calendar-sync--date-weekday
+ (list (nth 0 s) (nth 1 s) (nth 2 s)))))))))
+
(provide 'test-calendar-sync--expand-yearly)
;;; test-calendar-sync--expand-yearly.el ends here
diff --git a/tests/test-calendar-sync--format-timestamp.el b/tests/test-calendar-sync--format-timestamp.el
index 5b8a6d02..b84e625d 100644
--- a/tests/test-calendar-sync--format-timestamp.el
+++ b/tests/test-calendar-sync--format-timestamp.el
@@ -56,5 +56,59 @@
;; start-hour is nil so time-str should be nil
(should-not (string-match-p "[0-9][0-9]:[0-9][0-9]-" result))))
+;;; Multi-day spans (org range syntax)
+
+(ert-deftest test-calendar-sync--format-timestamp-multi-day-timed-spans-dates ()
+ "A timed event ending on a later date renders as an org range.
+The end DATE used to be discarded and only its time kept, so a four-day
+conference produced the same timestamp as a same-day meeting and the agenda
+showed it on day one only."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 15 14 0) '(2026 3 18 15 30))))
+ (should (equal result "<2026-03-15 Sun 14:00>--<2026-03-18 Wed 15:30>"))))
+
+(ert-deftest test-calendar-sync--format-timestamp-multi-day-all-day-excludes-dtend ()
+ "An all-day span ends the day before DTEND, which is non-inclusive.
+RFC 5545 3.6.1: DTEND is \"the non-inclusive end of the event\", so an
+all-day event running Mar 15-17 carries DTEND 2026-03-18. Rendering DTEND
+verbatim would add a phantom fourth day."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 15 nil nil) '(2026 3 18 nil nil))))
+ (should (equal result "<2026-03-15 Sun>--<2026-03-17 Tue>"))))
+
+(ert-deftest test-calendar-sync--format-timestamp-single-all-day-stays-single ()
+ "A one-day all-day event stays a single stamp, never a degenerate range.
+Its DTEND is the next day (non-inclusive), so a naive range would turn every
+single all-day event into a two-day one -- a worse regression than the
+collapse this range support fixes."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 15 nil nil) '(2026 3 16 nil nil))))
+ (should (equal result "<2026-03-15 Sun>"))
+ (should-not (string-match-p "--" result))))
+
+(ert-deftest test-calendar-sync--format-timestamp-same-day-timed-stays-compact ()
+ "A same-day timed event keeps the compact HH:MM-HH:MM form, not a range.
+Guards the common case against the range branch."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 15 14 0) '(2026 3 15 15 30))))
+ (should (equal result "<2026-03-15 Sun 14:00-15:30>"))
+ (should-not (string-match-p "--" result))))
+
+(ert-deftest test-calendar-sync--format-timestamp-multi-day-all-day-two-days ()
+ "Boundary: the shortest real all-day span (two days) renders as a range.
+DTEND 2026-03-17 means the event covers Mar 15-16; one day fewer and it
+collapses to the single-stamp case above."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 15 nil nil) '(2026 3 17 nil nil))))
+ (should (equal result "<2026-03-15 Sun>--<2026-03-16 Mon>"))))
+
+(ert-deftest test-calendar-sync--format-timestamp-multi-day-span-crosses-month ()
+ "Boundary: an all-day span crossing a month boundary decrements correctly.
+DTEND 2026-04-01 means the event's last day is 2026-03-31, which exercises
+the borrow in `calendar-sync--add-days'."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 30 nil nil) '(2026 4 1 nil nil))))
+ (should (equal result "<2026-03-30 Mon>--<2026-03-31 Tue>"))))
+
(provide 'test-calendar-sync--format-timestamp)
;;; test-calendar-sync--format-timestamp.el ends here
diff --git a/tests/test-calendar-sync--get-exdates.el b/tests/test-calendar-sync--get-exdates.el
index 3283bbae..981a1857 100644
--- a/tests/test-calendar-sync--get-exdates.el
+++ b/tests/test-calendar-sync--get-exdates.el
@@ -103,6 +103,35 @@ END:VEVENT"))
(should (= 1 (length result)))
(should (string= "20260210T130000" (car result))))))
+(ert-deftest test-calendar-sync--get-exdates-boundary-comma-separated-returns-all ()
+ "Boundary: comma-separated EXDATE values on one line are each returned.
+RFC 5545 permits multiple datetimes per EXDATE line; missing the split
+drops those exclusions, so cancelled instances resurrect in the agenda."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260203T130000
+RRULE:FREQ=WEEKLY;BYDAY=TU
+EXDATE:20260210T130000,20260217T130000,20260224T130000
+SUMMARY:Weekly Meeting
+END:VEVENT"))
+ (let ((result (calendar-sync--get-exdates event)))
+ (should (= 3 (length result)))
+ (should (member "20260210T130000" result))
+ (should (member "20260217T130000" result))
+ (should (member "20260224T130000" result)))))
+
+(ert-deftest test-calendar-sync--get-exdates-boundary-comma-separated-with-tzid ()
+ "Boundary: comma-separated EXDATE values sharing a TZID are each returned."
+ (let ((event "BEGIN:VEVENT
+DTSTART;TZID=America/New_York:20260203T130000
+RRULE:FREQ=WEEKLY;BYDAY=TU
+EXDATE;TZID=America/New_York:20260210T130000,20260217T130000
+SUMMARY:Weekly Meeting
+END:VEVENT"))
+ (let ((result (calendar-sync--get-exdates event)))
+ (should (= 2 (length result)))
+ (should (member "20260210T130000" result))
+ (should (member "20260217T130000" result)))))
+
;;; Error Cases
(ert-deftest test-calendar-sync--get-exdates-error-empty-string-returns-nil ()
diff --git a/tests/test-calendar-sync--nth-weekday-of-month.el b/tests/test-calendar-sync--nth-weekday-of-month.el
new file mode 100644
index 00000000..afb0bd35
--- /dev/null
+++ b/tests/test-calendar-sync--nth-weekday-of-month.el
@@ -0,0 +1,67 @@
+;;; test-calendar-sync--nth-weekday-of-month.el --- Tests for calendar-sync--nth-weekday-of-month -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the nth-weekday-of-month helper backing monthly/yearly BYDAY
+;; expansion. Fixed dates are safe here: the function is pure calendar
+;; arithmetic with no relation to the current time.
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-normal-second-wednesday ()
+ "Normal: 2nd Wednesday of Jan 2026 is the 14th."
+ ;; Jan 2026: Jan 1 is a Thursday; Wednesdays fall on 7, 14, 21, 28.
+ (should (= (calendar-sync--nth-weekday-of-month 2026 1 3 2) 14)))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-normal-first-monday ()
+ "Normal: 1st Monday of Feb 2026 is the 2nd."
+ ;; Feb 2026: Feb 1 is a Sunday; Mondays fall on 2, 9, 16, 23.
+ (should (= (calendar-sync--nth-weekday-of-month 2026 2 1 1) 2)))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-normal-last-tuesday ()
+ "Normal: last Tuesday of Mar 2026 is the 31st (negative ordinal)."
+ ;; Mar 2026: Tuesdays fall on 3, 10, 17, 24, 31.
+ (should (= (calendar-sync--nth-weekday-of-month 2026 3 2 -1) 31)))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-normal-second-to-last-friday ()
+ "Normal: -2 ordinal picks the second-to-last Friday."
+ ;; May 2026: Fridays fall on 1, 8, 15, 22, 29.
+ (should (= (calendar-sync--nth-weekday-of-month 2026 5 5 -2) 22)))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-boundary-fifth-occurrence-exists ()
+ "Boundary: 5th Friday exists in May 2026."
+ (should (= (calendar-sync--nth-weekday-of-month 2026 5 5 5) 29)))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-boundary-fifth-occurrence-missing ()
+ "Boundary: 5th Wednesday of Feb 2026 does not exist -- returns nil."
+ ;; Feb 2026 has four Wednesdays (4, 11, 18, 25).
+ (should (null (calendar-sync--nth-weekday-of-month 2026 2 3 5))))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-boundary-first-day-is-target ()
+ "Boundary: the 1st of the month itself is the 1st occurrence."
+ ;; Apr 2026: Apr 1 is a Wednesday.
+ (should (= (calendar-sync--nth-weekday-of-month 2026 4 3 1) 1)))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-boundary-leap-february ()
+ "Boundary: leap-year February (2028) handled -- last Tuesday is the 29th."
+ ;; Feb 2028: Feb 29 exists and is a Tuesday.
+ (should (= (calendar-sync--nth-weekday-of-month 2028 2 2 -1) 29)))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-error-zero-ordinal-nil ()
+ "Error: ordinal 0 is meaningless -- returns nil."
+ (should (null (calendar-sync--nth-weekday-of-month 2026 1 3 0))))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-error-out-of-range-negative-nil ()
+ "Error: -6th occurrence never exists in a month -- returns nil."
+ (should (null (calendar-sync--nth-weekday-of-month 2026 1 3 -6))))
+
+(provide 'test-calendar-sync--nth-weekday-of-month)
+;;; test-calendar-sync--nth-weekday-of-month.el ends here
diff --git a/tests/test-calendar-sync--parse-byday-entry.el b/tests/test-calendar-sync--parse-byday-entry.el
new file mode 100644
index 00000000..4a9b4ef5
--- /dev/null
+++ b/tests/test-calendar-sync--parse-byday-entry.el
@@ -0,0 +1,41 @@
+;;; test-calendar-sync--parse-byday-entry.el --- Tests for calendar-sync--parse-byday-entry -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for parsing a single RRULE BYDAY entry ("2WE", "-1TU", "SU") into
+;; an (ordinal . weekday-number) cons. Ordinal is nil for a bare weekday.
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--parse-byday-entry-normal-positive-ordinal ()
+ "Normal: \"2WE\" parses to ordinal 2, Wednesday (3)."
+ (should (equal (calendar-sync--parse-byday-entry "2WE") '(2 . 3))))
+
+(ert-deftest test-calendar-sync--parse-byday-entry-normal-negative-ordinal ()
+ "Normal: \"-1TU\" parses to ordinal -1, Tuesday (2)."
+ (should (equal (calendar-sync--parse-byday-entry "-1TU") '(-1 . 2))))
+
+(ert-deftest test-calendar-sync--parse-byday-entry-normal-bare-weekday ()
+ "Normal: \"SU\" parses to nil ordinal, Sunday (7)."
+ (should (equal (calendar-sync--parse-byday-entry "SU") '(nil . 7))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--parse-byday-entry-boundary-double-digit-ordinal ()
+ "Boundary: \"53MO\" (yearly-scale ordinal) parses without truncation."
+ (should (equal (calendar-sync--parse-byday-entry "53MO") '(53 . 1))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--parse-byday-entry-error-garbage-nil ()
+ "Error: an unrecognizable entry returns nil."
+ (should (null (calendar-sync--parse-byday-entry "XX")))
+ (should (null (calendar-sync--parse-byday-entry "")))
+ (should (null (calendar-sync--parse-byday-entry nil))))
+
+(provide 'test-calendar-sync--parse-byday-entry)
+;;; test-calendar-sync--parse-byday-entry.el ends here
diff --git a/tests/test-calendar-sync--parse-event.el b/tests/test-calendar-sync--parse-event.el
index 9c343db2..b3f58ba2 100644
--- a/tests/test-calendar-sync--parse-event.el
+++ b/tests/test-calendar-sync--parse-event.el
@@ -78,5 +78,37 @@
(let ((vevent "BEGIN:VEVENT\nSUMMARY:Orphan\nEND:VEVENT"))
(should (null (calendar-sync--parse-event vevent)))))
+;;; STATUS:CANCELLED Cases
+
+(ert-deftest test-calendar-sync--parse-event-error-cancelled-returns-nil ()
+ "Error: a STATUS:CANCELLED event returns nil -- cancelled events don't render."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (vevent (concat "BEGIN:VEVENT\n"
+ "SUMMARY:Cancelled Meeting\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "STATUS:CANCELLED\n"
+ "END:VEVENT")))
+ (should (null (calendar-sync--parse-event vevent)))))
+
+(ert-deftest test-calendar-sync--parse-event-boundary-cancelled-case-insensitive ()
+ "Boundary: STATUS value matching is case-insensitive."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (vevent (concat "BEGIN:VEVENT\n"
+ "SUMMARY:Cancelled Meeting\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "STATUS:Cancelled\n"
+ "END:VEVENT")))
+ (should (null (calendar-sync--parse-event vevent)))))
+
+(ert-deftest test-calendar-sync--parse-event-normal-confirmed-still-parses ()
+ "Normal: STATUS:CONFIRMED events still parse."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (vevent (concat "BEGIN:VEVENT\n"
+ "SUMMARY:Confirmed Meeting\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "STATUS:CONFIRMED\n"
+ "END:VEVENT")))
+ (should (calendar-sync--parse-event vevent))))
+
(provide 'test-calendar-sync--parse-event)
;;; test-calendar-sync--parse-event.el ends here
diff --git a/tests/test-calendar-sync--parse-exception-event.el b/tests/test-calendar-sync--parse-exception-event.el
index a26a7418..1c9411f3 100644
--- a/tests/test-calendar-sync--parse-exception-event.el
+++ b/tests/test-calendar-sync--parse-exception-event.el
@@ -82,5 +82,33 @@ than a half-built plist."
"END:VEVENT")))
(should-not (calendar-sync--parse-exception-event event))))
+;;; STATUS:CANCELLED Cases
+
+(ert-deftest test-calendar-sync--parse-exception-event-normal-cancelled-flag ()
+ "Normal: a STATUS:CANCELLED override carries :cancelled t, so the
+matching occurrence can be removed rather than overridden."
+ (let* ((start (test-calendar-sync-time-days-from-now 7 10 0))
+ (end (test-calendar-sync-time-days-from-now 7 11 0))
+ (event (concat "BEGIN:VEVENT\n"
+ "UID:override@google.com\n"
+ "RECURRENCE-ID:20260203T090000Z\n"
+ "SUMMARY:Craig / Ryan\n"
+ "STATUS:CANCELLED\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime end) "\n"
+ "END:VEVENT"))
+ (plist (calendar-sync--parse-exception-event event)))
+ (should plist)
+ (should (plist-get plist :cancelled))))
+
+(ert-deftest test-calendar-sync--parse-exception-event-boundary-no-status-not-cancelled ()
+ "Boundary: an override without STATUS is not cancelled."
+ (let* ((start (test-calendar-sync-time-days-from-now 7 10 0))
+ (end (test-calendar-sync-time-days-from-now 7 11 0))
+ (plist (calendar-sync--parse-exception-event
+ (test-cs-parse-exc--override-event start end))))
+ (should plist)
+ (should-not (plist-get plist :cancelled))))
+
(provide 'test-calendar-sync--parse-exception-event)
;;; test-calendar-sync--parse-exception-event.el ends here
diff --git a/tests/test-calendar-sync--parse-rrule.el b/tests/test-calendar-sync--parse-rrule.el
index 099e4e44..2668c1ac 100644
--- a/tests/test-calendar-sync--parse-rrule.el
+++ b/tests/test-calendar-sync--parse-rrule.el
@@ -206,5 +206,26 @@
(should (= (plist-get result :count) 10)))
(test-calendar-sync--parse-rrule-teardown)))
+;;; BYSETPOS / BYMONTH Cases
+
+(ert-deftest test-calendar-sync--parse-rrule-normal-bysetpos-returns-number ()
+ "Normal: BYSETPOS parses to a number (Proton emits BYDAY=SU;BYSETPOS=2)."
+ (let ((result (calendar-sync--parse-rrule "FREQ=MONTHLY;BYDAY=SU;BYSETPOS=2")))
+ (should (eq (plist-get result :freq) 'monthly))
+ (should (equal (plist-get result :byday) '("SU")))
+ (should (= (plist-get result :bysetpos) 2))))
+
+(ert-deftest test-calendar-sync--parse-rrule-normal-bymonth-returns-number ()
+ "Normal: BYMONTH parses to a number (yearly nth-weekday rules carry it)."
+ (let ((result (calendar-sync--parse-rrule "FREQ=YEARLY;BYMONTH=3;BYDAY=2SU")))
+ (should (eq (plist-get result :freq) 'yearly))
+ (should (= (plist-get result :bymonth) 3))
+ (should (equal (plist-get result :byday) '("2SU")))))
+
+(ert-deftest test-calendar-sync--parse-rrule-boundary-negative-bysetpos ()
+ "Boundary: negative BYSETPOS (last matching day) parses."
+ (let ((result (calendar-sync--parse-rrule "FREQ=MONTHLY;BYDAY=FR;BYSETPOS=-1")))
+ (should (= (plist-get result :bysetpos) -1))))
+
(provide 'test-calendar-sync--parse-rrule)
;;; test-calendar-sync--parse-rrule.el ends here
diff --git a/tests/test-calendar-sync-properties.el b/tests/test-calendar-sync-properties.el
index c25bb99f..0b01cbd9 100644
--- a/tests/test-calendar-sync-properties.el
+++ b/tests/test-calendar-sync-properties.el
@@ -77,12 +77,21 @@ For any COUNT value N, expansion never produces more than N occurrences."
;;; Property 2: UNTIL Boundary
+;; These two asserted the wrong invariant until 2026-07-16: they required every
+;; occurrence to fall strictly BEFORE UNTIL, which is the exclusive reading RFC
+;; 5545 3.3.10 contradicts ("bounds the recurrence rule in an inclusive manner";
+;; a UNTIL synchronized with the recurrence "becomes the last instance"). They
+;; were written against the expansion loop's strict `before-date-p' guard and so
+;; pinned the very defect that dropped the last instance of every bounded series.
+;; The property is on-or-before; the upper bound is what UNTIL is for.
+
(ert-deftest test-calendar-sync-property-until-bounds-daily ()
- "Property: No daily occurrence starts on or after UNTIL date."
+ "Property: no daily occurrence starts after the UNTIL date.
+UNTIL is an inclusive bound (RFC 5545 3.3.10), so landing exactly on it is
+correct and only a later date violates the property."
(dotimes (_ test-calendar-sync-property-trials)
(let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
(until-days (+ 10 (random 60)))
- ;; UNTIL must be date-only (3 elements) for calendar-sync--before-date-p
(until-date (test-calendar-sync-time-date-only until-days))
(base-event (list :summary "Until Test" :start start-date))
(rrule (list :freq 'daily :interval 1 :until until-date))
@@ -90,16 +99,35 @@ For any COUNT value N, expansion never produces more than N occurrences."
(occurrences (calendar-sync--expand-daily base-event rrule range)))
(dolist (occ occurrences)
(let ((occ-start (plist-get occ :start)))
- (should (calendar-sync--before-date-p
+ (should (calendar-sync--date-on-or-before-p
(list (nth 0 occ-start) (nth 1 occ-start) (nth 2 occ-start))
until-date)))))))
+(ert-deftest test-calendar-sync-property-until-bounds-daily-reaches-until ()
+ "Property: a daily series stepping by one day always reaches its UNTIL date.
+With interval 1 the recurrence is synchronized with any UNTIL, so the last
+instance must be UNTIL itself. This is the half the old exclusive property
+could never have caught -- it only bounded from above, so silently dropping
+the final occurrence satisfied it."
+ (dotimes (_ test-calendar-sync-property-trials)
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (until-days (+ 10 (random 60)))
+ (until-date (test-calendar-sync-time-date-only until-days))
+ (base-event (list :summary "Until Test" :start start-date))
+ (rrule (list :freq 'daily :interval 1 :until until-date))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range))
+ (last-start (plist-get (car (last occurrences)) :start)))
+ (should occurrences)
+ (should (equal (seq-take last-start 3) until-date)))))
+
(ert-deftest test-calendar-sync-property-until-bounds-weekly ()
- "Property: No weekly occurrence starts on or after UNTIL date."
+ "Property: no weekly occurrence starts after the UNTIL date.
+UNTIL is an inclusive bound (RFC 5545 3.3.10), so landing exactly on it is
+correct and only a later date violates the property."
(dotimes (_ test-calendar-sync-property-trials)
(let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
(until-days (+ 14 (random 60)))
- ;; UNTIL must be date-only (3 elements) for calendar-sync--before-date-p
(until-date (test-calendar-sync-time-date-only until-days))
(weekdays (test-calendar-sync-random-weekday-subset))
(base-event (list :summary "Until Test" :start start-date))
@@ -108,7 +136,7 @@ For any COUNT value N, expansion never produces more than N occurrences."
(occurrences (calendar-sync--expand-weekly base-event rrule range)))
(dolist (occ occurrences)
(let ((occ-start (plist-get occ :start)))
- (should (calendar-sync--before-date-p
+ (should (calendar-sync--date-on-or-before-p
(list (nth 0 occ-start) (nth 1 occ-start) (nth 2 occ-start))
until-date)))))))
diff --git a/tests/test-calendar-sync-source-fetch-sentinel.el b/tests/test-calendar-sync-source-fetch-sentinel.el
new file mode 100644
index 00000000..0b7ba1cf
--- /dev/null
+++ b/tests/test-calendar-sync-source-fetch-sentinel.el
@@ -0,0 +1,72 @@
+;;; test-calendar-sync-source-fetch-sentinel.el --- Tests for the fetch sentinel -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; calendar-sync--fetch-sentinel-finish is the extracted tail of the async
+;; .ics fetch sentinel. The async-worker tests stub the whole fetch, so its
+;; success, failure, and temp-file-cleanup branches were never exercised.
+;; These tests drive the helper directly with fake success/failure inputs,
+;; no live curl process.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'calendar-sync)
+
+(ert-deftest test-calendar-sync-fetch-sentinel-success-passes-temp-file ()
+ "Normal: on success the callback gets the temp file and it is not deleted."
+ (let ((temp-file (make-temp-file "calendar-sync-sentinel-" nil ".ics"))
+ (buffer (generate-new-buffer " *cs-test*"))
+ (got 'unset))
+ (unwind-protect
+ (progn
+ (calendar-sync--fetch-sentinel-finish
+ t "finished\n" temp-file buffer (lambda (r) (setq got r)))
+ (should (equal got temp-file))
+ (should (file-exists-p temp-file))
+ (should-not (buffer-live-p buffer)))
+ (when (file-exists-p temp-file) (delete-file temp-file))
+ (when (buffer-live-p buffer) (kill-buffer buffer)))))
+
+(ert-deftest test-calendar-sync-fetch-sentinel-failure-deletes-and-passes-nil ()
+ "Error: on failure the temp file is deleted and the callback gets nil."
+ (let ((temp-file (make-temp-file "calendar-sync-sentinel-" nil ".ics"))
+ (buffer (generate-new-buffer " *cs-test*"))
+ (got 'unset)
+ (logged nil))
+ (unwind-protect
+ (cl-letf (((symbol-function 'calendar-sync--log-silently)
+ (lambda (&rest _) (setq logged t))))
+ (calendar-sync--fetch-sentinel-finish
+ nil "exited abnormally with code 22\n" temp-file buffer
+ (lambda (r) (setq got r)))
+ (should (null got))
+ (should-not (file-exists-p temp-file))
+ (should logged)
+ (should-not (buffer-live-p buffer)))
+ (when (file-exists-p temp-file) (delete-file temp-file))
+ (when (buffer-live-p buffer) (kill-buffer buffer)))))
+
+(ert-deftest test-calendar-sync-fetch-sentinel-failure-tolerates-missing-temp-file ()
+ "Boundary: failure with the temp file already gone does not error."
+ (let ((temp-file (make-temp-file "calendar-sync-sentinel-" nil ".ics"))
+ (got 'unset))
+ (delete-file temp-file)
+ (cl-letf (((symbol-function 'calendar-sync--log-silently) #'ignore))
+ (calendar-sync--fetch-sentinel-finish
+ nil "failed\n" temp-file nil (lambda (r) (setq got r)))
+ (should (null got)))))
+
+(ert-deftest test-calendar-sync-fetch-sentinel-tolerates-dead-buffer ()
+ "Boundary: a already-dead process buffer is not touched on success."
+ (let ((temp-file (make-temp-file "calendar-sync-sentinel-" nil ".ics"))
+ (got 'unset))
+ (unwind-protect
+ (progn
+ (calendar-sync--fetch-sentinel-finish
+ t "finished\n" temp-file nil (lambda (r) (setq got r)))
+ (should (equal got temp-file)))
+ (when (file-exists-p temp-file) (delete-file temp-file)))))
+
+(provide 'test-calendar-sync-source-fetch-sentinel)
+;;; test-calendar-sync-source-fetch-sentinel.el ends here
diff --git a/tests/test-config-utilities--recompile-emacs-home.el b/tests/test-config-utilities--recompile-emacs-home.el
index 18d17f96..da364e24 100644
--- a/tests/test-config-utilities--recompile-emacs-home.el
+++ b/tests/test-config-utilities--recompile-emacs-home.el
@@ -81,20 +81,29 @@ Returns the temp dir path."
(should-not (file-exists-p (expand-file-name "sub/c.elc" dir))))
(delete-directory dir t))))
-(ert-deftest test-config-utilities-recompile-removes-eln-dir-on-native-path ()
- "Boundary: the native path removes the eln cache directory when present."
+(ert-deftest test-config-utilities-recompile-removes-eln-cache-dir-on-native-path ()
+ "Boundary: the native path removes the eln-cache directory when present.
+The native cache is eln-cache/, not eln/, so that is the directory to clear."
(let ((dir (test-config-utilities--make-recompile-fixture))
- (eln-dir nil))
+ (eln-cache-dir nil))
(unwind-protect
(progn
- (setq eln-dir (expand-file-name "eln" dir))
- (make-directory eln-dir)
- (with-temp-file (expand-file-name "stale.eln" eln-dir) (insert ""))
+ (setq eln-cache-dir (expand-file-name "eln-cache" dir))
+ (make-directory eln-cache-dir)
+ (with-temp-file (expand-file-name "stale.eln" eln-cache-dir) (insert ""))
(cl-letf (((symbol-function 'native-compile-async) (lambda (&rest _) nil)))
(cj/--recompile-emacs-home dir t))
- (should-not (file-exists-p eln-dir)))
+ (should-not (file-exists-p eln-cache-dir)))
(delete-directory dir t))))
+(ert-deftest test-config-utilities-native-comp-detection-not-boundp ()
+ "Regression: native-comp detection must not test `boundp' of the async
+function -- native-compile-async is a function, so `boundp' is always nil and
+native compilation would never be selected. On a native-comp build, detection
+returns non-nil."
+ (when (and (fboundp 'native-comp-available-p) (native-comp-available-p))
+ (should (cj/--native-comp-p))))
+
(ert-deftest test-config-utilities-recompile-removes-elc-dir-on-byte-path ()
"Boundary: the byte path removes the elc cache directory when present."
(let ((dir (test-config-utilities--make-recompile-fixture))
diff --git a/tests/test-custom-buffer-file--view-email-in-buffer.el b/tests/test-custom-buffer-file--view-email-in-buffer.el
index 99e0e44d..b0209b78 100644
--- a/tests/test-custom-buffer-file--view-email-in-buffer.el
+++ b/tests/test-custom-buffer-file--view-email-in-buffer.el
@@ -13,6 +13,7 @@
;;; Code:
(require 'ert)
+(require 'cl-lib)
(require 'testutil-general)
(require 'custom-buffer-file)
@@ -233,5 +234,24 @@ Note: shr may insert newlines between words for wrapping."
(kill-buffer)))
(test-email--teardown)))
+(ert-deftest test-custom-buffer-file--view-email-no-displayable-destroys-handle ()
+ "Error: the MIME handle is destroyed even when no displayable part is found.
+The `user-error' fires before cleanup, so without `unwind-protect' the dissected
+handle leaks."
+ (test-email--setup)
+ (unwind-protect
+ (let ((eml-file (test-email--create-eml-file test-email--image-only))
+ (destroy-called nil))
+ (with-current-buffer (find-file-noselect eml-file)
+ (let ((real (symbol-function 'mm-destroy-parts)))
+ (cl-letf (((symbol-function 'mm-destroy-parts)
+ (lambda (handle)
+ (setq destroy-called t)
+ (funcall real handle))))
+ (should-error (cj/view-email-in-buffer) :type 'user-error)))
+ (should destroy-called)
+ (kill-buffer)))
+ (test-email--teardown)))
+
(provide 'test-custom-buffer-file--view-email-in-buffer)
;;; test-custom-buffer-file--view-email-in-buffer.el ends here
diff --git a/tests/test-custom-buffer-file-copy-link-to-buffer-file.el b/tests/test-custom-buffer-file-copy-link-to-buffer-file.el
index 262968d6..5ee57b3a 100644
--- a/tests/test-custom-buffer-file-copy-link-to-buffer-file.el
+++ b/tests/test-custom-buffer-file-copy-link-to-buffer-file.el
@@ -4,7 +4,8 @@
;; Tests for the cj/copy-link-to-buffer-file function from custom-buffer-file.el
;;
;; This function copies the full file:// path of the current buffer's file to
-;; the kill ring. For non-file buffers, it does nothing (no error).
+;; the kill ring. For non-file buffers, it signals a user-error, matching its
+;; sibling copy commands.
;;; Code:
@@ -58,12 +59,12 @@
(test-copy-link-teardown)))
(ert-deftest test-copy-link-non-file-buffer ()
- "Should do nothing for non-file buffer without error."
+ "Error: a non-file buffer signals `user-error' and leaves the kill ring alone."
(test-copy-link-setup)
(unwind-protect
(with-temp-buffer
(setq kill-ring nil)
- (cj/copy-link-to-buffer-file)
+ (should-error (cj/copy-link-to-buffer-file) :type 'user-error)
(should (null kill-ring)))
(test-copy-link-teardown)))
@@ -195,13 +196,13 @@
(test-copy-link-teardown)))
(ert-deftest test-copy-link-scratch-buffer ()
- "Should do nothing for *scratch* buffer."
+ "Error: the *scratch* buffer (no file) signals `user-error'."
(test-copy-link-setup)
(unwind-protect
(progn
(setq kill-ring nil)
(with-current-buffer "*scratch*"
- (cj/copy-link-to-buffer-file)
+ (should-error (cj/copy-link-to-buffer-file) :type 'user-error)
(should (null kill-ring))))
(test-copy-link-teardown)))
diff --git a/tests/test-custom-case-title-case-region.el b/tests/test-custom-case-title-case-region.el
index 383ae927..82b4966a 100644
--- a/tests/test-custom-case-title-case-region.el
+++ b/tests/test-custom-case-title-case-region.el
@@ -60,16 +60,17 @@ an active region, and return the result."
"The Art of War")))
(ert-deftest test-custom-case-title-case-region-normal-all-minor-words ()
- "All minor words in the skip list should be lowercased in mid-sentence."
+ "All minor words in the skip list should be lowercased in mid-sentence.
+\"is\" is a linking verb, so it is a major word and is capitalized."
(should (equal (test-title-case--on-string
"go a an and as at but by for if in is nor of on or so the to yet go")
- "Go a an and as at but by for if in is nor of on or so the to yet Go")))
+ "Go a an and as at but by for if in Is nor of on or so the to yet Go")))
(ert-deftest test-custom-case-title-case-region-normal-four-letter-words-capitalized ()
"Words of four or more letters should always be capitalized.
-Note: 'is' is explicitly in the minor word list, so it stays lowercase."
+\"is\" is a linking verb (a major word), so it is capitalized too."
(should (equal (test-title-case--on-string "this is from that with over")
- "This is From That With Over")))
+ "This Is From That With Over")))
(ert-deftest test-custom-case-title-case-region-normal-allcaps-input ()
"All-caps input should be downcased first, then title-cased."
@@ -94,7 +95,16 @@ Note: 'is' is explicitly in the minor word list, so it stays lowercase."
(ert-deftest test-custom-case-title-case-region-normal-question-resets ()
"Word immediately after a question mark should be capitalized, even if minor."
(should (equal (test-title-case--on-string "really? the answer is no")
- "Really? The Answer is No")))
+ "Really? The Answer Is No")))
+
+(ert-deftest test-custom-case-title-case-region-normal-last-word-capitalized ()
+ "The last word is always capitalized, even a minor one."
+ (should (equal (test-title-case--on-string "the art of") "The Art Of")))
+
+(ert-deftest test-custom-case-title-case-region-normal-period-restarts ()
+ "A word after a sentence-ending period should be capitalized."
+ (should (equal (test-title-case--on-string "one. the next sentence")
+ "One. The Next Sentence")))
(ert-deftest test-custom-case-title-case-region-normal-hyphenated-word ()
"Second part of a hyphenated word should NOT be capitalized."
@@ -141,7 +151,7 @@ Note: 'is' is explicitly in the minor word list, so it stays lowercase."
(ert-deftest test-custom-case-title-case-region-boundary-unicode-words ()
"Unicode characters should pass through without error."
(should (equal (test-title-case--on-string "the café is nice")
- "The Café is Nice")))
+ "The Café Is Nice")))
(ert-deftest test-custom-case-title-case-region-boundary-numbers-in-text ()
"Numbers mixed with text should not break title casing."
diff --git a/tests/test-custom-comments-comment-inline-border.el b/tests/test-custom-comments-comment-inline-border.el
index 78e86035..305a2c7a 100644
--- a/tests/test-custom-comments-comment-inline-border.el
+++ b/tests/test-custom-comments-comment-inline-border.el
@@ -120,6 +120,16 @@ Returns the buffer string for assertions."
(let ((result (test-inline-border-at-column 0 ";;" "" "=" "" 10)))
(should (string-match-p ";" result))))
+(ert-deftest test-inline-border-elisp-fills-exact-width-all-parities ()
+ "Boundary: even, odd, and empty text all fill LENGTH exactly.
+Even-length and empty text used to come out two columns short because the
+right decoration count keyed off text-length parity instead of the remaining
+width, so stacked dividers of differing text lengths misaligned."
+ (dolist (text '("" "X" "EVEN" "ODD" "Header"))
+ (let* ((result (test-inline-border-at-column 0 ";;" "" "=" text 50))
+ (line (string-trim-right result "\n")))
+ (should (= 50 (length line))))))
+
(ert-deftest test-inline-border-elisp-text-centering-even ()
"Should center text properly with even length."
(let ((result (test-inline-border-at-column 0 ";;" "" "=" "EVEN" 70)))
diff --git a/tests/test-custom-comments-comment-reformat.el b/tests/test-custom-comments-comment-reformat.el
index 83248aee..91b7dfe3 100644
--- a/tests/test-custom-comments-comment-reformat.el
+++ b/tests/test-custom-comments-comment-reformat.el
@@ -146,19 +146,12 @@ Insert CONTENT-BEFORE, select all, run cj/comment-reformat, verify EXPECTED-AFTE
(should (string-match-p ";; Start line 1.*Start line 2" (buffer-string)))))
(ert-deftest test-comment-reformat-elisp-no-region-active ()
- "Should show message when no region selected."
+ "Should signal `user-error' when no region is selected."
(with-temp-buffer
(emacs-lisp-mode)
(insert ";; Comment line")
(deactivate-mark)
- (let ((message-log-max nil)
- (messages '()))
- ;; Capture messages
- (cl-letf (((symbol-function 'message)
- (lambda (format-string &rest args)
- (push (apply #'format format-string args) messages))))
- (cj/comment-reformat)
- (should (string-match-p "No region was selected" (car messages)))))))
+ (should-error (cj/comment-reformat) :type 'user-error)))
(ert-deftest test-comment-reformat-elisp-read-only-buffer ()
"Should signal error in read-only buffer."
diff --git a/tests/test-custom-datetime-all-methods.el b/tests/test-custom-datetime-all-methods.el
index 62b421bd..1f31c6c4 100644
--- a/tests/test-custom-datetime-all-methods.el
+++ b/tests/test-custom-datetime-all-methods.el
@@ -54,9 +54,10 @@
(should (string-match-p "14:30:45" result))))
(ert-deftest test-custom-datetime-all-methods-normal-sortable-time ()
- "cj/insert-sortable-time should insert time with AM/PM and timezone."
+ "cj/insert-sortable-time should insert 24-hour time so it sorts lexically."
(let ((result (test-datetime--run #'cj/insert-sortable-time)))
- (should (string-match-p "02:30:45 PM" result))))
+ (should (string-match-p "14:30:45" result))
+ (should-not (string-match-p "PM" result))))
(ert-deftest test-custom-datetime-all-methods-normal-readable-time ()
"cj/insert-readable-time should insert short time with AM/PM."
diff --git a/tests/test-custom-line-paragraph-duplicate-line-or-region.el b/tests/test-custom-line-paragraph-duplicate-line-or-region.el
index 84f5bc2d..9e501086 100644
--- a/tests/test-custom-line-paragraph-duplicate-line-or-region.el
+++ b/tests/test-custom-line-paragraph-duplicate-line-or-region.el
@@ -327,6 +327,40 @@
(should (> (length (buffer-string)) (length "line one\nline two\nline three"))))
(test-duplicate-line-or-region-teardown)))
+(ert-deftest test-duplicate-line-or-region-mid-line-bounds-duplicate-whole-lines ()
+ "A region ending mid-line duplicates every whole line it touches, no splits.
+The old open-line loop split the mid-line-ending line instead."
+ (test-duplicate-line-or-region-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "aaa\nbbb\nccc")
+ (transient-mark-mode 1)
+ (goto-char (point-min))
+ (forward-char 1) ; mid first line
+ (set-mark (point))
+ (forward-line 1)
+ (forward-char 2) ; mid second line
+ (activate-mark)
+ (cj/duplicate-line-or-region)
+ (should (string= "aaa\nbbb\naaa\nbbb\nccc" (buffer-string))))
+ (test-duplicate-line-or-region-teardown)))
+
+(ert-deftest test-duplicate-line-or-region-ends-at-bol-no-extra-empty-line ()
+ "A region ending at beginning-of-line duplicates only the fully-included lines.
+The old open-line loop duplicated a stray empty line here."
+ (test-duplicate-line-or-region-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "aaa\nbbb\nccc")
+ (transient-mark-mode 1)
+ (goto-char (point-min))
+ (set-mark (point))
+ (forward-line 2) ; region "aaa\nbbb\n", ends at bol of ccc
+ (activate-mark)
+ (cj/duplicate-line-or-region)
+ (should (string= "aaa\nbbb\naaa\nbbb\nccc" (buffer-string))))
+ (test-duplicate-line-or-region-teardown)))
+
(ert-deftest test-duplicate-line-or-region-trailing-whitespace ()
"Should preserve trailing whitespace."
(test-duplicate-line-or-region-setup)
diff --git a/tests/test-custom-line-paragraph-join-line-or-region.el b/tests/test-custom-line-paragraph-join-line-or-region.el
index f8738910..5d421683 100644
--- a/tests/test-custom-line-paragraph-join-line-or-region.el
+++ b/tests/test-custom-line-paragraph-join-line-or-region.el
@@ -62,8 +62,8 @@
(should (string-match-p "line one line two" (buffer-string))))
(test-join-line-or-region-teardown)))
-(ert-deftest test-join-line-or-region-no-region-adds-newline-after-join ()
- "Without region, should add newline after joining."
+(ert-deftest test-join-line-or-region-no-region-adds-newline-at-end-of-buffer ()
+ "Without region, joining the last line adds a trailing newline at end of buffer."
(test-join-line-or-region-setup)
(unwind-protect
(with-temp-buffer
@@ -73,6 +73,20 @@
(should (string-suffix-p "\n" (buffer-string))))
(test-join-line-or-region-teardown)))
+(ert-deftest test-join-line-or-region-no-region-mid-buffer-no-blank-line ()
+ "Without region, joining a non-last line must not insert a blank line.
+The trailing newline belongs only at end of buffer; adding it unconditionally
+left a stray blank line between the joined line and the rest of the buffer."
+ (test-join-line-or-region-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "line one\nline two\nline three")
+ (goto-char (point-min))
+ (forward-line 1) ; point on "line two", not the last line
+ (cj/join-line-or-region)
+ (should (string= "line one line two\nline three" (buffer-string))))
+ (test-join-line-or-region-teardown)))
+
(ert-deftest test-join-line-or-region-with-region-joins-all-lines ()
"With region, should join all lines in region."
(test-join-line-or-region-setup)
diff --git a/tests/test-custom-line-paragraph-jump-to-matching-paren.el b/tests/test-custom-line-paragraph-jump-to-matching-paren.el
index 31853da6..bd24faed 100644
--- a/tests/test-custom-line-paragraph-jump-to-matching-paren.el
+++ b/tests/test-custom-line-paragraph-jump-to-matching-paren.el
@@ -83,11 +83,11 @@ POINT-POSITION is 1-indexed (1 = first character)."
;;; Normal Cases - Backward Jump (Closing to Opening)
(ert-deftest test-jump-paren-backward-simple ()
- "Should jump backward from closing paren to opening paren."
+ "Should jump from a closing paren to its matching opening paren."
;; Text: "(hello)"
;; Start at position 7 (on closing paren)
- ;; Should end at position 2 (after opening paren)
- (should (= 2 (test-jump-to-matching-paren "(hello)" 7))))
+ ;; Should end at position 1 (the matching opening paren)
+ (should (= 1 (test-jump-to-matching-paren "(hello)" 7))))
(ert-deftest test-jump-paren-backward-nested ()
"Should jump backward over nested parens from after outer closing."
@@ -97,11 +97,11 @@ POINT-POSITION is 1-indexed (1 = first character)."
(should (= 1 (test-jump-to-matching-paren "(foo (bar))" 12))))
(ert-deftest test-jump-paren-backward-inner-nested ()
- "Should jump backward from inner closing paren."
+ "Should jump from an inner closing paren to its matching inner opener."
;; Text: "(foo (bar))"
;; Start at position 10 (on inner closing paren)
- ;; Should end at position 7 (after inner opening paren)
- (should (= 7 (test-jump-to-matching-paren "(foo (bar))" 10))))
+ ;; Should end at position 6 (the matching inner opening paren)
+ (should (= 6 (test-jump-to-matching-paren "(foo (bar))" 10))))
(ert-deftest test-jump-bracket-backward ()
"Should jump backward from after closing bracket."
@@ -145,11 +145,11 @@ POINT-POSITION is 1-indexed (1 = first character)."
(should (= 1 (test-jump-to-matching-paren "(hello" 1))))
(ert-deftest test-jump-paren-unmatched-closing ()
- "Should move to beginning from unmatched closing paren."
+ "Should stay put on an unmatched closing paren (no matching opener)."
;; Text: "hello)"
;; Start at position 6 (on closing paren with no opening)
- ;; backward-sexp with unmatched closing paren goes to beginning
- (should (= 1 (test-jump-to-matching-paren "hello)" 6))))
+ ;; There is no matching opener, so point is restored and stays at 6
+ (should (= 6 (test-jump-to-matching-paren "hello)" 6))))
;;; Boundary Cases - Empty Delimiters
@@ -161,11 +161,11 @@ POINT-POSITION is 1-indexed (1 = first character)."
(should (= 3 (test-jump-to-matching-paren "()" 1))))
(ert-deftest test-jump-paren-empty-backward ()
- "Should stay put when on closing paren of empty parens."
+ "Should jump from the closing paren of empty parens to its opener."
;; Text: "()"
;; Start at position 2 (on closing paren)
- ;; backward-sexp from closing of empty parens gives an error, so stays at 2
- (should (= 2 (test-jump-to-matching-paren "()" 2))))
+ ;; Should end at position 1 (the matching opening paren)
+ (should (= 1 (test-jump-to-matching-paren "()" 2))))
;;; Boundary Cases - Multiple Delimiter Types
diff --git a/tests/test-custom-ordering-number-lines.el b/tests/test-custom-ordering-number-lines.el
index adda84f0..142e5561 100644
--- a/tests/test-custom-ordering-number-lines.el
+++ b/tests/test-custom-ordering-number-lines.el
@@ -122,9 +122,10 @@ Returns the transformed string."
(should (string= result "1. "))))
(ert-deftest test-number-lines-empty-lines ()
- "Should number empty lines."
+ "Should number empty lines, treating the final newline as a terminator.
+The old split counted the trailing newline as a spurious third line."
(let ((result (test-number-lines "\n\n" "N. " nil)))
- (should (string= result "1. \n2. \n3. "))))
+ (should (string= result "1. \n2. \n"))))
(ert-deftest test-number-lines-with-existing-numbers ()
"Should number lines that already have content."
diff --git a/tests/test-custom-ordering-reverse-lines.el b/tests/test-custom-ordering-reverse-lines.el
index 3c71362d..5b8c01ac 100644
--- a/tests/test-custom-ordering-reverse-lines.el
+++ b/tests/test-custom-ordering-reverse-lines.el
@@ -86,9 +86,11 @@ Returns the transformed string."
(should (string= result "b\n\na"))))
(ert-deftest test-reverse-lines-trailing-newline ()
- "Should handle trailing newline."
+ "Should reverse the lines and preserve the trailing newline.
+The old split dropped the trailing newline into a leading empty line,
+producing \"\\nline2\\nline1\"."
(let ((result (test-reverse-lines "line1\nline2\n")))
- (should (string= result "\nline2\nline1"))))
+ (should (string= result "line2\nline1\n"))))
(ert-deftest test-reverse-lines-only-newlines ()
"Should reverse lines that are only newlines."
diff --git a/tests/test-custom-text-enclose-indent.el b/tests/test-custom-text-enclose-indent.el
index e9042d35..f37d1800 100644
--- a/tests/test-custom-text-enclose-indent.el
+++ b/tests/test-custom-text-enclose-indent.el
@@ -43,6 +43,35 @@ Returns the transformed string."
Returns the transformed string."
(cj/--dedent-lines text count))
+;;; Interactive default resolution (the prefix-arg decoupling fix)
+
+(ert-deftest test-indent-lines-interactive-no-prefix-is-four-spaces ()
+ "Interactive: no prefix indents by 4, spaces when `indent-tabs-mode' is nil.
+The old \"p\\nP\" spec defaulted count to 1 and forced tabs on any prefix."
+ (with-temp-buffer
+ (setq-local indent-tabs-mode nil)
+ (insert "line")
+ (let ((current-prefix-arg nil))
+ (call-interactively #'cj/indent-lines-in-region-or-buffer))
+ (should (string= " line" (buffer-string)))))
+
+(ert-deftest test-indent-lines-interactive-follows-indent-tabs-mode ()
+ "Interactive: tabs-vs-spaces follows `indent-tabs-mode', not the prefix arg."
+ (with-temp-buffer
+ (setq-local indent-tabs-mode t)
+ (insert "line")
+ (let ((current-prefix-arg nil))
+ (call-interactively #'cj/indent-lines-in-region-or-buffer))
+ (should (string= "\t\t\t\tline" (buffer-string)))))
+
+(ert-deftest test-dedent-lines-interactive-no-prefix-is-four ()
+ "Interactive: no prefix removes up to 4 leading whitespace characters."
+ (with-temp-buffer
+ (insert " line") ; eight leading spaces
+ (let ((current-prefix-arg nil))
+ (call-interactively #'cj/dedent-lines-in-region-or-buffer))
+ (should (string= " line" (buffer-string)))))
+
;;; Indent Tests - Normal Cases with Spaces
(ert-deftest test-indent-single-line-4-spaces ()
diff --git a/tests/test-dashboard-config-launchers.el b/tests/test-dashboard-config-launchers.el
index 53c46caa..76fbcc42 100644
--- a/tests/test-dashboard-config-launchers.el
+++ b/tests/test-dashboard-config-launchers.el
@@ -28,20 +28,21 @@
;; Telegram moved from "g" to "G" so "g" is free for dashboard refresh.
;; Signal ("S") added as the 14th launcher.
;; Weather ("w") added after Agenda as the 15th launcher (top-row daily glance).
-(defconst test-dash--keys '("c" "d" "t" "a" "w" "r" "b" "f" "m" "e" "i" "G" "s" "l" "S"))
+(defconst test-dash--keys '("c" "d" "t" "a" "w" "r" "b" "f" "m" "e" "i" "G" "s" "l"))
;; ----------------------------- launcher table --------------------------------
(ert-deftest test-dashboard-launchers-keys-in-order ()
- "Normal: 15 launchers with the expected keys in display order."
- (should (= 15 (length cj/dashboard--launchers)))
+ "Normal: 14 launchers with the expected keys in display order.
+(Signal left the table when the signel client was retired to archive/.)"
+ (should (= 14 (length cj/dashboard--launchers)))
(should (equal test-dash--keys (mapcar (lambda (l) (nth 0 l)) cj/dashboard--launchers))))
(ert-deftest test-dashboard-launchers-labels-in-order ()
"Normal: labels in display order (Telegram and Slack reordered so Slack sits
next to Linear on the last navigator row)."
(should (equal '("Code" "Files" "Terminal" "Agenda" "Weather" "Feeds" "Books"
- "Flashcards" "Music" "Email" "IRC" "Telegram" "Slack" "Linear" "Signal")
+ "Flashcards" "Music" "Email" "IRC" "Telegram" "Slack" "Linear")
(mapcar (lambda (l) (nth 3 l)) cj/dashboard--launchers))))
(ert-deftest test-dashboard-row-sizes-cover-all-launchers ()
@@ -51,9 +52,9 @@ next to Linear on the last navigator row)."
;; --------------------------- navigator rows ----------------------------------
-(ert-deftest test-dashboard-navigator-rows-grouped-5-4-3-3 ()
- "Normal: navigator derives rows per `cj/dashboard--row-sizes' (5 4 3 3), with
-Weather joining the top row and Slack, Linear, and Signal sharing the last row."
+(ert-deftest test-dashboard-navigator-rows-grouped-5-4-3-2 ()
+ "Normal: navigator derives rows per `cj/dashboard--row-sizes' (5 4 3 2), with
+Weather joining the top row and Slack and Linear pairing on the last row."
(cl-letf (((symbol-function 'nerd-icons-faicon) (lambda (n &rest _) (concat "I:" n)))
((symbol-function 'nerd-icons-devicon) (lambda (n &rest _) (concat "I:" n)))
((symbol-function 'nerd-icons-mdicon) (lambda (n &rest _) (concat "I:" n)))
@@ -62,10 +63,10 @@ Weather joining the top row and Slack, Linear, and Signal sharing the last row."
((symbol-function 'nerd-icons-wicon) (lambda (n &rest _) (concat "I:" n))))
(let ((rows (cj/dashboard--navigator-rows)))
(should (= 4 (length rows)))
- (should (equal '(5 4 3 3) (mapcar #'length rows)))
+ (should (equal '(5 4 3 2) (mapcar #'length rows)))
(should (equal '("Code" "Files" "Terminal" "Agenda" "Weather")
(mapcar (lambda (b) (nth 1 b)) (nth 0 rows))))
- (should (equal '("Slack" "Linear" "Signal")
+ (should (equal '("Slack" "Linear")
(mapcar (lambda (b) (nth 1 b)) (nth 3 rows))))
(let ((btn (car (car rows)))) ; (icon label tooltip action nil " " "")
(should (string= "I:nf-fa-code" (nth 0 btn)))
@@ -100,7 +101,6 @@ Weather joining the top row and Slack, Linear, and Signal sharing the last row."
((symbol-function 'cj/slack-start) (lambda (&rest _) (push 'slack calls)))
((symbol-function 'cj/telega) (lambda (&rest _) (push 'tg calls)))
((symbol-function 'pearl-list-issues) (lambda (&rest _) (push 'linear calls)))
- ((symbol-function 'cj/signel-message) (lambda (&rest _) (push 'signal calls)))
;; wttrin is invoked via `call-interactively', so the stub must be
;; a command -- a plain variadic lambda masked the real arity bug.
((symbol-function 'wttrin) (lambda (&rest _) (interactive) (push 'weather calls))))
@@ -112,9 +112,8 @@ Weather joining the top row and Slack, Linear, and Signal sharing the last row."
(should (memq 'linear calls))
(should (memq 'm-toggle calls))
(should (memq 'm-load calls))
- (should (memq 'signal calls))
(should (memq 'weather calls))
- (should (= 16 (length calls)))))) ; 15 keys, Music fires two
+ (should (= 15 (length calls)))))) ; 14 keys, Music fires two
(provide 'test-dashboard-config-launchers)
;;; test-dashboard-config-launchers.el ends here
diff --git a/tests/test-dev-fkeys--f4-clean-rebuild-impl.el b/tests/test-dev-fkeys--f4-clean-rebuild-impl.el
index 27c7c56a..bed51d79 100644
--- a/tests/test-dev-fkeys--f4-clean-rebuild-impl.el
+++ b/tests/test-dev-fkeys--f4-clean-rebuild-impl.el
@@ -3,7 +3,11 @@
;;; Commentary:
;; Tests for the "Clean + Rebuild" action handler. Runs the heuristic clean
;; command via `compile' from the project root, then chains
-;; `projectile-compile-project' on success via the one-shot finish hook.
+;; `projectile-compile-project' on success via a one-shot finish hook
+;; installed buffer-locally in the compilation buffer `compile' returns.
+;; The global `compilation-finish-functions' is never touched, so a quit
+;; before the compile starts or an unrelated concurrent compile can never
+;; fire the chained rebuild.
;;; Code:
@@ -24,6 +28,18 @@ Bind the dir path to ROOT in BODY. Cleans up on exit."
,@body)
(delete-directory root t))))
+(defmacro test-dev-fkeys-cr--with-compilation-buffer (buf &rest body)
+ "Run BODY with BUF bound to a temp buffer standing in for a compilation buffer."
+ (declare (indent 1))
+ `(let ((,buf (generate-new-buffer " *test-compilation*")))
+ (unwind-protect
+ (progn ,@body)
+ (kill-buffer ,buf))))
+
+(defun test-dev-fkeys-cr--local-hooks (buf)
+ "Return the buffer-local finish hooks of BUF, without the t marker."
+ (remq t (buffer-local-value 'compilation-finish-functions buf)))
+
;;; Normal Cases
(ert-deftest test-dev-fkeys-clean-rebuild-impl-runs-derived-clean-cmd ()
@@ -34,48 +50,50 @@ Components integrated:
- `cj/--f4-clean-rebuild-impl' (unit under test)
- `cj/--f4-derive-clean-cmd' (real)
- `compile' (MOCKED — captures the command string)
-- `projectile-compile-project' (MOCKED — no-op)
-- `compilation-finish-functions' (real, scoped via let)"
+- `projectile-compile-project' (MOCKED — no-op)"
(test-dev-fkeys-cr--with-project '("Makefile")
- (let ((compile-calls nil)
- (compilation-finish-functions nil))
+ (let ((compile-calls nil))
(cl-letf (((symbol-function 'compile)
- (lambda (cmd) (push cmd compile-calls)))
+ (lambda (cmd) (push cmd compile-calls) nil))
((symbol-function 'projectile-compile-project)
(lambda (_arg) nil)))
(cj/--f4-clean-rebuild-impl root)
(should (equal compile-calls '("make clean")))))))
-(ert-deftest test-dev-fkeys-clean-rebuild-impl-installs-finish-hook ()
- "Normal: handler installs exactly one hook in `compilation-finish-functions'."
+(ert-deftest test-dev-fkeys-clean-rebuild-impl-installs-hook-in-compilation-buffer ()
+ "Normal: the one-shot hook lands buffer-locally in the buffer `compile'
+returns; the global `compilation-finish-functions' stays untouched."
(test-dev-fkeys-cr--with-project '("go.mod")
- (let ((compilation-finish-functions nil))
- (cl-letf (((symbol-function 'compile) (lambda (_cmd) nil))
- ((symbol-function 'projectile-compile-project)
- (lambda (_arg) nil)))
- (cj/--f4-clean-rebuild-impl root)
- (should (= (length compilation-finish-functions) 1))))))
+ (test-dev-fkeys-cr--with-compilation-buffer buf
+ (let ((compilation-finish-functions nil))
+ (cl-letf (((symbol-function 'compile) (lambda (_cmd) buf))
+ ((symbol-function 'projectile-compile-project)
+ (lambda (_arg) nil)))
+ (cj/--f4-clean-rebuild-impl root)
+ (should (null compilation-finish-functions))
+ (should (= 1 (length (test-dev-fkeys-cr--local-hooks buf)))))))))
(ert-deftest test-dev-fkeys-clean-rebuild-impl-hook-runs-projectile-compile-on-success ()
- "Normal: when the clean step finishes successfully, the installed hook
+ "Normal: when the clean step finishes successfully, the buffer-local hook
calls `projectile-compile-project' to do the rebuild."
(test-dev-fkeys-cr--with-project '("Cargo.toml")
- (let ((compile-calls 0)
- (compilation-finish-functions nil))
- (cl-letf (((symbol-function 'compile) (lambda (_cmd) nil))
- ((symbol-function 'projectile-compile-project)
- (lambda (_arg) (cl-incf compile-calls))))
- (cj/--f4-clean-rebuild-impl root)
- (run-hook-with-args 'compilation-finish-functions nil "finished\n")
- (should (= compile-calls 1))))))
+ (test-dev-fkeys-cr--with-compilation-buffer buf
+ (let ((compile-calls 0)
+ (compilation-finish-functions nil))
+ (cl-letf (((symbol-function 'compile) (lambda (_cmd) buf))
+ ((symbol-function 'projectile-compile-project)
+ (lambda (_arg) (cl-incf compile-calls))))
+ (cj/--f4-clean-rebuild-impl root)
+ (with-current-buffer buf
+ (run-hook-with-args 'compilation-finish-functions buf "finished\n"))
+ (should (= compile-calls 1)))))))
(ert-deftest test-dev-fkeys-clean-rebuild-impl-runs-clean-from-project-root ()
"Normal: the clean compile runs with default-directory bound to ROOT."
(test-dev-fkeys-cr--with-project '("Eask")
- (let ((seen-dir nil)
- (compilation-finish-functions nil))
+ (let ((seen-dir nil))
(cl-letf (((symbol-function 'compile)
- (lambda (_cmd) (setq seen-dir default-directory)))
+ (lambda (_cmd) (setq seen-dir default-directory) nil))
((symbol-function 'projectile-compile-project)
(lambda (_arg) nil)))
(cj/--f4-clean-rebuild-impl root)
@@ -87,14 +105,28 @@ calls `projectile-compile-project' to do the rebuild."
(ert-deftest test-dev-fkeys-clean-rebuild-impl-hook-skips-rebuild-on-failure ()
"Boundary: when the clean step fails, projectile-compile-project does not run."
(test-dev-fkeys-cr--with-project '("Makefile")
- (let ((compile-calls 0)
- (compilation-finish-functions nil))
+ (test-dev-fkeys-cr--with-compilation-buffer buf
+ (let ((compile-calls 0)
+ (compilation-finish-functions nil))
+ (cl-letf (((symbol-function 'compile) (lambda (_cmd) buf))
+ ((symbol-function 'projectile-compile-project)
+ (lambda (_arg) (cl-incf compile-calls))))
+ (cj/--f4-clean-rebuild-impl root)
+ (with-current-buffer buf
+ (run-hook-with-args 'compilation-finish-functions
+ buf "exited abnormally\n"))
+ (should (= compile-calls 0)))))))
+
+(ert-deftest test-dev-fkeys-clean-rebuild-impl-dead-compile-buffer-no-global-hook ()
+ "Boundary: when `compile' returns no live buffer, nothing is installed
+anywhere — the global hook list stays empty."
+ (test-dev-fkeys-cr--with-project '("Makefile")
+ (let ((compilation-finish-functions nil))
(cl-letf (((symbol-function 'compile) (lambda (_cmd) nil))
((symbol-function 'projectile-compile-project)
- (lambda (_arg) (cl-incf compile-calls))))
+ (lambda (_arg) nil)))
(cj/--f4-clean-rebuild-impl root)
- (run-hook-with-args 'compilation-finish-functions nil "exited abnormally\n")
- (should (= compile-calls 0))))))
+ (should (null compilation-finish-functions))))))
;;; Error Cases
diff --git a/tests/test-dev-fkeys--f4-compile-and-run-impl.el b/tests/test-dev-fkeys--f4-compile-and-run-impl.el
index d59a6cd6..34e5bdf3 100644
--- a/tests/test-dev-fkeys--f4-compile-and-run-impl.el
+++ b/tests/test-dev-fkeys--f4-compile-and-run-impl.el
@@ -2,8 +2,11 @@
;;; Commentary:
;; Tests for the "Compile + Run" action handler. After kicking off the
-;; compile, attaches a one-shot `compilation-finish-functions' hook that
-;; runs the project on success.
+;; compile, attaches a one-shot finish hook buffer-locally in the
+;; compilation buffer projectile returns, so the global
+;; `compilation-finish-functions' is never touched. A quit at
+;; projectile's compile prompt therefore can never leave an armed hook
+;; that a later unrelated compile would fire.
;;; Code:
@@ -12,6 +15,14 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'dev-fkeys)
+(defmacro test-dev-fkeys-car--with-buffer (buf &rest body)
+ "Run BODY with BUF bound to a temp buffer standing in for a compilation buffer."
+ (declare (indent 1))
+ `(let ((,buf (generate-new-buffer " *test-compilation*")))
+ (unwind-protect
+ (progn ,@body)
+ (kill-buffer ,buf))))
+
;;; Normal Cases
(ert-deftest test-dev-fkeys-compile-and-run-impl-invokes-projectile-compile ()
@@ -19,57 +30,77 @@
Components integrated:
- `cj/--f4-compile-and-run-impl' (unit under test)
-- `projectile-compile-project' (MOCKED via cl-letf)
-- `compilation-finish-functions' (real, scoped via let)"
- (let ((compile-calls 0)
- (compilation-finish-functions nil))
+- `projectile-compile-project' (MOCKED via cl-letf)"
+ (let ((compile-calls 0))
(cl-letf (((symbol-function 'projectile-compile-project)
- (lambda (_arg) (cl-incf compile-calls))))
+ (lambda (_arg) (cl-incf compile-calls) nil)))
(cj/--f4-compile-and-run-impl)
(should (= compile-calls 1)))))
-(ert-deftest test-dev-fkeys-compile-and-run-impl-installs-finish-hook ()
- "Normal: handler installs exactly one hook in `compilation-finish-functions'."
- (let ((compilation-finish-functions nil))
- (cl-letf (((symbol-function 'projectile-compile-project)
- (lambda (_arg) nil)))
- (cj/--f4-compile-and-run-impl)
- (should (= (length compilation-finish-functions) 1)))))
+(ert-deftest test-dev-fkeys-compile-and-run-impl-installs-hook-in-compilation-buffer ()
+ "Normal: the one-shot hook lands buffer-locally in the compilation buffer;
+the global `compilation-finish-functions' stays untouched."
+ (test-dev-fkeys-car--with-buffer buf
+ (let ((compilation-finish-functions nil))
+ (cl-letf (((symbol-function 'projectile-compile-project)
+ (lambda (_arg) buf)))
+ (cj/--f4-compile-and-run-impl)
+ (should (null compilation-finish-functions))
+ (should (= 1 (length (remq t (buffer-local-value
+ 'compilation-finish-functions buf)))))))))
(ert-deftest test-dev-fkeys-compile-and-run-impl-hook-runs-projectile-run-on-success ()
- "Normal: when the compile finishes successfully, the installed hook calls
-`projectile-run-project'.
+ "Normal: when the compile finishes successfully, the buffer-local hook
+calls `projectile-run-project'.
Components integrated:
- `cj/--f4-compile-and-run-impl' (unit under test)
-- `projectile-compile-project' (MOCKED — no-op)
+- `projectile-compile-project' (MOCKED — returns the compilation buffer)
- `projectile-run-project' (MOCKED — counts calls)
-- `compilation-finish-functions' (real)
+- `compilation-finish-functions' (real, buffer-local)
- `run-hook-with-args' (real — simulates compile.el firing the hook)"
- (let ((run-calls 0)
- (compilation-finish-functions nil))
- (cl-letf (((symbol-function 'projectile-compile-project)
- (lambda (_arg) nil))
- ((symbol-function 'projectile-run-project)
- (lambda (_arg) (cl-incf run-calls))))
- (cj/--f4-compile-and-run-impl)
- (run-hook-with-args 'compilation-finish-functions nil "finished\n")
- (should (= run-calls 1)))))
+ (test-dev-fkeys-car--with-buffer buf
+ (let ((run-calls 0)
+ (compilation-finish-functions nil))
+ (cl-letf (((symbol-function 'projectile-compile-project)
+ (lambda (_arg) buf))
+ ((symbol-function 'projectile-run-project)
+ (lambda (_arg) (cl-incf run-calls))))
+ (cj/--f4-compile-and-run-impl)
+ (with-current-buffer buf
+ (run-hook-with-args 'compilation-finish-functions buf "finished\n"))
+ (should (= run-calls 1))))))
;;; Boundary Cases
(ert-deftest test-dev-fkeys-compile-and-run-impl-hook-skips-projectile-run-on-failure ()
"Boundary: when the compile fails, projectile-run-project must not run.
The hook still self-removes (covered in the make-once-hook tests)."
- (let ((run-calls 0)
- (compilation-finish-functions nil))
+ (test-dev-fkeys-car--with-buffer buf
+ (let ((run-calls 0)
+ (compilation-finish-functions nil))
+ (cl-letf (((symbol-function 'projectile-compile-project)
+ (lambda (_arg) buf))
+ ((symbol-function 'projectile-run-project)
+ (lambda (_arg) (cl-incf run-calls))))
+ (cj/--f4-compile-and-run-impl)
+ (with-current-buffer buf
+ (run-hook-with-args 'compilation-finish-functions
+ buf "exited abnormally\n"))
+ (should (= run-calls 0))))))
+
+(ert-deftest test-dev-fkeys-compile-and-run-impl-quit-leaves-no-global-hook ()
+ "Boundary: a quit at projectile's prompt leaves no armed hook anywhere.
+This is the regression the buffer-local install exists to prevent: the
+old shape armed a global hook before the prompt, so C-g left it live and
+the next unrelated compile fired the chained run."
+ (let ((compilation-finish-functions nil))
(cl-letf (((symbol-function 'projectile-compile-project)
- (lambda (_arg) nil))
- ((symbol-function 'projectile-run-project)
- (lambda (_arg) (cl-incf run-calls))))
- (cj/--f4-compile-and-run-impl)
- (run-hook-with-args 'compilation-finish-functions nil "exited abnormally\n")
- (should (= run-calls 0)))))
+ (lambda (_arg) (signal 'quit nil))))
+ (condition-case nil
+ (cj/--f4-compile-and-run-impl)
+ (quit nil))
+ (should (null compilation-finish-functions)))))
(provide 'test-dev-fkeys--f4-compile-and-run-impl)
;;; test-dev-fkeys--f4-compile-and-run-impl.el ends here
diff --git a/tests/test-dev-fkeys--f4-make-once-hook.el b/tests/test-dev-fkeys--f4-make-once-hook.el
index b6c71dd7..4fc84e63 100644
--- a/tests/test-dev-fkeys--f4-make-once-hook.el
+++ b/tests/test-dev-fkeys--f4-make-once-hook.el
@@ -95,6 +95,21 @@ hook exactly once per compile, so the practical contract is one-shot."
(funcall hook nil "interrupt\n"))
(should (= called 0))))
+(ert-deftest test-dev-fkeys-make-once-hook-removes-itself-buffer-locally ()
+ "Boundary: a hook installed buffer-locally removes its local entry when
+run in that buffer — the shape used by the F4 chained-compile handlers."
+ (let ((buf (generate-new-buffer " *test-once-hook*"))
+ (called 0))
+ (unwind-protect
+ (let ((hook (cj/--f4-make-once-hook (lambda () (cl-incf called)))))
+ (with-current-buffer buf
+ (add-hook 'compilation-finish-functions hook nil t)
+ (funcall hook buf "finished\n")
+ (should-not (memq hook (buffer-local-value
+ 'compilation-finish-functions buf))))
+ (should (= called 1)))
+ (kill-buffer buf))))
+
;;; Error Cases
(ert-deftest test-dev-fkeys-make-once-hook-then-fn-error-still-removes-hook ()
diff --git a/tests/test-diff-config--ediff-options.el b/tests/test-diff-config--ediff-options.el
new file mode 100644
index 00000000..a43637d9
--- /dev/null
+++ b/tests/test-diff-config--ediff-options.el
@@ -0,0 +1,27 @@
+;;; test-diff-config--ediff-options.el --- Tests for ediff diff options -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Pins the removal of the global "-w" default for `ediff-diff-options'.
+;; With "-w", every ediff session ignores ALL whitespace, so
+;; indentation-only changes (significant in Python, Makefiles, YAML)
+;; compare as identical. Whitespace-ignoring is a per-session toggle
+;; (ediff's `##'), not a global default.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'diff-config)
+
+;;; Normal Cases
+
+(ert-deftest test-diff-config-ediff-options-no-global-whitespace-ignore ()
+ "Normal: after ediff loads, no global -w sits in ediff-diff-options.
+The use-package :custom values apply when the deferred package loads,
+so the assertion must run with ediff actually loaded."
+ (require 'ediff)
+ (should (not (string-match-p "-w" (or ediff-diff-options "")))))
+
+(provide 'test-diff-config--ediff-options)
+;;; test-diff-config--ediff-options.el ends here
diff --git a/tests/test-dirvish-config--quantize-thumb-size.el b/tests/test-dirvish-config--quantize-thumb-size.el
new file mode 100644
index 00000000..a26ef090
--- /dev/null
+++ b/tests/test-dirvish-config--quantize-thumb-size.el
@@ -0,0 +1,60 @@
+;;; test-dirvish-config--quantize-thumb-size.el --- thumbnail width-bucket tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/--dirvish-quantize-thumb-size' rounds dirvish's computed thumbnail size to
+;; a coarse pixel bucket so small preview-window jitter maps to one stable cache
+;; key instead of a fresh miss + regenerate (the webm thumbnail-flash bug). Pure
+;; math; the :filter-return advice that wires it onto `dirvish-media--img-size'
+;; is verified live.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'dirvish-config)
+
+(declare-function cj/--dirvish-quantize-thumb-size "dirvish-config" (size bucket))
+
+;;; ------------------------------- rounding -----------------------------------
+
+(ert-deftest test-dirvish-quantize-rounds-down ()
+ "Normal: a size below the bucket midpoint rounds down to the bucket."
+ (should (= (cj/--dirvish-quantize-thumb-size 931 100) 900)))
+
+(ert-deftest test-dirvish-quantize-rounds-up ()
+ "Normal: a size above the bucket midpoint rounds up to the next bucket."
+ (should (= (cj/--dirvish-quantize-thumb-size 552 100) 600)))
+
+(ert-deftest test-dirvish-quantize-on-bucket-unchanged ()
+ "Normal: a size already on a bucket boundary is returned unchanged."
+ (should (= (cj/--dirvish-quantize-thumb-size 600 100) 600)))
+
+(ert-deftest test-dirvish-quantize-jitter-maps-to-one-key ()
+ "Boundary: nearby sizes within a bucket collapse to the same cache key."
+ (should (= (cj/--dirvish-quantize-thumb-size 931 100)
+ (cj/--dirvish-quantize-thumb-size 949 100))))
+
+(ert-deftest test-dirvish-quantize-float-input ()
+ "Boundary: a float size (pre-floor) still yields an integer bucket."
+ (let ((r (cj/--dirvish-quantize-thumb-size 931.4 100)))
+ (should (integerp r))
+ (should (= r 900))))
+
+;;; ------------------------------- clamping -----------------------------------
+
+(ert-deftest test-dirvish-quantize-tiny-clamps-to-bucket ()
+ "Boundary: a size that would round to 0 clamps up to one bucket, never 0."
+ (should (= (cj/--dirvish-quantize-thumb-size 1 100) 100)))
+
+;;; ------------------------------ disabled path -------------------------------
+
+(ert-deftest test-dirvish-quantize-zero-bucket-passthrough ()
+ "Error/disabled: a zero bucket returns the size unchanged."
+ (should (= (cj/--dirvish-quantize-thumb-size 931 0) 931)))
+
+(ert-deftest test-dirvish-quantize-nil-bucket-passthrough ()
+ "Error/disabled: a nil bucket returns the size unchanged."
+ (should (= (cj/--dirvish-quantize-thumb-size 931 nil) 931)))
+
+(provide 'test-dirvish-config--quantize-thumb-size)
+;;; test-dirvish-config--quantize-thumb-size.el ends here
diff --git a/tests/test-dirvish-config-runtime-requires.el b/tests/test-dirvish-config-runtime-requires.el
index 34fb67ac..ca94e4b5 100644
--- a/tests/test-dirvish-config-runtime-requires.el
+++ b/tests/test-dirvish-config-runtime-requires.el
@@ -3,15 +3,13 @@
;;; Commentary:
;; dirvish-config.el builds `dirvish-quick-access-entries' from `code-dir',
;; `music-dir', `pix-dir' (and friends) at load time and binds keys to
-;; `cj/xdg-open' / `cj/open-file-with-command', so it depends on user-constants
-;; and system-utils at runtime. Those were declared with `eval-when-compile',
-;; which leaves the compiled module without the requires at load — fragile
-;; under init order. This is a dependency-contract smoke test: requiring
-;; dirvish-config in isolation must pull both features in, so it fails if the
-;; requires are dropped entirely. (It can't catch a downgrade back to
-;; `eval-when-compile', since that form still runs when the file loads as
-;; source, which the test harness does — that regression is guarded by keeping
-;; the plain requires in review, not by this test.)
+;; `cj/xdg-open' (external-open) and `cj/open-file-with-command'
+;; (system-utils), so it depends on user-constants, system-utils, and
+;; external-open at runtime. This is a dependency-contract smoke test:
+;; requiring dirvish-config in isolation must pull those features in, so it
+;; fails if the requires are dropped entirely. Run it with `make test-file'
+;; for a clean signal: in the full suite another file may already have loaded
+;; external-open, masking a regression here.
;;; Code:
@@ -26,5 +24,13 @@
"Normal: requiring dirvish-config pulls in system-utils at runtime."
(should (featurep 'system-utils)))
+(ert-deftest test-dirvish-config-loads-external-open ()
+ "Normal: requiring dirvish-config pulls in external-open at runtime.
+The keys `o' and the OS-handler fallback call `cj/xdg-open', which lives
+in external-open; without the require the binding works only when init
+order happens to load external-open first."
+ (should (featurep 'external-open))
+ (should (fboundp 'cj/xdg-open)))
+
(provide 'test-dirvish-config-runtime-requires)
;;; test-dirvish-config-runtime-requires.el ends here
diff --git a/tests/test-dwim-shell-config-runtime-requires.el b/tests/test-dwim-shell-config-runtime-requires.el
new file mode 100644
index 00000000..ab53e7d4
--- /dev/null
+++ b/tests/test-dwim-shell-config-runtime-requires.el
@@ -0,0 +1,24 @@
+;;; test-dwim-shell-config-runtime-requires.el --- dwim-shell-config declares its deps -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; dwim-shell-config.el calls `cj/xdg-open' (external-open) to open a
+;; conversion's output file, but declared it only with `declare-function'
+;; and never required external-open. The binding works at runtime only
+;; because init.el happens to load external-open first — fragile under init
+;; order, and the "Direct test load: yes" header claims otherwise. This is
+;; a dependency-contract smoke test: requiring dwim-shell-config in isolation
+;; must pull external-open in. Run with `make test-file' for a clean signal;
+;; in the full suite another file may already have loaded external-open.
+
+;;; Code:
+
+(require 'ert)
+(require 'dwim-shell-config)
+
+(ert-deftest test-dwim-shell-config-loads-external-open ()
+ "Normal: requiring dwim-shell-config pulls in external-open at runtime."
+ (should (featurep 'external-open))
+ (should (fboundp 'cj/xdg-open)))
+
+(provide 'test-dwim-shell-config-runtime-requires)
+;;; test-dwim-shell-config-runtime-requires.el ends here
diff --git a/tests/test-eat-config--xtwinops.el b/tests/test-eat-config--xtwinops.el
new file mode 100644
index 00000000..29f87f2f
--- /dev/null
+++ b/tests/test-eat-config--xtwinops.el
@@ -0,0 +1,120 @@
+;;; test-eat-config--xtwinops.el --- Tests for the EAT XTWINOPS window-size reply -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for the XTWINOPS (CSI <n> t) window-size responder. eat 0.9.4
+;; has no CSI <n> t handler, so it silently drops the window-size requests
+;; tmux 3.7b sends to learn the cell pixel size it needs before it will emit
+;; Sixel -- images then never render inside EAT. The module answers three
+;; requests: 14 (text area in pixels), 16 (cell size in pixels), 18 (text area
+;; in characters).
+;;
+;; Two pure pieces carry the logic and are tested here directly:
+;; - `cj/--eat-xtwinops-report' computes the reply string from the display and
+;; cell dimensions.
+;; - `cj/--eat-xtwinops-queries' extracts the request numbers from a chunk of
+;; terminal output.
+;; The thin accessor glue (`cj/--eat-send-window-size-report', which reads the
+;; live `eat--t-term' struct) is verified in the running daemon, since eat's
+;; structs are not loadable under `make test' (no package-initialize). The
+;; detector's dispatch is tested against a recording stub of the responder.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub keymap dep before loading the module (matches the other module tests).
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'eat-config)
+
+;;; --------------------------- reply computation ----------------------------
+
+(ert-deftest test-eat-config-xtwinops-report-normal-14-text-area-pixels ()
+ "Normal: request 14 reports text-area size in pixels (rows*ch by cols*cw)."
+ ;; 80x24 chars, 10x20 px cells -> height 24*20=480, width 80*10=800.
+ (should (equal (cj/--eat-xtwinops-report 14 80 24 10 20)
+ "\e[4;480;800t")))
+
+(ert-deftest test-eat-config-xtwinops-report-normal-16-cell-pixels ()
+ "Normal: request 16 reports the cell size in pixels (height then width)."
+ (should (equal (cj/--eat-xtwinops-report 16 80 24 10 20)
+ "\e[6;20;10t")))
+
+(ert-deftest test-eat-config-xtwinops-report-normal-18-text-area-chars ()
+ "Normal: request 18 reports the text-area size in characters (rows then cols)."
+ (should (equal (cj/--eat-xtwinops-report 18 80 24 10 20)
+ "\e[8;24;80t")))
+
+(ert-deftest test-eat-config-xtwinops-report-boundary-unit-cells ()
+ "Boundary: with eat's default 1x1 px cells, pixel dims equal the char dims."
+ (should (equal (cj/--eat-xtwinops-report 14 80 24 1 1) "\e[4;24;80t"))
+ (should (equal (cj/--eat-xtwinops-report 16 80 24 1 1) "\e[6;1;1t")))
+
+(ert-deftest test-eat-config-xtwinops-report-error-unknown-request-is-nil ()
+ "Error: any request number other than 14/16/18 returns nil (unanswered)."
+ (should (null (cj/--eat-xtwinops-report 15 80 24 10 20)))
+ (should (null (cj/--eat-xtwinops-report 24 80 24 10 20)))
+ (should (null (cj/--eat-xtwinops-report nil 80 24 10 20))))
+
+;;; ----------------------------- query detection ----------------------------
+
+(ert-deftest test-eat-config-xtwinops-queries-normal-single ()
+ "Normal: a lone CSI 14 t query is detected."
+ (should (equal (cj/--eat-xtwinops-queries "\e[14t") '(14))))
+
+(ert-deftest test-eat-config-xtwinops-queries-normal-embedded-multiple ()
+ "Normal: several queries embedded in other output are returned in order."
+ (should (equal (cj/--eat-xtwinops-queries "foo\e[14tbar\e[18tbaz\e[16t")
+ '(14 18 16))))
+
+(ert-deftest test-eat-config-xtwinops-queries-boundary-none ()
+ "Boundary: output with no XTWINOPS query returns nil, including empty."
+ (should (null (cj/--eat-xtwinops-queries "")))
+ (should (null (cj/--eat-xtwinops-queries "hello\e[0m\e[2J"))))
+
+(ert-deftest test-eat-config-xtwinops-queries-error-unhandled-ops-ignored ()
+ "Error: CSI t ops we do not answer (and parametrized forms) are not matched.
+`\\e[24t' is a resize op, `\\e[3;14t' carries a leading param -- neither is the
+bare CSI 14/16/18 t we answer, so both are ignored."
+ (should (null (cj/--eat-xtwinops-queries "\e[24t")))
+ (should (null (cj/--eat-xtwinops-queries "\e[3;14t")))
+ (should (null (cj/--eat-xtwinops-queries "\e[114t"))))
+
+;;; --------------------------- advice-needed guard --------------------------
+
+(ert-deftest test-eat-config-xtwinops-advice-needed-normal-eat-0-9-4 ()
+ "Normal: on eat 0.9.4 (no upstream CSI t clause) the advice is needed."
+ ;; The upstream parser clause defines `eat--t-send-window-size-report';
+ ;; 0.9.4 does not, so it must be unbound here.
+ (should-not (fboundp 'eat--t-send-window-size-report))
+ (should (cj/--eat-xtwinops-advice-needed-p)))
+
+(ert-deftest test-eat-config-xtwinops-advice-needed-boundary-upstream-ships ()
+ "Boundary: once upstream defines the parser clause, the advice must NOT
+install -- both would answer and tmux gets a double reply."
+ (cl-letf (((symbol-function 'eat--t-send-window-size-report) #'ignore))
+ (should-not (cj/--eat-xtwinops-advice-needed-p))))
+
+;;; ------------------------- detector dispatch (seam) -----------------------
+
+(ert-deftest test-eat-config-xtwinops-answer-dispatches-once-per-query ()
+ "Integration: the detector calls the responder once per query, in order.
+Mocks only our own responder seam (`cj/--eat-send-window-size-report'); the
+detector under test does the real query extraction."
+ (let ((calls '()))
+ (cl-letf (((symbol-function 'cj/--eat-send-window-size-report)
+ (lambda (n) (push n calls))))
+ (cj/--eat-answer-xtwinops "\e[14t\e[16t\e[18t"))
+ (should (equal (nreverse calls) '(14 16 18)))))
+
+(ert-deftest test-eat-config-xtwinops-answer-no-query-no-call ()
+ "Boundary: output with no query never calls the responder."
+ (let ((called nil))
+ (cl-letf (((symbol-function 'cj/--eat-send-window-size-report)
+ (lambda (_n) (setq called t))))
+ (cj/--eat-answer-xtwinops "no query here\e[2J"))
+ (should-not called)))
+
+(provide 'test-eat-config--xtwinops)
+;;; test-eat-config--xtwinops.el ends here
diff --git a/tests/test-external-open--open-with-argv.el b/tests/test-external-open--open-with-argv.el
new file mode 100644
index 00000000..27a7e811
--- /dev/null
+++ b/tests/test-external-open--open-with-argv.el
@@ -0,0 +1,59 @@
+;;; test-external-open--open-with-argv.el --- Tests for cj/--open-with-argv -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/--open-with-argv, the pure builder that turns the
+;; user-typed "open with" command plus a file path into an argv list.
+;; The argv shape is the hardening: the file is one list element, so paths
+;; with spaces or shell metacharacters never meet a shell.
+;;
+;; Test organization:
+;; - Normal Cases: bare program, program with args, quoted argument
+;; - Boundary Cases: file with spaces and metacharacters stays one element
+;; - Error Cases: empty and whitespace-only commands
+;;
+;;; Code:
+
+(require 'ert)
+(require 'external-open)
+
+;;; Normal Cases
+
+(ert-deftest test-external-open--open-with-argv-normal-bare-program ()
+ "Normal: a bare program name yields (PROGRAM FILE)."
+ (should (equal (cj/--open-with-argv "vlc" "/tmp/foo.mp4")
+ '("vlc" "/tmp/foo.mp4"))))
+
+(ert-deftest test-external-open--open-with-argv-normal-program-with-args ()
+ "Normal: a command typed with arguments splits into argv words."
+ (should (equal (cj/--open-with-argv "mpv --fs --loop" "/tmp/foo.mp4")
+ '("mpv" "--fs" "--loop" "/tmp/foo.mp4"))))
+
+(ert-deftest test-external-open--open-with-argv-normal-quoted-arg-survives ()
+ "Normal: a double-quoted argument stays one word."
+ (should (equal (cj/--open-with-argv "player \"two words\"" "/tmp/foo.mp4")
+ '("player" "two words" "/tmp/foo.mp4"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-external-open--open-with-argv-boundary-file-with-spaces ()
+ "Boundary: a path with spaces is one argv element, untouched."
+ (let ((file "/tmp/my file (draft).mp4"))
+ (should (equal (car (last (cj/--open-with-argv "vlc" file))) file))))
+
+(ert-deftest test-external-open--open-with-argv-boundary-file-with-metacharacters ()
+ "Boundary: shell metacharacters in the path arrive verbatim."
+ (let ((file "/tmp/a;b&c$(d)'e.mp4"))
+ (should (equal (car (last (cj/--open-with-argv "vlc" file))) file))))
+
+;;; Error Cases
+
+(ert-deftest test-external-open--open-with-argv-error-empty-command ()
+ "Error: an empty command signals user-error."
+ (should-error (cj/--open-with-argv "" "/tmp/foo.mp4") :type 'user-error))
+
+(ert-deftest test-external-open--open-with-argv-error-whitespace-command ()
+ "Error: a whitespace-only command signals user-error."
+ (should-error (cj/--open-with-argv " " "/tmp/foo.mp4") :type 'user-error))
+
+(provide 'test-external-open--open-with-argv)
+;;; test-external-open--open-with-argv.el ends here
diff --git a/tests/test-external-open-commands.el b/tests/test-external-open-commands.el
index 3d8adc15..3b2d32b8 100644
--- a/tests/test-external-open-commands.el
+++ b/tests/test-external-open-commands.el
@@ -64,19 +64,28 @@
(should-error (cj/open-this-file-with "vlc") :type 'user-error)))
(ert-deftest test-external-open-open-this-file-with-spawns-detached-process ()
- "Normal: posix path invokes `call-process-shell-command' with nohup + bg."
- (let ((cmd nil))
+ "Normal: posix path launches an argv `call-process' with DESTINATION 0."
+ (let ((captured nil))
(with-temp-buffer
- (setq buffer-file-name "/tmp/foo.mp4")
+ (setq buffer-file-name "/tmp/my file.mp4")
(cl-letf (((symbol-function 'env-windows-p) (lambda () nil))
- ((symbol-function 'call-process-shell-command)
- (lambda (c _infile _buf &rest _)
- (setq cmd c))))
- (cj/open-this-file-with "vlc"))
+ ((symbol-function 'executable-find)
+ (lambda (&rest _) "/usr/bin/vlc"))
+ ((symbol-function 'call-process)
+ (lambda (&rest args) (setq captured args) 0)))
+ (cj/open-this-file-with "vlc --fs"))
(setq buffer-file-name nil))
- (should (string-match-p "^nohup vlc " cmd))
- (should (string-match-p "&$" cmd))
- (should (string-match-p ">/dev/null" cmd))))
+ (should (equal captured '("vlc" nil 0 nil "--fs" "/tmp/my file.mp4")))))
+
+(ert-deftest test-external-open-open-this-file-with-errors-missing-program ()
+ "Error: a program not on PATH signals user-error before launching."
+ (with-temp-buffer
+ (setq buffer-file-name "/tmp/foo.mp4")
+ (cl-letf (((symbol-function 'env-windows-p) (lambda () nil))
+ ((symbol-function 'executable-find) (lambda (&rest _) nil)))
+ (should-error (cj/open-this-file-with "no-such-program")
+ :type 'user-error))
+ (setq buffer-file-name nil)))
;;; cj/find-file-auto
diff --git a/tests/test-flycheck-config-ledger-hook.el b/tests/test-flycheck-config-ledger-hook.el
new file mode 100644
index 00000000..e9444b71
--- /dev/null
+++ b/tests/test-flycheck-config-ledger-hook.el
@@ -0,0 +1,31 @@
+;;; test-flycheck-config-ledger-hook.el --- flycheck reaches ledger buffers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `flycheck-ledger' registers a `ledger' checker, but a checker only runs where
+;; `flycheck-mode' is on. Until 2026-07-10 flycheck-config enabled the mode in
+;; `sh-mode' and `emacs-lisp-mode' only, and no `global-flycheck-mode' existed, so
+;; an unbalanced transaction in a ledger file produced no warning at all.
+;;
+;; These tests pin the hook, not the checker. Whether the `ledger' checker itself
+;; works is flycheck-ledger's problem; whether it ever gets a chance to run is ours.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'flycheck-config)
+
+(ert-deftest test-flycheck-config-enables-flycheck-in-ledger-buffers ()
+ "Normal: opening a ledger buffer turns `flycheck-mode' on.
+Without this, `flycheck-ledger' is loaded, its checker is registered, and
+nothing ever lints a financial file."
+ (should (memq #'flycheck-mode (default-value 'ledger-mode-hook))))
+
+(ert-deftest test-flycheck-config-keeps-its-existing-mode-hooks ()
+ "Boundary: adding ledger doesn't displace the modes flycheck already covered."
+ (should (memq #'flycheck-mode (default-value 'sh-mode-hook)))
+ (should (memq #'flycheck-mode (default-value 'emacs-lisp-mode-hook))))
+
+(provide 'test-flycheck-config-ledger-hook)
+;;; test-flycheck-config-ledger-hook.el ends here
diff --git a/tests/test-flyspell-and-abbrev.el b/tests/test-flyspell-and-abbrev.el
index ef8cc637..3b494d5e 100644
--- a/tests/test-flyspell-and-abbrev.el
+++ b/tests/test-flyspell-and-abbrev.el
@@ -97,5 +97,27 @@
(cj/flyspell-on-for-buffer-type)))
(should mode-called)))
+;; --------------------------- cj/flyspell-then-abbrev -------------------------
+
+(ert-deftest test-flyspell-then-abbrev-enables-mode-not-bare-rescan ()
+ "Regression: cj/flyspell-then-abbrev routes the initial scan through
+cj/flyspell-on-for-buffer-type, which enables flyspell-mode so it sticks.
+The bare flyspell-buffer it replaced never turned the mode on, so the guard
+never tripped and every C-' press re-scanned the whole buffer (O(buffer) per
+keypress in large files)."
+ (let (on-called scan-called)
+ (cl-letf (((symbol-function 'cj/--require-spell-checker) #'ignore)
+ ((symbol-function 'cj/flyspell-on-for-buffer-type)
+ (lambda () (setq on-called t)))
+ ((symbol-function 'flyspell-buffer)
+ (lambda (&rest _) (setq scan-called t)))
+ ((symbol-function 'cj/flyspell-goto-previous-misspelling)
+ (lambda (&rest _) nil)))
+ (with-temp-buffer
+ (text-mode)
+ (cj/flyspell-then-abbrev nil)))
+ (should on-called)
+ (should-not scan-called)))
+
(provide 'test-flyspell-and-abbrev)
;;; test-flyspell-and-abbrev.el ends here
diff --git a/tests/test-font-config--frame-lifecycle.el b/tests/test-font-config--frame-lifecycle.el
deleted file mode 100644
index 8f338b99..00000000
--- a/tests/test-font-config--frame-lifecycle.el
+++ /dev/null
@@ -1,75 +0,0 @@
-;;; test-font-config--frame-lifecycle.el --- Tests for the lifted font frame helpers -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; cj/apply-font-settings-to-frame, cj/cleanup-frame-list, and
-;; cj/maybe-install-nerd-icons-fonts were defined inside use-package
-;; :config / with-eval-after-load (unreachable under `make test'). Lifting
-;; them to top level makes their branching unit-testable; env-gui-p and the
-;; package side-effect calls are mocked at the boundary.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-
-(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'font-config)
-
-(defvar cj/fontaine-configured-frames)
-
-(ert-deftest test-font-cleanup-frame-list-removes-frame ()
- "Normal: cleanup drops the given frame from the configured list."
- (let ((cj/fontaine-configured-frames '(fr1 fr2 fr3)))
- (cj/cleanup-frame-list 'fr2)
- (should (equal cj/fontaine-configured-frames '(fr1 fr3)))))
-
-(ert-deftest test-font-apply-gui-unconfigured-sets-preset ()
- "Normal: a GUI frame not yet configured gets the preset and is tracked."
- (let ((cj/fontaine-configured-frames nil)
- (called nil))
- (cl-letf (((symbol-function 'env-gui-p) (lambda () t))
- ((symbol-function 'fontaine-set-preset) (lambda (_p) (setq called t))))
- (cj/apply-font-settings-to-frame (selected-frame)))
- (should called)
- (should (member (selected-frame) cj/fontaine-configured-frames))))
-
-(ert-deftest test-font-apply-already-configured-is-noop ()
- "Boundary: an already-configured frame is not re-preset."
- (let ((cj/fontaine-configured-frames (list (selected-frame)))
- (called nil))
- (cl-letf (((symbol-function 'env-gui-p) (lambda () t))
- ((symbol-function 'fontaine-set-preset) (lambda (_p) (setq called t))))
- (cj/apply-font-settings-to-frame (selected-frame)))
- (should-not called)))
-
-(ert-deftest test-font-apply-non-gui-is-noop ()
- "Boundary: without a GUI nothing is applied or tracked."
- (let ((cj/fontaine-configured-frames nil)
- (called nil))
- (cl-letf (((symbol-function 'env-gui-p) (lambda () nil))
- ((symbol-function 'fontaine-set-preset) (lambda (_p) (setq called t))))
- (cj/apply-font-settings-to-frame (selected-frame)))
- (should-not called)
- (should-not (member (selected-frame) cj/fontaine-configured-frames))))
-
-(ert-deftest test-font-maybe-install-icons-gui-missing-installs ()
- "Normal: GUI present and font missing triggers the install."
- (let ((installed nil))
- (cl-letf (((symbol-function 'env-gui-p) (lambda () t))
- ((symbol-function 'cj/font-installed-p) (lambda (_n) nil))
- ((symbol-function 'nerd-icons-install-fonts) (lambda (&rest _) (setq installed t)))
- ((symbol-function 'remove-hook) #'ignore))
- (cj/maybe-install-nerd-icons-fonts))
- (should installed)))
-
-(ert-deftest test-font-maybe-install-icons-already-present-skips ()
- "Boundary: an installed font means no install attempt."
- (let ((installed nil))
- (cl-letf (((symbol-function 'env-gui-p) (lambda () t))
- ((symbol-function 'cj/font-installed-p) (lambda (_n) t))
- ((symbol-function 'nerd-icons-install-fonts) (lambda (&rest _) (setq installed t))))
- (cj/maybe-install-nerd-icons-fonts))
- (should-not installed)))
-
-(provide 'test-font-config--frame-lifecycle)
-;;; test-font-config--frame-lifecycle.el ends here
diff --git a/tests/test-font-config.el b/tests/test-font-config.el
index 393a7758..06ea226b 100644
--- a/tests/test-font-config.el
+++ b/tests/test-font-config.el
@@ -4,11 +4,10 @@
;; font-config.el is mostly top-level font/package setup. These smoke tests
;; cover the logic that should stay correct regardless of which fonts are
-;; installed: the install check, and the daemon-frame font applier (env-gui-p
-;; guard plus idempotency). The module :demand's fontaine and references
-;; nerd-icons, so the tests skip when those packages are absent rather than
-;; failing on a bare checkout. GUI and font lookups are stubbed so the run
-;; stays headless.
+;; installed: the install check, task-oriented Fontaine picker, persistence,
+;; and emoji setup. The module :demand's fontaine and references nerd-icons, so
+;; the tests skip when those packages are absent rather than failing on a bare
+;; checkout. GUI and font lookups are stubbed so the run stays headless.
;;; Code:
@@ -41,34 +40,32 @@
(cl-letf (((symbol-function 'find-font) (lambda (&rest _) nil)))
(should (null (cj/font-installed-p "No Such Font 12345")))))
-;;; cj/apply-font-settings-to-frame
+;;; cj/maybe-install-nerd-icons-fonts
-(ert-deftest test-font-config-apply-font-settings-noop-without-gui ()
- "Boundary: on a non-GUI frame the applier does nothing and does not error."
+(ert-deftest test-font-config-nerd-icons-missing-font-installs-on-gui ()
+ "Normal: a missing Nerd Icons font is installed on a GUI frame."
(skip-unless test-font-config--available)
(require 'font-config)
- (let ((cj/fontaine-configured-frames nil)
- (applied nil))
- (cl-letf (((symbol-function 'env-gui-p) (lambda (&rest _) nil))
- ((symbol-function 'fontaine-set-preset)
- (lambda (&rest _) (setq applied t))))
- (cj/apply-font-settings-to-frame (selected-frame))
- (should-not applied)
- (should-not cj/fontaine-configured-frames))))
-
-(ert-deftest test-font-config-apply-font-settings-applies-once-per-frame ()
- "Normal: on a GUI frame the applier sets the preset once and is idempotent."
- (skip-unless test-font-config--available)
- (require 'font-config)
- (let ((cj/fontaine-configured-frames nil)
- (calls 0))
- (cl-letf (((symbol-function 'env-gui-p) (lambda (&rest _) t))
- ((symbol-function 'fontaine-set-preset)
- (lambda (&rest _) (setq calls (1+ calls)))))
- (cj/apply-font-settings-to-frame (selected-frame))
- (cj/apply-font-settings-to-frame (selected-frame))
- (should (= calls 1))
- (should (memq (selected-frame) cj/fontaine-configured-frames)))))
+ (let ((installed nil))
+ (cl-letf (((symbol-function 'env-gui-p) (lambda () t))
+ ((symbol-function 'cj/font-installed-p) (lambda (_name) nil))
+ ((symbol-function 'nerd-icons-install-fonts)
+ (lambda (&rest _) (setq installed t)))
+ ((symbol-function 'remove-hook) #'ignore))
+ (cj/maybe-install-nerd-icons-fonts))
+ (should installed)))
+
+(ert-deftest test-font-config-nerd-icons-installed-font-skips-install ()
+ "Boundary: an installed Nerd Icons font needs no install attempt."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((installed nil))
+ (cl-letf (((symbol-function 'env-gui-p) (lambda () t))
+ ((symbol-function 'cj/font-installed-p) (lambda (_name) t))
+ ((symbol-function 'nerd-icons-install-fonts)
+ (lambda (&rest _) (setq installed t))))
+ (cj/maybe-install-nerd-icons-fonts))
+ (should-not installed)))
;;; cj/setup-emoji-fontset
@@ -93,5 +90,248 @@
((symbol-function 'set-fontset-font) (lambda (&rest _) t)))
(should (progn (cj/setup-emoji-fontset) t))))
+;;; cj/set-emojify-display-style
+
+(defvar emojify-display-style)
+
+(ert-deftest test-font-config-emojify-display-style-image-on-gui ()
+ "Normal: on a GUI frame the emoji display style is `image'."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((emojify-display-style nil))
+ (cl-letf (((symbol-function 'env-gui-p) (lambda (&rest _) t)))
+ (cj/set-emojify-display-style)
+ (should (eq emojify-display-style 'image)))))
+
+(ert-deftest test-font-config-emojify-display-style-unicode-without-gui ()
+ "Boundary: without a GUI the emoji display style is `unicode'."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((emojify-display-style nil))
+ (cl-letf (((symbol-function 'env-gui-p) (lambda (&rest _) nil)))
+ (cj/set-emojify-display-style)
+ (should (eq emojify-display-style 'unicode)))))
+
+;;; cj/display-available-fonts
+
+(ert-deftest test-font-config-display-available-fonts-second-call-no-error ()
+ "Error: a second invocation does not signal on the read-only buffer."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (cl-letf (((symbol-function 'font-family-list)
+ (lambda (&rest _) '("Fixture Font A" "Fixture Font B"))))
+ (unwind-protect
+ (progn
+ (cj/display-available-fonts)
+ ;; The first call ends in `special-mode' (read-only); the second must
+ ;; not signal when it erases and rewrites the buffer.
+ (cj/display-available-fonts)
+ (with-current-buffer "*Available Fonts*"
+ (should (> (buffer-size) 0))
+ (should buffer-read-only)))
+ (when (get-buffer "*Available Fonts*")
+ (kill-buffer "*Available Fonts*")))))
+
+;;; Fontaine workflow profiles
+
+(ert-deftest test-font-config-fontaine-presets-are-task-oriented ()
+ "Normal: the picker exposes eight complete workflow destinations."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (should (equal (delq t (mapcar #'car fontaine-presets))
+ '(everyday writing reading coding-xs coding-m coding-l coding-xl
+ presentation))))
+
+(ert-deftest test-font-config-fontaine-candidates-describe-end-state ()
+ "Normal: every profile label names its purpose, fonts, and point size."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((candidates (cj/fontaine-profile-candidates)))
+ (should (= (length candidates) 8))
+ (should (member (nth 0 candidates)
+ '("Everyday — Berkeley Mono + Lexend · 13 pt"
+ "Everyday — Berkeley Mono + Lexend · 14 pt")))
+ (should (equal (nth 1 candidates)
+ "Writing — Berkeley Mono + Merriweather · 14 pt"))
+ (should (equal (nth 2 candidates)
+ "Reading — Merriweather · 14 pt"))
+ (should (equal (nth 3 candidates)
+ "Coding XS — Berkeley Mono · 11 pt"))
+ (should (equal (nth 4 candidates)
+ "Coding M — Berkeley Mono · 13 pt"))
+ (should (equal (nth 5 candidates)
+ "Coding L — Berkeley Mono · 14 pt"))
+ (should (equal (nth 6 candidates)
+ "Coding XL — Berkeley Mono · 16 pt"))
+ (should (equal (nth 7 candidates)
+ "Presentation — Berkeley Mono + Lexend · 20 pt"))))
+
+(ert-deftest test-font-config-fontaine-candidate-round-trips-to-profile ()
+ "Boundary: a displayed destination maps back to its Fontaine symbol."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (dolist (profile '(everyday writing reading coding-xs coding-m coding-l coding-xl
+ presentation))
+ (let ((label (cj/fontaine-profile-label profile)))
+ (should (eq (cj/fontaine-profile-from-label label) profile)))))
+
+(ert-deftest test-font-config-fontaine-uses-one-monospace-family ()
+ "Normal: every workflow profile uses Berkeley Mono for fixed pitch."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (dolist (profile '(everyday writing coding-xs coding-m coding-l coding-xl presentation))
+ (let ((properties (fontaine--get-preset-properties profile)))
+ (should (equal (plist-get properties :default-family)
+ "BerkeleyMono Nerd Font")))))
+
+(ert-deftest test-font-config-fontaine-reading-is-merriweather-only ()
+ "Normal: Reading uses Merriweather for every primary face family."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((properties (fontaine--get-preset-properties 'reading)))
+ (dolist (property '(:default-family
+ :fixed-pitch-family
+ :fixed-pitch-serif-family
+ :variable-pitch-family))
+ (should (equal (plist-get properties property) "Merriweather")))))
+
+(ert-deftest test-font-config-fontaine-reading-properties-are-public ()
+ "Normal: consumers can resolve Reading without Fontaine private functions."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((properties (cj/fontaine-profile-properties 'reading)))
+ (should (equal (plist-get properties :default-family) "Merriweather"))
+ (should (= (plist-get properties :default-height) 140))))
+
+(ert-deftest test-font-config-fontaine-remaps-reading-buffer-locally ()
+ "Normal: the local adapter applies all Reading families at an override height."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((calls nil))
+ (cl-letf (((symbol-function 'face-remap-add-relative)
+ (lambda (face &rest properties)
+ (push (cons face properties) calls)
+ face)))
+ (should (equal (cj/fontaine-remap-buffer-to-profile 'reading 180)
+ '(default fixed-pitch fixed-pitch-serif variable-pitch))))
+ (dolist (face '(default fixed-pitch fixed-pitch-serif variable-pitch))
+ (should (member (list face :family "Merriweather" :height 180)
+ calls)))))
+
+(ert-deftest test-font-config-fontaine-ui-buffer-remaps-default-to-berkeley ()
+ "Normal: minibuffer and echo buffers remap their default face to Berkeley."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((base nil))
+ (cl-letf (((symbol-function 'face-remap-set-base)
+ (lambda (face &rest specs) (setq base (cons face specs)))))
+ (cj/fontaine-remap-ui-buffer))
+ (should (equal base
+ '(default (:family "BerkeleyMono Nerd Font"))))))
+
+(ert-deftest test-font-config-fontaine-ui-chrome-stays-berkeley ()
+ "Normal: Fontaine reasserts Berkeley on chrome faces and echo buffers."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((faces nil)
+ (remap-count 0))
+ (cl-letf (((symbol-function 'facep) (lambda (_face) t))
+ ((symbol-function 'set-face-attribute)
+ (lambda (face _frame &rest properties)
+ (push (cons face properties) faces)))
+ ((symbol-function 'get-buffer) (lambda (_name) (current-buffer)))
+ ((symbol-function 'cj/fontaine-remap-ui-buffer)
+ (lambda () (setq remap-count (1+ remap-count)))))
+ (cj/fontaine-keep-ui-chrome-monospace))
+ (dolist (face '(mode-line mode-line-active mode-line-inactive
+ minibuffer-prompt))
+ (should (member (list face :family "BerkeleyMono Nerd Font") faces)))
+ (should (= remap-count 2))))
+
+(ert-deftest test-font-config-fontaine-ui-chrome-hooks-are-installed ()
+ "Boundary: profile, theme, and minibuffer changes restore UI typography."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (should (memq 'cj/fontaine-keep-ui-chrome-monospace
+ fontaine-set-preset-hook))
+ (should (memq 'cj/fontaine-keep-ui-chrome-monospace
+ enable-theme-functions))
+ (should (memq 'cj/fontaine-remap-ui-buffer minibuffer-setup-hook)))
+
+(ert-deftest test-font-config-fontaine-unknown-label-has-no-profile ()
+ "Error: an unknown destination label does not select a preset."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (should-not (cj/fontaine-profile-from-label "Missing profile")))
+
+(ert-deftest test-font-config-fontaine-annotation-marks-current-profile ()
+ "Normal: completion marks only the active workflow destination."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((fontaine-current-preset 'writing))
+ (should (equal (cj/fontaine-profile-annotation
+ (cj/fontaine-profile-label 'writing))
+ " current"))
+ (should (equal (cj/fontaine-profile-annotation
+ (cj/fontaine-profile-label 'coding-m))
+ ""))))
+
+(ert-deftest test-font-config-fontaine-selector-applies-picked-profile ()
+ "Normal: the one-prompt picker maps its complete label before applying."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((picked (cj/fontaine-profile-label 'presentation))
+ (applied nil))
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (_prompt _choices &rest _) picked))
+ ((symbol-function 'cj/fontaine-apply-profile)
+ (lambda (profile) (setq applied profile))))
+ (cj/fontaine-select-profile)
+ (should (eq applied 'presentation)))))
+
+(ert-deftest test-font-config-fontaine-restores-valid-profile ()
+ "Normal: startup restores a persisted workflow profile."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (cl-letf (((symbol-function 'fontaine-restore-latest-preset)
+ (lambda () 'writing)))
+ (should (eq (cj/fontaine-restored-or-default-profile) 'writing))))
+
+(ert-deftest test-font-config-fontaine-rejects-obsolete-restored-profile ()
+ "Boundary: an old brand or point-size preset falls back to everyday."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (cl-letf (((symbol-function 'fontaine-restore-latest-preset)
+ (lambda () '13-point-font)))
+ (should (eq (cj/fontaine-restored-or-default-profile) 'everyday))))
+
+(ert-deftest test-font-config-fontaine-has-no-per-frame-reset-hook ()
+ "Boundary: creating a daemon frame cannot overwrite the active profile."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (should-not (memq 'cj/apply-font-settings-to-frame
+ server-after-make-frame-hook))
+ (should-not (memq 'cj/cleanup-frame-list delete-frame-functions)))
+
+(ert-deftest test-font-config-fontaine-apply-records-profile-for-persistence ()
+ "Normal: applying a profile updates Fontaine history before setting it."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((fontaine-preset-history nil)
+ (applied nil))
+ (cl-letf (((symbol-function 'fontaine-set-preset)
+ (lambda (profile) (setq applied profile))))
+ (cj/fontaine-apply-profile 'coding-m)
+ (should (eq applied 'coding-m))
+ (should (equal (car fontaine-preset-history) "coding-m")))))
+
+(ert-deftest test-font-config-fontaine-apply-rejects-unknown-profile ()
+ "Error: applying an unknown workflow profile signals a user error."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (cl-letf (((symbol-function 'fontaine-set-preset)
+ (lambda (_profile) (ert-fail "must not apply"))))
+ (should-error (cj/fontaine-apply-profile 'missing) :type 'user-error)))
+
(provide 'test-font-config)
;;; test-font-config.el ends here
diff --git a/tests/test-httpd-config--defer.el b/tests/test-httpd-config--defer.el
new file mode 100644
index 00000000..1a1fbbed
--- /dev/null
+++ b/tests/test-httpd-config--defer.el
@@ -0,0 +1,34 @@
+;;; test-httpd-config--defer.el --- Tests for httpd-config lazy loading -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Pins httpd-config's load-time behavior: merely loading the module must
+;; not create the www directory (that belongs to the moment simple-httpd
+;; actually loads) and must not pull in simple-httpd itself —
+;; impatient-mode requires it on demand.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;;; Boundary Cases
+
+(ert-deftest test-httpd-config-load-creates-no-www-dir ()
+ "Boundary: loading httpd-config does not create www/ in user-emacs-directory."
+ (let* ((sandbox (make-temp-file "httpd-config-test-" t))
+ (user-emacs-directory (file-name-as-directory sandbox)))
+ (unwind-protect
+ (progn
+ (require 'httpd-config)
+ (should-not (file-directory-p
+ (expand-file-name "www" user-emacs-directory))))
+ (delete-directory sandbox t))))
+
+(ert-deftest test-httpd-config-load-does-not-load-simple-httpd ()
+ "Boundary: loading httpd-config leaves simple-httpd unloaded."
+ (require 'httpd-config)
+ (should-not (featurep 'simple-httpd)))
+
+(provide 'test-httpd-config--defer)
+;;; test-httpd-config--defer.el ends here
diff --git a/tests/test-integration-calendar-sync-timezone.el b/tests/test-integration-calendar-sync-timezone.el
index 304d3233..a3f65146 100644
--- a/tests/test-integration-calendar-sync-timezone.el
+++ b/tests/test-integration-calendar-sync-timezone.el
@@ -187,12 +187,21 @@ Components integrated:
Validates:
- Org timestamp format is correct (<YYYY-MM-DD Day HH:MM-HH:MM>)
-- Hour in timestamp is the converted local hour"
- (let* ((source-time (list 2026 2 2 19 0))
+- Hour in timestamp is the converted local hour
+
+The date is generated relative to now because `calendar-sync--parse-ics'
+drops events outside `calendar-sync--get-date-range' (today minus
+`calendar-sync-past-months', plus `calendar-sync-future-months'). This test
+used a hardcoded 2026-02-02, which sat inside that window when it was written
+and fell out of it once three months had passed -- the event was filtered
+before rendering and the assertion failed against an empty org buffer. The
+sibling tests survived hardcoded dates only because they call
+`calendar-sync--parse-event' directly, which applies no range filter."
+ (let* ((source-time (test-calendar-sync-time-days-from-now 7 19 0))
(ics (test-integration-tz--make-ics-with-tzid-event
"Test Event" source-time "Europe/Lisbon"))
- (expected-local (test-calendar-sync-convert-tz-via-date
- 2026 2 2 19 0 "Europe/Lisbon"))
+ (expected-local (apply #'test-calendar-sync-convert-tz-via-date
+ (append source-time (list "Europe/Lisbon"))))
(expected-hour (nth 3 expected-local))
(org-output (calendar-sync--parse-ics ics)))
(should org-output)
diff --git a/tests/test-integration-recording-toggle-workflow.el b/tests/test-integration-recording-toggle-workflow.el
index e73ef87e..6258fc58 100644
--- a/tests/test-integration-recording-toggle-workflow.el
+++ b/tests/test-integration-recording-toggle-workflow.el
@@ -43,6 +43,24 @@
;;; Setup and Teardown
+(defun test-integration-toggle--fake-pactl (cmd &rest _)
+ "Answer pactl queries for CMD from fixture devices, never the real machine.
+
+`cj/recording-get-devices' runs `cj/recording--validate-system-audio', which
+shells out to pactl, decides a fixture device name is not a real source, and
+\"auto-fixes\" the configured device to the default sink's monitor. Left
+unmocked that clobbers the test's device with whatever hardware the developer
+has plugged in, so the assertions compare against a JDS Labs DAC rather than
+the fixture. Answering at the shell boundary keeps the real validation logic
+under test while the machine stays out of it."
+ (cond
+ ((string-match-p "get-default-sink" cmd) "fixture-sink\n")
+ ((string-match-p "list sources short" cmd)
+ "0\ttest-monitor\tmodule-x\ts16le 2ch 44100Hz\tIDLE\n1\tcached-monitor\tmodule-x\ts16le 2ch 44100Hz\tIDLE\n")
+ ((string-match-p "list sinks short" cmd)
+ "0\tfixture-sink\tmodule-x\ts16le 2ch 44100Hz\tRUNNING\n")
+ (t "")))
+
(defun test-integration-toggle-setup ()
"Reset all variables before each test."
(setq cj/video-recording-ffmpeg-process nil)
@@ -102,6 +120,8 @@ Validates:
(setq setup-called t)
(setq cj/recording-mic-device "test-mic")
(setq cj/recording-system-device "test-monitor")))
+ ((symbol-function 'shell-command-to-string)
+ #'test-integration-toggle--fake-pactl)
((symbol-function 'file-directory-p)
(lambda (_dir) t))
((symbol-function 'start-process-shell-command)
@@ -168,6 +188,8 @@ Validates:
(ffmpeg-cmd nil))
(cl-letf (((symbol-function 'cj/recording-quick-setup)
(lambda () (setq setup-called t)))
+ ((symbol-function 'shell-command-to-string)
+ #'test-integration-toggle--fake-pactl)
((symbol-function 'file-directory-p)
(lambda (_dir) t))
((symbol-function 'start-process-shell-command)
diff --git a/tests/test-integration-recurring-events.el b/tests/test-integration-recurring-events.el
index 3cae1a20..8339d167 100644
--- a/tests/test-integration-recurring-events.el
+++ b/tests/test-integration-recurring-events.el
@@ -34,53 +34,78 @@
;;; Test Data
-(defconst test-integration-recurring-events--weekly-ics
- "BEGIN:VCALENDAR
-VERSION:2.0
-PRODID:-//Test//Test//EN
-BEGIN:VEVENT
-DTSTART;TZID=America/Chicago:20251118T103000
-DTEND;TZID=America/Chicago:20251118T110000
-RRULE:FREQ=WEEKLY;BYDAY=SA
-SUMMARY:GTFO
-UID:test-weekly@example.com
-END:VEVENT
-END:VCALENDAR"
- "Test ICS with weekly recurring event (GTFO use case).")
+;; Fixtures that reach `calendar-sync--parse-ics' must carry dates relative to
+;; now. That entry point drops any event outside
+;; `calendar-sync--get-date-range' (today minus `calendar-sync-past-months',
+;; plus `calendar-sync-future-months'), so a hardcoded DTSTART works only until
+;; the rolling window moves past it. Three tests here rotted exactly that way:
+;; their November-2025 fixtures aged out of the window, the events were filtered
+;; before rendering, and the assertions failed against an empty org buffer. The
+;; weekly fixtures survived only because an unbounded RRULE keeps generating
+;; occurrences into the window no matter how old its DTSTART is.
+;;
+;; Fixtures given straight to `calendar-sync--parse-event' can stay static --
+;; that path applies no range filter.
-(defconst test-integration-recurring-events--daily-with-count-ics
- "BEGIN:VCALENDAR
+(defun test-integration-recurring-events--ics-stamp (offset-days hour minute)
+ "Return an ICS UTC datetime OFFSET-DAYS from today at HOUR:MINUTE."
+ (let ((d (test-calendar-sync-time-days-from-now offset-days hour minute)))
+ (format "%04d%02d%02dT%02d%02d00Z" (nth 0 d) (nth 1 d) (nth 2 d) hour minute)))
+
+(defun test-integration-recurring-events--daily-with-count-ics ()
+ "ICS with a COUNT=5 daily series starting inside the sync window."
+ (format "BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
-DTSTART:20251120T090000Z
-DTEND:20251120T100000Z
+DTSTART:%s
+DTEND:%s
RRULE:FREQ=DAILY;COUNT=5
SUMMARY:Daily Standup
UID:test-daily@example.com
END:VEVENT
END:VCALENDAR"
- "Test ICS with daily recurring event limited by COUNT.")
+ (test-integration-recurring-events--ics-stamp 2 9 0)
+ (test-integration-recurring-events--ics-stamp 2 10 0)))
-(defconst test-integration-recurring-events--mixed-ics
- "BEGIN:VCALENDAR
+(defun test-integration-recurring-events--mixed-ics ()
+ "ICS mixing a one-time event and a recurring one, both inside the window."
+ (format "BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
-DTSTART:20251125T140000Z
-DTEND:20251125T150000Z
+DTSTART:%s
+DTEND:%s
SUMMARY:One-time Meeting
UID:test-onetime@example.com
END:VEVENT
BEGIN:VEVENT
-DTSTART;TZID=America/Chicago:20251201T093000
-DTEND;TZID=America/Chicago:20251201T103000
+DTSTART;TZID=America/Chicago:%s
+DTEND;TZID=America/Chicago:%s
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
SUMMARY:Recurring Standup
UID:test-recurring@example.com
END:VEVENT
END:VCALENDAR"
- "Test ICS with mix of recurring and non-recurring events.")
+ (test-integration-recurring-events--ics-stamp 3 14 0)
+ (test-integration-recurring-events--ics-stamp 3 15 0)
+ ;; TZID form carries no Z suffix.
+ (string-remove-suffix "Z" (test-integration-recurring-events--ics-stamp 5 9 30))
+ (string-remove-suffix "Z" (test-integration-recurring-events--ics-stamp 5 10 30))))
+
+(defconst test-integration-recurring-events--weekly-ics
+ "BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Test//Test//EN
+BEGIN:VEVENT
+DTSTART;TZID=America/Chicago:20251118T103000
+DTEND;TZID=America/Chicago:20251118T110000
+RRULE:FREQ=WEEKLY;BYDAY=SA
+SUMMARY:GTFO
+UID:test-weekly@example.com
+END:VEVENT
+END:VCALENDAR"
+ "Test ICS with weekly recurring event (GTFO use case).")
;;; Normal Cases - Complete Workflow
@@ -133,7 +158,7 @@ Validates:
- Exactly 5 occurrences created"
(test-integration-recurring-events-setup)
(unwind-protect
- (let ((org-output (calendar-sync--parse-ics test-integration-recurring-events--daily-with-count-ics)))
+ (let ((org-output (calendar-sync--parse-ics (test-integration-recurring-events--daily-with-count-ics))))
(should (stringp org-output))
;; Should generate exactly 5 Daily Standup entries
@@ -160,7 +185,7 @@ Validates:
- Events are sorted chronologically"
(test-integration-recurring-events-setup)
(unwind-protect
- (let ((org-output (calendar-sync--parse-ics test-integration-recurring-events--mixed-ics)))
+ (let ((org-output (calendar-sync--parse-ics (test-integration-recurring-events--mixed-ics))))
(should (stringp org-output))
;; Should have one-time meeting
@@ -197,12 +222,19 @@ Validates:
(test-integration-recurring-events-setup)
(unwind-protect
(let* ((org-output (calendar-sync--parse-ics test-integration-recurring-events--weekly-ics))
- (now (current-time))
- (three-months-ago (time-subtract now (* 90 24 3600)))
- (twelve-months-future (time-add now (* 365 24 3600))))
+ (range (calendar-sync--get-date-range))
+ (window-start (nth 0 range))
+ (window-end (nth 1 range)))
(should (stringp org-output))
- ;; Parse all dates from output
+ ;; Every emitted occurrence must fall within the pipeline's own window
+ ;; (-3/+12 calendar months at day granularity, from
+ ;; `calendar-sync--get-date-range'). Asserting against that boundary,
+ ;; rather than a hand-rolled now +/- fixed-day approximation, keeps the
+ ;; test robust on every date: a valid boundary occurrence stamped at
+ ;; midnight used to read as out-of-range against a now-90d bound taken
+ ;; at the current clock time, so the test failed or passed depending on
+ ;; the day it ran.
(with-temp-buffer
(insert org-output)
(goto-char (point-min))
@@ -212,9 +244,9 @@ Validates:
(month (string-to-number (match-string 2)))
(day (string-to-number (match-string 3)))
(event-time (encode-time 0 0 0 day month year)))
- ;; All dates should be within window
- (when (or (time-less-p event-time three-months-ago)
- (time-less-p twelve-months-future event-time))
+ ;; Within [window-start, window-end] inclusive.
+ (when (or (time-less-p event-time window-start)
+ (time-less-p window-end event-time))
(setq all-dates-in-range nil))))
(should all-dates-in-range))))
(test-integration-recurring-events-teardown)))
@@ -291,18 +323,21 @@ Validates:
- Valid events still processed"
(test-integration-recurring-events-setup)
(unwind-protect
- (let* ((incomplete-ics "BEGIN:VCALENDAR
+ (let* ((incomplete-ics (format "BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
-DTSTART:20251201T100000Z
+DTSTART:%s
RRULE:FREQ=DAILY;COUNT=2
END:VEVENT
BEGIN:VEVENT
SUMMARY:Valid Event
-DTSTART:20251201T110000Z
-DTEND:20251201T120000Z
+DTSTART:%s
+DTEND:%s
END:VEVENT
-END:VCALENDAR")
+END:VCALENDAR"
+ (test-integration-recurring-events--ics-stamp 4 10 0)
+ (test-integration-recurring-events--ics-stamp 4 11 0)
+ (test-integration-recurring-events--ics-stamp 4 12 0)))
(org-output (calendar-sync--parse-ics incomplete-ics)))
;; Should still generate output (for valid event)
(should (stringp org-output))
diff --git a/tests/test-keyboard-compat-setup.el b/tests/test-keyboard-compat-setup.el
index 1c5cd434..a23e24e1 100644
--- a/tests/test-keyboard-compat-setup.el
+++ b/tests/test-keyboard-compat-setup.el
@@ -61,6 +61,14 @@ string can return a meta-prefix event count rather than nil.)"
(cj/keyboard-compat-terminal-setup)
(should (equal input-decode-map (make-sparse-keymap)))))
+(ert-deftest test-keyboard-compat-terminal-setup-on-tty-setup-hook ()
+ "Normal: terminal setup is registered on `tty-setup-hook', which runs for each
+new tty frame. `input-decode-map' is terminal-local, so `emacs-startup-hook'
+\(once, at daemon start, with no tty) leaves every later `emacsclient -t' frame
+without the arrow-key decodings. The GUI half already frame-scopes itself."
+ (should (memq 'cj/keyboard-compat-terminal-setup tty-setup-hook))
+ (should-not (memq 'cj/keyboard-compat-terminal-setup emacs-startup-hook)))
+
;; -------------------------- cj/keyboard-compat-gui-setup ---------------------
(defmacro test-kbc--gui (gui-p &rest body)
@@ -71,18 +79,20 @@ string can return a meta-prefix event count rather than nil.)"
,@body)))
(defconst test-kbc--meta-shift-letters
- '(?o ?m ?y ?f ?w ?e ?l ?r ?v ?h ?t ?z ?u ?d ?i ?c ?b ?k)
- "The 18 letters whose M-<UPPER> form is translated to M-S-<lower> in GUI mode.")
+ '(?o ?m ?y ?f ?w ?e ?l ?r ?v ?h ?t ?z ?u ?d ?i ?c ?b)
+ "The 17 letters whose M-<UPPER> form is translated to M-S-<lower> in GUI mode.")
(ert-deftest test-keyboard-compat-gui-setup-translates-spot-checks ()
- "Normal: in GUI mode, M-O -> M-S-o and M-K -> M-S-k (sampled)."
+ "Normal: in GUI mode, M-O -> M-S-o and M-B -> M-S-b (sampled).
+M-K is no longer translated: show-kill-ring, its only consumer, was retired."
(test-kbc--gui t
(cj/keyboard-compat-gui-setup)
(should (equal (lookup-key key-translation-map (kbd "M-O")) (kbd "M-S-o")))
- (should (equal (lookup-key key-translation-map (kbd "M-K")) (kbd "M-S-k")))
- (should (equal (lookup-key key-translation-map (kbd "M-D")) (kbd "M-S-d")))))
+ (should (equal (lookup-key key-translation-map (kbd "M-B")) (kbd "M-S-b")))
+ (should (equal (lookup-key key-translation-map (kbd "M-D")) (kbd "M-S-d")))
+ (should-not (lookup-key key-translation-map (kbd "M-K")))))
-(ert-deftest test-keyboard-compat-gui-setup-translates-all-eighteen ()
+(ert-deftest test-keyboard-compat-gui-setup-translates-all-seventeen ()
"Normal: every documented M-<UPPER> maps to its M-S-<lower> form."
(test-kbc--gui t
(cj/keyboard-compat-gui-setup)
diff --git a/tests/test-ledger-config.el b/tests/test-ledger-config.el
new file mode 100644
index 00000000..5224c184
--- /dev/null
+++ b/tests/test-ledger-config.el
@@ -0,0 +1,70 @@
+;;; test-ledger-config.el --- Characterization tests for ledger-config -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Captures the behavior ledger-config.el has today, before any guardrail work
+;; changes it. See docs/design/2026-07-10-ledger-config-audit.org for the audit
+;; these tests pin.
+;;
+;; The clean-on-save helpers are defined in the `use-package' `:preface', which
+;; use-package emits unconditionally, so they exist under `make test' even though
+;; ledger-mode itself never loads there (no `package-initialize' in the test run).
+;;
+;; `ledger-mode-clean-buffer' is stubbed: it is the boundary this config delegates
+;; to, and it rewrites the whole buffer. What these tests pin is whether our hook
+;; calls it, not what it does.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ledger-config)
+
+(ert-deftest test-ledger-config-clean-before-save-cleans-when-enabled ()
+ "Normal: with `cj/ledger-clean-on-save' set, the save hook cleans the buffer."
+ (let ((called 0)
+ (cj/ledger-clean-on-save t))
+ (cl-letf (((symbol-function 'ledger-mode-clean-buffer)
+ (lambda (&rest _) (setq called (1+ called)))))
+ (cj/ledger--clean-before-save))
+ (should (= 1 called))))
+
+(ert-deftest test-ledger-config-clean-before-save-skips-when-disabled ()
+ "Boundary: with `cj/ledger-clean-on-save' nil, the save hook does nothing."
+ (let ((called 0)
+ (cj/ledger-clean-on-save nil))
+ (cl-letf (((symbol-function 'ledger-mode-clean-buffer)
+ (lambda (&rest _) (setq called (1+ called)))))
+ (cj/ledger--clean-before-save))
+ (should (= 0 called))))
+
+(ert-deftest test-ledger-config-clean-before-save-demotes-errors ()
+ "Error: a failing clean does not signal, so the file still saves.
+This is the current contract. It also means a clean that fails partway
+leaves the buffer in whatever state it reached, because nothing rolls back."
+ (let ((cj/ledger-clean-on-save t)
+ (inhibit-message t))
+ (cl-letf (((symbol-function 'ledger-mode-clean-buffer)
+ (lambda (&rest _) (error "boom"))))
+ (should (progn (cj/ledger--clean-before-save) t)))))
+
+(ert-deftest test-ledger-config-enable-clean-on-save-is-buffer-local ()
+ "Normal: the hook installs buffer-locally, not globally."
+ (let ((global-before (default-value 'before-save-hook)))
+ (with-temp-buffer
+ (cj/ledger--enable-clean-on-save)
+ (should (memq #'cj/ledger--clean-before-save before-save-hook))
+ (should-not (memq #'cj/ledger--clean-before-save
+ (default-value 'before-save-hook))))
+ (should (equal global-before (default-value 'before-save-hook)))))
+
+(ert-deftest test-ledger-config-clean-on-save-defaults-on ()
+ "Normal: clean-on-save ships enabled.
+Pinned because the audit questions whether a whole-buffer sort belongs on
+every save of a financial file. If that default flips, this test should
+fail and be updated deliberately."
+ (should (eq t (default-value 'cj/ledger-clean-on-save))))
+
+(provide 'test-ledger-config)
+;;; test-ledger-config.el ends here
diff --git a/tests/test-local-repository--car-member.el b/tests/test-local-repository--car-member.el
deleted file mode 100644
index 30ae58c6..00000000
--- a/tests/test-local-repository--car-member.el
+++ /dev/null
@@ -1,58 +0,0 @@
-;;; test-local-repository--car-member.el --- Tests for localrepo--car-member -*- lexical-binding: t -*-
-
-;;; Commentary:
-;; Tests for `localrepo--car-member' in local-repository.el — the predicate
-;; localrepo-initialize uses to check whether an archive id is already
-;; registered in package-archives / package-archive-priorities.
-
-;;; Code:
-
-(require 'ert)
-(require 'local-repository)
-
-;;; Normal Cases
-
-(ert-deftest test-local-repository-localrepo--car-member-found ()
- "Normal: VALUE present as a car returns the matching tail (non-nil)."
- (should (equal (localrepo--car-member 'b '((a . 1) (b . 2) (c . 3)))
- '(b c))))
-
-(ert-deftest test-local-repository-localrepo--car-member-not-found ()
- "Normal: VALUE absent from every car returns nil."
- (should-not (localrepo--car-member 'z '((a . 1) (b . 2)))))
-
-(ert-deftest test-local-repository-localrepo--car-member-string-car ()
- "Normal: car comparison uses `equal', so string keys match by value."
- (should (localrepo--car-member "localrepo"
- '(("gnu" . "url1") ("localrepo" . "url2")))))
-
-;;; Boundary Cases
-
-(ert-deftest test-local-repository-localrepo--car-member-empty-list ()
- "Boundary: an empty list never matches."
- (should-not (localrepo--car-member 'a nil)))
-
-(ert-deftest test-local-repository-localrepo--car-member-single-match ()
- "Boundary: a single-element list whose car matches returns non-nil."
- (should (localrepo--car-member 'only '((only . 1)))))
-
-(ert-deftest test-local-repository-localrepo--car-member-single-no-match ()
- "Boundary: a single-element list whose car differs returns nil."
- (should-not (localrepo--car-member 'x '((only . 1)))))
-
-(ert-deftest test-local-repository-localrepo--car-member-nil-value-with-nil-car ()
- "Boundary: a nil VALUE matches a cons whose car is nil."
- (should (localrepo--car-member nil '((nil . 1) (a . 2)))))
-
-(ert-deftest test-local-repository-localrepo--car-member-nil-value-no-nil-car ()
- "Boundary: a nil VALUE with no nil car returns nil."
- (should-not (localrepo--car-member nil '((a . 1) (b . 2)))))
-
-;;; Error Cases
-
-(ert-deftest test-local-repository-localrepo--car-member-non-cons-element ()
- "Error: a non-cons element makes `car' signal wrong-type-argument."
- (should-error (localrepo--car-member 'x '(1 2)) :type 'wrong-type-argument))
-
-(provide 'test-local-repository--car-member)
-;;; test-local-repository--car-member.el ends here
diff --git a/tests/test-local-repository.el b/tests/test-local-repository.el
new file mode 100644
index 00000000..132f8dc4
--- /dev/null
+++ b/tests/test-local-repository.el
@@ -0,0 +1,32 @@
+;;; test-local-repository.el --- Tests for the local-repository update command -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/update-localrepo-repository' refreshes the checked-in local package
+;; archive at `localrepo-location' (owned by early-init.el) via elpa-mirror.
+;; The elpa-mirror call is mocked at the boundary.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'local-repository)
+
+;; localrepo-location is a defconst in early-init.el, which `make test' never
+;; loads. Declare it special here so the `let' below binds it dynamically and
+;; the module reads that value.
+(defvar localrepo-location nil)
+
+(ert-deftest test-local-repository-update-targets-early-init-location ()
+ "Normal: the mirror update targets `localrepo-location', the single archive
+path early-init.el owns, not a divergent module-local copy."
+ (let ((localrepo-location "/tmp/test-localrepo/")
+ (captured nil))
+ (cl-letf (((symbol-function 'elpamr-create-mirror-for-installed)
+ (lambda (dir &rest _) (setq captured dir))))
+ (cj/update-localrepo-repository)
+ (should (equal captured "/tmp/test-localrepo/")))))
+
+(provide 'test-local-repository)
+;;; test-local-repository.el ends here
diff --git a/tests/test-lorem-optimum.el b/tests/test-lorem-optimum.el
index f928c972..b7d97a8e 100644
--- a/tests/test-lorem-optimum.el
+++ b/tests/test-lorem-optimum.el
@@ -253,5 +253,26 @@ an empty string, not an error."
(let ((cj/lipsum-chain (cj/markov-chain-create)))
(should (equal "" (cj/lipsum-title)))))
+;;; cj/lipsum entry point
+
+(ert-deftest test-lipsum-returns-string-with-populated-chain ()
+ "Normal: cj/lipsum returns a non-empty string when the chain is trained."
+ (let ((cj/lipsum-chain
+ (test-learn "Lorem ipsum dolor sit amet consectetur adipiscing elit")))
+ (let ((result (cj/lipsum 5)))
+ (should (stringp result))
+ (should (> (length result) 0)))))
+
+(ert-deftest test-lipsum-empty-chain-signals-user-error ()
+ "Error: cj/lipsum on an empty chain signals a user-error naming the fix,
+rather than returning nil and letting cj/lipsum-insert do (insert nil),
+which raises a cryptic wrong-type error far from the cause."
+ (let ((cj/lipsum-chain (cj/markov-chain-create)))
+ (should-error (cj/lipsum 5) :type 'user-error)))
+
+(ert-deftest test-lipsum-is-interactive-command ()
+ "Normal: cj/lipsum is a command, as its Commentary (M-x cj/lipsum) advertises."
+ (should (commandp 'cj/lipsum)))
+
(provide 'test-lorem-optimum)
;;; test-lorem-optimum.el ends here
diff --git a/tests/test-mail-config--account-search-queries.el b/tests/test-mail-config--account-search-queries.el
index 9f1b6b3e..02a74692 100644
--- a/tests/test-mail-config--account-search-queries.el
+++ b/tests/test-mail-config--account-search-queries.el
@@ -38,9 +38,12 @@
(ert-deftest test-mail-make-account-map-closures-capture-distinct-queries ()
"Normal: each binding runs its own account-scoped search (no closure leak).
-mu4e-search is mocked to capture the query each command passes."
+mu4e-search is mocked to capture the query each command passes; require is
+mocked so the commands' mu4e load never pulls the real package in batch."
(let ((searched '()))
- (cl-letf (((symbol-function 'mu4e-search)
+ (cl-letf (((symbol-function 'require)
+ (lambda (feature &rest _) feature))
+ ((symbol-function 'mu4e-search)
(lambda (q) (push q searched))))
(let ((map (cj/--mail-make-account-map "dmail")))
(funcall (keymap-lookup map "i"))
@@ -49,5 +52,20 @@ mu4e-search is mocked to capture the query each command passes."
(should (member "maildir:/dmail/INBOX AND flag:unread AND NOT flag:trashed"
searched))))
+(ert-deftest test-mail-make-account-map-loads-mu4e-before-search ()
+ "Error: a nav command loads mu4e before it calls `mu4e-search'.
+The C-; e maps register eagerly at startup, but `mu4e-search' carries no
+autoload cookie, so a nav key pressed before mu4e's first launch signaled
+void-function. The command must require mu4e first, then search."
+ (let ((events '()))
+ (cl-letf (((symbol-function 'require)
+ (lambda (feature &rest _) (push (list :require feature) events) feature))
+ ((symbol-function 'mu4e-search)
+ (lambda (q) (push (list :search q) events))))
+ (funcall (keymap-lookup (cj/--mail-make-account-map "cmail") "i")))
+ (should (equal (nreverse events)
+ '((:require mu4e)
+ (:search "maildir:/cmail/INBOX"))))))
+
(provide 'test-mail-config--account-search-queries)
;;; test-mail-config--account-search-queries.el ends here
diff --git a/tests/test-mail-config-transport.el b/tests/test-mail-config-transport.el
index 0240102a..9329ac70 100644
--- a/tests/test-mail-config-transport.el
+++ b/tests/test-mail-config-transport.el
@@ -66,6 +66,23 @@ EXECUTABLES is an alist of program name strings to executable paths."
(should (equal test-mail-config--warnings
'((mail-config . "msmtp not found; SMTP mail sending unavailable")))))))
+(ert-deftest test-mail-config-transport-msmtp-missing-sets-descriptive-fallback ()
+ "Error: with msmtp absent, the send functions get a descriptive fallback.
+The old behavior left `message-send-mail-function' nil (the top-level defvar
+pre-empts message.el's default), so the first send died with \"invalid
+function: nil\". The fallback must be installed on both send variables and
+must signal a `user-error' that names msmtp."
+ (test-mail-config--with-executables nil
+ (let (send-mail-function message-send-mail-function)
+ (cj/mail-configure-smtpmail)
+ (should (eq send-mail-function #'cj/mail--send-mail-unavailable))
+ (should (eq message-send-mail-function #'cj/mail--send-mail-unavailable))
+ (should-error (cj/mail--send-mail-unavailable) :type 'user-error)
+ (condition-case err
+ (cj/mail--send-mail-unavailable)
+ (user-error
+ (should (string-match-p "msmtp" (cadr err))))))))
+
(ert-deftest test-mail-config-transport-mbsync-present-builds-command ()
"When mbsync exists, build the mu4e sync command."
(test-mail-config--with-executables '(("mbsync" . "/usr/bin/mbsync"))
diff --git a/tests/test-media-utils--argv.el b/tests/test-media-utils--argv.el
new file mode 100644
index 00000000..81317b60
--- /dev/null
+++ b/tests/test-media-utils--argv.el
@@ -0,0 +1,72 @@
+;;; test-media-utils--argv.el --- Tests for media-utils argv builders -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for the pure helpers behind cj/media-play-it's shell-free
+;; launch: cj/media--yt-dlp-argv (stream-URL resolution command),
+;; cj/media--stream-urls (yt-dlp -g output parsing), and
+;; cj/media--play-argv (player launch argv). Argv lists are the
+;; hardening: URLs and args never meet a shell.
+;;
+;; Test organization:
+;; - Normal Cases: formats present/absent, args split, multi-line output
+;; - Boundary Cases: metacharacter URLs verbatim, empty output, nil args
+;; - Error Cases: none (builders are total; error paths live in the caller)
+;;
+;;; Code:
+
+(require 'ert)
+(require 'media-utils)
+
+;;; cj/media--yt-dlp-argv
+
+(ert-deftest test-media-utils--yt-dlp-argv-normal-with-formats ()
+ "Normal: formats join with / behind -f, URL last."
+ (should (equal (cj/media--yt-dlp-argv "https://example.com/v" '("22" "18" "best"))
+ '("yt-dlp" "-f" "22/18/best" "-g" "https://example.com/v"))))
+
+(ert-deftest test-media-utils--yt-dlp-argv-normal-without-formats ()
+ "Normal: nil formats drops the -f pair."
+ (should (equal (cj/media--yt-dlp-argv "https://example.com/v" nil)
+ '("yt-dlp" "-g" "https://example.com/v"))))
+
+(ert-deftest test-media-utils--yt-dlp-argv-boundary-metacharacter-url ()
+ "Boundary: a URL with shell metacharacters stays one verbatim element."
+ (let ((url "https://example.com/v?a=1&b=$(x);c='d'"))
+ (should (equal (car (last (cj/media--yt-dlp-argv url nil))) url))))
+
+;;; cj/media--stream-urls
+
+(ert-deftest test-media-utils--stream-urls-normal-two-lines ()
+ "Normal: each non-empty output line is one stream URL."
+ (should (equal (cj/media--stream-urls "https://a/video\nhttps://a/audio\n")
+ '("https://a/video" "https://a/audio"))))
+
+(ert-deftest test-media-utils--stream-urls-boundary-blank-and-crlf ()
+ "Boundary: blank lines and CR line endings are stripped."
+ (should (equal (cj/media--stream-urls "https://a/v\r\n\n \nhttps://a/u\r\n")
+ '("https://a/v" "https://a/u"))))
+
+(ert-deftest test-media-utils--stream-urls-boundary-empty-output ()
+ "Boundary: empty output yields nil."
+ (should-not (cj/media--stream-urls "")))
+
+;;; cj/media--play-argv
+
+(ert-deftest test-media-utils--play-argv-normal-args-split ()
+ "Normal: the player's raw args string splits into argv words."
+ (should (equal (cj/media--play-argv "vlc" "--no-video --intf dummy"
+ '("https://a/v"))
+ '("vlc" "--no-video" "--intf" "dummy" "https://a/v"))))
+
+(ert-deftest test-media-utils--play-argv-boundary-nil-args ()
+ "Boundary: nil args yields program + URLs only."
+ (should (equal (cj/media--play-argv "mpv" nil '("https://a/v"))
+ '("mpv" "https://a/v"))))
+
+(ert-deftest test-media-utils--play-argv-boundary-multiple-urls ()
+ "Boundary: every resolved stream URL is appended in order."
+ (should (equal (cj/media--play-argv "mpv" nil '("https://a/v" "https://a/u"))
+ '("mpv" "https://a/v" "https://a/u"))))
+
+(provide 'test-media-utils--argv)
+;;; test-media-utils--argv.el ends here
diff --git a/tests/test-media-utils.el b/tests/test-media-utils.el
index 841b6faf..23b36eeb 100644
--- a/tests/test-media-utils.el
+++ b/tests/test-media-utils.el
@@ -38,34 +38,41 @@
;; ----------------------------- cj/media-play-it ------------------------------
(ert-deftest test-media-play-it-direct-playback-command ()
- "Normal: a player that needs no stream URL gets a plain command, no yt-dlp."
+ "Normal: a player that needs no stream URL launches an argv process, no yt-dlp."
(let (captured cj/default-media-player)
(setq cj/default-media-player 'mpv)
(cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/mpv"))
- ((symbol-function 'start-process-shell-command)
- (lambda (_n _b cmd) (setq captured cmd) 'proc))
+ ((symbol-function 'start-process)
+ (lambda (&rest args) (setq captured args) 'proc))
((symbol-function 'set-process-sentinel) #'ignore)
((symbol-function 'message) #'ignore)
((symbol-function 'cj/log-silently) #'ignore))
(cj/media-play-it "https://example.com/v"))
- (should (string-match-p "mpv" captured))
- (should (string-match-p "example\\.com" captured))
- (should-not (string-match-p "yt-dlp" captured))))
-
-(ert-deftest test-media-play-it-stream-url-wraps-yt-dlp ()
- "Normal: a player needing a stream URL wraps the URL in a yt-dlp -g call."
- (let (captured cj/default-media-player)
+ ;; (NAME BUFFER PROGRAM . ARGS) -- program + args are the argv.
+ (should (equal (nthcdr 2 captured) '("mpv" "https://example.com/v")))
+ (should-not (member "yt-dlp" captured))))
+
+(ert-deftest test-media-play-it-stream-url-resolves-via-yt-dlp ()
+ "Normal: a stream-URL player resolves through a yt-dlp -g capture, then
+launches the player with the resolved URL as argv -- no shell either step."
+ (let (yt-argv captured cj/default-media-player)
(setq cj/default-media-player 'vlc)
- (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/vlc"))
- ((symbol-function 'start-process-shell-command)
- (lambda (_n _b cmd) (setq captured cmd) 'proc))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/x"))
+ ((symbol-function 'call-process)
+ (lambda (program _infile _dest _display &rest args)
+ (setq yt-argv (cons program args))
+ (insert "https://stream.example.com/resolved\n")
+ 0))
+ ((symbol-function 'start-process)
+ (lambda (&rest args) (setq captured args) 'proc))
((symbol-function 'set-process-sentinel) #'ignore)
((symbol-function 'message) #'ignore)
((symbol-function 'cj/log-silently) #'ignore))
(cj/media-play-it "https://example.com/v"))
- (should (string-match-p "yt-dlp" captured))
- (should (string-match-p "-g" captured))
- (should (string-match-p "-f 22/18/best" captured))))
+ (should (equal yt-argv
+ '("yt-dlp" "-f" "22/18/best" "-g" "https://example.com/v")))
+ (should (equal (nthcdr 2 captured)
+ '("vlc" "https://stream.example.com/resolved")))))
(ert-deftest test-media-play-it-missing-player-errors ()
"Error: an unavailable player command signals an error before launching."
@@ -74,6 +81,73 @@
(cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(should-error (cj/media-play-it "https://example.com/v")))))
+(ert-deftest test-media-play-it-missing-yt-dlp-errors ()
+ "Error: a stream-URL player with no yt-dlp on PATH aborts before resolving."
+ (let (cj/default-media-player)
+ (setq cj/default-media-player 'vlc)
+ (cl-letf (((symbol-function 'executable-find)
+ (lambda (cmd &rest _) (and (equal cmd "vlc") "/usr/bin/vlc"))))
+ (should-error (cj/media-play-it "https://example.com/v")))))
+
+(ert-deftest test-media-play-it-yt-dlp-failure-errors ()
+ "Error: a non-zero yt-dlp exit surfaces as an error, player never launches."
+ (let (launched cj/default-media-player)
+ (setq cj/default-media-player 'vlc)
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/x"))
+ ((symbol-function 'call-process)
+ (lambda (&rest _) (insert "ERROR: no video\n") 1))
+ ((symbol-function 'start-process)
+ (lambda (&rest _) (setq launched t) 'proc))
+ ((symbol-function 'message) #'ignore))
+ (should-error (cj/media-play-it "https://example.com/v")))
+ (should-not launched)))
+
+(ert-deftest test-media-play-it-yt-dlp-empty-output-errors ()
+ "Error: a zero-exit yt-dlp with no output still errors, player never launches."
+ (let (launched cj/default-media-player)
+ (setq cj/default-media-player 'vlc)
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/x"))
+ ((symbol-function 'call-process) (lambda (&rest _) 0))
+ ((symbol-function 'start-process)
+ (lambda (&rest _) (setq launched t) 'proc))
+ ((symbol-function 'message) #'ignore))
+ (should-error (cj/media-play-it "https://example.com/v")))
+ (should-not launched)))
+
+;; -------------------------- cj/media--play-sentinel --------------------------
+
+(ert-deftest test-media-utils--play-sentinel-normal-finished-kills-buffer ()
+ "Normal: a finished event reports success and reaps the process buffer."
+ (let ((buf (generate-new-buffer " *sentinel-test*"))
+ (said nil))
+ (cl-letf (((symbol-function 'process-buffer) (lambda (_p) buf))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq said (apply #'format fmt args)))))
+ (funcall (cj/media--play-sentinel "https://a/v") 'proc "finished\n"))
+ (should (string-match-p "Finished" said))
+ (should-not (buffer-live-p buf))))
+
+(ert-deftest test-media-utils--play-sentinel-normal-abnormal-exit-kills-buffer ()
+ "Normal: an abnormal exit reports failure and reaps the process buffer."
+ (let ((buf (generate-new-buffer " *sentinel-test*"))
+ (said nil))
+ (cl-letf (((symbol-function 'process-buffer) (lambda (_p) buf))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq said (apply #'format fmt args)))))
+ (funcall (cj/media--play-sentinel "https://a/v") 'proc "exited abnormally with code 2\n"))
+ (should (string-match-p "failed" said))
+ (should-not (buffer-live-p buf))))
+
+(ert-deftest test-media-utils--play-sentinel-boundary-other-event-keeps-buffer ()
+ "Boundary: a non-terminal event (e.g. stop) leaves the buffer alone."
+ (let ((buf (generate-new-buffer " *sentinel-test*")))
+ (unwind-protect
+ (cl-letf (((symbol-function 'process-buffer) (lambda (_p) buf))
+ ((symbol-function 'message) #'ignore))
+ (funcall (cj/media--play-sentinel "https://a/v") 'proc "stopped\n")
+ (should (buffer-live-p buf)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
;; ------------------------------- cj/yt-dl-it ---------------------------------
(ert-deftest test-media-yt-dl-it-errors-without-yt-dlp ()
diff --git a/tests/test-mu4e-attachments.el b/tests/test-mu4e-attachments.el
index 0a780977..986c2746 100644
--- a/tests/test-mu4e-attachments.el
+++ b/tests/test-mu4e-attachments.el
@@ -75,6 +75,56 @@ so this fails the same way whether or not mu4e's MIME support is loadable."
(should-error (cj/mu4e--save-attachment-part part "/downloads")
:type 'user-error)))
+(ert-deftest test-mu4e-attachments-save-part-errors-on-stale-handle ()
+ "Error: a handle whose MIME buffer was killed fails with a clear error.
+The selection buffer captures handles when it opens; a real MIME handle's
+car is the buffer holding the part's bytes, and viewing another message
+kills it. Saving through it must signal a `user-error' naming the file,
+not die in `mm-save-part-to-file' (or save another message's bytes)."
+ (let* ((dead (generate-new-buffer "stale-mime-part"))
+ (part (test-mu4e-attachments--part "invoice.pdf" 3 (list dead))))
+ (kill-buffer dead)
+ (should-error (cj/mu4e--save-attachment-part part "/downloads")
+ :type 'user-error)
+ (condition-case err
+ (cj/mu4e--save-attachment-part part "/downloads")
+ (user-error (should (string-match-p "invoice\\.pdf" (cadr err)))))))
+
+(ert-deftest test-mu4e-attachments-save-part-live-buffer-handle-saves ()
+ "Normal: a handle whose MIME buffer is alive saves normally."
+ (let* ((live (generate-new-buffer "live-mime-part"))
+ (part (test-mu4e-attachments--part "invoice.pdf" 3 (list live)))
+ (mu4e-uniquify-save-file-name-function #'identity)
+ saved)
+ (unwind-protect
+ (cl-letf (((symbol-function 'mu4e-join-paths)
+ (lambda (&rest pieces) (mapconcat #'identity pieces "/")))
+ ((symbol-function 'mm-save-part-to-file)
+ (lambda (_handle path) (setq saved path))))
+ (should (equal (cj/mu4e--save-attachment-part part "/downloads")
+ "/downloads/invoice.pdf"))
+ (should (equal saved "/downloads/invoice.pdf")))
+ (kill-buffer live))))
+
+(ert-deftest test-mu4e-attachments-save-parts-mid-batch-failure-propagates ()
+ "Error: a mid-batch save failure propagates; earlier parts stay saved.
+Characterizes the batch path: no silent skip of the failing part, and the
+files already written are not rolled back."
+ (let ((parts (list (test-mu4e-attachments--part "a.pdf" 1)
+ (test-mu4e-attachments--part "b.pdf" 2)
+ (test-mu4e-attachments--part "c.pdf" 3)))
+ (saved '()))
+ (cl-letf (((symbol-function 'cj/mu4e--save-attachment-part)
+ (lambda (part _dir)
+ (let ((name (plist-get part :filename)))
+ (when (equal name "b.pdf")
+ (user-error "Stale handle: %s" name))
+ (push name saved)
+ name))))
+ (should-error (cj/mu4e--save-attachment-parts parts "/downloads")
+ :type 'user-error))
+ (should (equal (nreverse saved) '("a.pdf")))))
+
(ert-deftest test-mu4e-attachments-save-all-prompts-once ()
"Normal: the save-all command prompts for a directory once and saves all parts."
(let ((parts (list (test-mu4e-attachments--part "a.pdf" 1)
diff --git a/tests/test-music-config--art-cache-key.el b/tests/test-music-config--art-cache-key.el
new file mode 100644
index 00000000..bc869808
--- /dev/null
+++ b/tests/test-music-config--art-cache-key.el
@@ -0,0 +1,69 @@
+;;; test-music-config--art-cache-key.el --- Tests for cover-art cache key -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music-art--cache-key': the stable cache-file basename for
+;; a track. A url with a known #RADIOBROWSERUUID keys on the uuid (so the same
+;; station shares one cached logo); any other url keys on a hash of its address;
+;; a file keys on a hash of its path.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(ert-deftest test-music-config--art-cache-key-normal-uuid ()
+ "Normal: a url with a UUID in the entries keys on the UUID."
+ (let ((track (emms-track 'url "https://ck.somafm.com/gs"))
+ (entries '(("https://ck.somafm.com/gs" :name "GS" :uuid "uuid-42" :favicon nil))))
+ (should (string= (cj/music-art--cache-key track entries) "uuid-42"))))
+
+(ert-deftest test-music-config--art-cache-key-boundary-url-no-uuid ()
+ "Boundary: a url with no UUID keys on a stable url- hash, not the raw URL."
+ (let* ((url "https://ck2.example.net/live")
+ (track (emms-track 'url url))
+ (key (cj/music-art--cache-key track nil)))
+ (should (string-prefix-p "url-" key))
+ (should (string= key (concat "url-" (sha1 url))))))
+
+(ert-deftest test-music-config--art-cache-key-normal-file ()
+ "Normal: a file keys on a file- hash of its path."
+ (let* ((path "/music/Kind of Blue/01.flac")
+ (track (emms-track 'file path)))
+ (should (string= (cj/music-art--cache-key track nil)
+ (concat "file-" (sha1 path))))))
+
+(ert-deftest test-music-config--art-cache-key-boundary-empty-uuid-falls-to-hash ()
+ "Boundary: an empty-string UUID is treated as absent, so the url hashes."
+ (let* ((url "https://ck3.example.net/x")
+ (track (emms-track 'url url))
+ (entries (list (list url :name "X" :uuid "" :favicon nil))))
+ (should (string= (cj/music-art--cache-key track entries)
+ (concat "url-" (sha1 url))))))
+
+(ert-deftest test-music-config--art-cache-key-normal-track-property ()
+ "Normal: a queued lookup station carries its uuid as a track property and
+needs no entries at all."
+ (let ((track (emms-track 'url "https://ck4.example.net/live")))
+ (emms-track-set track 'radio-uuid "prop-uuid")
+ (should (string= (cj/music-art--cache-key track nil) "prop-uuid"))))
+
+(ert-deftest test-music-config--art-cache-key-normal-property-beats-entries ()
+ "Normal: the track property wins over a conflicting entries uuid."
+ (let* ((url "https://ck5.example.net/live")
+ (track (emms-track 'url url))
+ (entries (list (list url :name "X" :uuid "entries-uuid" :favicon nil))))
+ (emms-track-set track 'radio-uuid "prop-uuid")
+ (should (string= (cj/music-art--cache-key track entries) "prop-uuid"))))
+
+(provide 'test-music-config--art-cache-key)
+;;; test-music-config--art-cache-key.el ends here
diff --git a/tests/test-music-config--art-favicon-url.el b/tests/test-music-config--art-favicon-url.el
new file mode 100644
index 00000000..9e7b92f8
--- /dev/null
+++ b/tests/test-music-config--art-favicon-url.el
@@ -0,0 +1,69 @@
+;;; test-music-config--art-favicon-url.el --- Tests for stream favicon URL -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music-art--favicon-url': the direct favicon image URL for
+;; a url track, taken from the #RADIOBROWSERFAVICON captured at station creation.
+;; A station with only a UUID resolves its favicon via a separate byuuid lookup
+;; (done in the impure orchestrator), so this pure helper returns nil there.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(ert-deftest test-music-config--art-favicon-url-normal-captured ()
+ "Normal: a captured #RADIOBROWSERFAVICON is returned directly."
+ (let ((track (emms-track 'url "https://fav.somafm.com/gs"))
+ (entries '(("https://fav.somafm.com/gs"
+ :name "GS" :uuid "u1" :favicon "https://cdn.example/gs.png"))))
+ (should (string= (cj/music-art--favicon-url track entries)
+ "https://cdn.example/gs.png"))))
+
+(ert-deftest test-music-config--art-favicon-url-boundary-uuid-only ()
+ "Boundary: a station with a UUID but no captured favicon returns nil
+\(the byuuid lookup is the orchestrator's job)."
+ (let ((track (emms-track 'url "https://fav2.example.net/live"))
+ (entries '(("https://fav2.example.net/live"
+ :name "X" :uuid "u2" :favicon nil))))
+ (should (null (cj/music-art--favicon-url track entries)))))
+
+(ert-deftest test-music-config--art-favicon-url-boundary-empty-favicon ()
+ "Boundary: an empty-string favicon is treated as absent."
+ (let ((track (emms-track 'url "https://fav3.example.net/live"))
+ (entries '(("https://fav3.example.net/live" :name "X" :uuid "u3" :favicon ""))))
+ (should (null (cj/music-art--favicon-url track entries)))))
+
+(ert-deftest test-music-config--art-favicon-url-error-file-track ()
+ "Error: a file track has no stream favicon URL."
+ (let ((track (emms-track 'file "/music/x.flac")))
+ (should (null (cj/music-art--favicon-url track nil)))))
+
+(ert-deftest test-music-config--art-favicon-url-normal-track-property ()
+ "Normal: a queued lookup station carries its favicon as a track property."
+ (let ((track (emms-track 'url "https://fp.example.net/live")))
+ (emms-track-set track 'radio-favicon "https://fp.example.net/icon.png")
+ (should (string= (cj/music-art--favicon-url track nil)
+ "https://fp.example.net/icon.png"))))
+
+(ert-deftest test-music-config--art-favicon-url-normal-property-beats-entries ()
+ "Normal: the track property wins over a conflicting entries favicon."
+ (let* ((url "https://fp2.example.net/live")
+ (track (emms-track 'url url))
+ (entries (list (list url :name "X" :uuid nil
+ :favicon "https://entries.example/e.png"))))
+ (emms-track-set track 'radio-favicon "https://prop.example/p.png")
+ (should (string= (cj/music-art--favicon-url track entries)
+ "https://prop.example/p.png"))))
+
+(provide 'test-music-config--art-favicon-url)
+;;; test-music-config--art-favicon-url.el ends here
diff --git a/tests/test-music-config--art-valid-image.el b/tests/test-music-config--art-valid-image.el
new file mode 100644
index 00000000..c8de0eda
--- /dev/null
+++ b/tests/test-music-config--art-valid-image.el
@@ -0,0 +1,51 @@
+;;; test-music-config--art-valid-image.el --- Tests for fetched-image validation -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music-art--valid-image-p': recognize whether fetched bytes
+;; are actually a displayable image, so an empty body, an HTML error page served
+;; 200, or a text response is rejected before it lands in the cache. Detection
+;; is by image header (`image-type-from-data'), which works headless.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(defconst test-art--png-1x1
+ (base64-decode-string
+ (concat "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk"
+ "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="))
+ "A minimal valid 1x1 PNG, as raw bytes.")
+
+(ert-deftest test-music-config--art-valid-image-normal-png ()
+ "Normal: real PNG bytes are recognized as a valid image."
+ (should (cj/music-art--valid-image-p test-art--png-1x1)))
+
+(ert-deftest test-music-config--art-valid-image-error-html ()
+ "Error: an HTML error page served 200 is not a valid image."
+ (should-not (cj/music-art--valid-image-p "<html><body>502 Bad Gateway</body></html>")))
+
+(ert-deftest test-music-config--art-valid-image-error-text ()
+ "Error: arbitrary text is not a valid image."
+ (should-not (cj/music-art--valid-image-p "this is not an image")))
+
+(ert-deftest test-music-config--art-valid-image-boundary-empty ()
+ "Boundary: an empty body is not a valid image."
+ (should-not (cj/music-art--valid-image-p "")))
+
+(ert-deftest test-music-config--art-valid-image-boundary-nil ()
+ "Boundary: nil is not a valid image."
+ (should-not (cj/music-art--valid-image-p nil)))
+
+(provide 'test-music-config--art-valid-image)
+;;; test-music-config--art-valid-image.el ends here
diff --git a/tests/test-music-config--bar-fill.el b/tests/test-music-config--bar-fill.el
new file mode 100644
index 00000000..f6d8dac8
--- /dev/null
+++ b/tests/test-music-config--bar-fill.el
@@ -0,0 +1,63 @@
+;;; test-music-config--bar-fill.el --- Tests for progress-bar fill math -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--bar-fill': the pure computation of how many cells
+;; of a WIDTH-cell progress bar are filled given ELAPSED and TOTAL seconds.
+;; A stream (no duration) is indeterminate; the live elapsed source (mpv) is
+;; wired in a later phase, so this helper only does the clamp-and-round math.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+;;; Normal
+
+(ert-deftest test-music-config--bar-fill-normal-half ()
+ "Normal: halfway through fills half the cells."
+ (should (= (cj/music--bar-fill 30 60 20) 10)))
+
+(ert-deftest test-music-config--bar-fill-normal-quarter ()
+ "Normal: a quarter elapsed rounds to a quarter of the cells."
+ (should (= (cj/music--bar-fill 15 60 20) 5)))
+
+;;; Boundary
+
+(ert-deftest test-music-config--bar-fill-boundary-zero-elapsed ()
+ "Boundary: nothing elapsed fills no cells."
+ (should (= (cj/music--bar-fill 0 60 20) 0)))
+
+(ert-deftest test-music-config--bar-fill-boundary-full ()
+ "Boundary: elapsed equal to total fills every cell."
+ (should (= (cj/music--bar-fill 60 60 20) 20)))
+
+(ert-deftest test-music-config--bar-fill-boundary-over-clamps ()
+ "Boundary: elapsed past total clamps to full, never overflows."
+ (should (= (cj/music--bar-fill 90 60 20) 20)))
+
+(ert-deftest test-music-config--bar-fill-boundary-nil-elapsed ()
+ "Boundary: an unknown elapsed (nil) fills no cells."
+ (should (= (cj/music--bar-fill nil 60 20) 0)))
+
+;;; Error / indeterminate
+
+(ert-deftest test-music-config--bar-fill-indeterminate-nil-total ()
+ "Error: a stream (nil total) is indeterminate, not a cell count."
+ (should (eq (cj/music--bar-fill 30 nil 20) 'indeterminate)))
+
+(ert-deftest test-music-config--bar-fill-indeterminate-zero-total ()
+ "Error: a zero total is indeterminate rather than a divide-by-zero."
+ (should (eq (cj/music--bar-fill 30 0 20) 'indeterminate)))
+
+(provide 'test-music-config--bar-fill)
+;;; test-music-config--bar-fill.el ends here
diff --git a/tests/test-music-config--bar-string.el b/tests/test-music-config--bar-string.el
new file mode 100644
index 00000000..bb6ac96f
--- /dev/null
+++ b/tests/test-music-config--bar-string.el
@@ -0,0 +1,50 @@
+;;; test-music-config--bar-string.el --- Tests for the block progress bar -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--bar-string': render a WIDTH-cell block bar from a
+;; filled-cell count (from `cj/music--bar-fill'), or an "on air" marker when the
+;; fill is `indeterminate' (a live stream with no duration). Face-carrying
+;; text; the tests assert the rendered length and content, not the faces.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(ert-deftest test-music-config--bar-string-normal-half ()
+ "Normal: 10 of 20 cells filled renders a 20-cell bar."
+ (let ((s (substring-no-properties (cj/music--bar-string 10 20))))
+ (should (= (length s) 20))
+ (should (= (cl-count ?█ s) 10))
+ (should (= (cl-count ?░ s) 10))))
+
+(ert-deftest test-music-config--bar-string-boundary-empty ()
+ "Boundary: zero fill is all empty cells."
+ (let ((s (substring-no-properties (cj/music--bar-string 0 20))))
+ (should (= (cl-count ?█ s) 0))
+ (should (= (cl-count ?░ s) 20))))
+
+(ert-deftest test-music-config--bar-string-boundary-full ()
+ "Boundary: full fill is all filled cells."
+ (let ((s (substring-no-properties (cj/music--bar-string 20 20))))
+ (should (= (cl-count ?█ s) 20))
+ (should (= (cl-count ?░ s) 0))))
+
+(ert-deftest test-music-config--bar-string-indeterminate-on-air ()
+ "Error/indeterminate: a stream renders an on-air marker, not a bar."
+ (let ((s (substring-no-properties (cj/music--bar-string 'indeterminate 20))))
+ (should (string-match-p "on air" s))
+ (should (= (cl-count ?█ s) 0))))
+
+(provide 'test-music-config--bar-string)
+;;; test-music-config--bar-string.el ends here
diff --git a/tests/test-music-config--completion-table.el b/tests/test-music-config--completion-table.el
index 5e33e655..6b3da442 100644
--- a/tests/test-music-config--completion-table.el
+++ b/tests/test-music-config--completion-table.el
@@ -19,6 +19,10 @@
(defvar-keymap cj/custom-keymap
:doc "Stub keymap for testing")
+;; Declare special here too (the module's bare defvar is file-local) so the
+;; registration test's `let' binds dynamically.
+(defvar marginalia-annotator-registry)
+
;; Load production code
(require 'music-config)
@@ -131,5 +135,15 @@
;; Should not crash, returns empty
(should (null result))))
+;;; Marginalia registration
+
+(ert-deftest test-music-config--completion-table-registers-with-marginalia ()
+ "Normal: building the table registers cj-music-file so marginalia
+right-aligns the size/date annotations."
+ (let ((marginalia-annotator-registry '()))
+ (cj/music--completion-table '("a.mp3"))
+ (should (equal (assq 'cj-music-file marginalia-annotator-registry)
+ '(cj-music-file builtin none)))))
+
(provide 'test-music-config--completion-table)
;;; test-music-config--completion-table.el ends here
diff --git a/tests/test-music-config--delete-playlist-file.el b/tests/test-music-config--delete-playlist-file.el
new file mode 100644
index 00000000..ace51a4d
--- /dev/null
+++ b/tests/test-music-config--delete-playlist-file.el
@@ -0,0 +1,135 @@
+;;; test-music-config--delete-playlist-file.el --- Tests for playlist file deletion -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for cj/music--delete-playlist-file function.
+;; Tests the internal helper that removes a playlist's .m3u file,
+;; clears the playlist buffer's file association when it pointed at
+;; the deleted file, and refreshes the radio metadata cache.
+;;
+;; Test organization:
+;; - Normal Cases: Existing file is deleted
+;; - Boundary Cases: Association cleared only when it matches; cache refresh
+;; - Error Cases: Nil path, nonexistent path
+;;
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Stub missing dependencies before loading music-config
+(defvar-keymap cj/custom-keymap
+ :doc "Stub keymap for testing")
+
+;; Add EMMS elpa directory to load path for batch testing
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir
+ (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'emms-playlist-mode)
+(require 'music-config)
+
+;;; Test helpers
+
+(defun test-delete-playlist--setup ()
+ "Create test base dir and ensure playlist buffer exists."
+ (cj/create-test-base-dir)
+ (let ((buf (get-buffer-create cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (emms-playlist-mode)
+ (setq emms-playlist-buffer-p t))
+ (setq emms-playlist-buffer buf)
+ buf))
+
+(defun test-delete-playlist--teardown ()
+ "Clean up test playlist buffer and temp files."
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (setq cj/music-playlist-file nil))
+ (kill-buffer buf))
+ (cj/delete-test-base-dir))
+
+;;; Normal Cases
+
+(ert-deftest test-music-config--delete-playlist-file-normal-removes-file ()
+ "Normal: an existing playlist file is deleted from disk."
+ (test-delete-playlist--setup)
+ (unwind-protect
+ (let ((file (cj/create-temp-test-file-with-content
+ "#EXTM3U\n/tmp/song.mp3\n" "playlist.m3u")))
+ (cj/music--delete-playlist-file file)
+ (should-not (file-exists-p file)))
+ (test-delete-playlist--teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-config--delete-playlist-file-boundary-clears-matching-association ()
+ "Boundary: deleting the associated playlist file clears the association."
+ (test-delete-playlist--setup)
+ (unwind-protect
+ (let ((file (cj/create-temp-test-file-with-content
+ "#EXTM3U\n" "playlist.m3u")))
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (setq cj/music-playlist-file file))
+ (cj/music--delete-playlist-file file)
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (should-not cj/music-playlist-file)))
+ (test-delete-playlist--teardown)))
+
+(ert-deftest test-music-config--delete-playlist-file-boundary-keeps-other-association ()
+ "Boundary: deleting a different file leaves the association untouched."
+ (test-delete-playlist--setup)
+ (unwind-protect
+ (let ((doomed (cj/create-temp-test-file-with-content
+ "#EXTM3U\n" "doomed.m3u"))
+ (kept (cj/create-temp-test-file-with-content
+ "#EXTM3U\n" "kept.m3u")))
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (setq cj/music-playlist-file kept))
+ (cj/music--delete-playlist-file doomed)
+ (should (file-exists-p kept))
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (should (equal cj/music-playlist-file kept))))
+ (test-delete-playlist--teardown)))
+
+(ert-deftest test-music-config--delete-playlist-file-boundary-refreshes-radio-cache ()
+ "Boundary: deletion clears the cached radio metadata."
+ (test-delete-playlist--setup)
+ (unwind-protect
+ (let ((file (cj/create-temp-test-file-with-content
+ "#EXTM3U\n" "playlist.m3u")))
+ (setq cj/music--radio-metadata-cache '(("stale" . "entry")))
+ (cj/music--delete-playlist-file file)
+ (should-not cj/music--radio-metadata-cache))
+ (test-delete-playlist--teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-music-config--delete-playlist-file-error-nil-path ()
+ "Error: nil path signals user-error."
+ (test-delete-playlist--setup)
+ (unwind-protect
+ (should-error (cj/music--delete-playlist-file nil) :type 'user-error)
+ (test-delete-playlist--teardown)))
+
+(ert-deftest test-music-config--delete-playlist-file-error-nonexistent-path ()
+ "Error: a path that does not exist signals user-error and touches nothing."
+ (test-delete-playlist--setup)
+ (unwind-protect
+ (let ((kept (cj/create-temp-test-file-with-content
+ "#EXTM3U\n" "kept.m3u")))
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (setq cj/music-playlist-file kept))
+ (should-error
+ (cj/music--delete-playlist-file
+ (expand-file-name "no-such.m3u" cj/test-base-dir))
+ :type 'user-error)
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (should (equal cj/music-playlist-file kept))))
+ (test-delete-playlist--teardown)))
+
+(provide 'test-music-config--delete-playlist-file)
+;;; test-music-config--delete-playlist-file.el ends here
diff --git a/tests/test-music-config--display-name.el b/tests/test-music-config--display-name.el
new file mode 100644
index 00000000..c1065f3a
--- /dev/null
+++ b/tests/test-music-config--display-name.el
@@ -0,0 +1,139 @@
+;;; test-music-config--display-name.el --- Tests for track display-name -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--display-name' and `cj/music--format-meta'.
+;;
+;; display-name is the pure, name-only resolver shared by the header's Current
+;; line and the playlist row renderer: for a tagged track it returns
+;; "Artist - Title" (no duration -- duration is right-aligned meta, not name);
+;; for an untagged file the clean filename; for a url track the #EXTINF label
+;; from a passed name-map, else a tidied host; unknown types fall back to
+;; emms-track-simple-description.
+;;
+;; format-meta returns the right-aligned meta string for a row: a file's
+;; duration as "[M:SS]", empty otherwise.
+;;
+;; Track independence: `emms-track' returns a track cached by (type . name)
+;; once EMMS is loaded, so two tests sharing a name would share a mutated
+;; object. Every test below uses a UNIQUE track name to stay independent.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'emms-playlist-mode)
+(require 'music-config)
+
+;;; Helpers
+
+(defun test-display-name--file (path &optional title artist duration)
+ "Create a file TRACK with PATH and optional TITLE ARTIST DURATION."
+ (let ((track (emms-track 'file path)))
+ (when title (emms-track-set track 'info-title title))
+ (when artist (emms-track-set track 'info-artist artist))
+ (when duration (emms-track-set track 'info-playing-time duration))
+ track))
+
+(defun test-display-name--url (url &optional title artist)
+ "Create a url TRACK with URL and optional TITLE ARTIST."
+ (let ((track (emms-track 'url url)))
+ (when title (emms-track-set track 'info-title title))
+ (when artist (emms-track-set track 'info-artist artist))
+ track))
+
+;;; Normal -- tagged tracks (no duration in the name)
+
+(ert-deftest test-music-config--display-name-normal-artist-title ()
+ "Normal: tagged track shows Artist - Title, no duration bracket."
+ (let ((track (test-display-name--file
+ "/dn/artist-title.flac" "So What" "Miles Davis" 562)))
+ (should (string= (cj/music--display-name track) "Miles Davis - So What"))))
+
+(ert-deftest test-music-config--display-name-normal-title-only ()
+ "Normal: title without artist shows the title alone."
+ (let ((track (test-display-name--file "/dn/title-only.mp3" "Flamenco Sketches" nil 566)))
+ (should (string= (cj/music--display-name track) "Flamenco Sketches"))))
+
+;;; Normal -- untagged file
+
+(ert-deftest test-music-config--display-name-normal-file-no-tags ()
+ "Normal: untagged file shows filename without path or extension."
+ (let ((track (test-display-name--file "/dn/Kind of Blue/02 - Freddie.flac")))
+ (should (string= (cj/music--display-name track) "02 - Freddie"))))
+
+;;; Normal -- url resolves to #EXTINF label from the name-map
+
+(ert-deftest test-music-config--display-name-normal-url-label-from-map ()
+ "Normal: a url track resolves to its #EXTINF label via the name-map."
+ (let ((track (test-display-name--url "https://ice6.somafm.com/groovesalad-256-mp3"))
+ (map '(("https://ice6.somafm.com/groovesalad-256-mp3" . "SomaFM Groove Salad"))))
+ (should (string= (cj/music--display-name track map) "SomaFM Groove Salad"))))
+
+;;; Normal -- url with tags formats like a tagged track
+
+(ert-deftest test-music-config--display-name-normal-url-with-tags ()
+ "Normal: a url track carrying tags uses Artist - Title, not the URL."
+ (let ((track (test-display-name--url "https://tagged.example.com/stream"
+ "Jazz FM" "Radio Station")))
+ (should (string= (cj/music--display-name track) "Radio Station - Jazz FM"))))
+
+;;; Boundary -- url with no label falls back to a tidied host
+
+(ert-deftest test-music-config--display-name-boundary-url-host-fallback ()
+ "Boundary: a url with no label and no map falls back to the tidied host."
+ (let ((track (test-display-name--url "https://ice6.hostonly.somafm.com/gs")))
+ (should (string= (cj/music--display-name track) "somafm.com"))))
+
+(ert-deftest test-music-config--display-name-boundary-url-not-in-map ()
+ "Boundary: a url absent from a non-empty map still falls to the host."
+ (let ((track (test-display-name--url "https://stream.other.net/live"))
+ (map '(("https://ice6.somafm.com/x" . "Groove Salad"))))
+ (should (string= (cj/music--display-name track map) "other.net"))))
+
+(ert-deftest test-music-config--display-name-boundary-unicode-title ()
+ "Boundary: unicode in the title is preserved."
+ (let ((track (test-display-name--file "/dn/unicode.mp3" "夜に駆ける" "YOASOBI" 258)))
+ (should (string= (cj/music--display-name track) "YOASOBI - 夜に駆ける"))))
+
+(ert-deftest test-music-config--display-name-boundary-file-multiple-dots ()
+ "Boundary: only the final extension is stripped."
+ (let ((track (test-display-name--file "/dn/disc.1.track.03.flac")))
+ (should (string= (cj/music--display-name track) "disc.1.track.03"))))
+
+;;; Error -- unknown type falls back without erroring
+
+(ert-deftest test-music-config--display-name-error-unknown-type ()
+ "Error: an unknown track type falls back to a string, no error."
+ (let* ((track (emms-track 'streamlist "https://unknown.example.com/playlist.m3u"))
+ (result (cj/music--display-name track)))
+ (should (stringp result))
+ (should (string-match-p "example\\.com" result))))
+
+;;; format-meta
+
+(ert-deftest test-music-config--format-meta-normal-file-duration ()
+ "Normal: a file with a duration yields the bracketed M:SS meta."
+ (let ((track (test-display-name--file "/dn/meta-dur.flac" "So What" "Miles" 562)))
+ (should (string= (cj/music--format-meta track) "[9:22]"))))
+
+(ert-deftest test-music-config--format-meta-boundary-no-duration ()
+ "Boundary: a track with no duration yields an empty meta string."
+ (let ((track (test-display-name--file "/dn/meta-nodur.flac" "So What" "Miles")))
+ (should (string= (cj/music--format-meta track) ""))))
+
+(ert-deftest test-music-config--format-meta-boundary-url-no-meta ()
+ "Boundary: a url stream with no duration yields empty meta."
+ (let ((track (test-display-name--url "https://meta.example.com/stream")))
+ (should (string= (cj/music--format-meta track) ""))))
+
+(provide 'test-music-config--display-name)
+;;; test-music-config--display-name.el ends here
diff --git a/tests/test-music-config--get-m3u-basenames.el b/tests/test-music-config--get-m3u-basenames.el
index 91c8af70..1e3875df 100644
--- a/tests/test-music-config--get-m3u-basenames.el
+++ b/tests/test-music-config--get-m3u-basenames.el
@@ -9,7 +9,7 @@
;; Test organization:
;; - Normal Cases: Multiple files, single file
;; - Boundary Cases: Empty directory, extension removal
-;; - Error Cases: Nonexistent directory
+;; - Error Cases: Nonexistent directory is skipped (not fatal)
;;
;;; Code:
@@ -47,7 +47,7 @@
(rename-file file2 (expand-file-name "jazz.m3u" test-dir))
(rename-file file3 (expand-file-name "classical.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-basenames)))
(should (= (length result) 3))
;; Sort for consistent comparison
@@ -63,7 +63,7 @@
(file1 (cj/create-temp-test-file-with-content "" "favorites.m3u")))
(rename-file file1 (expand-file-name "favorites.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-basenames)))
(should (= (length result) 1))
(should (equal (car result) "favorites")))))
@@ -76,7 +76,7 @@
(test-music-config--get-m3u-basenames-setup)
(unwind-protect
(let* ((test-dir (cj/create-test-subdirectory "empty-playlists")))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-basenames)))
(should (null result)))))
(test-music-config--get-m3u-basenames-teardown)))
@@ -89,7 +89,7 @@
(file1 (cj/create-temp-test-file-with-content "" "test.m3u")))
(rename-file file1 (expand-file-name "playlist.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-basenames)))
(should (equal result '("playlist")))
;; Verify no .m3u extension present
@@ -104,18 +104,17 @@
(file1 (cj/create-temp-test-file-with-content "" "test.m3u")))
(rename-file file1 (expand-file-name "My Favorite Songs.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-basenames)))
(should (equal result '("My Favorite Songs"))))))
(test-music-config--get-m3u-basenames-teardown)))
;;; Error Cases
-(ert-deftest test-music-config--get-m3u-basenames-error-nonexistent-directory-signals-error ()
- "Nonexistent directory signals error."
- (let ((cj/music-m3u-root "/nonexistent/directory/path"))
- (should-error (cj/music--get-m3u-basenames)
- :type 'file-error)))
+(ert-deftest test-music-config--get-m3u-basenames-error-nonexistent-directory-skipped ()
+ "Nonexistent directories in the roots list are skipped, returning empty."
+ (let ((cj/music-m3u-roots '("/nonexistent/directory/path")))
+ (should-not (cj/music--get-m3u-basenames))))
(provide 'test-music-config--get-m3u-basenames)
;;; test-music-config--get-m3u-basenames.el ends here
diff --git a/tests/test-music-config--get-m3u-files.el b/tests/test-music-config--get-m3u-files.el
index 2d31d554..356735a9 100644
--- a/tests/test-music-config--get-m3u-files.el
+++ b/tests/test-music-config--get-m3u-files.el
@@ -4,12 +4,12 @@
;;
;;; Commentary:
;; Unit tests for cj/music--get-m3u-files function.
-;; Tests the helper that discovers M3U files in the music directory.
+;; Tests the helper that discovers M3U files across cj/music-m3u-roots.
;;
;; Test organization:
;; - Normal Cases: Multiple M3U files, single file
;; - Boundary Cases: Empty directory, non-M3U files, various filenames
-;; - Error Cases: Nonexistent directory
+;; - Error Cases: Nonexistent directory is skipped (not fatal)
;;
;;; Code:
@@ -48,7 +48,7 @@
(rename-file file2 (expand-file-name "playlist2.m3u" test-dir))
(rename-file file3 (expand-file-name "playlist3.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (= (length result) 3))
;; Check structure: list of (basename . fullpath) conses
@@ -70,7 +70,7 @@
(file1 (cj/create-temp-test-file-with-content "" "myplaylist.m3u")))
(rename-file file1 (expand-file-name "myplaylist.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (= (length result) 1))
(should (equal (caar result) "myplaylist.m3u"))
@@ -84,7 +84,7 @@
(test-music-config--get-m3u-files-setup)
(unwind-protect
(let* ((test-dir (cj/create-test-subdirectory "empty-playlists")))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (null result)))))
(test-music-config--get-m3u-files-teardown)))
@@ -101,7 +101,7 @@
(rename-file mp3-file (expand-file-name "song.mp3" test-dir))
(rename-file json-file (expand-file-name "data.json" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (null result)))))
(test-music-config--get-m3u-files-teardown)))
@@ -114,7 +114,7 @@
(file1 (cj/create-temp-test-file-with-content "" "my-playlist.m3u")))
(rename-file file1 (expand-file-name "My Favorite Songs.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (= (length result) 1))
(should (equal (caar result) "My Favorite Songs.m3u")))))
@@ -132,7 +132,7 @@
(rename-file txt-file (expand-file-name "readme.txt" test-dir))
(rename-file mp3-file (expand-file-name "song.mp3" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (= (length result) 1))
(should (equal (caar result) "playlist.m3u")))))
@@ -140,11 +140,10 @@
;;; Error Cases
-(ert-deftest test-music-config--get-m3u-files-error-nonexistent-directory-signals-error ()
- "Nonexistent directory signals error."
- (let ((cj/music-m3u-root "/nonexistent/directory/path"))
- (should-error (cj/music--get-m3u-files)
- :type 'file-error)))
+(ert-deftest test-music-config--get-m3u-files-error-nonexistent-directory-skipped ()
+ "Nonexistent directories in the roots list are skipped, returning empty."
+ (let ((cj/music-m3u-roots '("/nonexistent/directory/path")))
+ (should-not (cj/music--get-m3u-files))))
(provide 'test-music-config--get-m3u-files)
;;; test-music-config--get-m3u-files.el ends here
diff --git a/tests/test-music-config--m3u-entries.el b/tests/test-music-config--m3u-entries.el
new file mode 100644
index 00000000..1eaf1345
--- /dev/null
+++ b/tests/test-music-config--m3u-entries.el
@@ -0,0 +1,67 @@
+;;; test-music-config--m3u-entries.el --- Tests for #EXTINF/UUID/favicon parse -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--m3u-entries': parse .m3u text into an alist of
+;; (stream-url . plist), each plist carrying :name (the #EXTINF label), :uuid
+;; (#RADIOBROWSERUUID), and :favicon (#RADIOBROWSERFAVICON). This is the one
+;; pure parser both the name resolution (Phase 1) and the cover-art layer read.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(ert-deftest test-music-config--m3u-entries-normal-name-only ()
+ "Normal: an #EXTINF + url pair yields :name with nil :uuid and :favicon."
+ (let* ((text "#EXTM3U\n#EXTINF:1,SomaFM Groove Salad\nhttps://ice6.somafm.com/gs\n")
+ (e (cdr (assoc "https://ice6.somafm.com/gs" (cj/music--m3u-entries text)))))
+ (should (equal (plist-get e :name) "SomaFM Groove Salad"))
+ (should (null (plist-get e :uuid)))
+ (should (null (plist-get e :favicon)))))
+
+(ert-deftest test-music-config--m3u-entries-normal-uuid-and-favicon ()
+ "Normal: UUID and favicon comment lines are captured onto the entry."
+ (let* ((text (concat "#EXTM3U\n#EXTINF:1,Jazz24\n"
+ "#RADIOBROWSERUUID:abc-123\n"
+ "#RADIOBROWSERFAVICON:https://cdn.example/jazz.png\n"
+ "https://jazz.example/live\n"))
+ (e (cdr (assoc "https://jazz.example/live" (cj/music--m3u-entries text)))))
+ (should (equal (plist-get e :name) "Jazz24"))
+ (should (equal (plist-get e :uuid) "abc-123"))
+ (should (equal (plist-get e :favicon) "https://cdn.example/jazz.png"))))
+
+(ert-deftest test-music-config--m3u-entries-normal-multiple-reset ()
+ "Normal: fields reset between stations (station B has no UUID leak from A)."
+ (let* ((text (concat "#EXTINF:1,A\n#RADIOBROWSERUUID:aaa\nhttps://a.example/1\n"
+ "#EXTINF:1,B\nhttps://b.example/2\n"))
+ (entries (cj/music--m3u-entries text))
+ (b (cdr (assoc "https://b.example/2" entries))))
+ (should (equal (plist-get b :name) "B"))
+ (should (null (plist-get b :uuid)))))
+
+(ert-deftest test-music-config--m3u-entries-boundary-url-without-extinf ()
+ "Boundary: a bare url with no #EXTINF is skipped."
+ (should (null (cj/music--m3u-entries "#EXTM3U\nhttps://plain.example/stream\n"))))
+
+(ert-deftest test-music-config--m3u-entries-boundary-empty ()
+ "Boundary: empty text yields nil."
+ (should (null (cj/music--m3u-entries ""))))
+
+(ert-deftest test-music-config--m3u-entries-boundary-comma-in-name ()
+ "Boundary: a comma inside the #EXTINF label is preserved."
+ (let* ((text "#EXTINF:1,Radio, the Good Kind\nhttps://x.example/s\n")
+ (e (cdr (assoc "https://x.example/s" (cj/music--m3u-entries text)))))
+ (should (equal (plist-get e :name) "Radio, the Good Kind"))))
+
+(provide 'test-music-config--m3u-entries)
+;;; test-music-config--m3u-entries.el ends here
diff --git a/tests/test-music-config--m3u-file-tracks.el b/tests/test-music-config--m3u-file-tracks.el
index badc9817..e3cbd72e 100644
--- a/tests/test-music-config--m3u-file-tracks.el
+++ b/tests/test-music-config--m3u-file-tracks.el
@@ -189,5 +189,23 @@
"Parse nil input returns nil gracefully."
(should (null (cj/music--m3u-file-tracks nil))))
+;;; Non-music filtering
+
+(ert-deftest test-music-config--m3u-file-tracks-filters-non-music-local-files ()
+ "Normal: a local non-music path (a saved cover.jpg line) is dropped;
+music files and stream URLs pass through."
+ (test-music-config--m3u-file-tracks-setup)
+ (unwind-protect
+ (let* ((content (concat "/home/user/music/track1.mp3\n"
+ "/home/user/music/album/cover.jpg\n"
+ "https://somafm.com/stream\n"
+ "/home/user/music/track2.flac\n"))
+ (m3u-file (cj/create-temp-test-file-with-content content "test.m3u"))
+ (tracks (cj/music--m3u-file-tracks m3u-file)))
+ (should (equal tracks '("/home/user/music/track1.mp3"
+ "https://somafm.com/stream"
+ "/home/user/music/track2.flac"))))
+ (test-music-config--m3u-file-tracks-teardown)))
+
(provide 'test-music-config--m3u-file-tracks)
;;; test-music-config--m3u-file-tracks.el ends here
diff --git a/tests/test-music-config--m3u-labels.el b/tests/test-music-config--m3u-labels.el
new file mode 100644
index 00000000..02f328cd
--- /dev/null
+++ b/tests/test-music-config--m3u-labels.el
@@ -0,0 +1,62 @@
+;;; test-music-config--m3u-labels.el --- Tests for #EXTINF label extraction -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--m3u-labels': parse .m3u text into an alist of
+;; (stream-url . #EXTINF-label) pairs. This is the pure core that lets a url
+;; track resolve to its station name (the label written at creation) instead
+;; of the raw stream URL.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(ert-deftest test-music-config--m3u-labels-normal-single ()
+ "Normal: one #EXTINF + url pair yields one (url . label) cons."
+ (let ((text "#EXTM3U\n#EXTINF:1,SomaFM Groove Salad\nhttps://ice6.somafm.com/gs\n"))
+ (should (equal (cj/music--m3u-labels text)
+ '(("https://ice6.somafm.com/gs" . "SomaFM Groove Salad"))))))
+
+(ert-deftest test-music-config--m3u-labels-normal-uuid-line-between ()
+ "Normal: a #RADIOBROWSERUUID line between #EXTINF and the url is skipped."
+ (let ((text (concat "#EXTM3U\n#EXTINF:1,Jazz24\n"
+ "#RADIOBROWSERUUID:abc-123\nhttps://jazz.example/live\n")))
+ (should (equal (cj/music--m3u-labels text)
+ '(("https://jazz.example/live" . "Jazz24"))))))
+
+(ert-deftest test-music-config--m3u-labels-normal-multiple ()
+ "Normal: multiple stations each yield their own pair."
+ (let ((text (concat "#EXTM3U\n"
+ "#EXTINF:1,Station A\nhttps://a.example/1\n"
+ "#EXTINF:-1,Station B\nhttps://b.example/2\n")))
+ (should (equal (cj/music--m3u-labels text)
+ '(("https://a.example/1" . "Station A")
+ ("https://b.example/2" . "Station B"))))))
+
+(ert-deftest test-music-config--m3u-labels-boundary-url-without-extinf ()
+ "Boundary: a bare url with no preceding #EXTINF produces no pair."
+ (let ((text "#EXTM3U\nhttps://plain.example/stream\n"))
+ (should (null (cj/music--m3u-labels text)))))
+
+(ert-deftest test-music-config--m3u-labels-boundary-empty ()
+ "Boundary: empty text yields nil."
+ (should (null (cj/music--m3u-labels ""))))
+
+(ert-deftest test-music-config--m3u-labels-boundary-comma-in-name ()
+ "Boundary: a comma inside the label is preserved (split on the first only)."
+ (let ((text "#EXTINF:1,Radio, the Good Kind\nhttps://x.example/s\n"))
+ (should (equal (cj/music--m3u-labels text)
+ '(("https://x.example/s" . "Radio, the Good Kind"))))))
+
+(provide 'test-music-config--m3u-labels)
+;;; test-music-config--m3u-labels.el ends here
diff --git a/tests/test-music-config--m3u-roots.el b/tests/test-music-config--m3u-roots.el
new file mode 100644
index 00000000..626415b6
--- /dev/null
+++ b/tests/test-music-config--m3u-roots.el
@@ -0,0 +1,97 @@
+;;; test-music-config--m3u-roots.el --- multi-directory M3U sourcing tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The player sources .m3u playlists from a LIST of directories
+;; (`cj/music-m3u-roots') so the local-library playlists (~/music) and the
+;; dotfiles-tracked internet-radio playlists (MPD's playlist_directory) surface
+;; together for selection and loading. Two pieces are tested:
+;;
+;; - `cj/music--dedup-m3u-files' — pure: turns a flat list of paths into
+;; (BASENAME . PATH) conses, first occurrence of a basename winning.
+;; - `cj/music--get-m3u-files' — unions the roots on disk, skips missing dirs,
+;; and applies the dedup so an earlier root shadows a same-named later one.
+;;
+;;; Code:
+
+(require 'ert)
+
+;; Stub missing dependencies before loading music-config.
+(defvar-keymap cj/custom-keymap
+ :doc "Stub keymap for testing")
+
+(require 'music-config)
+
+(declare-function cj/music--dedup-m3u-files "music-config" (paths))
+(declare-function cj/music--get-m3u-files "music-config" ())
+(defvar cj/music-m3u-roots)
+
+;;; --------------------------- cj/music--dedup-m3u-files ----------------------
+
+(ert-deftest test-music-config-dedup-m3u-distinct ()
+ "Normal: distinct basenames across dirs all appear, in order."
+ (should (equal (cj/music--dedup-m3u-files
+ '("/music/rhcp.m3u" "/radio/90s Sounds.m3u"))
+ '(("rhcp.m3u" . "/music/rhcp.m3u")
+ ("90s Sounds.m3u" . "/radio/90s Sounds.m3u")))))
+
+(ert-deftest test-music-config-dedup-m3u-single ()
+ "Normal: a single path yields a single cons."
+ (should (equal (cj/music--dedup-m3u-files '("/music/blues.m3u"))
+ '(("blues.m3u" . "/music/blues.m3u")))))
+
+(ert-deftest test-music-config-dedup-m3u-collision-first-wins ()
+ "Boundary: a basename in two dirs keeps the first path (earlier root wins)."
+ (should (equal (cj/music--dedup-m3u-files
+ '("/music/jazz.m3u" "/radio/jazz.m3u"))
+ '(("jazz.m3u" . "/music/jazz.m3u")))))
+
+(ert-deftest test-music-config-dedup-m3u-empty ()
+ "Boundary: an empty path list yields nil."
+ (should-not (cj/music--dedup-m3u-files '())))
+
+;;; ---------------------------- cj/music--get-m3u-files -----------------------
+
+(ert-deftest test-music-config-get-m3u-unions-roots ()
+ "Normal: M3Us from every existing root are unioned."
+ (let ((a (make-temp-file "m3u-a-" t))
+ (b (make-temp-file "m3u-b-" t)))
+ (unwind-protect
+ (progn
+ (write-region "" nil (expand-file-name "local.m3u" a))
+ (write-region "" nil (expand-file-name "radio.m3u" b))
+ (let* ((cj/music-m3u-roots (list a b))
+ (bases (mapcar #'car (cj/music--get-m3u-files))))
+ (should (member "local.m3u" bases))
+ (should (member "radio.m3u" bases))))
+ (delete-directory a t)
+ (delete-directory b t))))
+
+(ert-deftest test-music-config-get-m3u-skips-missing-root ()
+ "Error: a non-existent directory in the list is skipped, not fatal."
+ (let ((a (make-temp-file "m3u-a-" t)))
+ (unwind-protect
+ (progn
+ (write-region "" nil (expand-file-name "local.m3u" a))
+ (let* ((cj/music-m3u-roots (list a "/no/such/dir/here"))
+ (bases (mapcar #'car (cj/music--get-m3u-files))))
+ (should (equal bases '("local.m3u")))))
+ (delete-directory a t))))
+
+(ert-deftest test-music-config-get-m3u-collision-first-root-wins ()
+ "Boundary: same basename in two roots resolves to the earlier root's file."
+ (let ((a (make-temp-file "m3u-a-" t))
+ (b (make-temp-file "m3u-b-" t)))
+ (unwind-protect
+ (progn
+ (write-region "" nil (expand-file-name "jazz.m3u" a))
+ (write-region "" nil (expand-file-name "jazz.m3u" b))
+ (let* ((cj/music-m3u-roots (list a b))
+ (pair (assoc "jazz.m3u" (cj/music--get-m3u-files))))
+ (should (string-prefix-p a (cdr pair)))))
+ (delete-directory a t)
+ (delete-directory b t))))
+
+(provide 'test-music-config--m3u-roots)
+;;; test-music-config--m3u-roots.el ends here
diff --git a/tests/test-music-config--m3u-text.el b/tests/test-music-config--m3u-text.el
new file mode 100644
index 00000000..14c9f2bf
--- /dev/null
+++ b/tests/test-music-config--m3u-text.el
@@ -0,0 +1,93 @@
+;;; test-music-config--m3u-text.el --- playlist .m3u emitter tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The custom .m3u emitter behind playlist save. The stock EMMS m3u writer
+;; emits bare URLs, which would throw away a station's name/uuid/favicon on
+;; save; this emitter writes the same #EXTINF / #RADIOBROWSERUUID /
+;; #RADIOBROWSERFAVICON lines the parser (`cj/music--m3u-entries') reads, so
+;; save -> load round-trips a station's display name and cover-art metadata.
+;; Metadata comes from track properties first, the m3u-scan entries as
+;; fallback (a loaded legacy playlist has entries but no properties).
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(declare-function cj/music--m3u-text "music-config" (tracks entries))
+(declare-function cj/music-radio--station-track "music-config" (st))
+
+(ert-deftest test-music-m3u-text-normal-file-track-bare-path ()
+ "Normal: a file track is a bare absolute path under the #EXTM3U header."
+ (let ((text (cj/music--m3u-text (list (emms-track 'file "/music/a.flac")) nil)))
+ (should (string-prefix-p "#EXTM3U\n" text))
+ (should (string-match-p "^/music/a\\.flac$" text))))
+
+(ert-deftest test-music-m3u-text-normal-url-track-from-properties ()
+ "Normal: a url track with properties emits uuid, favicon, EXTINF, and url."
+ (let* ((track (cj/music-radio--station-track
+ '(:name "Groove Salad" :url "https://ck.somafm.com/gs"
+ :stationuuid "uuid-1" :favicon "https://somafm.com/i.png")))
+ (text (cj/music--m3u-text (list track) nil)))
+ (should (string-match-p "^#RADIOBROWSERUUID:uuid-1$" text))
+ (should (string-match-p "^#RADIOBROWSERFAVICON:https://somafm\\.com/i\\.png$" text))
+ (should (string-match-p "^#EXTINF:-1,Groove Salad$" text))
+ (should (string-match-p "^https://ck\\.somafm\\.com/gs$" text))))
+
+(ert-deftest test-music-m3u-text-normal-url-track-from-entries-fallback ()
+ "Normal: a propertyless url track (a loaded legacy playlist) resolves its
+metadata from the ENTRIES alist."
+ (let* ((url "https://legacy.example/stream")
+ (track (emms-track 'url url))
+ (entries (list (list url :name "Legacy FM" :uuid "uuid-9"
+ :favicon "https://legacy.example/f.ico")))
+ (text (cj/music--m3u-text (list track) entries)))
+ (should (string-match-p "^#RADIOBROWSERUUID:uuid-9$" text))
+ (should (string-match-p "^#EXTINF:-1,Legacy FM$" text))))
+
+(ert-deftest test-music-m3u-text-boundary-url-track-no-metadata ()
+ "Boundary: a url track with neither properties nor entries still gets an
+EXTINF label (the tidied host) and no uuid/favicon lines."
+ (let ((text (cj/music--m3u-text
+ (list (emms-track 'url "https://ice6.somafm.com/live")) nil)))
+ (should (string-match-p "^#EXTINF:-1,somafm\\.com$" text))
+ (should-not (string-match-p "RADIOBROWSERUUID" text))
+ (should-not (string-match-p "RADIOBROWSERFAVICON" text))))
+
+(ert-deftest test-music-m3u-text-normal-mixed-order-preserved ()
+ "Normal: a mixed queue keeps its track order in the file."
+ (let* ((f (emms-track 'file "/music/b.mp3"))
+ (u (cj/music-radio--station-track '(:name "S" :url "https://s.example/x")))
+ (text (cj/music--m3u-text (list f u) nil)))
+ (should (< (string-match "^/music/b\\.mp3$" text)
+ (string-match "^https://s\\.example/x$" text)))))
+
+(ert-deftest test-music-m3u-text-round-trip-through-parser ()
+ "Normal: the parser recovers name, uuid, and favicon from emitted text."
+ (let* ((track (cj/music-radio--station-track
+ '(:name "Round Trip" :url "https://rt.example/s"
+ :stationuuid "uuid-rt" :favicon "https://rt.example/f.png")))
+ (entries (cj/music--m3u-entries (cj/music--m3u-text (list track) nil)))
+ (meta (cdr (assoc "https://rt.example/s" entries))))
+ (should (equal (plist-get meta :name) "Round Trip"))
+ (should (equal (plist-get meta :uuid) "uuid-rt"))
+ (should (equal (plist-get meta :favicon) "https://rt.example/f.png"))))
+
+(ert-deftest test-music-m3u-text-boundary-empty-playlist-header-only ()
+ "Boundary: an empty track list is just the #EXTM3U header."
+ (should (equal (cj/music--m3u-text nil nil) "#EXTM3U\n")))
+
+(provide 'test-music-config--m3u-text)
+;;; test-music-config--m3u-text.el ends here
diff --git a/tests/test-music-config--music-files-recursive.el b/tests/test-music-config--music-files-recursive.el
new file mode 100644
index 00000000..f5f5fc5b
--- /dev/null
+++ b/tests/test-music-config--music-files-recursive.el
@@ -0,0 +1,88 @@
+;;; test-music-config--music-files-recursive.el --- Tests for filtered directory collection -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Directory adds used to hand the whole tree to emms-add-directory-tree,
+;; which adds every file it finds -- cover.jpg and friends ended up as
+;; playlist rows. The filtered walk returns only files passing
+;; cj/music--valid-file-p, skipping hidden dirs/files, sorted. Real temp-dir
+;; fixtures; the EMMS boundary is mocked only in the command-level test.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'music-config)
+
+(defmacro test-music-files--with-fixture (var &rest body)
+ "Run BODY with VAR bound to a temp music-directory fixture."
+ (declare (indent 1))
+ `(let ((,var (make-temp-file "music-test-" t)))
+ (unwind-protect
+ (progn
+ (make-directory (expand-file-name "album" ,var))
+ (make-directory (expand-file-name ".hidden" ,var))
+ (dolist (f '("song.mp3" "cover.jpg" "album/track.flac"
+ "album/folder.png" "album/notes.txt"
+ ".hidden/secret.mp3" ".stray.ogg"))
+ (write-region "" nil (expand-file-name f ,var)))
+ ,@body)
+ (delete-directory ,var t))))
+
+;;; Normal Cases
+
+(ert-deftest test-music-files-recursive-music-only ()
+ "Normal: only files with accepted music extensions come back, sorted;
+cover art, text files, and hidden entries stay out."
+ (test-music-files--with-fixture root
+ (should (equal (mapcar (lambda (f) (file-relative-name f root))
+ (cj/music--music-files-recursive root))
+ '("album/track.flac" "song.mp3")))))
+
+(ert-deftest test-music-add-directory-recursive-adds-only-music ()
+ "Normal: the directory-add command feeds only music files to EMMS."
+ (test-music-files--with-fixture root
+ (let (added)
+ (cl-letf (((symbol-function 'cj/music--ensure-playlist-buffer)
+ (lambda () (current-buffer)))
+ ((symbol-function 'emms-add-file)
+ (lambda (f) (push f added))))
+ (cj/music-add-directory-recursive root))
+ (should (equal (mapcar (lambda (f) (file-relative-name f root))
+ (nreverse added))
+ '("album/track.flac" "song.mp3"))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-files-recursive-case-insensitive-extensions ()
+ "Boundary: extensions match case-insensitively (Song.MP3 counts)."
+ (let ((root (make-temp-file "music-test-case-" t)))
+ (unwind-protect
+ (progn
+ (write-region "" nil (expand-file-name "Song.MP3" root))
+ (should (= 1 (length (cj/music--music-files-recursive root)))))
+ (delete-directory root t))))
+
+(ert-deftest test-music-files-recursive-empty-directory ()
+ "Boundary: a directory with no music files returns nil."
+ (let ((root (make-temp-file "music-test-empty-" t)))
+ (unwind-protect
+ (progn
+ (write-region "" nil (expand-file-name "readme.txt" root))
+ (should-not (cj/music--music-files-recursive root)))
+ (delete-directory root t))))
+
+;;; Error Cases
+
+(ert-deftest test-music-add-directory-recursive-not-a-directory-errors ()
+ "Error: a non-directory argument signals user-error."
+ (should-error (cj/music-add-directory-recursive "/nonexistent/nowhere")
+ :type 'user-error))
+
+(provide 'test-music-config--music-files-recursive)
+;;; test-music-config--music-files-recursive.el ends here
diff --git a/tests/test-music-config--pin-point.el b/tests/test-music-config--pin-point.el
new file mode 100644
index 00000000..2d6fa916
--- /dev/null
+++ b/tests/test-music-config--pin-point.el
@@ -0,0 +1,78 @@
+;;; test-music-config--pin-point.el --- Tests for the playlist gutter cursor -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The playlist cursor lives pinned at the start of the row (the number
+;; gutter). The rows are rendered track lines, not editable text, and
+;; vertical motion over thumbnails and stretch-space drifts point to
+;; arbitrary visual columns (usually line end). A buffer-local
+;; post-command snap enforces the model for every motion command. The one
+;; exception is an active isearch, which owns point placement until it ends.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'music-config)
+
+;;; Normal Cases
+
+(ert-deftest test-music-pin-point-snaps-mid-line-to-bol ()
+ "Normal: point mid-row snaps back to the beginning of the line."
+ (with-temp-buffer
+ (insert "track one\ntrack two\n")
+ (goto-char (point-min))
+ (forward-char 5)
+ (cj/music--pin-point-to-bol)
+ (should (bolp))
+ (should (= (point) (point-min)))))
+
+(ert-deftest test-music-pin-point-noop-at-bol ()
+ "Normal: point already at the row start stays put."
+ (with-temp-buffer
+ (insert "track one\ntrack two\n")
+ (goto-char (point-min))
+ (forward-line 1)
+ (let ((before (point)))
+ (cj/music--pin-point-to-bol)
+ (should (= (point) before)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-pin-point-skips-during-isearch ()
+ "Boundary: an active isearch owns point; the pin defers until it ends."
+ (with-temp-buffer
+ (insert "track one\ntrack two\n")
+ (goto-char (point-min))
+ (forward-char 5)
+ (let ((isearch-mode t))
+ (cj/music--pin-point-to-bol))
+ (should-not (bolp))))
+
+(ert-deftest test-music-pin-point-empty-buffer-no-error ()
+ "Boundary: an empty buffer is a no-op, no error."
+ (with-temp-buffer
+ (should-not (cj/music--pin-point-to-bol))
+ (should (bolp))))
+
+;;; Hook wiring
+
+(ert-deftest test-music-pin-point-ensure-wires-post-command-hook ()
+ "Normal: the playlist buffer gets the pin on its buffer-local
+post-command-hook."
+ (let (created)
+ (cl-letf (((symbol-function 'emms-playlist-mode) #'ignore))
+ (unwind-protect
+ (progn
+ (setq created (cj/music--ensure-playlist-buffer))
+ (with-current-buffer created
+ (should (member #'cj/music--pin-point-to-bol post-command-hook))))
+ (when (buffer-live-p created) (kill-buffer created))))))
+
+(provide 'test-music-config--pin-point)
+;;; test-music-config--pin-point.el ends here
diff --git a/tests/test-music-config--playlist-dock.el b/tests/test-music-config--playlist-dock.el
new file mode 100644
index 00000000..1dbe9fbb
--- /dev/null
+++ b/tests/test-music-config--playlist-dock.el
@@ -0,0 +1,114 @@
+;;; test-music-config--playlist-dock.el --- Tests for the F10 playlist dock -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The F10 playlist always docks at the bottom, whatever the frame's shape.
+;; It used to pick `right' on a wide frame via `cj/preferred-dock-direction',
+;; which produced an unwanted three-way split on a wide terminal.
+;;
+;; `cj/side-window-display' is the window-system boundary here, so it is the
+;; thing stubbed (an ordinary defun -- safe to `cl-letf', unlike the frame-*
+;; subrs). Stubbing it lets these tests assert what the toggle *asks for*
+;; without needing a live frame under `--batch'.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'music-config)
+
+(defmacro test-music-dock--with-captured-side (captured &rest body)
+ "Run BODY with the playlist display path stubbed, recording its args in CAPTURED.
+CAPTURED is set to a plist of :side, :size-var and :default-size."
+ (declare (indent 1))
+ `(let ((buffer (generate-new-buffer " *test-playlist*")))
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/emms--setup) (lambda (&rest _) nil))
+ ((symbol-function 'cj/music--ensure-playlist-buffer)
+ (lambda (&rest _) buffer))
+ ((symbol-function 'emms-playlist-mode-center-current)
+ (lambda (&rest _) nil))
+ ((symbol-function 'cj/side-window-display)
+ (lambda (_buf side size-var default-size)
+ (setq ,captured (list :side side :size-var size-var
+ :default-size default-size))
+ (selected-window))))
+ ,@body)
+ (kill-buffer buffer))))
+
+(ert-deftest test-music-config-playlist-toggle-docks-bottom-on-wide-frame ()
+ "Normal: a wide frame still docks the playlist at the bottom.
+A wide frame used to dock right, splitting the frame three ways."
+ (let (captured)
+ (test-music-dock--with-captured-side captured
+ (cl-letf (((symbol-function 'frame-width) (lambda (&rest _) 400)))
+ (cj/music-playlist-toggle)))
+ (should (eq 'bottom (plist-get captured :side)))))
+
+(ert-deftest test-music-config-playlist-toggle-docks-bottom-on-narrow-frame ()
+ "Boundary: a narrow frame docks at the bottom, as it always did."
+ (let (captured)
+ (test-music-dock--with-captured-side captured
+ (cl-letf (((symbol-function 'frame-width) (lambda (&rest _) 40)))
+ (cj/music-playlist-toggle)))
+ (should (eq 'bottom (plist-get captured :side)))))
+
+(ert-deftest test-music-config-playlist-toggle-uses-height-memory ()
+ "Normal: the bottom dock carries the height fraction and its memory var."
+ (let (captured)
+ (test-music-dock--with-captured-side captured
+ (cj/music-playlist-toggle))
+ (should (eq 'cj/--music-playlist-height (plist-get captured :size-var)))
+ (should (= cj/music-playlist-window-height (plist-get captured :default-size)))))
+
+(ert-deftest test-music-config-playlist-dock-has-no-width-knobs ()
+ "Error: the right-dock width variables are gone, not merely unused.
+A stale `cj/music-playlist-window-width' would read as a live knob that
+silently does nothing."
+ (should-not (boundp 'cj/music-playlist-window-width))
+ (should-not (boundp 'cj/--music-playlist-width))
+ (should-not (fboundp 'cj/--music-playlist-side)))
+
+(ert-deftest test-music-config-playlist-default-height-is-half ()
+ "Normal: the dock opens at half the frame height by default (Craig,
+2026-07-18 -- a third still read too short for a real playlist)."
+ (should (= cj/music-playlist-window-height 0.5)))
+
+(ert-deftest test-music-config-playlist-toggle-off-discards-shrunk-height ()
+ "Error: a captured height below the default is discarded. Window churn
+squeezes the dock, and remembering the squeeze reopens it too short on
+every later toggle."
+ (let ((buffer (generate-new-buffer " *test-playlist-shrink*"))
+ (cj/--music-playlist-height nil))
+ (unwind-protect
+ (save-window-excursion
+ (set-window-buffer (selected-window) buffer)
+ (let ((cj/music-playlist-buffer-name (buffer-name buffer)))
+ (cl-letf (((symbol-function 'cj/side-window-capture-size)
+ (lambda (_w _side var) (set var 0.2)))
+ ((symbol-function 'delete-window) #'ignore)
+ ((symbol-function 'message) #'ignore))
+ (cj/music-playlist-toggle)))
+ (should (null cj/--music-playlist-height)))
+ (kill-buffer buffer))))
+
+(ert-deftest test-music-config-playlist-toggle-off-keeps-enlarged-height ()
+ "Normal: a captured height at or above the default is remembered, so a
+deliberate enlargement sticks for the session."
+ (let ((buffer (generate-new-buffer " *test-playlist-grow*"))
+ (cj/--music-playlist-height nil))
+ (unwind-protect
+ (save-window-excursion
+ (set-window-buffer (selected-window) buffer)
+ (let ((cj/music-playlist-buffer-name (buffer-name buffer)))
+ (cl-letf (((symbol-function 'cj/side-window-capture-size)
+ (lambda (_w _side var) (set var 0.5)))
+ ((symbol-function 'delete-window) #'ignore)
+ ((symbol-function 'message) #'ignore))
+ (cj/music-playlist-toggle)))
+ (should (= 0.5 cj/--music-playlist-height)))
+ (kill-buffer buffer))))
+
+(provide 'test-music-config--playlist-dock)
+;;; test-music-config--playlist-dock.el ends here
diff --git a/tests/test-music-config--playlist-open-position.el b/tests/test-music-config--playlist-open-position.el
new file mode 100644
index 00000000..cd83e65b
--- /dev/null
+++ b/tests/test-music-config--playlist-open-position.el
@@ -0,0 +1,147 @@
+;;; test-music-config--playlist-open-position.el --- Tests for playlist landing position -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Opening the playlist lands point by one rule: the beginning of the playing
+;; track's line when a song is playing, else the top of the list. The old
+;; behavior keyed off EMMS's selected track, which stays set while stopped, so
+;; the playlist opened deep in the list at a stale position. The decision is
+;; a pure helper; the window landing (point + upper-third recenter) is tested
+;; with recenter stubbed at the display boundary.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Declare the emms vars special HERE too: the module's bare (defvar
+;; emms-player-playing-p) marks them special only for code compiled in
+;; that file, so a plain `let' in this lexical-binding test file would
+;; bind them lexically and the module would never see the value (the
+;; scope-shadowing trap from the testing rules).
+(defvar emms-player-playing-p)
+(defvar emms-playlist-selected-marker)
+
+(require 'music-config)
+
+(defmacro test-music-open-pos--with-buffer (var &rest body)
+ "Run BODY with VAR bound to a temp 3-track playlist-shaped buffer."
+ (declare (indent 1))
+ `(let ((,var (generate-new-buffer " *test-open-pos*")))
+ (unwind-protect
+ (progn
+ (with-current-buffer ,var
+ (insert "track one\ntrack two\ntrack three\n"))
+ ,@body)
+ (when (buffer-live-p ,var) (kill-buffer ,var)))))
+
+(defun test-music-open-pos--marker (buffer line offset)
+ "Marker in BUFFER at LINE (1-based) plus OFFSET chars."
+ (with-current-buffer buffer
+ (save-excursion
+ (goto-char (point-min))
+ (forward-line (1- line))
+ (forward-char offset)
+ (point-marker))))
+
+;;; Normal Cases
+
+(ert-deftest test-music-playlist-open-position-playing-lands-on-playing-line-start ()
+ "Normal: playing -> the playing track's line, at its beginning (even when
+the marker sits mid-line)."
+ (test-music-open-pos--with-buffer buf
+ (let ((emms-player-playing-p t)
+ (emms-playlist-selected-marker (test-music-open-pos--marker buf 2 4)))
+ (should (= (cj/music--playlist-open-position buf)
+ (with-current-buffer buf
+ (save-excursion (goto-char (point-min)) (forward-line 1) (point))))))))
+
+(ert-deftest test-music-playlist-open-position-stopped-lands-at-top ()
+ "Normal: not playing -> top of the list, even though EMMS still has a
+stale selected track."
+ (test-music-open-pos--with-buffer buf
+ (let ((emms-player-playing-p nil)
+ (emms-playlist-selected-marker (test-music-open-pos--marker buf 3 0)))
+ (should (= (cj/music--playlist-open-position buf) 1)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-playlist-open-position-playing-no-marker-lands-at-top ()
+ "Boundary: playing but no usable marker -> top of the list."
+ (test-music-open-pos--with-buffer buf
+ (let ((emms-player-playing-p t)
+ (emms-playlist-selected-marker nil))
+ (should (= (cj/music--playlist-open-position buf) 1)))))
+
+(ert-deftest test-music-playlist-open-position-marker-in-other-buffer-lands-at-top ()
+ "Boundary: a marker pointing into a different buffer is ignored."
+ (test-music-open-pos--with-buffer buf
+ (with-temp-buffer
+ (insert "elsewhere\n")
+ (let ((emms-player-playing-p t)
+ (emms-playlist-selected-marker (point-marker)))
+ (should (= (cj/music--playlist-open-position buf) 1))))))
+
+(ert-deftest test-music-playlist-open-position-empty-buffer ()
+ "Boundary: an empty playlist lands at point-min without error."
+ (let ((buf (generate-new-buffer " *test-open-pos-empty*")))
+ (unwind-protect
+ (let ((emms-player-playing-p nil)
+ (emms-playlist-selected-marker nil))
+ (should (= (cj/music--playlist-open-position buf) 1)))
+ (kill-buffer buf))))
+
+;;; Landing (window boundary stubbed)
+
+(ert-deftest test-music-playlist-land-point-playing-recenter-upper-third ()
+ "Normal: landing on a playing row sets window point to its line start and
+recenters into the upper third."
+ (test-music-open-pos--with-buffer buf
+ (let ((emms-player-playing-p t)
+ (emms-playlist-selected-marker (test-music-open-pos--marker buf 2 4))
+ (recenter-arg 'not-called))
+ (save-window-excursion
+ (set-window-buffer (selected-window) buf)
+ (cl-letf (((symbol-function 'recenter)
+ (lambda (&optional arg &rest _) (setq recenter-arg arg))))
+ (cj/music--playlist-land-point (selected-window) buf))
+ (should (= (window-point (selected-window))
+ (with-current-buffer buf
+ (save-excursion (goto-char (point-min)) (forward-line 1) (point)))))
+ (should (integerp recenter-arg))
+ (should (>= recenter-arg 1))))))
+
+(ert-deftest test-music-playlist-land-point-stopped-top-no-recenter ()
+ "Normal: landing while stopped puts window point at the top; no recenter."
+ (test-music-open-pos--with-buffer buf
+ (let ((emms-player-playing-p nil)
+ (emms-playlist-selected-marker nil)
+ (recenter-called nil))
+ (save-window-excursion
+ (set-window-buffer (selected-window) buf)
+ (cl-letf (((symbol-function 'recenter)
+ (lambda (&rest _) (setq recenter-called t))))
+ (cj/music--playlist-land-point (selected-window) buf))
+ (should (= (window-point (selected-window)) 1))
+ (should-not recenter-called)))))
+
+;;; hl-line in the playlist buffer
+
+(ert-deftest test-music-playlist-ensure-enables-hl-line ()
+ "Normal: the playlist buffer gets hl-line-mode so the current row is
+findable even when the cursor sits on album art."
+ (let (created)
+ (cl-letf (((symbol-function 'emms-playlist-mode) #'ignore))
+ (unwind-protect
+ (progn
+ (setq created (cj/music--ensure-playlist-buffer))
+ (with-current-buffer created
+ (should hl-line-mode)))
+ (when (buffer-live-p created) (kill-buffer created))))))
+
+(provide 'test-music-config--playlist-open-position)
+;;; test-music-config--playlist-open-position.el ends here
diff --git a/tests/test-music-config--playlist-side.el b/tests/test-music-config--playlist-side.el
deleted file mode 100644
index f4969469..00000000
--- a/tests/test-music-config--playlist-side.el
+++ /dev/null
@@ -1,45 +0,0 @@
-;;; test-music-config--playlist-side.el --- Tests for the F10 dock-side helper -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; `cj/--music-playlist-side' maps the shared dock rule's verdict to a
-;; `display-buffer-in-side-window' side: `right' stays `right', anything
-;; else becomes `bottom'. The decision itself lives in
-;; `cj/preferred-dock-direction' (tested in test-cj-window-geometry-lib.el);
-;; here we stub it (an ordinary defun -- safe to `cl-letf', unlike the
-;; frame-* subrs) to prove the mapping and that the width fraction is
-;; passed through.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-
-(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'music-config)
-
-(ert-deftest test-music-config--playlist-side-right-verdict-is-right ()
- "Normal: a `right' verdict from the dock rule docks the playlist right."
- (cl-letf (((symbol-function 'cj/preferred-dock-direction)
- (lambda (&rest _) 'right)))
- (should (eq (cj/--music-playlist-side) 'right))))
-
-(ert-deftest test-music-config--playlist-side-below-verdict-is-bottom ()
- "Normal: a `below' verdict maps to the `bottom' side window."
- (cl-letf (((symbol-function 'cj/preferred-dock-direction)
- (lambda (&rest _) 'below)))
- (should (eq (cj/--music-playlist-side) 'bottom))))
-
-(ert-deftest test-music-config--playlist-side-passes-width-fraction ()
- "Normal: the playlist's width fraction reaches the dock rule."
- (let ((cj/music-playlist-window-width 0.4)
- captured)
- (cl-letf (((symbol-function 'cj/preferred-dock-direction)
- (lambda (cols frac &rest _)
- (setq captured (list cols frac))
- 'below)))
- (cj/--music-playlist-side)
- (should (= (nth 1 captured) 0.4))
- (should (integerp (nth 0 captured))))))
-
-(provide 'test-music-config--playlist-side)
-;;; test-music-config--playlist-side.el ends here
diff --git a/tests/test-music-config--radio-station-track.el b/tests/test-music-config--radio-station-track.el
new file mode 100644
index 00000000..5816c776
--- /dev/null
+++ b/tests/test-music-config--radio-station-track.el
@@ -0,0 +1,135 @@
+;;; test-music-config--radio-station-track.el --- station->track + enqueue tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The queue-first radio model: a picked station becomes an EMMS url track
+;; carrying its metadata as track properties (info-title, radio-uuid,
+;; radio-favicon) instead of being written to an .m3u. Covers the pure
+;; station->track builder and the enqueue-and-play buffer mechanics (with
+;; playback mocked at the EMMS boundary).
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(declare-function cj/music-radio--station-track "music-config" (st))
+(declare-function cj/music-radio--enqueue-and-play "music-config" (tracks))
+
+;;; --------------------------- station-track -----------------------------------
+
+(ert-deftest test-music-radio-station-track-normal ()
+ "Normal: a full station plist yields a url track with all three properties."
+ (let ((track (cj/music-radio--station-track
+ '(:name "Adroit Jazz Underground"
+ :url_resolved "https://icecast.walmradio.com:8443/jazz"
+ :stationuuid "ea8059be-d119"
+ :favicon "https://walmradio.com/icon.png"))))
+ (should (eq (emms-track-type track) 'url))
+ (should (equal (emms-track-name track) "https://icecast.walmradio.com:8443/jazz"))
+ (should (equal (emms-track-get track 'info-title) "Adroit Jazz Underground"))
+ (should (equal (emms-track-get track 'radio-uuid) "ea8059be-d119"))
+ (should (equal (emms-track-get track 'radio-favicon) "https://walmradio.com/icon.png"))))
+
+(ert-deftest test-music-radio-station-track-no-url-is-nil ()
+ "Error: a station with no stream URL yields nil, not a broken track."
+ (should-not (cj/music-radio--station-track '(:name "No URL" :url_resolved "" :url ""))))
+
+(ert-deftest test-music-radio-station-track-boundary-no-name ()
+ "Boundary: a nameless station falls back to \"Radio\" for its title."
+ (let ((track (cj/music-radio--station-track '(:url "https://s.example/live"))))
+ (should (equal (emms-track-get track 'info-title) "Radio"))))
+
+(ert-deftest test-music-radio-station-track-boundary-newline-name-stripped ()
+ "Boundary: newlines in an external station name are flattened to spaces."
+ (let ((track (cj/music-radio--station-track
+ '(:name "Line\nBreak" :url "https://s.example/live"))))
+ (should (equal (emms-track-get track 'info-title) "Line Break"))))
+
+(ert-deftest test-music-radio-station-track-boundary-empty-uuid-favicon-absent ()
+ "Boundary: empty-string uuid/favicon are treated as absent, not stored."
+ (let ((track (cj/music-radio--station-track
+ '(:name "X" :url "https://s.example/live" :stationuuid "" :favicon ""))))
+ (should-not (emms-track-get track 'radio-uuid))
+ (should-not (emms-track-get track 'radio-favicon))))
+
+;;; ------------------------- enqueue-and-play ----------------------------------
+
+(defmacro test-music-radio--with-playlist (&rest body)
+ "Run BODY with a fresh, uniquely named playlist buffer and playback mocked."
+ `(let* ((cj/music-playlist-buffer-name
+ (generate-new-buffer-name "*test-radio-enqueue*"))
+ (emms-player-playing-p nil)
+ (started 0) (stopped 0))
+ (unwind-protect
+ (cl-letf (((symbol-function 'emms-start)
+ (lambda () (setq started (1+ started))))
+ ((symbol-function 'emms-stop)
+ (lambda () (setq stopped (1+ stopped)))))
+ ,@body)
+ (when (get-buffer cj/music-playlist-buffer-name)
+ (kill-buffer cj/music-playlist-buffer-name)))))
+
+(ert-deftest test-music-radio-enqueue-and-play-normal-appends-and-plays ()
+ "Normal: tracks land in the playlist buffer in order; the first is selected
+and playback starts."
+ (test-music-radio--with-playlist
+ (let ((t1 (cj/music-radio--station-track '(:name "One" :url "https://one.example/a")))
+ (t2 (cj/music-radio--station-track '(:name "Two" :url "https://two.example/b"))))
+ (cj/music-radio--enqueue-and-play (list t1 t2))
+ (with-current-buffer cj/music-playlist-buffer-name
+ (let ((names '()))
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (when-let ((tr (emms-playlist-track-at (point))))
+ (push (emms-track-name tr) names))
+ (forward-line 1)))
+ (should (equal (nreverse names)
+ '("https://one.example/a" "https://two.example/b"))))
+ (should (equal (emms-track-name (emms-playlist-selected-track))
+ "https://one.example/a")))
+ (should (= started 1)))))
+
+(ert-deftest test-music-radio-enqueue-and-play-boundary-appends-after-existing ()
+ "Boundary: an existing queue is kept; new tracks append and the first NEW
+track is the one selected."
+ (test-music-radio--with-playlist
+ (let ((old (cj/music-radio--station-track '(:name "Old" :url "https://old.example/x")))
+ (new (cj/music-radio--station-track '(:name "New" :url "https://new.example/y"))))
+ (cj/music-radio--enqueue-and-play (list old))
+ (cj/music-radio--enqueue-and-play (list new))
+ (with-current-buffer cj/music-playlist-buffer-name
+ (should (equal (emms-track-name (emms-playlist-selected-track))
+ "https://new.example/y"))))))
+
+(ert-deftest test-music-radio-enqueue-and-play-interrupts-current-playback ()
+ "Normal: when something is playing, enqueue stops it before starting."
+ (test-music-radio--with-playlist
+ (let ((emms-player-playing-p t)
+ (tr (cj/music-radio--station-track '(:name "Z" :url "https://z.example/s"))))
+ (cj/music-radio--enqueue-and-play (list tr))
+ (should (= stopped 1))
+ (should (= started 1)))))
+
+(ert-deftest test-music-radio-enqueue-and-play-boundary-nil-is-no-op ()
+ "Boundary: an empty track list does nothing — no buffer churn, no playback."
+ (test-music-radio--with-playlist
+ (cj/music-radio--enqueue-and-play nil)
+ (should (= started 0))
+ (should (= stopped 0))))
+
+(provide 'test-music-config--radio-station-track)
+;;; test-music-config--radio-station-track.el ends here
diff --git a/tests/test-music-config--radio-tags.el b/tests/test-music-config--radio-tags.el
new file mode 100644
index 00000000..60e1c9d8
--- /dev/null
+++ b/tests/test-music-config--radio-tags.el
@@ -0,0 +1,79 @@
+;;; test-music-config--radio-tags.el --- Tests for radio-browser tag pre-population -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The tag-search prompt completes over the popular tags fetched from
+;; radio-browser's /json/tags endpoint (cached per session). These tests cover
+;; the pure pieces: the endpoint URL, the parse (trim + drop-empty + dedupe,
+;; since the source data is user-generated and dirty), and the session cache.
+;; The network GET is mocked at the boundary.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'music-config)
+
+(defconst test-music-radio-tags--fixture
+ (concat "[{\"name\":\"jazz \",\"stationcount\":300},"
+ "{\"name\":\" jazz\",\"stationcount\":5},"
+ "{\"name\":\"\",\"stationcount\":2},"
+ "{\"name\":\"rock\",\"stationcount\":100}]")
+ "A recorded /json/tags response with whitespace, empty, and duplicate names.")
+
+;;; Normal Cases
+
+(ert-deftest test-music-radio-tags-url-shape ()
+ "Normal: the tags URL targets /json/tags ordered by station count with the limit."
+ (let* ((cj/music-radio-tag-limit 500)
+ (u (cj/music-radio--tags-url "de1.api.radio-browser.info")))
+ (should (string-match-p "/json/tags" u))
+ (should (string-match-p "order=stationcount" u))
+ (should (string-match-p "limit=500" u))))
+
+(ert-deftest test-music-radio-parse-tags-trims-dedupes-drops-empty ()
+ "Normal: tag names come back trimmed, deduped, and without empties."
+ (should (equal (cj/music-radio--parse-tags test-music-radio-tags--fixture)
+ '("jazz" "rock"))))
+
+(ert-deftest test-music-radio-available-tags-caches-per-session ()
+ "Normal: the fetch runs once; later calls serve the cache."
+ (let ((cj/music-radio--tags-cache nil)
+ (calls 0))
+ (cl-letf (((symbol-function 'cj/music-radio--http-get)
+ (lambda (_url) (cl-incf calls) test-music-radio-tags--fixture)))
+ (should (equal (cj/music-radio--available-tags) '("jazz" "rock")))
+ (should (equal (cj/music-radio--available-tags) '("jazz" "rock")))
+ (should (= calls 1)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-radio-parse-tags-empty-array ()
+ "Boundary: an empty tag array parses to nil."
+ (should-not (cj/music-radio--parse-tags "[]")))
+
+;;; Error Cases
+
+(ert-deftest test-music-radio-available-tags-fetch-failure-returns-nil-and-retries ()
+ "Error: a failed fetch yields nil, leaves the cache empty, and retries next call."
+ (let ((cj/music-radio--tags-cache nil)
+ (calls 0))
+ (cl-letf (((symbol-function 'cj/music-radio--http-get)
+ (lambda (_url) (cl-incf calls) nil)))
+ (should-not (cj/music-radio--available-tags))
+ (should-not cj/music-radio--tags-cache)
+ (should-not (cj/music-radio--available-tags))
+ (should (= calls 2)))))
+
+(ert-deftest test-music-radio-parse-tags-malformed-user-errors ()
+ "Error: a non-JSON body signals user-error, not a raw parse error."
+ (should-error (cj/music-radio--parse-tags "<html>502</html>")
+ :type 'user-error))
+
+(provide 'test-music-config--radio-tags)
+;;; test-music-config--radio-tags.el ends here
diff --git a/tests/test-music-config--radio.el b/tests/test-music-config--radio.el
new file mode 100644
index 00000000..a91612bf
--- /dev/null
+++ b/tests/test-music-config--radio.el
@@ -0,0 +1,207 @@
+;;; test-music-config--radio.el --- radio-browser lookup pure-logic tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The pure pieces behind the radio-browser search command (spec
+;; docs/specs/2026-07-06-radio-browser-lookup-spec.org): parsing a recorded
+;; JSON response, picking a station's stream URL, formatting the marginalia
+;; annotation (Variant B: codec/bitrate/country/votes/tags), and building the
+;; search URL. The station->track builder and the queue mechanics live in
+;; test-music-config--radio-station-track.el; the network GET and the
+;; interactive command are exercised in the daemon, not here.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Declare special here too (the module's bare defvar is file-local) so the
+;; registration test's `let' binds dynamically.
+(defvar marginalia-annotator-registry)
+
+(require 'music-config)
+
+(declare-function cj/music-radio--parse-search "music-config" (json-text))
+(declare-function cj/music-radio--station-url "music-config" (st))
+(declare-function cj/music-radio--tags-snippet "music-config" (tags n))
+(declare-function cj/music-radio--format-candidate "music-config" (st))
+(declare-function cj/music-radio--search-url "music-config" (server query))
+
+(defconst test-music-radio--fixture
+ (concat "[{\"stationuuid\":\"ea8059be-d119-4de3-b27b-0d9bd6aedb17\","
+ "\"name\":\"Adroit Jazz Underground\",\"url_resolved\":\"https://icecast.walmradio.com:8443/jazz\","
+ "\"codec\":\"MP3\",\"bitrate\":320,\"countrycode\":\"US\",\"votes\":174208,\"tags\":\"bebop,hard bop,cool\"},"
+ "{\"stationuuid\":\"00000000-no-url\",\"name\":\"No URL Station\",\"url_resolved\":\"\",\"url\":\"\","
+ "\"codec\":\"AAC\",\"bitrate\":0,\"countrycode\":\"FR\",\"votes\":5,\"tags\":\"\"}]")
+ "A two-station recorded radio-browser search response.")
+
+(defun test-music-radio--first ()
+ "First station plist from the fixture."
+ (car (cj/music-radio--parse-search test-music-radio--fixture)))
+
+;;; --------------------------- parse-search -----------------------------------
+
+(ert-deftest test-music-radio-parse-search-normal ()
+ "Normal: a recorded response parses to station plists with the expected fields."
+ (let ((stations (cj/music-radio--parse-search test-music-radio--fixture)))
+ (should (= (length stations) 2))
+ (should (equal (plist-get (car stations) :name) "Adroit Jazz Underground"))
+ (should (equal (plist-get (car stations) :stationuuid)
+ "ea8059be-d119-4de3-b27b-0d9bd6aedb17"))))
+
+(ert-deftest test-music-radio-parse-search-empty ()
+ "Boundary: an empty result array parses to nil."
+ (should-not (cj/music-radio--parse-search "[]")))
+
+(ert-deftest test-music-radio-parse-search-malformed-user-errors ()
+ "Error: a non-JSON body (a gateway page) signals user-error, not a raw parse error."
+ (should-error (cj/music-radio--parse-search "<html>502 Bad Gateway</html>")
+ :type 'user-error))
+
+;;; --------------------------- station-url ------------------------------------
+
+(ert-deftest test-music-radio-station-url-resolved ()
+ "Normal: url_resolved wins when present."
+ (should (equal (cj/music-radio--station-url (test-music-radio--first))
+ "https://icecast.walmradio.com:8443/jazz")))
+
+(ert-deftest test-music-radio-station-url-fallback-to-url ()
+ "Boundary: an empty url_resolved falls back to url."
+ (should (equal (cj/music-radio--station-url
+ '(:url_resolved "" :url "http://fallback.test/stream"))
+ "http://fallback.test/stream")))
+
+(ert-deftest test-music-radio-station-url-none ()
+ "Error: neither url_resolved nor url yields nil."
+ (should-not (cj/music-radio--station-url '(:url_resolved "" :url ""))))
+
+;;; --------------------------- tags-snippet -----------------------------------
+
+(ert-deftest test-music-radio-tags-snippet-takes-first-n ()
+ "Normal: the first N comma-separated tags render trimmed."
+ (should (equal (cj/music-radio--tags-snippet "bebop, hard bop, cool, free jazz" 3)
+ "bebop, hard bop, cool")))
+
+(ert-deftest test-music-radio-tags-snippet-empty ()
+ "Boundary: empty or nil tags render as the empty string."
+ (should (equal (cj/music-radio--tags-snippet "" 3) ""))
+ (should (equal (cj/music-radio--tags-snippet nil 3) "")))
+
+;;; --------------------------- format-candidate (Variant B) -------------------
+
+(ert-deftest test-music-radio-format-candidate-variant-b ()
+ "Normal: the annotation carries codec, votes, and tags (Variant B)."
+ (let ((ann (cj/music-radio--format-candidate (test-music-radio--first))))
+ (should (string-match-p "MP3" ann))
+ (should (string-match-p "174208" ann))
+ (should (string-match-p "bebop" ann))))
+
+;;; --------------------------- search-url -------------------------------------
+
+(ert-deftest test-music-radio-search-url-encodes-query-and-limit ()
+ "Normal: the search URL hex-encodes the query and carries the limit; name is the default field."
+ (let* ((cj/music-radio-search-limit 30)
+ (u (cj/music-radio--search-url "de1.api.radio-browser.info" "smooth jazz")))
+ (should (string-match-p "name=smooth%20jazz" u))
+ (should (string-match-p "limit=30" u))
+ (should (string-match-p "/json/stations/search" u))))
+
+(ert-deftest test-music-radio-search-url-tag-field ()
+ "Normal: field \"tag\" searches the tag= parameter instead of name=."
+ (let ((u (cj/music-radio--search-url "de1.api.radio-browser.info" "ambient" "tag")))
+ (should (string-match-p "tag=ambient" u))
+ (should-not (string-match-p "name=ambient" u))))
+
+(declare-function cj/music-radio--candidates "music-config" (stations))
+
+;;; --------------------------- candidates (dedup) -----------------------------
+
+(ert-deftest test-music-radio-candidates-distinct ()
+ "Normal: distinct station names produce distinct display keys mapping to their stations."
+ (let* ((stations '((:name "Jazz Radio" :codec "MP3" :bitrate 128)
+ (:name "Blues FM" :codec "AAC" :bitrate 64)))
+ (cands (cj/music-radio--candidates stations)))
+ (should (= (length cands) 2))
+ (should (assoc "Jazz Radio" cands))
+ (should (assoc "Blues FM" cands))))
+
+(ert-deftest test-music-radio-candidates-same-name-disambiguated ()
+ "Boundary: two stations with the same name get distinct display keys."
+ (let* ((stations '((:name "Jazz Radio" :codec "MP3" :bitrate 128 :stationuuid "a")
+ (:name "Jazz Radio" :codec "OGG" :bitrate 192 :stationuuid "b")))
+ (cands (cj/music-radio--candidates stations))
+ (keys (mapcar #'car cands)))
+ (should (= (length cands) 2))
+ (should (= (length (delete-dups (copy-sequence keys))) 2))))
+
+;;; --------------------------- query whitespace --------------------------------
+
+(ert-deftest test-music-radio-search-and-play-trims-query ()
+ "Normal: surrounding whitespace on the query is stripped before the search.
+A trailing space in the minibuffer otherwise reaches the API as %20 and
+matches nothing."
+ (let (captured)
+ (cl-letf (((symbol-function 'cj/emms--setup) #'ignore)
+ ((symbol-function 'cj/music-radio--search)
+ (lambda (query _field) (setq captured query) nil)))
+ (should-error (cj/music-radio--search-and-play " jazz " "tag")
+ :type 'user-error))
+ (should (equal captured "jazz"))))
+
+(ert-deftest test-music-radio-search-and-play-whitespace-only-no-search ()
+ "Error: a whitespace-only query errors out before any network search."
+ (let (searched)
+ (cl-letf (((symbol-function 'cj/emms--setup) #'ignore)
+ ((symbol-function 'cj/music-radio--search)
+ (lambda (&rest _) (setq searched t) nil)))
+ (should-error (cj/music-radio--search-and-play " " "tag")
+ :type 'user-error))
+ (should-not searched)))
+
+;;; --------------------------- column alignment --------------------------------
+
+(ert-deftest test-music-radio-format-candidate-votes-column-fixed-width ()
+ "Normal: the votes field pads to a fixed width so the tags column aligns
+across stations with different vote counts."
+ (let* ((low (cj/music-radio--format-candidate
+ '(:codec "MP3" :bitrate 128 :countrycode "US" :votes 7 :tags "jazz")))
+ (high (cj/music-radio--format-candidate
+ '(:codec "MP3" :bitrate 128 :countrycode "US" :votes 174208 :tags "jazz"))))
+ (should (= (string-match "jazz" low) (string-match "jazz" high)))))
+
+(ert-deftest test-music-radio-completion-table-annotates-station ()
+ "Normal: the table's annotation function returns the Variant-B string for
+a station candidate (marginalia handles the right-alignment)."
+ (let* ((candidates '(("Jazz FM" . (:codec "MP3" :bitrate 128 :countrycode "US"
+ :votes 5 :tags "jazz"))))
+ (table (cj/music-radio--completion-table candidates))
+ (meta (funcall table "" nil 'metadata))
+ (annotate (alist-get 'annotation-function (cdr meta))))
+ (should (functionp annotate))
+ (should-not (alist-get 'affixation-function (cdr meta)))
+ (should (string-match-p "MP3" (funcall annotate "Jazz FM")))
+ (should (string-match-p "jazz" (funcall annotate "Jazz FM")))))
+
+(ert-deftest test-music-radio-completion-table-done-sentinel-no-annotation ()
+ "Boundary: the [done] sentinel has no station and annotates as nil."
+ (let* ((candidates '(("[done]") ("Station" . (:codec "MP3" :bitrate 128
+ :countrycode "US" :votes 1 :tags "x"))))
+ (table (cj/music-radio--completion-table candidates))
+ (annotate (alist-get 'annotation-function
+ (cdr (funcall table "" nil 'metadata)))))
+ (should-not (funcall annotate "[done]"))))
+
+(ert-deftest test-music-radio-completion-table-registers-with-marginalia ()
+ "Normal: building the table registers cj-radio-station so marginalia
+right-aligns the table's own annotations."
+ (let ((marginalia-annotator-registry '()))
+ (cj/music-radio--completion-table '(("X" . (:codec "MP3"))))
+ (should (equal (assq 'cj-radio-station marginalia-annotator-registry)
+ '(cj-radio-station builtin none)))))
+
+(provide 'test-music-config--radio)
+;;; test-music-config--radio.el ends here
diff --git a/tests/test-music-config--renumber-rows.el b/tests/test-music-config--renumber-rows.el
new file mode 100644
index 00000000..d5bc5141
--- /dev/null
+++ b/tests/test-music-config--renumber-rows.el
@@ -0,0 +1,232 @@
+;;; test-music-config--renumber-rows.el --- Tests for playlist row numbering -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Playlist rows carry a numeric overlay prefix so the cursor stays visible
+;; when it sits on a cover-art thumbnail and each row's position in the list
+;; is readable. The renumber walks the buffer and rebuilds the overlays; a
+;; buffer-local after-change hook debounces it behind an idle timer. Overlays
+;; leave the buffer text untouched (EMMS owns it), so these tests drive plain
+;; temp buffers.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'music-config)
+
+(defun test-music-renumber--numbers (buffer)
+ "Return the overlay number strings in BUFFER, in position order."
+ (with-current-buffer buffer
+ (mapcar (lambda (ov) (overlay-get ov 'before-string))
+ (sort (seq-filter (lambda (ov) (overlay-get ov 'cj-music-row-number))
+ (overlays-in (point-min) (point-max)))
+ (lambda (a b) (< (overlay-start a) (overlay-start b)))))))
+
+;;; Normal Cases
+
+(ert-deftest test-music-renumber-rows-numbers-each-line ()
+ "Normal: every non-blank line gets a sequential number overlay."
+ (with-temp-buffer
+ (insert "track one\ntrack two\ntrack three\n")
+ (cj/music--renumber-rows (current-buffer))
+ (should (equal (mapcar #'substring-no-properties
+ (test-music-renumber--numbers (current-buffer)))
+ '(" 1 " " 2 " " 3 ")))))
+
+(ert-deftest test-music-renumber-rows-idempotent ()
+ "Normal: renumbering twice leaves one overlay per line, not two."
+ (with-temp-buffer
+ (insert "track one\ntrack two\n")
+ (cj/music--renumber-rows (current-buffer))
+ (cj/music--renumber-rows (current-buffer))
+ (should (= 2 (length (test-music-renumber--numbers (current-buffer)))))))
+
+(ert-deftest test-music-renumber-rows-number-carries-cursor-property ()
+ "Normal: the number string carries a cursor property. Point is pinned at
+the row start, and without the property redisplay draws the cursor after
+the before-string -- on the album-art thumbnail, where it's invisible."
+ (with-temp-buffer
+ (insert "track one\n")
+ (cj/music--renumber-rows (current-buffer))
+ (let ((s (car (test-music-renumber--numbers (current-buffer)))))
+ (should (get-text-property 0 'cursor s)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-renumber-rows-skips-blank-lines ()
+ "Boundary: blank lines are not numbered and don't advance the count."
+ (with-temp-buffer
+ (insert "track one\n\ntrack two\n")
+ (cj/music--renumber-rows (current-buffer))
+ (should (equal (mapcar #'substring-no-properties
+ (test-music-renumber--numbers (current-buffer)))
+ '(" 1 " " 2 ")))))
+
+(ert-deftest test-music-renumber-rows-empty-buffer-no-overlays ()
+ "Boundary: an empty buffer gets no overlays and no error."
+ (with-temp-buffer
+ (cj/music--renumber-rows (current-buffer))
+ (should-not (test-music-renumber--numbers (current-buffer)))))
+
+;;; Error Cases
+
+(ert-deftest test-music-renumber-rows-dead-buffer-noop ()
+ "Error: renumbering a killed buffer is a silent no-op (the debounce timer
+can fire after the playlist buffer is gone)."
+ (let ((buf (generate-new-buffer " *test-renumber-dead*")))
+ (kill-buffer buf)
+ (should-not (cj/music--renumber-rows buf))))
+
+(ert-deftest test-music-renumber-rows-number-outranks-header-overlay ()
+ "Normal: number overlays carry a priority above the header overlay's 100.
+The header block is a same-position overlay string at the buffer start;
+without the higher priority, row 1's number renders above the header
+instead of next to its own track."
+ (with-temp-buffer
+ (insert "track one\n")
+ (cj/music--renumber-rows (current-buffer))
+ (let ((ov (car (seq-filter (lambda (o) (overlay-get o 'cj-music-row-number))
+ (overlays-in (point-min) (point-max))))))
+ (should (> (or (overlay-get ov 'priority) 0) 100)))))
+
+(ert-deftest test-music-ensure-playlist-buffer-logical-line-motion ()
+ "Normal: the playlist moves by logical lines, not screen lines. The
+multi-line header overlay string at position 1 otherwise absorbs every
+next-line from the top row -- vertical motion steps through the header's
+display and maps back to the same buffer position, so arrows look dead."
+ (let (created)
+ (cl-letf (((symbol-function 'emms-playlist-mode) #'ignore))
+ (unwind-protect
+ (progn
+ (setq created (cj/music--ensure-playlist-buffer))
+ (with-current-buffer created
+ (should (local-variable-p 'line-move-visual))
+ (should-not line-move-visual)))
+ (when (buffer-live-p created) (kill-buffer created))))))
+
+;;; Current-row indicator
+
+(ert-deftest test-music-highlight-current-number-marks-current-row ()
+ "Normal: the current row's number renders inverse-video; moving to another
+row restores the old one and marks the new one. The block cursor only
+draws in the selected window, so the number itself carries the mark."
+ (with-temp-buffer
+ (insert "track one\ntrack two\ntrack three\n")
+ (cj/music--renumber-rows (current-buffer))
+ (goto-char (point-min))
+ (forward-line 1)
+ (cj/music--highlight-current-number)
+ (let ((numbers (test-music-renumber--numbers (current-buffer))))
+ (should-not (plist-get (get-text-property 0 'face (nth 0 numbers)) :inverse-video))
+ (should (plist-get (get-text-property 0 'face (nth 1 numbers)) :inverse-video)))
+ (forward-line 1)
+ (cj/music--highlight-current-number)
+ (let ((numbers (test-music-renumber--numbers (current-buffer))))
+ (should-not (plist-get (get-text-property 0 'face (nth 1 numbers)) :inverse-video))
+ (should (plist-get (get-text-property 0 'face (nth 2 numbers)) :inverse-video)))))
+
+(ert-deftest test-music-highlight-current-number-survives-renumber ()
+ "Boundary: a renumber rebuilds the overlays; the highlight re-applies to
+the current row rather than pointing at a dead overlay."
+ (with-temp-buffer
+ (insert "track one\ntrack two\n")
+ (cj/music--renumber-rows (current-buffer))
+ (goto-char (point-min))
+ (forward-line 1)
+ (cj/music--highlight-current-number)
+ (cj/music--renumber-rows (current-buffer))
+ (let ((numbers (test-music-renumber--numbers (current-buffer))))
+ (should (plist-get (get-text-property 0 'face (nth 1 numbers)) :inverse-video)))))
+
+(ert-deftest test-music-highlight-current-number-keeps-cursor-property ()
+ "Boundary: re-facing a number keeps the cursor property intact."
+ (with-temp-buffer
+ (insert "track one\n")
+ (cj/music--renumber-rows (current-buffer))
+ (goto-char (point-min))
+ (cj/music--highlight-current-number)
+ (let ((s (car (test-music-renumber--numbers (current-buffer)))))
+ (should (get-text-property 0 'cursor s)))))
+
+(ert-deftest test-music-ensure-playlist-buffer-sticky-hl-line ()
+ "Normal: hl-line in the playlist stays visible when the window isn't
+selected -- the dock is glanced at from other windows constantly."
+ (let (created)
+ (cl-letf (((symbol-function 'emms-playlist-mode) #'ignore))
+ (unwind-protect
+ (progn
+ (setq created (cj/music--ensure-playlist-buffer))
+ (should (buffer-local-value 'hl-line-sticky-flag created)))
+ (when (buffer-live-p created) (kill-buffer created))))))
+
+;;; Sticky header
+
+(ert-deftest test-music-stick-header-moves-overlay-to-window-start ()
+ "Normal: a scroll re-anchors the header overlay at the new window start,
+so the header block stays at the top of the window while the list scrolls."
+ (with-temp-buffer
+ (insert "track one\ntrack two\ntrack three\ntrack four\n")
+ (setq cj/music--header-overlay (make-overlay (point-min) (point-min)))
+ (save-window-excursion
+ (set-window-buffer (selected-window) (current-buffer))
+ (let ((start (save-excursion (goto-char (point-min)) (forward-line 2) (point))))
+ (cj/music--stick-header (selected-window) start)
+ (should (= (overlay-start cj/music--header-overlay) start))
+ ;; Converges: the same start again is a no-op, not a loop.
+ (cj/music--stick-header (selected-window) start)
+ (should (= (overlay-start cj/music--header-overlay) start))))))
+
+(ert-deftest test-music-stick-header-no-overlay-noop ()
+ "Boundary: no header overlay yet -- the scroll handler is a silent no-op."
+ (with-temp-buffer
+ (insert "track one\n")
+ (setq cj/music--header-overlay nil)
+ (save-window-excursion
+ (set-window-buffer (selected-window) (current-buffer))
+ (should-not (cj/music--stick-header (selected-window) (point-min))))))
+
+(ert-deftest test-music-header-anchor-position-displayed-vs-not ()
+ "Normal: the header anchors at the displaying window's start; an
+undisplayed buffer anchors at the top."
+ (with-temp-buffer
+ (insert "track one\ntrack two\ntrack three\ntrack four\n")
+ (save-window-excursion
+ (set-window-buffer (selected-window) (current-buffer))
+ (let ((start (save-excursion (goto-char (point-min)) (forward-line 2) (point))))
+ (set-window-start (selected-window) start)
+ (should (= (cj/music--header-anchor-position) start))))
+ ;; Not displayed after the excursion restores the old config.
+ (should (= (cj/music--header-anchor-position) (point-min)))))
+
+(ert-deftest test-music-ensure-playlist-buffer-wires-scroll-hook ()
+ "Normal: the playlist buffer re-sticks its header on every window scroll."
+ (let (created)
+ (cl-letf (((symbol-function 'emms-playlist-mode) #'ignore))
+ (unwind-protect
+ (progn
+ (setq created (cj/music--ensure-playlist-buffer))
+ (with-current-buffer created
+ (should (member #'cj/music--stick-header window-scroll-functions))))
+ (when (buffer-live-p created) (kill-buffer created))))))
+
+;;; Hook wiring
+
+(ert-deftest test-music-renumber-ensure-playlist-buffer-wires-hook ()
+ "Normal: the playlist buffer gets the debounced renumber on after-change."
+ (let (created)
+ (cl-letf (((symbol-function 'emms-playlist-mode) #'ignore))
+ (unwind-protect
+ (progn
+ (setq created (cj/music--ensure-playlist-buffer))
+ (with-current-buffer created
+ (should (member #'cj/music--schedule-renumber after-change-functions))))
+ (when (buffer-live-p created) (kill-buffer created))))))
+
+(provide 'test-music-config--renumber-rows)
+;;; test-music-config--renumber-rows.el ends here
diff --git a/tests/test-music-config--safe-filename.el b/tests/test-music-config--safe-filename.el
deleted file mode 100644
index 8105ee15..00000000
--- a/tests/test-music-config--safe-filename.el
+++ /dev/null
@@ -1,97 +0,0 @@
-;;; test-music-config--safe-filename.el --- Tests for filename sanitization -*- coding: utf-8; lexical-binding: t; -*-
-;;
-;; Author: Craig Jennings <c@cjennings.net>
-;;
-;;; Commentary:
-;; Unit tests for cj/music--safe-filename function.
-;; Tests the pure helper that sanitizes filenames by replacing invalid chars.
-;;
-;; Test organization:
-;; - Normal Cases: Valid filenames unchanged, spaces replaced
-;; - Boundary Cases: Special chars, unicode, slashes, consecutive invalid chars
-;; - Error Cases: Nil input
-;;
-;;; Code:
-
-(require 'ert)
-
-;; Stub missing dependencies before loading music-config
-(defvar-keymap cj/custom-keymap
- :doc "Stub keymap for testing")
-
-;; Load production code
-(require 'music-config)
-
-;;; Normal Cases
-
-(ert-deftest test-music-config--safe-filename-normal-alphanumeric-unchanged ()
- "Validate alphanumeric filename remains unchanged."
- (should (string= (cj/music--safe-filename "MyPlaylist123")
- "MyPlaylist123")))
-
-(ert-deftest test-music-config--safe-filename-normal-with-hyphens-unchanged ()
- "Validate filename with hyphens remains unchanged."
- (should (string= (cj/music--safe-filename "my-playlist-name")
- "my-playlist-name")))
-
-(ert-deftest test-music-config--safe-filename-normal-with-underscores-unchanged ()
- "Validate filename with underscores remains unchanged."
- (should (string= (cj/music--safe-filename "my_playlist_name")
- "my_playlist_name")))
-
-(ert-deftest test-music-config--safe-filename-normal-spaces-replaced ()
- "Validate spaces are replaced with underscores."
- (should (string= (cj/music--safe-filename "My Favorite Songs")
- "My_Favorite_Songs")))
-
-;;; Boundary Cases
-
-(ert-deftest test-music-config--safe-filename-boundary-special-chars-replaced ()
- "Validate special characters are replaced with underscores."
- (should (string= (cj/music--safe-filename "playlist@#$%^&*()")
- "playlist_________")))
-
-(ert-deftest test-music-config--safe-filename-boundary-unicode-replaced ()
- "Validate unicode characters are replaced with underscores."
- (should (string= (cj/music--safe-filename "中文歌曲")
- "____")))
-
-(ert-deftest test-music-config--safe-filename-boundary-mixed-valid-invalid ()
- "Validate mixed valid and invalid characters."
- (should (string= (cj/music--safe-filename "Rock & Roll")
- "Rock___Roll")))
-
-(ert-deftest test-music-config--safe-filename-boundary-dots-replaced ()
- "Validate dots are replaced with underscores."
- (should (string= (cj/music--safe-filename "my.playlist.name")
- "my_playlist_name")))
-
-(ert-deftest test-music-config--safe-filename-boundary-slashes-replaced ()
- "Validate slashes are replaced with underscores."
- (should (string= (cj/music--safe-filename "folder/file")
- "folder_file")))
-
-(ert-deftest test-music-config--safe-filename-boundary-consecutive-invalid-chars ()
- "Validate consecutive invalid characters each become underscores."
- (should (string= (cj/music--safe-filename "test!!!name")
- "test___name")))
-
-(ert-deftest test-music-config--safe-filename-boundary-empty-string-unchanged ()
- "Validate empty string remains unchanged."
- (should (string= (cj/music--safe-filename "")
- "")))
-
-(ert-deftest test-music-config--safe-filename-boundary-only-invalid-chars ()
- "Validate string with only invalid characters becomes all underscores."
- (should (string= (cj/music--safe-filename "!@#$%")
- "_____")))
-
-;;; Error Cases
-
-(ert-deftest test-music-config--safe-filename-error-nil-input-signals-error ()
- "Validate nil input signals error."
- (should-error (cj/music--safe-filename nil)
- :type 'wrong-type-argument))
-
-(provide 'test-music-config--safe-filename)
-;;; test-music-config--safe-filename.el ends here
diff --git a/tests/test-music-config--save-helpers.el b/tests/test-music-config--save-helpers.el
new file mode 100644
index 00000000..ffb0a477
--- /dev/null
+++ b/tests/test-music-config--save-helpers.el
@@ -0,0 +1,90 @@
+;;; test-music-config--save-helpers.el --- save default-name + directory tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The pure helpers behind the queue-first save flow: which name the save
+;; prompt pre-fills (`cj/music--save-default-name') and which directory the
+;; file lands in (`cj/music--save-directory'). An all-stream queue saves into
+;; the radio playlist home (`cj/music-radio-save-dir'); anything else saves
+;; into `cj/music-m3u-root'.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(declare-function cj/music--save-default-name "music-config" (tracks file entries))
+(declare-function cj/music--save-directory "music-config" (tracks))
+(declare-function cj/music-radio--station-track "music-config" (st))
+
+;;; --------------------------- save-default-name -------------------------------
+
+(ert-deftest test-music-save-default-name-normal-associated-file-wins ()
+ "Normal: an associated playlist file names the save, station or not."
+ (let ((tr (cj/music-radio--station-track '(:name "S" :url "https://s.example/x"))))
+ (should (equal (cj/music--save-default-name (list tr) "/pl/jazz.m3u" nil)
+ "jazz"))))
+
+(ert-deftest test-music-save-default-name-normal-station-title ()
+ "Normal: with no file, the first url track's station name pre-fills."
+ (let ((tr (cj/music-radio--station-track
+ '(:name "Groove Salad" :url "https://gs.example/x"))))
+ (should (equal (cj/music--save-default-name (list tr) nil nil)
+ "Groove Salad"))))
+
+(ert-deftest test-music-save-default-name-normal-first-url-track-wins ()
+ "Normal: a file track ahead of the station doesn't block the station name;
+the first URL track with a name wins."
+ (let ((f (emms-track 'file "/music/a.flac"))
+ (tr (cj/music-radio--station-track
+ '(:name "Second Pick" :url "https://sp.example/x"))))
+ (should (equal (cj/music--save-default-name (list f tr) nil nil)
+ "Second Pick"))))
+
+(ert-deftest test-music-save-default-name-normal-entries-fallback ()
+ "Normal: a propertyless url track resolves its name from ENTRIES."
+ (let* ((url "https://legacy.example/s")
+ (tr (emms-track 'url url))
+ (entries (list (list url :name "Legacy FM" :uuid nil :favicon nil))))
+ (should (equal (cj/music--save-default-name (list tr) nil entries)
+ "Legacy FM"))))
+
+(ert-deftest test-music-save-default-name-boundary-no-candidates-nil ()
+ "Boundary: no file, no url tracks -> nil (caller falls back to a timestamp)."
+ (should-not (cj/music--save-default-name
+ (list (emms-track 'file "/music/a.flac")) nil nil))
+ (should-not (cj/music--save-default-name nil nil nil)))
+
+;;; ---------------------------- save-directory ---------------------------------
+
+(ert-deftest test-music-save-directory-normal-all-streams-radio-dir ()
+ "Normal: an all-stream queue saves into the radio playlist dir."
+ (let ((u1 (emms-track 'url "https://a.example/1"))
+ (u2 (emms-track 'url "https://b.example/2")))
+ (should (equal (cj/music--save-directory (list u1 u2))
+ cj/music-radio-save-dir))))
+
+(ert-deftest test-music-save-directory-normal-mixed-goes-to-m3u-root ()
+ "Normal: any non-stream track routes the save to the music playlist root."
+ (let ((u (emms-track 'url "https://a.example/1"))
+ (f (emms-track 'file "/music/a.flac")))
+ (should (equal (cj/music--save-directory (list u f))
+ cj/music-m3u-root))))
+
+(ert-deftest test-music-save-directory-boundary-empty-goes-to-m3u-root ()
+ "Boundary: an empty queue defaults to the music playlist root."
+ (should (equal (cj/music--save-directory nil) cj/music-m3u-root)))
+
+(provide 'test-music-config--save-helpers)
+;;; test-music-config--save-helpers.el ends here
diff --git a/tests/test-music-config--tidy-host.el b/tests/test-music-config--tidy-host.el
new file mode 100644
index 00000000..92b104a6
--- /dev/null
+++ b/tests/test-music-config--tidy-host.el
@@ -0,0 +1,47 @@
+;;; test-music-config--tidy-host.el --- Tests for stream-URL host tidying -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--tidy-host': reduce a stream URL to a readable host
+;; label (scheme dropped, a leading "www." removed), used as the last-resort
+;; display name for a url track with no #EXTINF label.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(ert-deftest test-music-config--tidy-host-normal ()
+ "Normal: scheme and path are dropped, host kept."
+ (should (string= (cj/music--tidy-host "https://ice6.somafm.com/groovesalad-256-mp3")
+ "somafm.com")))
+
+(ert-deftest test-music-config--tidy-host-normal-http ()
+ "Normal: plain http host with a port keeps the host, drops the port."
+ (should (string= (cj/music--tidy-host "http://stream.example.org:8000/live")
+ "example.org")))
+
+(ert-deftest test-music-config--tidy-host-boundary-strip-www ()
+ "Boundary: a leading www. is stripped."
+ (should (string= (cj/music--tidy-host "https://www.radioparadise.com/m3u/mp3-128.m3u")
+ "radioparadise.com")))
+
+(ert-deftest test-music-config--tidy-host-boundary-bare-domain ()
+ "Boundary: a two-label domain is returned unchanged."
+ (should (string= (cj/music--tidy-host "http://somafm.com/") "somafm.com")))
+
+(ert-deftest test-music-config--tidy-host-error-not-a-url ()
+ "Error: a non-URL string is returned as-is rather than erroring."
+ (should (string= (cj/music--tidy-host "not a url") "not a url")))
+
+(provide 'test-music-config--tidy-host)
+;;; test-music-config--tidy-host.el ends here
diff --git a/tests/test-music-config--track-description.el b/tests/test-music-config--track-description.el
deleted file mode 100644
index a1a1cc6d..00000000
--- a/tests/test-music-config--track-description.el
+++ /dev/null
@@ -1,181 +0,0 @@
-;;; test-music-config--track-description.el --- Tests for track description -*- coding: utf-8; lexical-binding: t; -*-
-;;
-;; Author: Craig Jennings <c@cjennings.net>
-;;
-;;; Commentary:
-;; Unit tests for cj/music--track-description function.
-;; Tests the custom track description that replaces EMMS's default file-path display
-;; with human-readable formats based on track type and available metadata.
-;;
-;; Track construction: EMMS tracks are alists created with `emms-track' and
-;; populated with `emms-track-set'. No playlist buffer or player state needed.
-;;
-;; Test organization:
-;; - Normal Cases: Tagged tracks (artist+title+duration), partial metadata, file fallback, URL
-;; - Boundary Cases: Empty strings, missing fields, special characters, long names
-;; - Error Cases: Unknown track type fallback
-;;
-;;; Code:
-
-(require 'ert)
-
-;; Stub missing dependencies before loading music-config
-(defvar-keymap cj/custom-keymap
- :doc "Stub keymap for testing")
-
-;; Add EMMS elpa directory to load path for batch testing
-(let ((emms-dir (car (file-expand-wildcards
- (expand-file-name "elpa/emms-*" user-emacs-directory)))))
- (when emms-dir
- (add-to-list 'load-path emms-dir)))
-
-(require 'emms)
-(require 'emms-playlist-mode)
-(require 'music-config)
-
-;;; Test helpers
-
-(defun test-track-description--make-file-track (path &optional title artist duration)
- "Create a file TRACK with PATH and optional metadata TITLE, ARTIST, DURATION."
- (let ((track (emms-track 'file path)))
- (when title (emms-track-set track 'info-title title))
- (when artist (emms-track-set track 'info-artist artist))
- (when duration (emms-track-set track 'info-playing-time duration))
- track))
-
-(defun test-track-description--make-url-track (url &optional title artist duration)
- "Create a URL TRACK with URL and optional metadata TITLE, ARTIST, DURATION."
- (let ((track (emms-track 'url url)))
- (when title (emms-track-set track 'info-title title))
- (when artist (emms-track-set track 'info-artist artist))
- (when duration (emms-track-set track 'info-playing-time duration))
- track))
-
-;;; Normal Cases — Tagged tracks (artist + title + duration)
-
-(ert-deftest test-music-config--track-description-normal-full-metadata ()
- "Validate track with artist, title, and duration shows all three."
- (let ((track (test-track-description--make-file-track
- "/music/Kind of Blue/01 - So What.flac"
- "So What" "Miles Davis" 562)))
- (should (string= (cj/music--track-description track)
- "Miles Davis - So What [9:22]"))))
-
-(ert-deftest test-music-config--track-description-normal-title-and-artist-no-duration ()
- "Validate track with artist and title but no duration omits bracket."
- (let ((track (test-track-description--make-file-track
- "/test/uncached-nodur.mp3" "Blue in Green" "Miles Davis")))
- (should (string= (cj/music--track-description track)
- "Miles Davis - Blue in Green"))))
-
-(ert-deftest test-music-config--track-description-normal-title-only ()
- "Validate track with title but no artist shows title alone."
- (let ((track (test-track-description--make-file-track
- "/test/uncached-noartist.mp3" "Flamenco Sketches" nil 566)))
- (should (string= (cj/music--track-description track)
- "Flamenco Sketches [9:26]"))))
-
-(ert-deftest test-music-config--track-description-normal-title-only-no-duration ()
- "Validate track with only title shows just the title."
- (let ((track (test-track-description--make-file-track
- "/test/uncached-titleonly.mp3" "All Blues")))
- (should (string= (cj/music--track-description track)
- "All Blues"))))
-
-;;; Normal Cases — File tracks without tags
-
-(ert-deftest test-music-config--track-description-normal-file-no-tags ()
- "Validate untagged file shows filename without path or extension."
- (let ((track (test-track-description--make-file-track
- "/music/Kind of Blue/02 - Freddie Freeloader.flac")))
- (should (string= (cj/music--track-description track)
- "02 - Freddie Freeloader"))))
-
-(ert-deftest test-music-config--track-description-normal-file-nested-path ()
- "Validate deeply nested path still shows only the filename."
- (let ((track (test-track-description--make-file-track
- "/music/Jazz/Miles Davis/Kind of Blue/01 - So What.mp3")))
- (should (string= (cj/music--track-description track)
- "01 - So What"))))
-
-;;; Normal Cases — URL tracks
-
-(ert-deftest test-music-config--track-description-normal-url-plain ()
- "Validate plain URL is shown as-is."
- (let ((track (test-track-description--make-url-track
- "https://radio.example.com/stream")))
- (should (string= (cj/music--track-description track)
- "https://radio.example.com/stream"))))
-
-(ert-deftest test-music-config--track-description-normal-url-percent-encoded ()
- "Validate percent-encoded URL characters are decoded."
- (let ((track (test-track-description--make-url-track
- "https://radio.example.com/my%20station%21")))
- (should (string= (cj/music--track-description track)
- "https://radio.example.com/my station!"))))
-
-(ert-deftest test-music-config--track-description-normal-url-with-tags ()
- "Validate URL track with tags uses tag display, not URL."
- (let ((track (test-track-description--make-url-track
- "https://radio.example.com/stream"
- "Jazz FM" "Radio Station" 0)))
- ;; Duration 0 → nil from format-duration, so no bracket
- (should (string= (cj/music--track-description track)
- "Radio Station - Jazz FM"))))
-
-;;; Boundary Cases
-
-(ert-deftest test-music-config--track-description-boundary-empty-title-string ()
- "Validate empty title string is still truthy, shows empty result."
- (let ((track (test-track-description--make-file-track
- "/music/track.mp3" "" "Artist")))
- ;; Empty string is non-nil, so title branch is taken
- (should (string= (cj/music--track-description track)
- "Artist - "))))
-
-(ert-deftest test-music-config--track-description-boundary-file-no-extension ()
- "Validate file without extension shows full filename."
- (let ((track (test-track-description--make-file-track "/music/README")))
- (should (string= (cj/music--track-description track)
- "README"))))
-
-(ert-deftest test-music-config--track-description-boundary-file-multiple-dots ()
- "Validate file with multiple dots strips only the final extension."
- (let ((track (test-track-description--make-file-track
- "/music/disc.1.track.03.flac")))
- (should (string= (cj/music--track-description track)
- "disc.1.track.03"))))
-
-(ert-deftest test-music-config--track-description-boundary-unicode-title ()
- "Validate unicode characters in metadata are preserved."
- (let ((track (test-track-description--make-file-track
- "/music/track.mp3" "夜に駆ける" "YOASOBI" 258)))
- (should (string= (cj/music--track-description track)
- "YOASOBI - 夜に駆ける [4:18]"))))
-
-(ert-deftest test-music-config--track-description-boundary-url-utf8-percent-encoded ()
- "Validate percent-encoded UTF-8 in URL is decoded correctly."
- (let ((track (test-track-description--make-url-track
- "https://example.com/caf%C3%A9")))
- (should (string= (cj/music--track-description track)
- "https://example.com/café"))))
-
-(ert-deftest test-music-config--track-description-boundary-short-duration ()
- "Validate 1-second track formats correctly in bracket."
- (let ((track (test-track-description--make-file-track
- "/music/t.mp3" "Beep" nil 1)))
- (should (string= (cj/music--track-description track)
- "Beep [0:01]"))))
-
-;;; Error Cases
-
-(ert-deftest test-music-config--track-description-error-unknown-type-fallback ()
- "Validate unknown track type uses emms-track-simple-description fallback."
- (let ((track (emms-track 'streamlist "https://example.com/playlist.m3u")))
- ;; Should not error; falls through to simple-description
- (let ((result (cj/music--track-description track)))
- (should (stringp result))
- (should (string-match-p "example\\.com" result)))))
-
-(provide 'test-music-config--track-description)
-;;; test-music-config--track-description.el ends here
diff --git a/tests/test-music-config-commands.el b/tests/test-music-config-commands.el
index 3c585d0b..40fb2d81 100644
--- a/tests/test-music-config-commands.el
+++ b/tests/test-music-config-commands.el
@@ -30,17 +30,21 @@
;;; cj/music-add-directory-recursive
(ert-deftest test-music-add-directory-recursive-passes-dir-to-emms ()
- "Normal: add-directory-recursive routes through emms-add-directory-tree."
+ "Normal: add-directory-recursive feeds the directory's music files (and
+only those) to emms-add-file -- the raw-tree path added cover art too."
(let* ((tmp (file-name-as-directory (make-temp-file "cj-music-add-" t)))
- called)
+ added)
(unwind-protect
- (cl-letf (((symbol-function 'cj/music--ensure-playlist-buffer) #'ignore)
- ((symbol-function 'emms-add-directory-tree)
- (lambda (dir) (setq called dir)))
- ((symbol-function 'message) #'ignore))
- (cj/music-add-directory-recursive tmp))
+ (progn
+ (write-region "" nil (expand-file-name "song.mp3" tmp))
+ (write-region "" nil (expand-file-name "cover.jpg" tmp))
+ (cl-letf (((symbol-function 'cj/music--ensure-playlist-buffer) #'ignore)
+ ((symbol-function 'emms-add-file)
+ (lambda (f) (push f added)))
+ ((symbol-function 'message) #'ignore))
+ (cj/music-add-directory-recursive tmp)))
(delete-directory tmp t))
- (should (equal called tmp))))
+ (should (equal (mapcar #'file-name-nondirectory added) '("song.mp3")))))
(ert-deftest test-music-add-directory-recursive-errors-on-non-directory ()
"Error: passing a regular file (not a directory) signals user-error."
diff --git a/tests/test-music-config-create-radio-station.el b/tests/test-music-config-create-radio-station.el
index 1f4365a4..7d9adbed 100644
--- a/tests/test-music-config-create-radio-station.el
+++ b/tests/test-music-config-create-radio-station.el
@@ -1,153 +1,130 @@
-;;; test-music-config-create-radio-station.el --- Tests for radio station creation -*- coding: utf-8; lexical-binding: t; -*-
+;;; test-music-config-create-radio-station.el --- Tests for manual radio-station entry -*- coding: utf-8; lexical-binding: t; -*-
;;
;; Author: Craig Jennings <c@cjennings.net>
;;
;;; Commentary:
-;; Unit tests for cj/music-create-radio-station function.
-;; Tests M3U file creation for radio stations with stream URLs.
+;; Unit tests for cj/music-create-radio-station under the queue-first model:
+;; a hand-entered name + URL becomes a url track in the playlist queue (with
+;; the name as its title property) and playback starts. Nothing is written
+;; to disk — saving is the normal playlist-save flow.
;;
;; Test organization:
-;; - Normal Cases: Standard creation, EXTM3U format, safe filename
-;; - Boundary Cases: Unicode name, complex URL, overwrite confirmed
-;; - Error Cases: Empty name, empty URL, overwrite declined
-;;
+;; - Normal Cases: track queued with title, playback started, no file written
+;; - Boundary Cases: unicode name preserved verbatim
+;; - Error Cases: empty name, empty URL
+
;;; Code:
(require 'ert)
-(require 'testutil-general)
+(require 'cl-lib)
;; Stub missing dependencies before loading music-config
(defvar-keymap cj/custom-keymap
:doc "Stub keymap for testing")
-;; Load production code
-(require 'music-config)
-
-;;; Setup & Teardown
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
-(defun test-music-config-create-radio-station-setup ()
- "Setup test environment with temp directory for M3U output."
- (cj/create-test-base-dir)
- (cj/create-test-subdirectory "radio-playlists"))
+(require 'emms)
+(require 'music-config)
-(defun test-music-config-create-radio-station-teardown ()
- "Clean up test environment."
- (cj/delete-test-base-dir))
+(defmacro test-music-create-radio--with-env (&rest body)
+ "Run BODY with a fresh playlist buffer, playback mocked, messages captured."
+ `(let* ((cj/music-playlist-buffer-name
+ (generate-new-buffer-name "*test-create-radio*"))
+ (emms-player-playing-p nil)
+ (started 0) (msg nil))
+ (ignore started msg)
+ (unwind-protect
+ (cl-letf (((symbol-function 'emms-start)
+ (lambda () (setq started (1+ started))))
+ ((symbol-function 'emms-stop) #'ignore)
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (when fmt (setq msg (apply #'format fmt args))))))
+ ,@body)
+ (when (get-buffer cj/music-playlist-buffer-name)
+ (kill-buffer cj/music-playlist-buffer-name)))))
+
+(defun test-music-create-radio--queued-tracks ()
+ "Track objects currently in the test playlist buffer."
+ (let ((tracks '()))
+ (with-current-buffer cj/music-playlist-buffer-name
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (when-let ((tr (emms-playlist-track-at (point))))
+ (push tr tracks))
+ (forward-line 1))))
+ (nreverse tracks)))
;;; Normal Cases
-(ert-deftest test-music-config-create-radio-station-normal-creates-m3u-file ()
- "Creating a radio station produces an M3U file in the music root."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir))
- (cj/music-create-radio-station "Jazz FM" "http://stream.jazzfm.com/radio")
- (let ((expected-file (expand-file-name "Jazz_FM_Radio.m3u" test-dir)))
- (should (file-exists-p expected-file))))
- (test-music-config-create-radio-station-teardown))))
-
-(ert-deftest test-music-config-create-radio-station-normal-extm3u-format ()
- "Created file contains EXTM3U header, EXTINF with station name, and URL."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
+(ert-deftest test-music-config-create-radio-station-normal-queues-track ()
+ "Normal: name+url queues a url track carrying the name, and playback starts."
+ (test-music-create-radio--with-env
+ (cj/music-create-radio-station "Jazz FM" "http://stream.jazzfm.com/radio")
+ (let ((tracks (test-music-create-radio--queued-tracks)))
+ (should (= (length tracks) 1))
+ (should (eq (emms-track-type (car tracks)) 'url))
+ (should (equal (emms-track-name (car tracks)) "http://stream.jazzfm.com/radio"))
+ (should (equal (emms-track-get (car tracks) 'info-title) "Jazz FM")))
+ (should (= started 1))
+ (should (string-match-p "Jazz FM" msg))))
+
+(ert-deftest test-music-config-create-radio-station-normal-writes-no-file ()
+ "Normal: nothing lands on disk — saving is the playlist-save flow's job."
+ (let ((tmp (file-name-as-directory (make-temp-file "cj-radio-nofile-" t))))
(unwind-protect
- (let ((cj/music-m3u-root test-dir))
- (cj/music-create-radio-station "Jazz FM" "http://stream.jazzfm.com/radio")
- (let ((content (with-temp-buffer
- (insert-file-contents
- (expand-file-name "Jazz_FM_Radio.m3u" test-dir))
- (buffer-string))))
- (should (string-match-p "^#EXTM3U" content))
- (should (string-match-p "#EXTINF:-1,Jazz FM" content))
- (should (string-match-p "http://stream.jazzfm.com/radio" content))))
- (test-music-config-create-radio-station-teardown))))
-
-(ert-deftest test-music-config-create-radio-station-normal-safe-filename ()
- "Station name with special characters produces filesystem-safe filename."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir))
- (cj/music-create-radio-station "Rock & Roll 101.5" "http://example.com/stream")
- ;; Spaces and special chars replaced with underscores
- (let ((expected-file (expand-file-name "Rock___Roll_101_5_Radio.m3u" test-dir)))
- (should (file-exists-p expected-file))))
- (test-music-config-create-radio-station-teardown))))
+ (test-music-create-radio--with-env
+ (let ((cj/music-m3u-root tmp)
+ (cj/music-radio-save-dir tmp))
+ (cj/music-create-radio-station "NPR" "https://example.test/stream")
+ (should-not (directory-files tmp nil "\\.m3u\\'"))))
+ (delete-directory tmp t))))
;;; Boundary Cases
-(ert-deftest test-music-config-create-radio-station-boundary-unicode-name-safe-filename ()
- "Unicode station name produces safe filename while preserving name in EXTINF."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir))
- (cj/music-create-radio-station "Klassik Radio" "http://example.com/stream")
- ;; Name is all ASCII-safe, so filename uses it directly
- (should (file-exists-p (expand-file-name "Klassik_Radio_Radio.m3u" test-dir)))
- ;; Original name preserved in EXTINF inside the file
- (let ((content (with-temp-buffer
- (insert-file-contents
- (expand-file-name "Klassik_Radio_Radio.m3u" test-dir))
- (buffer-string))))
- (should (string-match-p "Klassik Radio" content))))
- (test-music-config-create-radio-station-teardown))))
-
-(ert-deftest test-music-config-create-radio-station-boundary-url-with-query-params ()
- "Complex URL with query parameters preserved in file content."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir)
- (url "https://stream.example.com/radio?format=mp3&quality=320&token=abc123"))
- (cj/music-create-radio-station "Test Radio" url)
- (let ((content (with-temp-buffer
- (insert-file-contents
- (expand-file-name "Test_Radio_Radio.m3u" test-dir))
- (buffer-string))))
- (should (string-match-p (regexp-quote url) content))))
- (test-music-config-create-radio-station-teardown))))
-
-(ert-deftest test-music-config-create-radio-station-boundary-overwrite-confirmed ()
- "Overwriting existing file when user confirms succeeds."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir))
- ;; Create initial file
- (cj/music-create-radio-station "MyRadio" "http://old.url/stream")
- (let ((file (expand-file-name "MyRadio_Radio.m3u" test-dir)))
- (should (file-exists-p file))
- ;; Overwrite with user confirming
- (cl-letf (((symbol-function 'yes-or-no-p) (lambda (_prompt) t)))
- (cj/music-create-radio-station "MyRadio" "http://new.url/stream"))
- ;; File should now contain new URL
- (let ((content (with-temp-buffer
- (insert-file-contents file)
- (buffer-string))))
- (should (string-match-p "http://new.url/stream" content))
- (should-not (string-match-p "http://old.url/stream" content)))))
- (test-music-config-create-radio-station-teardown))))
+(ert-deftest test-music-config-create-radio-station-boundary-unicode-name ()
+ "Boundary: a unicode name is kept verbatim on the track (no filename munging)."
+ (test-music-create-radio--with-env
+ (cj/music-create-radio-station "Café Del Mar ☕" "https://cafe.example/stream")
+ (should (equal (emms-track-get (car (test-music-create-radio--queued-tracks))
+ 'info-title)
+ "Café Del Mar ☕"))))
+
+;;; Keymap
+
+(ert-deftest test-music-config-radio-map-prefix-mirrors-playlist-keys ()
+ "Normal: C-; m r is a radio prefix whose n/t/m mirror the playlist buffer."
+ (let ((map (lookup-key cj/music-map "r")))
+ (should (keymapp map))
+ (should (eq (lookup-key map "n") 'cj/music-radio-search-by-name))
+ (should (eq (lookup-key map "t") 'cj/music-radio-search-by-tag))
+ (should (eq (lookup-key map "m") 'cj/music-create-radio-station))))
+
+(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."
+ (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)))
;;; Error Cases
(ert-deftest test-music-config-create-radio-station-error-empty-name-signals-user-error ()
- "Empty station name signals user-error."
- (should-error (cj/music-create-radio-station "" "http://example.com/stream")
- :type 'user-error))
+ "Error: empty name signals user-error."
+ (should-error (cj/music-create-radio-station "" "https://x") :type 'user-error))
(ert-deftest test-music-config-create-radio-station-error-empty-url-signals-user-error ()
- "Empty URL signals user-error."
- (should-error (cj/music-create-radio-station "Test Radio" "")
- :type 'user-error))
-
-(ert-deftest test-music-config-create-radio-station-error-overwrite-declined-signals-user-error ()
- "Declining overwrite signals user-error."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir))
- ;; Create initial file
- (cj/music-create-radio-station "MyRadio" "http://old.url/stream")
- ;; Decline overwrite
- (cl-letf (((symbol-function 'yes-or-no-p) (lambda (_prompt) nil)))
- (should-error (cj/music-create-radio-station "MyRadio" "http://new.url/stream")
- :type 'user-error)))
- (test-music-config-create-radio-station-teardown))))
+ "Error: empty URL signals user-error."
+ (should-error (cj/music-create-radio-station "NPR" "") :type 'user-error))
(provide 'test-music-config-create-radio-station)
;;; test-music-config-create-radio-station.el ends here
diff --git a/tests/test-music-config-helpers-untested.el b/tests/test-music-config-helpers-untested.el
index bfdb2634..87aa210c 100644
--- a/tests/test-music-config-helpers-untested.el
+++ b/tests/test-music-config-helpers-untested.el
@@ -154,17 +154,18 @@ test prelude inserts filler with `inhibit-read-only' bound."
;;; ---------- cj/music-add-directory-recursive ----------
(ert-deftest test-mc-add-directory-recursive-normal-calls-emms ()
- "Normal: with an existing directory, the recursive add reaches emms."
+ "Normal: with an existing directory, the recursive add reaches emms with
+each music file individually (the filtered walk, not the raw tree)."
(test-mc-untested--setup)
(unwind-protect
(let* ((dir cj/test-base-dir)
- (called-with nil))
- (cl-letf (((symbol-function 'emms-add-directory-tree)
- (lambda (d) (setq called-with d)))
+ (added nil))
+ (write-region "" nil (expand-file-name "one.mp3" dir))
+ (cl-letf (((symbol-function 'emms-add-file)
+ (lambda (f) (push f added)))
((symbol-function 'message) #'ignore))
(cj/music-add-directory-recursive dir))
- (should (equal (file-name-as-directory called-with)
- (file-name-as-directory dir))))
+ (should (member "one.mp3" (mapcar #'file-name-nondirectory added))))
(test-mc-untested--teardown)))
(ert-deftest test-mc-add-directory-recursive-error-not-a-directory ()
diff --git a/tests/test-music-config-more-commands.el b/tests/test-music-config-more-commands.el
index c351c1f1..530aa379 100644
--- a/tests/test-music-config-more-commands.el
+++ b/tests/test-music-config-more-commands.el
@@ -9,7 +9,9 @@
;; cj/music-playlist-edit
;; cj/music-playlist-toggle
;; cj/music-playlist-show
-;; cj/music-create-radio-station
+;;
+;; cj/music-create-radio-station lives in
+;; test-music-config-create-radio-station.el.
;;; Code:
@@ -17,12 +19,21 @@
(require 'cl-lib)
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
(require 'music-config)
;; Top-level defvars so let-binds reach the dynamic var under lexical
;; scope.
(defvar cj/music-playlist-file nil)
-(defvar emms-source-playlist-ask-before-overwrite t)
;;; cj/music-playlist-load
@@ -56,28 +67,87 @@
;;; cj/music-playlist-save
-(ert-deftest test-music-playlist-save-writes-fresh-name ()
- "Normal: save with a fresh name writes via emms-playlist-save."
- (let* ((tmp (file-name-as-directory (make-temp-file "cj-music-save-" t)))
- (cj/music-m3u-root tmp)
- saved-args msg)
- (unwind-protect
- (cl-letf (((symbol-function 'cj/music--get-m3u-basenames)
- (lambda () nil))
- ((symbol-function 'completing-read)
- (lambda (&rest _) "fresh"))
- ((symbol-function 'cj/music--ensure-playlist-buffer)
- (lambda () (current-buffer)))
- ((symbol-function 'emms-playlist-save)
- (lambda (fmt path) (setq saved-args (list fmt path))))
- ((symbol-function 'cj/music--sync-playlist-file) #'ignore)
- ((symbol-function 'message)
- (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
- (cj/music-playlist-save))
- (delete-directory tmp t))
- (should (equal (car saved-args) 'm3u))
- (should (string-match-p "fresh\\.m3u\\'" (cadr saved-args)))
- (should (string-match-p "Saved playlist" msg))))
+(defmacro test-music-save--with-env (&rest body)
+ "Run BODY with a fresh playlist buffer and temp save directories.
+Binds TMP-M3U and TMP-RADIO (both cleaned up) and captures completing-read's
+INITIAL argument in CR-INITIAL."
+ `(let* ((cj/music-playlist-buffer-name
+ (generate-new-buffer-name "*test-save*"))
+ (tmp-m3u (file-name-as-directory (make-temp-file "cj-save-m3u-" t)))
+ (tmp-radio (file-name-as-directory (make-temp-file "cj-save-radio-" t)))
+ (cj/music-m3u-root tmp-m3u)
+ (cj/music-radio-save-dir tmp-radio)
+ (cr-initial 'unset))
+ (ignore cr-initial)
+ (unwind-protect
+ (cl-letf (((symbol-function 'message) #'ignore)
+ ((symbol-function 'completing-read)
+ (lambda (_prompt _coll &optional _pred _req initial _hist def &rest _)
+ (setq cr-initial initial)
+ (or initial def "fallback"))))
+ ,@body)
+ (when (get-buffer cj/music-playlist-buffer-name)
+ (kill-buffer cj/music-playlist-buffer-name))
+ (delete-directory tmp-m3u t)
+ (delete-directory tmp-radio t))))
+
+(defun test-music-save--queue (tracks)
+ "Put TRACKS into the (fresh) test playlist buffer."
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (save-excursion
+ (goto-char (point-max))
+ (dolist (tr tracks)
+ (emms-playlist-insert-track tr)))))
+
+(ert-deftest test-music-playlist-save-station-queue-prefills-and-targets-radio-dir ()
+ "Normal: an all-stream queue pre-fills the station name and saves into the
+radio dir with the station metadata written."
+ (test-music-save--with-env
+ (let ((tr (emms-track 'url "https://gs.example/stream")))
+ (emms-track-set tr 'info-title "Groove Salad")
+ (emms-track-set tr 'radio-uuid "uuid-gs")
+ (test-music-save--queue (list tr)))
+ (cj/music-playlist-save)
+ (should (equal cr-initial "Groove Salad"))
+ (let ((file (expand-file-name "Groove Salad.m3u" tmp-radio)))
+ (should (file-exists-p file))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (let ((text (buffer-string)))
+ (should (string-match-p "^#EXTINF:-1,Groove Salad$" text))
+ (should (string-match-p "^#RADIOBROWSERUUID:uuid-gs$" text))
+ (should (string-match-p "^https://gs\\.example/stream$" text)))))
+ (should-not (directory-files tmp-m3u nil "\\.m3u\\'"))))
+
+(ert-deftest test-music-playlist-save-file-queue-targets-m3u-root-no-prefill ()
+ "Normal: a file queue saves into the music root with no station pre-fill."
+ (test-music-save--with-env
+ (test-music-save--queue (list (emms-track 'file "/music/a.flac")))
+ (cj/music-playlist-save)
+ (should-not cr-initial)
+ (should (= (length (directory-files tmp-m3u nil "\\.m3u\\'")) 1))
+ (should-not (directory-files tmp-radio nil "\\.m3u\\'"))))
+
+(ert-deftest test-music-playlist-save-associated-file-name-wins-over-station ()
+ "Normal: a queue with an associated playlist file defaults to that name,
+even when it contains stations."
+ (test-music-save--with-env
+ (let ((tr (emms-track 'url "https://gs.example/stream")))
+ (emms-track-set tr 'info-title "Groove Salad")
+ (test-music-save--queue (list tr)))
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (setq cj/music-playlist-file (expand-file-name "morning.m3u" tmp-radio)))
+ (cj/music-playlist-save)
+ (should-not cr-initial)
+ (should (file-exists-p (expand-file-name "morning.m3u" tmp-radio)))))
+
+(ert-deftest test-music-playlist-save-error-empty-name ()
+ "Error: an empty name at the prompt signals user-error, not a hidden .m3u."
+ (test-music-save--with-env
+ (test-music-save--queue (list (emms-track 'url "https://x.example/s")))
+ (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "")))
+ (should-error (cj/music-playlist-save) :type 'user-error))
+ (should-not (directory-files tmp-radio nil "m3u"))))
;;; cj/music-playlist-edit
@@ -138,36 +208,5 @@
(should (eq switched buf))
(should msg)))
-;;; cj/music-create-radio-station
-
-(ert-deftest test-music-create-radio-station-writes-m3u ()
- "Normal: with name+url, an EXTM3U-style file is written into music-m3u-root."
- (let* ((tmp (file-name-as-directory (make-temp-file "cj-music-radio-" t)))
- (cj/music-m3u-root tmp)
- msg)
- (unwind-protect
- (progn
- (cl-letf (((symbol-function 'message)
- (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
- (cj/music-create-radio-station "NPR" "https://example.test/stream"))
- (let ((file (expand-file-name "NPR_Radio.m3u" tmp)))
- (should (file-exists-p file))
- (with-temp-buffer
- (insert-file-contents file)
- (let ((text (buffer-string)))
- (should (string-match-p "#EXTM3U" text))
- (should (string-match-p "NPR" text))
- (should (string-match-p "https://example.test/stream" text))))))
- (delete-directory tmp t))
- (should (string-match-p "Created radio station" msg))))
-
-(ert-deftest test-music-create-radio-station-rejects-empty-name ()
- "Error: an empty name is rejected with user-error."
- (should-error (cj/music-create-radio-station "" "https://x") :type 'user-error))
-
-(ert-deftest test-music-create-radio-station-rejects-empty-url ()
- "Error: an empty URL is rejected with user-error."
- (should-error (cj/music-create-radio-station "NPR" "") :type 'user-error))
-
(provide 'test-music-config-more-commands)
;;; test-music-config-more-commands.el ends here
diff --git a/tests/test-nov-reading--config-defaults.el b/tests/test-nov-reading--config-defaults.el
new file mode 100644
index 00000000..ff52d8f5
--- /dev/null
+++ b/tests/test-nov-reading--config-defaults.el
@@ -0,0 +1,67 @@
+;;; test-nov-reading--config-defaults.el --- nov reading default/order tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Asserts the shipped reading-view defaults: a fresh EPUB opens dark, and the
+;; `c' cycle runs dark -> sepia -> light -> none -> back. The pure-logic file
+;; test-nov-reading--palette.el covers the cycle mechanics on fixtures; this file
+;; pins the real `cj/nov-reading-palettes' / `cj/nov-reading-default-palette'
+;; values so a reorder can't silently drift the default or the cycle order.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'nov-reading)
+
+(declare-function cj/nov--next-reading-palette "nov-reading" (current names))
+(defvar cj/nov-reading-palettes)
+(defvar cj/nov-reading-default-palette)
+(defvar cj/nov-reading-profile)
+(defvar cj/nov--typography-remap-cookies)
+
+(ert-deftest test-nov-reading-config-default-is-dark ()
+ "Normal: a fresh nov buffer opens on the dark palette."
+ (should (equal cj/nov-reading-default-palette "dark")))
+
+(ert-deftest test-nov-reading-config-order-is-dark-sepia-light ()
+ "Normal: the shipped palette order is dark, then sepia, then light."
+ (should (equal (mapcar #'car cj/nov-reading-palettes)
+ '("dark" "sepia" "light"))))
+
+(ert-deftest test-nov-reading-config-cycle-from-default ()
+ "Normal: cycling from the default advances dark -> sepia -> light -> none -> dark."
+ (let ((names (mapcar #'car cj/nov-reading-palettes)))
+ (should (equal (cj/nov--next-reading-palette "dark" names) "sepia"))
+ (should (equal (cj/nov--next-reading-palette "sepia" names) "light"))
+ (should-not (cj/nov--next-reading-palette "light" names))
+ (should (equal (cj/nov--next-reading-palette nil names) "dark"))))
+
+(ert-deftest test-nov-reading-config-uses-reading-font-profile ()
+ "Normal: nov typography names the shared Reading profile."
+ (should (eq cj/nov-reading-profile 'reading)))
+
+(ert-deftest test-nov-reading-depends-on-pure-profile-layer ()
+ "Boundary: loading nov shares profile data without loading Fontaine config."
+ (should (featurep 'font-profiles))
+ (should-not (featurep 'font-config)))
+
+(ert-deftest test-nov-reading-typography-remaps-shared-profile-locally ()
+ "Normal: nov applies Reading locally at its own base height without stacking."
+ (let ((cj/nov--typography-remap-cookies '(old-default old-fixed))
+ (removed nil)
+ (applied nil))
+ (cl-letf (((symbol-function 'face-remap-remove-relative)
+ (lambda (cookie) (push cookie removed)))
+ ((symbol-function 'cj/font-profile-remap-buffer)
+ (lambda (profile height)
+ (setq applied (list profile height))
+ '(new-default new-fixed))))
+ (cj/nov-reading-apply-typography))
+ (should (equal applied '(reading 180)))
+ (should (equal (sort removed #'string-lessp)
+ '(old-default old-fixed)))
+ (should (equal cj/nov--typography-remap-cookies
+ '(new-default new-fixed)))))
+
+(provide 'test-nov-reading--config-defaults)
+;;; test-nov-reading--config-defaults.el ends here
diff --git a/tests/test-org-agenda-config-commands.el b/tests/test-org-agenda-config-commands.el
index 76407439..139bf90f 100644
--- a/tests/test-org-agenda-config-commands.el
+++ b/tests/test-org-agenda-config-commands.el
@@ -176,5 +176,89 @@ large), so the standalone OVERDUE section was redundant."
(should (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Za-z]\\{3\\} 09:00>"
text)))))
+(ert-deftest test-org-agenda-add-timestamp-preserves-point-and-following-line ()
+ "Normal: the stamp lands between the entry and the next line, point unmoved.
+The docstring promises the event appears \"underneath the line-at-point\",
+so neither the entry nor whatever follows it may be disturbed."
+ (with-temp-buffer
+ (insert "* First\n* Second")
+ (goto-char (point-min))
+ (let ((start (progn (org-end-of-line) (point))))
+ (goto-char (point-min))
+ (cj/add-timestamp-to-org-entry "09:00")
+ ;; Point is left at the end of the entry it stamped.
+ (should (= (point) start)))
+ (let ((lines (split-string (buffer-string) "\n")))
+ (should (equal (nth 0 lines) "* First"))
+ (should (string-match-p "\\`<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Za-z]\\{3\\} 09:00>\\'"
+ (nth 1 lines)))
+ (should (equal (nth 2 lines) "* Second")))))
+
+(ert-deftest test-org-agenda-add-timestamp-empty-time-string ()
+ "Boundary: an empty time yields a bare date stamp with a trailing space.
+Characterizes current behavior -- the separator space is unconditional, so
+an empty S produces `<DATE >' rather than `<DATE>'. Harmless in an agenda
+\(org reads the date\), and pinned here so a future format change is a
+deliberate one."
+ (with-temp-buffer
+ (insert "* Heading here")
+ (goto-char (point-min))
+ (cj/add-timestamp-to-org-entry "")
+ (should (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Za-z]\\{3\\} >"
+ (buffer-string)))))
+
+(ert-deftest test-org-agenda-add-timestamp-unicode-time-string ()
+ "Boundary: non-ASCII in S survives into the stamp uncorrupted."
+ (with-temp-buffer
+ (insert "* Heading here")
+ (goto-char (point-min))
+ (cj/add-timestamp-to-org-entry "09:00 café ☕")
+ (should (string-match-p "09:00 café ☕>" (buffer-string)))))
+
+(ert-deftest test-org-agenda-add-timestamp-empty-buffer ()
+ "Boundary: an empty buffer still gets a stamp rather than signalling.
+`org-end-of-line' and `open-line' both no-op safely at point-min."
+ (with-temp-buffer
+ (cj/add-timestamp-to-org-entry "09:00")
+ (should (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Za-z]\\{3\\} 09:00>"
+ (buffer-string)))))
+
+(ert-deftest test-org-agenda-add-timestamp-read-only-buffer-signals ()
+ "Error: a read-only buffer signals rather than silently dropping the stamp."
+ (with-temp-buffer
+ (insert "* Heading")
+ (goto-char (point-min))
+ (setq buffer-read-only t)
+ (should-error (cj/add-timestamp-to-org-entry "09:00") :type 'buffer-read-only)))
+
+(defconst test-org-agenda--timeformat-special-at-load
+ (special-variable-p 'cj/timeformat)
+ "Whether `cj/timeformat' was special immediately after loading the module.
+Captured here at load, before any test body runs, because the fact under
+test is destroyed by observing it late: calling
+`cj/add-timestamp-to-org-entry' once would execute a `defvar' nested in the
+defun and make the symbol special retroactively. ERT runs tests
+alphabetically, so an in-test `special-variable-p' check passes on the
+strength of whichever test ran first -- green in a full-file run, red in
+isolation. Snapshotting at load makes the guard order-independent.")
+
+(ert-deftest test-org-agenda-timeformat-is-a-top-level-special-variable ()
+ "Normal: `cj/timeformat' is special and bound at load, not first call.
+It used to be `defvar'd inside `cj/add-timestamp-to-org-entry', so it was
+unbound until the command ran once and a `let' around the call bound it
+lexically instead of dynamically. Pinning both halves of the fix: the
+symbol is special at load time, and rebinding it actually reaches the
+command."
+ (should test-org-agenda--timeformat-special-at-load)
+ (should (equal (default-value 'cj/timeformat) "%Y-%m-%d %a"))
+ ;; The dynamic binding must reach the insertion.
+ (with-temp-buffer
+ (insert "* Heading")
+ (goto-char (point-min))
+ (let ((cj/timeformat "%Y"))
+ (cj/add-timestamp-to-org-entry "09:00"))
+ (should (string-match-p "\\`<[0-9]\\{4\\} 09:00>\\'"
+ (nth 1 (split-string (buffer-string) "\n"))))))
+
(provide 'test-org-agenda-config-commands)
;;; test-org-agenda-config-commands.el ends here
diff --git a/tests/test-org-agenda-frame.el b/tests/test-org-agenda-frame.el
new file mode 100644
index 00000000..21842600
--- /dev/null
+++ b/tests/test-org-agenda-frame.el
@@ -0,0 +1,999 @@
+;;; test-org-agenda-frame.el --- Tests for the fullscreen agenda frame -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Phase 1 of the org-agenda fullscreen frame (spec:
+;; docs/specs/2026-07-17-org-agenda-fullscreen-frame-spec.org). Frame lookup
+;; is mocked (frame-list / frame-live-p / frame-parameter), the house pattern
+;; from test-dirvish-config-popup.el, since --batch can't create real frames.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-agenda-frame)
+
+;; org-agenda isn't loaded in batch (no package-initialize), so declare the
+;; command list special and bound for the registration tests to let-bind.
+(defvar org-agenda-custom-commands nil)
+
+;;; cj/--agenda-frame — locate the marked frame
+
+(ert-deftest test-org-agenda-frame-find-returns-marked-live-frame ()
+ "Normal: returns the live frame carrying the `cj/agenda-frame' marker."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fb fc)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'fb)))))
+ (should (eq (cj/--agenda-frame) 'fb))))
+
+(ert-deftest test-org-agenda-frame-find-nil-when-none-marked ()
+ "Boundary: no frame carries the marker -> nil."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fc)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter) (lambda (_f _p) nil)))
+ (should (null (cj/--agenda-frame)))))
+
+(ert-deftest test-org-agenda-frame-find-ignores-dead-marked-frame ()
+ "Error: a marked but dead frame is not returned."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fb)))
+ ((symbol-function 'frame-live-p) (lambda (f) (not (eq f 'fb))))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'fb)))))
+ (should (null (cj/--agenda-frame)))))
+
+;;; cj/--agenda-frame-p — is FRAME a live agenda frame
+
+(ert-deftest test-org-agenda-frame-p-true-for-live-marked ()
+ "Normal: a live marked frame is an agenda frame."
+ (cl-letf (((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'fa)))))
+ (should (cj/--agenda-frame-p 'fa))))
+
+(ert-deftest test-org-agenda-frame-p-nil-for-unmarked ()
+ "Boundary: a live unmarked frame is not an agenda frame."
+ (cl-letf (((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter) (lambda (_f _p) nil)))
+ (should (null (cj/--agenda-frame-p 'fa)))))
+
+(ert-deftest test-org-agenda-frame-p-nil-for-dead-marked ()
+ "Error: a dead marked frame is not an agenda frame."
+ (cl-letf (((symbol-function 'frame-live-p) (lambda (_f) nil))
+ ((symbol-function 'frame-parameter)
+ (lambda (_f p) (eq p 'cj/agenda-frame))))
+ (should (null (cj/--agenda-frame-p 'fa)))))
+
+;;; cj/--agenda-frame-working-frame — route source files to a non-agenda frame
+
+(ert-deftest test-org-agenda-working-frame-returns-non-agenda-frame ()
+ "Normal: with no recorded launch frame, returns the first live non-agenda frame."
+ (let ((cj/--agenda-frame-launch-frame nil))
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(agenda work)))
+ ((symbol-function 'frame-live-p) (lambda (f) (memq f '(agenda work))))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'agenda)))))
+ (should (eq (cj/--agenda-frame-working-frame) 'work)))))
+
+(ert-deftest test-org-agenda-working-frame-prefers-live-launch-frame ()
+ "Normal: the recorded launch frame wins when live and non-agenda."
+ (let ((cj/--agenda-frame-launch-frame 'w2))
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(agenda w1 w2)))
+ ((symbol-function 'frame-live-p) (lambda (f) (memq f '(agenda w1 w2))))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'agenda)))))
+ (should (eq (cj/--agenda-frame-working-frame) 'w2)))))
+
+(ert-deftest test-org-agenda-working-frame-falls-back-when-launch-dead ()
+ "Boundary: a dead recorded launch frame falls back to another non-agenda frame."
+ (let ((cj/--agenda-frame-launch-frame 'gone))
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(agenda work)))
+ ((symbol-function 'frame-live-p) (lambda (f) (memq f '(agenda work))))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'agenda)))))
+ (should (eq (cj/--agenda-frame-working-frame) 'work)))))
+
+(ert-deftest test-org-agenda-working-frame-nil-when-only-agenda ()
+ "Error: the agenda frame is the only live frame -> nil (caller creates one)."
+ (let ((cj/--agenda-frame-launch-frame nil))
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(agenda)))
+ ((symbol-function 'frame-live-p) (lambda (f) (eq f 'agenda)))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'agenda)))))
+ (should (null (cj/--agenda-frame-working-frame))))))
+
+;;; cj/--agenda-frame-command — the dedicated F seven-day view
+
+(defun test-org-agenda-frame--block-settings ()
+ "Return the per-block settings alist of the agenda-frame command."
+ (let* ((cmd (cj/--agenda-frame-command))
+ (blocks (nth 2 cmd))
+ (agenda-block (car blocks)))
+ (nth 2 agenda-block)))
+
+(defun test-org-agenda-frame--general-settings ()
+ "Return the general (view-wide) settings alist of the agenda-frame command."
+ (nth 3 (cj/--agenda-frame-command)))
+
+(ert-deftest test-org-agenda-frame-command-key-is-F ()
+ "Normal: the command's key is F (collision-free with the existing d)."
+ (should (equal (nth 0 (cj/--agenda-frame-command)) "F")))
+
+(ert-deftest test-org-agenda-frame-command-today-anchored-7-day ()
+ "Normal: the span is seven days anchored to today, not Monday."
+ (let ((s (test-org-agenda-frame--block-settings)))
+ (should (equal (cadr (assq 'org-agenda-span s)) 7))
+ (should (equal (cadr (assq 'org-agenda-start-day s)) "0d"))
+ ;; start-on-weekday nil is what un-anchors the span from Monday.
+ (should (assq 'org-agenda-start-on-weekday s))
+ (should (null (cadr (assq 'org-agenda-start-on-weekday s))))))
+
+(ert-deftest test-org-agenda-frame-command-tight-prefix-format ()
+ "Normal: the view sets its own prefix format with a narrow category column.
+Without it the global agenda format applies, whose 25-char category pad
+leaves a wide blank gutter between the source name and the item."
+ (let ((s (test-org-agenda-frame--block-settings)))
+ (should (assq 'org-agenda-prefix-format s))
+ (should (string-match-p "%-10:c"
+ (cadr (assq 'org-agenda-prefix-format s))))))
+
+(ert-deftest test-org-agenda-frame-do-redo-leaves-sticky-alone ()
+ "Normal: the redo binds current-window but never touches sticky.
+org-agenda-redo handles the in-place rebuild itself (it binds sticky nil
+and redirects the buffer name); a sticky t reaching org-agenda-prepare
+mid-redo makes it throw \\='exit with no catch and the tick fails."
+ (let ((org-agenda-sticky nil)
+ seen-sticky seen-setup (params '()))
+ (with-temp-buffer
+ (insert "agenda line\n")
+ (cl-letf (((symbol-function 'org-agenda-redo)
+ (lambda (&rest _)
+ (setq seen-sticky org-agenda-sticky
+ seen-setup org-agenda-window-setup)))
+ ((symbol-function 'frame-parameter) (lambda (_f p) (alist-get p params)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setf (alist-get p params) v))))
+ (cj/--agenda-frame-do-redo 'af (current-buffer) nil)
+ (should (null seen-sticky))
+ (should (eq seen-setup 'current-window))))))
+
+(ert-deftest test-org-agenda-frame-command-follow-mode-off ()
+ "Boundary: follow-mode is forced off locally so a global default can't split."
+ (let ((s (test-org-agenda-frame--block-settings)))
+ (should (assq 'org-agenda-start-with-follow-mode s))
+ (should (null (cadr (assq 'org-agenda-start-with-follow-mode s))))))
+
+(ert-deftest test-org-agenda-frame-command-sticky-and-current-window ()
+ "Normal: current-window in the settings; sticky deliberately NOT there.
+The general settings are baked into the buffer's series-redo-cmd and
+re-applied on every redo; a sticky t there makes org-agenda-use-sticky-p
+true mid-redo (the buffer exists), and org-agenda-prepare throws \\='exit
+with no catch -- every refresh tick fails. Stickiness belongs only in
+the spawn wrapper, where it names the buffer."
+ (let ((g (test-org-agenda-frame--general-settings)))
+ (should-not (assq 'org-agenda-sticky g))
+ ;; org evaluates custom-command setting values via org-let, so the stored
+ ;; form is (quote current-window); eval it the way org would.
+ (should (eq (eval (cadr (assq 'org-agenda-window-setup g)) t)
+ 'current-window))))
+
+;;; cj/--agenda-frame-register-command — idempotent registration
+
+(ert-deftest test-org-agenda-frame-register-adds-entry ()
+ "Normal: registration inserts the F entry into org-agenda-custom-commands."
+ (let ((org-agenda-custom-commands '(("d" "Daily" nil))))
+ (cj/--agenda-frame-register-command)
+ (should (assoc "F" org-agenda-custom-commands))
+ (should (assoc "d" org-agenda-custom-commands))))
+
+(ert-deftest test-org-agenda-frame-register-is-idempotent ()
+ "Boundary: registering twice leaves exactly one F entry."
+ (let ((org-agenda-custom-commands nil))
+ (cj/--agenda-frame-register-command)
+ (cj/--agenda-frame-register-command)
+ (should (= 1 (seq-count (lambda (e) (equal (car e) "F"))
+ org-agenda-custom-commands)))))
+
+;;; Default-deny policy — denial handlers
+
+(ert-deftest test-org-agenda-frame-denied-readonly-messages ()
+ "Normal: the read-only denial shows the read-only message and acts on nothing."
+ (let (captured)
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (setq captured (apply #'format fmt args)))))
+ (cj/--agenda-frame-denied-readonly))
+ (should (string-match-p "read-only" captured))
+ (should (string-match-p "working frame" captured))))
+
+(ert-deftest test-org-agenda-frame-denied-fixed-view-messages ()
+ "Normal: the view-change denial shows the fixed-view message."
+ (let (captured)
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (setq captured (apply #'format fmt args)))))
+ (cj/--agenda-frame-denied-fixed-view))
+ (should (string-match-p "7-day view" captured))))
+
+;;; Default-deny policy — the keymap
+
+(ert-deftest test-org-agenda-frame-map-catch-all-is-readonly-deny ()
+ "Normal: the [t] default binding denies with the read-only handler."
+ (should (eq (lookup-key cj/agenda-frame-mode-map [t])
+ 'cj/--agenda-frame-denied-readonly)))
+
+(ert-deftest test-org-agenda-frame-map-navigation-allowed ()
+ "Normal: navigation keys resolve to their org-agenda commands."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "n")) 'org-agenda-next-line))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "p")) 'org-agenda-previous-line))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-g")) 'keyboard-quit)))
+
+(ert-deftest test-org-agenda-frame-map-point-motion-and-isearch-allowed ()
+ "Normal: read-only point motion and isearch work in the frame.
+C-a/C-e/C-f/C-b move point and C-s/C-r search; all are read-only and
+must not hit the deny catch-all."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-a")) 'move-beginning-of-line))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-e")) 'move-end-of-line))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-f")) 'forward-char))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-b")) 'backward-char))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-s")) 'isearch-forward))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-r")) 'isearch-backward)))
+
+(ert-deftest test-org-agenda-frame-map-engage-routed ()
+ "Normal: RET and TAB route to the working-frame engage command."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "RET"))
+ 'cj/--agenda-frame-engage-open))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "TAB"))
+ 'cj/--agenda-frame-engage-open)))
+
+(ert-deftest test-org-agenda-frame-map-engage-gui-function-keys ()
+ "Boundary: the GUI [return]/[tab] events engage too, not the [t] deny handler.
+Without these, the catch-all suppresses their translation to RET/TAB in a
+graphical frame and RET would be denied instead of opening the item."
+ (should (eq (lookup-key cj/agenda-frame-mode-map [return])
+ 'cj/--agenda-frame-engage-open))
+ (should (eq (lookup-key cj/agenda-frame-mode-map [tab])
+ 'cj/--agenda-frame-engage-open)))
+
+(ert-deftest test-org-agenda-frame-map-lifecycle-keys ()
+ "Normal: q/Q/x close the frame; r takes the safe-redo path."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "q")) 'cj/--agenda-frame-close))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "Q")) 'cj/--agenda-frame-close))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "x")) 'cj/--agenda-frame-close))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "r")) 'cj/--agenda-frame-safe-redo)))
+
+(ert-deftest test-org-agenda-frame-map-view-changers-fixed-view-deny ()
+ "Boundary: view-changing keys are explicitly denied with the fixed-view message,
+not caught by the read-only catch-all."
+ (dolist (key '("w" "d" "y" "f" "b" "j" "g"))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd key))
+ 'cj/--agenda-frame-denied-fixed-view))))
+
+(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
+catch-all denies them and the frame can't be closed by its own key."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "S-<f8>"))
+ 'cj/agenda-frame-toggle))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-M-<f8>"))
+ 'cj/org-agenda-refresh-files)))
+
+(ert-deftest test-org-agenda-frame-map-C-x-C-c-closes-frame ()
+ "Normal: C-x C-c in the agenda frame closes the frame, not the daemon.
+The global save-buffers-kill-terminal would kill Emacs itself here (a
+make-frame frame has no client), so the intuitive close gesture must be
+remapped to the frame close."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-x C-c"))
+ 'cj/--agenda-frame-close)))
+
+(ert-deftest test-org-agenda-frame-map-machinery-punched-through ()
+ "Boundary: input machinery is punched through the [t] catch-all.
+switch-frame events, mouse-wheel scrolling, mouse-1 clicks, and the help
+prefix must fall through to their global bindings (an explicit nil shadows
+the default in this map); otherwise every frame-focus change and every
+scroll spams the deny message."
+ (dolist (key (list [switch-frame]
+ [wheel-up] [wheel-down] [wheel-left] [wheel-right]
+ [double-wheel-up] [double-wheel-down]
+ [triple-wheel-up] [triple-wheel-down]
+ [mouse-1] [down-mouse-1] [drag-mouse-1]
+ (kbd "C-h")))
+ ;; accept-default t: a punched key returns nil (falls through to the
+ ;; global map); an unpunched key returns the catch-all deny handler.
+ (should (null (lookup-key cj/agenda-frame-mode-map key t)))))
+
+(ert-deftest test-org-agenda-frame-map-unpunched-still-denied ()
+ "Normal: an ordinary unbound key still hits the catch-all after the punches."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "t") t)
+ 'cj/--agenda-frame-denied-readonly)))
+
+(ert-deftest test-org-agenda-frame-maybe-enable-readds-kill-buffer-hook ()
+ "Normal: the finalize re-enable also re-adds the buffer-local kill hook.
+org-agenda-redo's kill-all-local-variables strips the hook installed at
+spawn; without the re-add, killing the buffer after the first refresh tick
+orphans the frame."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'agenda))
+ ((symbol-function 'get-buffer-window) (lambda (_b _f) 'win)))
+ (cj/--agenda-frame-maybe-enable-mode)
+ (should (memq 'cj/--agenda-frame-on-kill-buffer
+ (buffer-local-value 'kill-buffer-hook (current-buffer)))))))
+
+(ert-deftest test-org-agenda-frame-overlay-removable-after-local-var-wipe ()
+ "Error: the failure overlay is found by property, not a buffer-local var.
+kill-all-local-variables (every redo) wipes buffer-local vars while the
+overlay object survives erase-buffer, so a var-held overlay could never be
+removed after a later success -- the failure banner would stick forever."
+ (with-temp-buffer
+ (insert "x\n")
+ (cj/--agenda-frame-show-failure-overlay (current-buffer))
+ ;; Simulate the org-agenda-mode reset between failure and success.
+ (kill-all-local-variables)
+ (cj/--agenda-frame-remove-overlay (current-buffer))
+ ;; The visible banner (a before-string overlay) must be gone.
+ (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")))))
+
+;;; Default-deny policy — the minor mode + finalize re-enable
+
+(ert-deftest test-org-agenda-frame-mode-toggles ()
+ "Normal: the minor mode turns on and off in a buffer."
+ (with-temp-buffer
+ (cj/agenda-frame-mode 1)
+ (should cj/agenda-frame-mode)
+ (cj/agenda-frame-mode -1)
+ (should-not cj/agenda-frame-mode)))
+
+(ert-deftest test-org-agenda-frame-maybe-enable-in-agenda-frame ()
+ "Normal: after a build in the agenda frame, the policy is re-enabled."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'agenda))
+ ((symbol-function 'get-buffer-window) (lambda (_b _f) 'win)))
+ (cj/--agenda-frame-maybe-enable-mode)
+ (should cj/agenda-frame-mode))))
+
+(ert-deftest test-org-agenda-frame-maybe-enable-skips-other-buffers ()
+ "Boundary: a build not shown in the agenda frame leaves the policy off."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'agenda))
+ ((symbol-function 'get-buffer-window) (lambda (_b _f) nil)))
+ (cj/--agenda-frame-maybe-enable-mode)
+ (should-not cj/agenda-frame-mode))))
+
+(ert-deftest test-org-agenda-frame-maybe-enable-no-frame ()
+ "Boundary: no agenda frame at all -> policy stays off, no error."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () nil)))
+ (cj/--agenda-frame-maybe-enable-mode)
+ (should-not cj/agenda-frame-mode))))
+
+;;; Engage routing — frame target + open
+
+(ert-deftest test-org-agenda-frame-target-frame-uses-working-frame ()
+ "Normal: the engage target is the working frame when one exists."
+ (cl-letf (((symbol-function 'cj/--agenda-frame-working-frame) (lambda () 'work))
+ ((symbol-function 'make-frame) (lambda (&rest _) (error "should not create"))))
+ (should (eq (cj/--agenda-frame-target-frame) 'work))))
+
+(ert-deftest test-org-agenda-frame-target-frame-creates-when-none ()
+ "Boundary: no working frame -> a normal frame is created."
+ (cl-letf (((symbol-function 'cj/--agenda-frame-working-frame) (lambda () nil))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'new)))
+ (should (eq (cj/--agenda-frame-target-frame) 'new))))
+
+(ert-deftest test-org-agenda-frame-engage-open-no-item-errors ()
+ "Error: engaging on a line with no source item signals a user-error."
+ (cl-letf (((symbol-function 'cj/--agenda-frame-item-marker) (lambda () nil)))
+ (should-error (cj/--agenda-frame-engage-open) :type 'user-error)))
+
+(ert-deftest test-org-agenda-frame-engage-open-routes-to-source ()
+ "Normal: engage opens the item's source buffer at the item's position."
+ (let ((source (generate-new-buffer " *frame-engage-source*")))
+ (unwind-protect
+ (progn
+ (with-current-buffer source (insert "line one\nline two\nline three\n"))
+ (let ((marker (set-marker (make-marker) 10 source))
+ focused opened)
+ (cl-letf (((symbol-function 'cj/--agenda-frame-item-marker) (lambda () marker))
+ ((symbol-function 'cj/--agenda-frame-target-frame) (lambda () 'work))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f &rest _) (setq focused f)))
+ ((symbol-function 'pop-to-buffer-same-window)
+ (lambda (b &rest _) (setq opened b) (set-buffer b)))
+ ((symbol-function 'org-fold-show-context) (lambda (&rest _) nil)))
+ (cj/--agenda-frame-engage-open)
+ (should (eq focused 'work))
+ (should (eq opened source))
+ (should (eq (current-buffer) source))
+ (should (= (point) (line-beginning-position))))))
+ (kill-buffer source))))
+
+;;; Frame lifecycle — sticky buffer, timer-cancel, teardown cleanup
+
+(ert-deftest test-org-agenda-frame-sticky-buffer-name ()
+ "Normal: the sticky buffer is *Org Agenda(F)*; nil when absent."
+ (should (null (cj/--agenda-frame-sticky-buffer)))
+ (let ((buf (get-buffer-create "*Org Agenda(F)*")))
+ (unwind-protect
+ (should (eq (cj/--agenda-frame-sticky-buffer) buf))
+ (kill-buffer buf))))
+
+(ert-deftest test-org-agenda-frame-cancel-timer-safe-when-none ()
+ "Boundary: cancelling with no timer set does nothing and does not error."
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () nil)))
+ (should-not (cj/--agenda-frame-cancel-timer))))
+
+;;; Frame lifecycle — toggle dispatch
+
+(ert-deftest test-org-agenda-frame-toggle-spawns-when-none ()
+ "Normal: with no agenda frame, toggle spawns one."
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-spawn) (lambda () 'spawned)))
+ (should (eq (cj/--agenda-frame-toggle) 'spawned))))
+
+(ert-deftest test-org-agenda-frame-toggle-deletes-when-selected ()
+ "Normal: toggle from within the agenda frame deletes it."
+ (let (deleted)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'selected-frame) (lambda () 'af))
+ ((symbol-function 'cj/--agenda-frame-delete)
+ (lambda () (setq deleted t))))
+ (cj/--agenda-frame-toggle)
+ (should deleted))))
+
+(ert-deftest test-org-agenda-frame-toggle-raises-when-unfocused ()
+ "Normal: toggle from a working frame raises the existing agenda frame."
+ (let (raised)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'selected-frame) (lambda () 'work))
+ ((symbol-function 'cj/--agenda-frame-raise)
+ (lambda (f) (setq raised f))))
+ (cj/--agenda-frame-toggle)
+ (should (eq raised 'af)))))
+
+;;; Frame lifecycle — delete
+
+(ert-deftest test-org-agenda-frame-delete-deletes-live-frame ()
+ "Normal: delete removes the live agenda frame."
+ (let (deleted)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'frame-live-p) (lambda (f) (eq f 'af)))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--agenda-frame-delete)
+ (should (eq deleted 'af)))))
+
+(ert-deftest test-org-agenda-frame-delete-noop-when-none ()
+ "Boundary: delete with no agenda frame does nothing."
+ (let (called)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () nil))
+ ((symbol-function 'delete-frame) (lambda (_f &rest _) (setq called t))))
+ (cj/--agenda-frame-delete)
+ (should-not called))))
+
+;;; Frame lifecycle — cleanup on frame death and buffer kill
+
+(ert-deftest test-org-agenda-frame-on-delete-cancels-and-kills-buffer ()
+ "Normal: deleting the agenda frame cancels its timer and kills the sticky buffer."
+ (let ((buf (get-buffer-create "*Org Agenda(F)*"))
+ cancelled)
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--agenda-frame-p) (lambda (_f) t))
+ ((symbol-function 'cj/--agenda-frame-cancel-timer)
+ (lambda (&optional _f) (setq cancelled t))))
+ (cj/--agenda-frame-on-delete-frame 'af)
+ (should cancelled)
+ (should-not (buffer-live-p buf)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest test-org-agenda-frame-on-delete-ignores-non-agenda-frame ()
+ "Boundary: a non-agenda frame deletion triggers no cleanup."
+ (let (cancelled)
+ (cl-letf (((symbol-function 'cj/--agenda-frame-p) (lambda (_f) nil))
+ ((symbol-function 'cj/--agenda-frame-cancel-timer)
+ (lambda (&optional _f) (setq cancelled t))))
+ (cj/--agenda-frame-on-delete-frame 'work)
+ (should-not cancelled))))
+
+(ert-deftest test-org-agenda-frame-on-kill-buffer-deletes-frame ()
+ "Normal: killing the dedicated buffer deletes the frame."
+ (let (deleted)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'frame-live-p) (lambda (f) (eq f 'af)))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--agenda-frame-on-kill-buffer)
+ (should (eq deleted 'af)))))
+
+(ert-deftest test-org-agenda-frame-on-kill-buffer-guarded-during-teardown ()
+ "Boundary: during a teardown the buffer-kill hook does not re-delete the frame."
+ (let (deleted (cj/--agenda-frame-tearing-down t))
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--agenda-frame-on-kill-buffer)
+ (should-not deleted))))
+
+;;; Auto-dim suspension while the agenda frame lives
+
+(ert-deftest test-org-agenda-frame-spawn-suspends-auto-dim ()
+ "Normal: spawning the frame turns auto-dim off and remembers it was on.
+The refresh tick's selection swing marks the working window non-selected;
+auto-dim's debounced dim then lands after the tick and the working frame
+visibly dims every five minutes."
+ (defvar auto-dim-other-buffers-mode)
+ (let ((auto-dim-other-buffers-mode t)
+ (cj/--agenda-frame-dim-was-on nil)
+ calls)
+ (cl-letf (((symbol-function 'auto-dim-other-buffers-mode)
+ (lambda (arg) (push arg calls)))
+ ((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'af))
+ ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda) (lambda (&rest _) nil))
+ ((symbol-function 'delete-other-windows) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-start-timer) (lambda (_f) nil)))
+ (cj/--agenda-frame-spawn)
+ (should (equal calls '(-1)))
+ (should cj/--agenda-frame-dim-was-on))))
+
+(ert-deftest test-org-agenda-frame-spawn-leaves-auto-dim-when-off ()
+ "Boundary: auto-dim already off -> spawn doesn't touch it, no restore later."
+ (defvar auto-dim-other-buffers-mode)
+ (let ((auto-dim-other-buffers-mode nil)
+ (cj/--agenda-frame-dim-was-on nil)
+ calls)
+ (cl-letf (((symbol-function 'auto-dim-other-buffers-mode)
+ (lambda (arg) (push arg calls)))
+ ((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'af))
+ ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda) (lambda (&rest _) nil))
+ ((symbol-function 'delete-other-windows) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-start-timer) (lambda (_f) nil)))
+ (cj/--agenda-frame-spawn)
+ (should (null calls))
+ (should-not cj/--agenda-frame-dim-was-on))))
+
+(ert-deftest test-org-agenda-frame-on-delete-restores-auto-dim ()
+ "Normal: closing the frame restores auto-dim when spawn had turned it off."
+ (let ((cj/--agenda-frame-dim-was-on t)
+ calls)
+ (cl-letf (((symbol-function 'auto-dim-other-buffers-mode)
+ (lambda (arg) (push arg calls)))
+ ((symbol-function 'cj/--agenda-frame-p) (lambda (_f) t))
+ ((symbol-function 'cj/--agenda-frame-cancel-timer)
+ (lambda (&optional _f) nil)))
+ (cj/--agenda-frame-on-delete-frame 'af)
+ (should (equal calls '(1)))
+ (should-not cj/--agenda-frame-dim-was-on))))
+
+(ert-deftest test-org-agenda-frame-on-delete-no-dim-restore-when-untouched ()
+ "Boundary: closing without a suspended auto-dim doesn't enable it."
+ (let ((cj/--agenda-frame-dim-was-on nil)
+ calls)
+ (cl-letf (((symbol-function 'auto-dim-other-buffers-mode)
+ (lambda (arg) (push arg calls)))
+ ((symbol-function 'cj/--agenda-frame-p) (lambda (_f) t))
+ ((symbol-function 'cj/--agenda-frame-cancel-timer)
+ (lambda (&optional _f) nil)))
+ (cj/--agenda-frame-on-delete-frame 'af)
+ (should (null calls)))))
+
+;;; Frame lifecycle — transactional spawn rollback
+
+(ert-deftest test-org-agenda-frame-spawn-rolls-back-on-failure ()
+ "Error: a failure after make-frame deletes the partial frame, restores the
+working frame, and signals a user-error."
+ (let (deleted focus)
+ (cl-letf (((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'pf))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f &rest _) (setq focus f)))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda) (lambda (&rest _) (error "boom")))
+ ((symbol-function 'frame-live-p) (lambda (f) (memq f '(pf launch))))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (should-error (cj/--agenda-frame-spawn) :type 'user-error)
+ (should (eq deleted 'pf))
+ (should (eq focus 'launch)))))
+
+;;; Phase 2 — wall-clock alignment
+
+(ert-deftest test-org-agenda-frame-seconds-to-next-mark-aligned ()
+ "Normal: a time exactly on a 5-minute boundary yields a full period."
+ ;; 1000000200 is divisible by 300 (a :00/:05 wall-clock mark).
+ (should (= (cj/--agenda-frame-seconds-to-next-mark 1000000200 300) 300)))
+
+(ert-deftest test-org-agenda-frame-seconds-to-next-mark-midway ()
+ "Boundary: partway through a period returns the remainder to the next mark."
+ ;; 1000000200 + 120 -> 180 seconds remain to the next 300 mark.
+ (should (= (cj/--agenda-frame-seconds-to-next-mark 1000000320 300) 180)))
+
+;;; Phase 2 — deterministic point restoration
+
+(defun test-org-agenda-frame--make-agenda-buffer (lines source)
+ "Insert LINES into the current buffer; each is (TEXT . SRC-POS).
+A non-nil SRC-POS puts an org-marker into SOURCE at that position on the line."
+ (dolist (spec lines)
+ (let ((start (point)))
+ (insert (car spec) "\n")
+ (when (cdr spec)
+ (put-text-property start (1+ start) 'org-marker
+ (set-marker (make-marker) (cdr spec) source))))))
+
+(ert-deftest test-org-agenda-frame-restore-point-duplicate-nearest ()
+ "Normal: a source marker occurring twice restores the occurrence nearest the old line."
+ (let ((src (generate-new-buffer " *rp-src*")))
+ (unwind-protect
+ (with-temp-buffer
+ (with-current-buffer src (insert "aaaaaaaaaa\n"))
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("header" . nil) ("item @2" . 3) ("filler" . nil)
+ ("filler" . nil) ("item @5" . 3))
+ src)
+ (let ((old (set-marker (make-marker) 3 src)))
+ (cj/--agenda-frame-restore-point old 4))
+ ;; lines 2 and 5 both point at src pos 3; nearest to old-line 4 is line 5.
+ (should (= (line-number-at-pos) 5)))
+ (kill-buffer src))))
+
+(ert-deftest test-org-agenda-frame-restore-point-missing-clamps ()
+ "Boundary: a gone marker clamps the old line into range and lands on an item."
+ (let ((src (generate-new-buffer " *rp-src*")))
+ (unwind-protect
+ (with-temp-buffer
+ (with-current-buffer src (insert "aaaaaaaaaa\n"))
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("header" . nil) ("item" . 3) ("item" . 5))
+ src)
+ ;; old-line 99 is past the end; clamp to last line (an item).
+ (let ((gone (set-marker (make-marker) 99 src)))
+ (cj/--agenda-frame-restore-point gone 99))
+ (should (get-text-property (line-beginning-position) 'org-marker)))
+ (kill-buffer src))))
+
+(ert-deftest test-org-agenda-frame-restore-point-header-goes-to-first-item ()
+ "Boundary: with no marker match, a header line moves to the first item."
+ (let ((src (generate-new-buffer " *rp-src*")))
+ (unwind-protect
+ (with-temp-buffer
+ (with-current-buffer src (insert "aaaaaaaaaa\n"))
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("header" . nil) ("item one" . 3) ("item two" . 5))
+ src)
+ (cj/--agenda-frame-restore-point nil 1) ; line 1 is the header
+ (should (= (line-number-at-pos) 2))
+ (should (get-text-property (line-beginning-position) 'org-marker)))
+ (kill-buffer src))))
+
+(ert-deftest test-org-agenda-frame-restore-point-empty-buffer-start ()
+ "Error: an empty (item-less) view leaves point at buffer start."
+ (with-temp-buffer
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("only a header" . nil) ("no items here" . nil)) nil)
+ (cj/--agenda-frame-restore-point nil 2)
+ (should (= (point) (point-min)))))
+
+;;; Phase 2 — snapshot marker cloning
+
+(ert-deftest test-org-agenda-frame-clone-and-reinstall-markers ()
+ "Normal: cloned markers survive nulling the originals and reinstall live."
+ (let ((src (generate-new-buffer " *clone-src*")))
+ (unwind-protect
+ (let (clones)
+ (with-temp-buffer
+ (with-current-buffer src (insert "0123456789\n"))
+ (test-org-agenda-frame--make-agenda-buffer '(("item" . 4)) src)
+ (setq clones (cj/--agenda-frame-snapshot-markers (current-buffer)))
+ ;; Simulate org-agenda-reset-markers nulling the buffer's originals.
+ (let ((orig (get-text-property (point-min) 'org-marker)))
+ (set-marker orig nil)))
+ ;; Reinstall into a fresh buffer copy; the clone must still be live.
+ (with-temp-buffer
+ (test-org-agenda-frame--make-agenda-buffer '(("item" . nil)) nil)
+ (cj/--agenda-frame-reinstall-markers (current-buffer) clones)
+ (let ((m (get-text-property (point-min) 'org-marker)))
+ (should (markerp m))
+ (should (eq (marker-buffer m) src))
+ (should (= (marker-position m) 4)))))
+ (kill-buffer src))))
+
+;;; Phase 2 — failure latch (report once per consecutive-failure run)
+
+(ert-deftest test-org-agenda-frame-failure-latch-reports-once ()
+ "Normal: the first failure of a run reports; subsequent ones stay silent."
+ (let ((params '()))
+ (cl-letf (((symbol-function 'frame-parameter)
+ (lambda (_f p) (alist-get p params)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setf (alist-get p params) v))))
+ (should (cj/--agenda-frame-record-failure 'af)) ; 0 -> 1, report
+ (should-not (cj/--agenda-frame-record-failure 'af)) ; 1 -> 2, silent
+ (cj/--agenda-frame-clear-failure 'af)
+ (should (cj/--agenda-frame-record-failure 'af))))) ; reset -> report again
+
+;;; Phase 2 — timer start + duplicate prevention
+
+(ert-deftest test-org-agenda-frame-start-timer-sets-timer ()
+ "Normal: start-timer schedules and stores a timer when none exists."
+ (let (stored)
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (_f _p) nil))
+ ((symbol-function 'run-at-time) (lambda (&rest _) 'the-timer))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f _p v) (setq stored v))))
+ (should (eq (cj/--agenda-frame-start-timer 'af) 'the-timer))
+ (should (eq stored 'the-timer)))))
+
+(ert-deftest test-org-agenda-frame-start-timer-no-duplicate ()
+ "Boundary: a frame already carrying a live timer is not given a second one."
+ (let (called)
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (_f _p) 'existing))
+ ((symbol-function 'timerp) (lambda (x) (eq x 'existing)))
+ ((symbol-function 'run-at-time) (lambda (&rest _) (setq called t) 'new)))
+ (cj/--agenda-frame-start-timer 'af)
+ (should-not called))))
+
+;;; Phase 2 — do-redo orchestration (success and failure branches)
+
+(ert-deftest test-org-agenda-frame-do-redo-success-clears-and-releases ()
+ "Normal: a successful redo clears the failure latch and releases the snapshot."
+ (let ((params (list (cons 'cj/agenda-frame-fail-count 3)))
+ released)
+ (with-temp-buffer
+ (insert "agenda line\n")
+ (cl-letf (((symbol-function 'org-agenda-redo) (lambda (&rest _) nil))
+ ((symbol-function 'frame-parameter) (lambda (_f p) (alist-get p params)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setf (alist-get p params) v)))
+ ((symbol-function 'cj/--agenda-frame-release-snapshot)
+ (lambda (_s) (setq released t))))
+ (cj/--agenda-frame-do-redo 'af (current-buffer) nil)
+ (should (equal (alist-get 'cj/agenda-frame-fail-count params) 0))
+ (should released)))))
+
+(ert-deftest test-org-agenda-frame-do-redo-error-restores-reenables-reports ()
+ "Error: a redo that fails mid-rebuild restores the last-good buffer verbatim,
+re-enables the policy, shows one overlay, and reports once."
+ (let ((params '()) msgs)
+ (with-temp-buffer
+ (insert "good agenda content\n")
+ (unwind-protect
+ (cl-letf (((symbol-function 'org-agenda-redo)
+ (lambda (&rest _) (erase-buffer) (insert "PARTIAL") (error "boom")))
+ ((symbol-function 'frame-parameter) (lambda (_f p) (alist-get p params)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setf (alist-get p params) v)))
+ ((symbol-function 'message)
+ (lambda (fmt &rest a) (push (apply #'format fmt a) msgs))))
+ (cj/--agenda-frame-do-redo 'af (current-buffer) nil)
+ (should cj/agenda-frame-mode) ; policy re-enabled
+ (should (= 1 (length (cj/--agenda-frame-failure-overlays
+ (current-buffer))))) ; overlay shown
+ (should (string-match-p "good agenda content" (buffer-string))) ; restored
+ (should-not (string-match-p "PARTIAL" (buffer-string)))
+ (should (= 1 (seq-count (lambda (m) (string-match-p "refresh failed" m))
+ msgs))))
+ (cj/agenda-frame-mode -1)
+ (cj/--agenda-frame-remove-overlay (current-buffer))))))
+
+(ert-deftest test-org-agenda-frame-do-redo-follows-item-across-shift ()
+ "Normal: on a successful redo, point follows the same source item even when
+lines shift and the buffer's own markers are nulled (the reset-markers case).
+Guards against restoring by raw line number after the item moved."
+ (let ((src (generate-new-buffer " *shift-src*"))
+ (params '()))
+ (unwind-protect
+ (with-temp-buffer
+ (with-current-buffer src (insert "0123456789\n"))
+ ;; Before: item B (src pos 5) sits on line 3, and point is on it.
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("header" . nil) ("item A" . 3) ("item B" . 5)) src)
+ (goto-char (point-min)) (forward-line 2) ; line 3, item B
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (_f p) (alist-get p params)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setf (alist-get p params) v)))
+ ((symbol-function 'org-agenda-redo)
+ (lambda (&rest _)
+ ;; Null the buffer's originals (as org-agenda-reset-markers
+ ;; does), then rebuild with item B shifted to line 4.
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (let ((m (get-text-property (line-beginning-position) 'org-marker)))
+ (when (markerp m) (set-marker m nil)))
+ (forward-line 1)))
+ (erase-buffer)
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("header" . nil) ("new item" . 1) ("item A" . 3) ("item B" . 5))
+ src))))
+ (cj/--agenda-frame-do-redo 'af (current-buffer) nil))
+ ;; Point should be on the rebuilt item B (src pos 5), now line 4 --
+ ;; not clamped to old line 3 (which is now item A, src pos 3).
+ (let ((m (get-text-property (line-beginning-position) 'org-marker)))
+ (should (markerp m))
+ (should (= (marker-position m) 5))))
+ (kill-buffer src))))
+
+;;; Phase 2 — snapshot round-trip, release, safe-redo window contract, overlay
+
+(ert-deftest test-org-agenda-frame-restore-snapshot-round-trip ()
+ "Normal: snapshot then restore reinstates text and a live cloned marker."
+ (let ((src (generate-new-buffer " *ss-src*")))
+ (unwind-protect
+ (with-temp-buffer
+ (with-current-buffer src (insert "0123456789\n"))
+ (test-org-agenda-frame--make-agenda-buffer '(("item alpha" . 4)) src)
+ (let ((snap (cj/--agenda-frame-snapshot (current-buffer) nil)))
+ (erase-buffer)
+ (insert "CORRUPT")
+ (cj/--agenda-frame-restore-snapshot (current-buffer) snap nil)
+ (should (string-match-p "item alpha" (buffer-string)))
+ (let ((m (get-text-property (point-min) 'org-marker)))
+ (should (markerp m))
+ (should (eq (marker-buffer m) src))
+ (should (= (marker-position m) 4)))
+ (cj/--agenda-frame-release-snapshot snap)
+ (should-not (marker-buffer (cdr (car (plist-get snap :markers)))))))
+ (kill-buffer src))))
+
+(ert-deftest test-org-agenda-frame-safe-redo-selects-and-restores-window ()
+ "Normal: the active tick runs the redo and restores the prior window (no focus theft)."
+ (let (redone (prev (selected-window)))
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer)
+ (lambda () (current-buffer)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'get-buffer-window) (lambda (&rest _) prev))
+ ((symbol-function 'cj/--agenda-frame-do-redo)
+ (lambda (&rest _) (setq redone t))))
+ (cj/--agenda-frame-safe-redo)
+ (should redone)
+ (should (eq (selected-window) prev)))))
+
+(ert-deftest test-org-agenda-frame-overlay-idempotent ()
+ "Boundary: showing the failure overlay twice keeps a single overlay."
+ (with-temp-buffer
+ (insert "x\n")
+ (unwind-protect
+ (progn
+ (cj/--agenda-frame-show-failure-overlay (current-buffer))
+ (let ((first (car (cj/--agenda-frame-failure-overlays (current-buffer)))))
+ (cj/--agenda-frame-show-failure-overlay (current-buffer))
+ (should (equal (cj/--agenda-frame-failure-overlays (current-buffer))
+ (list first)))
+ (should (= 1 (seq-count (lambda (o) (overlay-get o 'before-string))
+ (overlays-in (point-min) (point-max)))))))
+ (cj/--agenda-frame-remove-overlay (current-buffer)))))
+
+;;; Phase 2 — public command + F8-family rebind
+
+(ert-deftest test-org-agenda-frame-public-toggle-wraps-private ()
+ "Normal: the interactive command delegates to the private toggle."
+ (let (called)
+ (cl-letf (((symbol-function 'cj/--agenda-frame-toggle)
+ (lambda () (setq called t))))
+ (call-interactively 'cj/agenda-frame-toggle)
+ (should called))))
+
+(ert-deftest test-org-agenda-frame-parameters-normal-tiled-frame ()
+ "Boundary: the agenda frame is a normal frame (the tiling WM places it
+side by side with the working frame), carrying the marker and a distinct name."
+ (let ((params (cj/--agenda-frame-make-parameters)))
+ (should (assq cj/--agenda-frame-parameter params))
+ (should (equal (cdr (assq 'name params)) "Full Agenda"))
+ ;; No fullscreen request -- a fullboth frame would cover the whole output
+ ;; instead of tiling beside the working frame.
+ (should-not (assq 'fullscreen params))))
+
+(ert-deftest test-org-agenda-frame-spawn-binds-sticky-and-current-window ()
+ "Normal: spawn dynamically binds sticky + current-window around the render.
+The custom command's own settings apply too late to name the buffer, so
+without these bindings the buffer is plain *Org Agenda* -- which matches
+the 0.75 below-selected display rule and splits the new frame with the
+working buffer left on top."
+ (let (seen-sticky seen-setup)
+ (cl-letf (((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'af))
+ ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda)
+ (lambda (&rest _)
+ (setq seen-sticky org-agenda-sticky
+ seen-setup org-agenda-window-setup)))
+ ((symbol-function 'delete-other-windows) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-start-timer) (lambda (_f) nil)))
+ (cj/--agenda-frame-spawn)
+ (should (eq seen-sticky t))
+ (should (eq seen-setup 'current-window)))))
+
+(ert-deftest test-org-agenda-frame-spawn-forces-single-window ()
+ "Boundary: spawn collapses the frame to one window after rendering.
+A display rule that still splits the frame must not leave a second window
+showing the launch buffer."
+ (let (collapsed)
+ (cl-letf (((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'af))
+ ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda) (lambda (&rest _) nil))
+ ((symbol-function 'delete-other-windows)
+ (lambda (&rest _) (setq collapsed t)))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-start-timer) (lambda (_f) nil)))
+ (cj/--agenda-frame-spawn)
+ (should collapsed))))
+
+(ert-deftest test-org-agenda-frame-spawn-starts-timer ()
+ "Normal: a successful spawn starts the refresh timer for the new frame."
+ (let (timed)
+ (cl-letf (((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'af))
+ ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-start-timer)
+ (lambda (f) (setq timed f))))
+ (should (eq (cj/--agenda-frame-spawn) 'af))
+ (should (eq timed 'af)))))
+
+(ert-deftest test-org-agenda-frame-safe-redo-inhibits-redisplay ()
+ "Normal: the tick runs with redisplay inhibited.
+The rebuild takes visible time; without this, the agenda window is the
+selected window for the whole rebuild and the user watches their cursor
+go hollow every five minutes -- indistinguishable from focus theft."
+ (let (seen (prev (selected-window)))
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer)
+ (lambda () (current-buffer)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'get-buffer-window) (lambda (&rest _) prev))
+ ((symbol-function 'cj/--agenda-frame-do-redo)
+ (lambda (&rest _) (setq seen inhibit-redisplay))))
+ (cj/--agenda-frame-safe-redo)
+ (should (eq seen t)))))
+
+(ert-deftest test-org-agenda-frame-safe-redo-skips-during-minibuffer ()
+ "Boundary: a tick while a minibuffer is active is skipped entirely.
+Reselecting windows under an active minibuffer session can break it; the
+next tick catches up."
+ (let (redone (prev (selected-window)))
+ (cl-letf (((symbol-function 'active-minibuffer-window) (lambda () 'mini))
+ ((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer)
+ (lambda () (current-buffer)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'get-buffer-window) (lambda (&rest _) prev))
+ ((symbol-function 'cj/--agenda-frame-do-redo)
+ (lambda (&rest _) (setq redone t))))
+ (cj/--agenda-frame-safe-redo)
+ (should-not redone))))
+
+(ert-deftest test-org-agenda-frame-safe-redo-noop-when-not-shown ()
+ "Boundary: a tick with the buffer not shown in the frame does not redo or error."
+ (let (redone)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'get-buffer-window) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--agenda-frame-do-redo)
+ (lambda (&rest _) (setq redone t))))
+ (cj/--agenda-frame-safe-redo)
+ (should-not redone))))
+
+(ert-deftest test-org-agenda-frame-install-keys-rebinds-f8-family ()
+ "Normal: S-<f8> toggles the frame; the force-rescan moves to C-M-<f8>."
+ (let ((map (make-sparse-keymap)))
+ (cj/--agenda-frame-install-keys map)
+ (should (eq (lookup-key map (kbd "S-<f8>")) 'cj/agenda-frame-toggle))
+ (should (eq (lookup-key map (kbd "C-M-<f8>")) 'cj/org-agenda-refresh-files))))
+
+(provide 'test-org-agenda-frame)
+;;; test-org-agenda-frame.el ends here
diff --git a/tests/test-org-capture-config--neutralize.el b/tests/test-org-capture-config--neutralize.el
new file mode 100644
index 00000000..4a03830a
--- /dev/null
+++ b/tests/test-org-capture-config--neutralize.el
@@ -0,0 +1,136 @@
+;;; test-org-capture-config--neutralize.el --- Popup neutralize-guard tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the "org-capture" popup neutralize guards. The popup frame opens
+;; showing the daemon's last buffer; if a capture aborts before its UI paints,
+;; the popup lingers on that buffer, and a live eat/vterm terminal shown there
+;; clamps the real frame to the popup's rows. The guards repoint every
+;; non-capture-UI window of the popup at *scratch*: one fires on frame creation
+;; (after-make-frame-functions), one on any buffer change
+;; (window-buffer-change-functions).
+;;
+;; The tests drive the real batch frame (renamed to "org-capture" and restored
+;; in cleanup, the same idiom as the popup-window integration test) rather than
+;; mocking frame primitives. `window-buffer-change-functions' only runs during
+;; redisplay, so the module's own hook cannot fire mid-test in batch.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'org)
+(require 'org-capture)
+(require 'user-constants)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-capture-config)
+
+(defmacro test-neutralize--with-popup-frame (buffer-name &rest body)
+ "Run BODY with the batch frame named \"org-capture\" showing BUFFER-NAME.
+The frame name reverts to auto-naming and the test buffer is killed
+afterward. BODY sees the buffer bound to `buf'."
+ (declare (indent 1))
+ `(let ((buf (get-buffer-create ,buffer-name)))
+ (unwind-protect
+ (progn
+ (set-frame-parameter nil 'name "org-capture")
+ (delete-other-windows)
+ (set-window-buffer (selected-window) buf)
+ ,@body)
+ ;; Restore to nil, not the saved name: the batch frame's auto name is
+ ;; "F1", and Emacs refuses to set F<num>-shaped names explicitly.
+ ;; nil reverts to auto-naming (same idiom as the popup-window test).
+ (set-frame-parameter nil 'name nil)
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+;;; cj/org-capture--neutralize-frame
+
+(ert-deftest test-org-capture-config-neutralize-frame-evicts-plain-buffer ()
+ "Normal: a non-capture-UI buffer in the popup frame is repointed to *scratch*."
+ (test-neutralize--with-popup-frame "neutralize-plain.org"
+ (cj/org-capture--neutralize-frame (selected-frame))
+ (should (equal (buffer-name (window-buffer (selected-window)))
+ "*scratch*"))))
+
+(ert-deftest test-org-capture-config-neutralize-frame-spares-capture-buffer ()
+ "Normal: a CAPTURE-* buffer is capture UI and stays put."
+ (test-neutralize--with-popup-frame "CAPTURE-neutralize.org"
+ (cj/org-capture--neutralize-frame (selected-frame))
+ (should (eq (window-buffer (selected-window)) buf))))
+
+(ert-deftest test-org-capture-config-neutralize-frame-spares-select-menu ()
+ "Normal: the *Org Select* template menu is capture UI and stays put."
+ (test-neutralize--with-popup-frame "*Org Select*"
+ (cj/org-capture--neutralize-frame (selected-frame))
+ (should (eq (window-buffer (selected-window)) buf))))
+
+(ert-deftest test-org-capture-config-neutralize-frame-scratch-untouched ()
+ "Boundary: a window already on *scratch* is left alone (idempotent)."
+ (let ((scratch (get-buffer-create "*scratch*")))
+ (unwind-protect
+ (progn
+ (set-frame-parameter nil 'name "org-capture")
+ (delete-other-windows)
+ (set-window-buffer (selected-window) scratch)
+ (cj/org-capture--neutralize-frame (selected-frame))
+ (should (eq (window-buffer (selected-window)) scratch))
+ ;; Second pass is also a no-op.
+ (cj/org-capture--neutralize-frame (selected-frame))
+ (should (eq (window-buffer (selected-window)) scratch)))
+ (set-frame-parameter nil 'name nil))))
+
+(ert-deftest test-org-capture-config-neutralize-frame-other-frame-untouched ()
+ "Boundary: a frame not named \"org-capture\" is never neutralized."
+ (let ((buf (get-buffer-create "neutralize-other-frame.org")))
+ (unwind-protect
+ (progn
+ (set-frame-parameter nil 'name "some-other-frame")
+ (delete-other-windows)
+ (set-window-buffer (selected-window) buf)
+ (cj/org-capture--neutralize-frame (selected-frame))
+ (should (eq (window-buffer (selected-window)) buf)))
+ (set-frame-parameter nil 'name nil)
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest test-org-capture-config-neutralize-frame-dead-input-no-error ()
+ "Error: nil and non-frame inputs are ignored without raising."
+ (should-not (cj/org-capture--neutralize-frame nil))
+ (should-not (cj/org-capture--neutralize-frame 'not-a-frame)))
+
+;;; cj/org-capture--neutralize-new-frame (Guard 1 wrapper)
+
+(ert-deftest test-org-capture-config-neutralize-new-frame-delegates ()
+ "Normal: the frame-creation wrapper neutralizes the popup frame."
+ (test-neutralize--with-popup-frame "neutralize-new-frame.org"
+ (cj/org-capture--neutralize-new-frame (selected-frame))
+ (should (equal (buffer-name (window-buffer (selected-window)))
+ "*scratch*"))))
+
+;;; cj/org-capture--neutralize-on-buffer-change (Guard 2 wrapper)
+
+(ert-deftest test-org-capture-config-neutralize-on-buffer-change-window-arg ()
+ "Normal: a window argument resolves to its frame and neutralizes it.
+`window-buffer-change-functions' passes a window when buffer-local."
+ (test-neutralize--with-popup-frame "neutralize-window-arg.org"
+ (cj/org-capture--neutralize-on-buffer-change (selected-window))
+ (should (equal (buffer-name (window-buffer (selected-window)))
+ "*scratch*"))))
+
+(ert-deftest test-org-capture-config-neutralize-on-buffer-change-frame-arg ()
+ "Normal: a frame argument passes straight through.
+`window-buffer-change-functions' passes a frame when global."
+ (test-neutralize--with-popup-frame "neutralize-frame-arg.org"
+ (cj/org-capture--neutralize-on-buffer-change (selected-frame))
+ (should (equal (buffer-name (window-buffer (selected-window)))
+ "*scratch*"))))
+
+;;; Hook wiring
+
+(ert-deftest test-org-capture-config-neutralize-hooks-registered ()
+ "Normal: both guards are installed on their hooks at module load."
+ (should (memq #'cj/org-capture--neutralize-new-frame
+ after-make-frame-functions))
+ (should (memq #'cj/org-capture--neutralize-on-buffer-change
+ window-buffer-change-functions)))
+
+(provide 'test-org-capture-config--neutralize)
+;;; test-org-capture-config--neutralize.el ends here
diff --git a/tests/test-org-config-noop-state-log.el b/tests/test-org-config-noop-state-log.el
new file mode 100644
index 00000000..125eb300
--- /dev/null
+++ b/tests/test-org-config-noop-state-log.el
@@ -0,0 +1,59 @@
+;;; test-org-config-noop-state-log.el --- Suppress no-op state-change logs -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; org's state-change logging writes "- State X from X" lines for no-op
+;; transitions (identical from/to state), which carry no information. With
+;; `org-log-into-drawer' nil those lines also land inline, where they wedge
+;; between a heading and its planning line and break org's parser. Two fixes
+;; in `cj/org-todo-settings': log state changes into :LOGBOOK: drawers
+;; (`org-log-into-drawer' t), and suppress no-op state logs at the
+;; `org-add-log-setup' choke point via `cj/org--suppress-noop-state-log'.
+
+;;; Code:
+
+(require 'ert)
+(require 'org) ;; declares the org-log-* vars special
+(require 'org-config)
+
+(ert-deftest test-org-config-log-into-drawer-enabled ()
+ "Normal: cj/org-todo-settings enables org-log-into-drawer (LOGBOOK)."
+ (let ((org-log-into-drawer nil))
+ (cj/org-todo-settings)
+ (should (eq org-log-into-drawer t))))
+
+(ert-deftest test-org-config-noop-state-log-p-identical-is-noop ()
+ "Normal: identical from/to state on a 'state purpose is a no-op."
+ (should (cj/org--noop-state-log-p 'state "TODO" "TODO")))
+
+(ert-deftest test-org-config-noop-state-log-p-real-change-not-noop ()
+ "Normal: a genuine state change is not a no-op."
+ (should-not (cj/org--noop-state-log-p 'state "DONE" "TODO")))
+
+(ert-deftest test-org-config-noop-state-log-p-nil-prev-not-noop ()
+ "Boundary: a nil previous state (initial log) is not a no-op."
+ (should-not (cj/org--noop-state-log-p 'state "TODO" nil)))
+
+(ert-deftest test-org-config-noop-state-log-p-non-state-purpose-not-noop ()
+ "Boundary: identical strings under a non-state purpose are not suppressed."
+ (should-not (cj/org--noop-state-log-p 'note "x" "x")))
+
+(ert-deftest test-org-config-noop-state-log-p-nil-both-not-noop ()
+ "Error: a nil/nil state pair is not a suppressible no-op."
+ (should-not (cj/org--noop-state-log-p 'state nil nil)))
+
+(ert-deftest test-org-config-suppress-advice-skips-noop ()
+ "Normal: the advice does NOT call through for a no-op state transition."
+ (let ((called nil))
+ (cj/org--suppress-noop-state-log
+ (lambda (&rest _) (setq called t)) 'state "TODO" "TODO")
+ (should-not called)))
+
+(ert-deftest test-org-config-suppress-advice-passes-real-change ()
+ "Normal: the advice calls through for a genuine state change."
+ (let ((called nil))
+ (cj/org--suppress-noop-state-log
+ (lambda (&rest _) (setq called t)) 'state "DONE" "TODO")
+ (should called)))
+
+(provide 'test-org-config-noop-state-log)
+;;; test-org-config-noop-state-log.el ends here
diff --git a/tests/test-org-contacts-config-find.el b/tests/test-org-contacts-config-find.el
new file mode 100644
index 00000000..60a5e713
--- /dev/null
+++ b/tests/test-org-contacts-config-find.el
@@ -0,0 +1,72 @@
+;;; test-org-contacts-config-find.el --- Tests for cj/org-contacts-find -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/org-contacts-find collects contact headings (name, position, and the
+;; EMAIL/PHONE annotation) before prompting, then jumps to the selected
+;; heading's stored position. These tests pin the collector helper and a
+;; command-level smoke test: the jump must land on the heading, never on a
+;; body line that merely mentions the name.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'org)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-contacts-config)
+
+;;; cj/--org-contacts-collect
+
+(ert-deftest test-org-contacts-collect-returns-name-position-info ()
+ "Normal: collector returns heading name, heading position, and EMAIL/PHONE."
+ (with-temp-buffer
+ (org-mode)
+ (insert "* Alice\n:PROPERTIES:\n:EMAIL: alice@example.com\n:END:\n"
+ "some body text mentioning Bob\n"
+ "* Bob\n:PROPERTIES:\n:PHONE: 555-1234\n:END:\n")
+ (let* ((alist (cj/--org-contacts-collect (current-buffer)))
+ (alice (assoc "Alice" alist))
+ (bob (assoc "Bob" alist)))
+ (should (equal (length alist) 2))
+ (should (equal (nth 2 alice) "alice@example.com"))
+ (should (equal (nth 2 bob) "555-1234"))
+ ;; positions land on the headings, not the body line that mentions Bob
+ (should (save-excursion (goto-char (nth 1 alice)) (looking-at "\\* Alice")))
+ (should (save-excursion (goto-char (nth 1 bob)) (looking-at "\\* Bob"))))))
+
+(ert-deftest test-org-contacts-collect-empty-buffer-returns-nil ()
+ "Boundary: a buffer with no headings yields an empty alist."
+ (with-temp-buffer
+ (org-mode)
+ (should (null (cj/--org-contacts-collect (current-buffer))))))
+
+(ert-deftest test-org-contacts-collect-heading-without-props-has-nil-info ()
+ "Error: a heading with no EMAIL or PHONE yields nil info."
+ (with-temp-buffer
+ (org-mode)
+ (insert "* Carol\n")
+ (let ((alist (cj/--org-contacts-collect (current-buffer))))
+ (should (equal (nth 2 (assoc "Carol" alist)) nil)))))
+
+;;; cj/org-contacts-find
+
+(ert-deftest test-org-contacts-find-jumps-to-heading-not-body ()
+ "Normal: selecting a contact jumps to its heading, not a body mention."
+ (let ((contacts-file (make-temp-file "org-contacts-test-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file contacts-file
+ (insert "* Alice\n:PROPERTIES:\n:EMAIL: alice@example.com\n:END:\n"
+ "note: call Bob about Alice\n"
+ "* Bob\n:PROPERTIES:\n:PHONE: 555-1234\n:END:\n"))
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (&rest _) "Bob"))
+ ((symbol-function 'org-fold-show-entry) #'ignore)
+ ((symbol-function 'org-reveal) #'ignore))
+ (cj/org-contacts-find)
+ (should (looking-at "\\* Bob"))))
+ (delete-file contacts-file))))
+
+(provide 'test-org-contacts-config-find)
+;;; test-org-contacts-config-find.el ends here
diff --git a/tests/test-org-drill-config-source.el b/tests/test-org-drill-config-source.el
new file mode 100644
index 00000000..ccda99ef
--- /dev/null
+++ b/tests/test-org-drill-config-source.el
@@ -0,0 +1,31 @@
+;;; test-org-drill-config-source.el --- Tests for org-drill source selection -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/--org-drill-source-keywords decides how use-package obtains org-drill:
+;; a local dev checkout via :load-path when it exists, otherwise the upstream
+;; repo via :vc. Without this, a machine lacking the checkout hits :demand t
+;; against a nonexistent :load-path and drill fails to load entirely.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-drill-config)
+
+(ert-deftest test-org-drill-source-keywords-uses-load-path-when-checkout-exists ()
+ "Normal: an existing checkout directory selects :load-path."
+ (let ((dir (make-temp-file "org-drill-checkout-" t)))
+ (unwind-protect
+ (should (equal (cj/--org-drill-source-keywords dir)
+ (list :load-path dir)))
+ (delete-directory dir t))))
+
+(ert-deftest test-org-drill-source-keywords-falls-back-to-vc-when-absent ()
+ "Error: a missing checkout falls back to a :vc install spec."
+ (let ((kws (cj/--org-drill-source-keywords "/nonexistent/org-drill-xyz")))
+ (should (eq (car kws) :vc))
+ (should (plist-get (nth 1 kws) :url))))
+
+(provide 'test-org-drill-config-source)
+;;; test-org-drill-config-source.el ends here
diff --git a/tests/test-org-reveal-config-keymap.el b/tests/test-org-reveal-config-keymap.el
new file mode 100644
index 00000000..17a21b19
--- /dev/null
+++ b/tests/test-org-reveal-config-keymap.el
@@ -0,0 +1,38 @@
+;;; test-org-reveal-config-keymap.el --- Tests for org-reveal-config prefix keymap -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The presentation commands are reached through `cj/reveal-map', a prefix
+;; keymap registered under "C-; p" via `cj/register-prefix-map'. These
+;; tests pin that structure so the module can't silently regress to raw
+;; `global-set-key' calls (which carry a hidden load-order dependency on
+;; keybindings.el establishing "C-;" as a prefix first).
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'keybindings)
+(require 'org-reveal-config)
+
+(ert-deftest test-org-reveal-config-reveal-map-is-a-keymap ()
+ "Normal: `cj/reveal-map' is a keymap."
+ (should (keymapp cj/reveal-map)))
+
+(ert-deftest test-org-reveal-config-reveal-map-bindings ()
+ "Normal: each presentation command is reachable under `cj/reveal-map'."
+ (dolist (pair '(("SPC" . cj/reveal-present)
+ ("e" . cj/reveal-export)
+ ("p" . cj/reveal-preview-start)
+ ("s" . cj/reveal-preview-stop)
+ ("h" . cj/reveal-insert-header)
+ ("H" . cj/reveal-remove-headers)
+ ("n" . cj/reveal-new)))
+ (should (eq (keymap-lookup cj/reveal-map (car pair)) (cdr pair)))))
+
+(ert-deftest test-org-reveal-config-reveal-map-registered-under-p ()
+ "Normal: `cj/reveal-map' is registered under \"p\" in `cj/custom-keymap'."
+ (should (eq (keymap-lookup cj/custom-keymap "p") cj/reveal-map)))
+
+(provide 'test-org-reveal-config-keymap)
+;;; test-org-reveal-config-keymap.el ends here
diff --git a/tests/test-org-roam-config-tag-and-find.el b/tests/test-org-roam-config-tag-and-find.el
index f98d8af0..97a7a683 100644
--- a/tests/test-org-roam-config-tag-and-find.el
+++ b/tests/test-org-roam-config-tag-and-find.el
@@ -147,6 +147,17 @@
;; The 4th arg is the subdir
(should (equal (nth 3 args) "recipes/"))))
+(ert-deftest test-org-roam-find-node-project-delegates-to-find-node ()
+ "Normal: `find-node-project' uses Project tag, 'p' key, project.org template."
+ (let ((args nil))
+ (cl-letf (((symbol-function 'cj/org-roam-find-node)
+ (lambda (&rest a) (setq args a))))
+ (cj/org-roam-find-node-project))
+ (should (equal (car args) "Project"))
+ (should (equal (cadr args) "p"))
+ ;; The 3rd arg is the template file, under the canonical roam-dir/templates/.
+ (should (string-suffix-p "templates/project.org" (nth 2 args)))))
+
;;; cj/org-roam-node-insert-immediate
(ert-deftest test-org-roam-node-insert-immediate-rebinds-templates-and-calls-insert ()
diff --git a/tests/test-pdf-config--reading-palette.el b/tests/test-pdf-config--reading-palette.el
new file mode 100644
index 00000000..6a02d838
--- /dev/null
+++ b/tests/test-pdf-config--reading-palette.el
@@ -0,0 +1,87 @@
+;;; test-pdf-config--reading-palette.el --- pdf reading-palette tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Pure-logic tests for the pdf-view reading-palette layer: name->colors
+;; resolution, the shipped default and order, and the cycle (palettes, then the
+;; no-palette state, wrapping). The live application (which drives
+;; `pdf-view-midnight-minor-mode') is exercised in the daemon, not here.
+;;
+;; Requires pdf-config, which uses use-package for pdf-tools; `make test' runs
+;; without the implicit package-initialize, so bootstrap `package' first (the
+;; shared format-wiring helper). pdf-tools is loaded lazily (:defer t), so this
+;; only needs the pure top-level helpers, not the pdf-view runtime.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(require 'testutil-format-wiring)
+
+(format-test--ensure-packages-init)
+(require 'pdf-config)
+
+(declare-function cj/pdf--reading-palette-colors "pdf-config" (name))
+(declare-function cj/pdf--next-reading-palette "pdf-config" (current names))
+(defvar cj/pdf-reading-palettes)
+(defvar cj/pdf-reading-default-palette)
+
+;;; ------------------------ cj/pdf--reading-palette-colors --------------------
+
+(ert-deftest test-pdf-reading-palette-colors-known ()
+ "Normal: a known palette resolves to a (FG . BG) cons of two strings."
+ (let ((colors (cj/pdf--reading-palette-colors "dark")))
+ (should (consp colors))
+ (should (stringp (car colors)))
+ (should (stringp (cdr colors)))))
+
+(ert-deftest test-pdf-reading-palette-colors-unknown ()
+ "Error: an unknown palette name resolves to nil."
+ (should-not (cj/pdf--reading-palette-colors "nope")))
+
+(ert-deftest test-pdf-reading-palette-colors-nil ()
+ "Boundary: a nil name (the no-palette state) resolves to nil."
+ (should-not (cj/pdf--reading-palette-colors nil)))
+
+;;; --------------------------- shipped default + order ------------------------
+
+(ert-deftest test-pdf-reading-default-is-dark ()
+ "Normal: a fresh PDF opens on the dark palette."
+ (should (equal cj/pdf-reading-default-palette "dark")))
+
+(ert-deftest test-pdf-reading-order-is-dark-sepia-light ()
+ "Normal: the shipped palette order is dark, then sepia, then light."
+ (should (equal (mapcar #'car cj/pdf-reading-palettes)
+ '("dark" "sepia" "light"))))
+
+;;; ------------------------- cj/pdf--next-reading-palette ---------------------
+
+(ert-deftest test-pdf-reading-next-palette-advances ()
+ "Normal: cycles to the next palette in order."
+ (should (equal (cj/pdf--next-reading-palette "dark" '("dark" "sepia" "light"))
+ "sepia")))
+
+(ert-deftest test-pdf-reading-next-palette-last-to-none ()
+ "Boundary: the last palette cycles to the no-palette state (nil)."
+ (should-not (cj/pdf--next-reading-palette "light" '("dark" "sepia" "light"))))
+
+(ert-deftest test-pdf-reading-next-palette-none-to-first ()
+ "Boundary: the no-palette state (nil) cycles to the first palette."
+ (should (equal (cj/pdf--next-reading-palette nil '("dark" "sepia" "light"))
+ "dark")))
+
+(ert-deftest test-pdf-reading-next-palette-unknown-current-falls-to-first ()
+ "Error: an unknown current palette falls back to the first."
+ (should (equal (cj/pdf--next-reading-palette "gone" '("dark" "sepia" "light"))
+ "dark")))
+
+(ert-deftest test-pdf-reading-cycle-from-default ()
+ "Normal: cycling from the default advances dark -> sepia -> light -> none -> dark."
+ (let ((names (mapcar #'car cj/pdf-reading-palettes)))
+ (should (equal (cj/pdf--next-reading-palette "dark" names) "sepia"))
+ (should (equal (cj/pdf--next-reading-palette "sepia" names) "light"))
+ (should-not (cj/pdf--next-reading-palette "light" names))
+ (should (equal (cj/pdf--next-reading-palette nil names) "dark"))))
+
+(provide 'test-pdf-config--reading-palette)
+;;; test-pdf-config--reading-palette.el ends here
diff --git a/tests/test-prog-c--tool-warnings.el b/tests/test-prog-c--tool-warnings.el
new file mode 100644
index 00000000..9d4da57e
--- /dev/null
+++ b/tests/test-prog-c--tool-warnings.el
@@ -0,0 +1,34 @@
+;;; test-prog-c--tool-warnings.el --- Load-time missing-tool warnings -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The config audit flagged clangd for the load-time missing-tool
+;; warning pyright/prettier already have. clang-format is the same
+;; class: its use-package block gates on `:if (executable-find ...)',
+;; which evaluates once at startup, so an absent binary silently
+;; disables the format key until the next restart — the warn is the
+;; only visible trace.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(require 'testutil-format-wiring)
+(format-test--ensure-packages-init)
+(require 'prog-c)
+
+(ert-deftest test-prog-c-warns-when-tools-missing ()
+ "Error: loading without the C tools on PATH warns for each one."
+ (let ((warned '()))
+ (cl-letf (((symbol-function 'executable-find) (lambda (&rest _) nil))
+ ((symbol-function 'display-warning)
+ (lambda (_type msg &rest _) (push msg warned))))
+ (load (expand-file-name "modules/prog-c.el" user-emacs-directory) nil t))
+ (dolist (tool '("clangd" "clang-format"))
+ (should (cl-some (lambda (m) (string-match-p (regexp-quote tool) m))
+ warned)))))
+
+(provide 'test-prog-c--tool-warnings)
+;;; test-prog-c--tool-warnings.el ends here
diff --git a/tests/test-prog-general-lsp.el b/tests/test-prog-general-lsp.el
new file mode 100644
index 00000000..e61e9a17
--- /dev/null
+++ b/tests/test-prog-general-lsp.el
@@ -0,0 +1,148 @@
+;;; test-prog-general-lsp.el --- LSP config + helper tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the LSP configuration in prog-general.el, the single owner of
+;; generic LSP policy (prog-lsp.el was folded in and removed 2026-07-10).
+;;
+;; Two layers:
+;; - Load-time invariants that hold the moment the config loads, before any
+;; server starts: lsp-enable-remote stays nil (so TRAMP files don't auto-start
+;; a slow LSP), and no mode accrues a duplicate lsp-deferred entry.
+;; - The pure helpers cj/lsp--add-file-watch-ignored-extras and
+;; cj/lsp--remove-eldoc-provider-global, exercised directly with Normal /
+;; Boundary / Error cases.
+;;
+;; The quiet-UI :config defaults (snippet off, symbol highlighting off,
+;; idle-delay 0.5, ...) are deferred to lsp-mode's own load (see the make-test
+;; no-package-initialize note in CLAUDE.md), so they aren't asserted here; the
+;; daemon verifies them.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'use-package)
+
+;; Declare lsp-mode's defcustom / eldoc's hook as special so `let' binds them
+;; dynamically. Real definitions load only when lsp-mode / eldoc activate; in
+;; the test environment these provide the value cells the helpers read through.
+(defvar lsp-file-watch-ignored-directories nil)
+(defvar eldoc-documentation-functions nil)
+
+(defun lsp-eldoc-function (&rest _args)
+ "Stub lsp-mode Eldoc function for tests.")
+
+(require 'prog-general)
+
+;;; Load-time invariants
+
+(ert-deftest test-prog-general-lsp-enable-remote-nil ()
+ "Normal: lsp-enable-remote is nil so LSP never auto-starts on TRAMP files."
+ (should (boundp 'lsp-enable-remote))
+ (should (null lsp-enable-remote)))
+
+(ert-deftest test-prog-general-lsp-no-duplicate-mode-hook ()
+ "Boundary: a mode never holds more than one lsp-deferred entry.
+The per-language modules add lsp-deferred to their own mode hooks; add-hook
+dedups identical symbols, and this pins that invariant so a future non-symbol
+\(lambda) addition that breaks it gets caught."
+ (dolist (hook '(c-mode-hook python-mode-hook go-ts-mode-hook))
+ (when (boundp hook)
+ (should (>= 1 (cl-count 'lsp-deferred (symbol-value hook)))))))
+
+;;; File-watch ignore helper — Normal
+
+(ert-deftest test-prog-general-lsp-file-watch-adds-all-patterns ()
+ "Normal: every entry from `cj/lsp-file-watch-ignored-extras' lands in the list."
+ (let ((lsp-file-watch-ignored-directories nil))
+ (cj/lsp--add-file-watch-ignored-extras)
+ (should (= (length lsp-file-watch-ignored-directories)
+ (length cj/lsp-file-watch-ignored-extras)))
+ (dolist (pattern cj/lsp-file-watch-ignored-extras)
+ (should (member pattern lsp-file-watch-ignored-directories)))))
+
+(ert-deftest test-prog-general-lsp-file-watch-extends-not-replaces ()
+ "Normal: pre-existing entries (lsp-mode defaults) are preserved."
+ (let ((lsp-file-watch-ignored-directories
+ '("[/\\\\]\\.git\\'" "[/\\\\]\\.svn\\'" "[/\\\\]\\.idea\\'")))
+ (cj/lsp--add-file-watch-ignored-extras)
+ (should (member "[/\\\\]\\.git\\'" lsp-file-watch-ignored-directories))
+ (should (member "[/\\\\]\\.svn\\'" lsp-file-watch-ignored-directories))
+ (should (member "[/\\\\]\\.idea\\'" lsp-file-watch-ignored-directories))
+ (dolist (pattern cj/lsp-file-watch-ignored-extras)
+ (should (member pattern lsp-file-watch-ignored-directories)))))
+
+(ert-deftest test-prog-general-lsp-file-watch-key-patterns-present ()
+ "Normal: specific expected directory names appear in the constant."
+ (dolist (name '("node_modules" "target" "__pycache__" ".venv" "venv"
+ "dist" "coverage" "test-results" "playwright-report"
+ ".terraform" ".ruff_cache" ".pytest_cache" ".mypy_cache"))
+ (should (cl-some (lambda (p) (string-match-p (regexp-quote name) p))
+ cj/lsp-file-watch-ignored-extras))))
+
+;;; File-watch ignore helper — Boundary
+
+(ert-deftest test-prog-general-lsp-file-watch-idempotent ()
+ "Boundary: calling twice leaves each pattern present exactly once."
+ (let ((lsp-file-watch-ignored-directories nil))
+ (cj/lsp--add-file-watch-ignored-extras)
+ (cj/lsp--add-file-watch-ignored-extras)
+ (should (= (length lsp-file-watch-ignored-directories)
+ (length cj/lsp-file-watch-ignored-extras)))
+ (dolist (pattern cj/lsp-file-watch-ignored-extras)
+ (should (= 1 (cl-count pattern lsp-file-watch-ignored-directories
+ :test #'equal))))))
+
+(ert-deftest test-prog-general-lsp-file-watch-all-patterns-non-empty ()
+ "Boundary: every pattern is a non-empty string."
+ (dolist (pattern cj/lsp-file-watch-ignored-extras)
+ (should (stringp pattern))
+ (should (not (string-empty-p pattern)))))
+
+(ert-deftest test-prog-general-lsp-file-watch-all-patterns-valid-regex ()
+ "Boundary: every pattern compiles as a valid Emacs regex."
+ (dolist (pattern cj/lsp-file-watch-ignored-extras)
+ (condition-case err
+ ;; string-match-p compiles the regex; invalid syntax raises invalid-regexp.
+ (string-match-p pattern "/some/sample/path")
+ (invalid-regexp
+ (ert-fail (format "Invalid regex %S: %s"
+ pattern (error-message-string err)))))))
+
+;;; File-watch ignore helper — Error
+
+(ert-deftest test-prog-general-lsp-file-watch-non-list-target ()
+ "Error: non-list target value triggers `add-to-list' wrong-type-argument."
+ (let ((lsp-file-watch-ignored-directories "not-a-list"))
+ (should-error (cj/lsp--add-file-watch-ignored-extras)
+ :type 'wrong-type-argument)))
+
+;;; Eldoc provider helper
+
+(ert-deftest test-prog-general-lsp-eldoc-provider-removed-globally ()
+ "Normal: remove lsp-mode's Eldoc provider from the global hook value.
+The per-buffer removal this replaced raced lsp-mode's own buffer-local hook
+population; removing globally before any LSP buffer attaches makes the absence
+stick for every subsequent lsp-managed buffer."
+ (let ((eldoc-documentation-functions
+ (list #'lsp-eldoc-function 'eldoc-documentation-default)))
+ (cj/lsp--remove-eldoc-provider-global)
+ (should-not (memq #'lsp-eldoc-function eldoc-documentation-functions))
+ (should (memq 'eldoc-documentation-default eldoc-documentation-functions))))
+
+(ert-deftest test-prog-general-lsp-eldoc-provider-removal-idempotent ()
+ "Boundary: re-running the removal after the provider is gone is a no-op."
+ (let ((eldoc-documentation-functions '(eldoc-documentation-default)))
+ (cj/lsp--remove-eldoc-provider-global)
+ (cj/lsp--remove-eldoc-provider-global)
+ (should (equal eldoc-documentation-functions
+ '(eldoc-documentation-default)))))
+
+(ert-deftest test-prog-general-lsp-no-obsolete-eldoc-hook-reference ()
+ "Regression: prog-general should not reference obsolete `lsp-eldoc-hook'."
+ (with-temp-buffer
+ (insert-file-contents (expand-file-name "modules/prog-general.el" user-emacs-directory))
+ (should-not (re-search-forward "\\_<lsp-eldoc-hook\\_>" nil t))))
+
+(provide 'test-prog-general-lsp)
+;;; test-prog-general-lsp.el ends here
diff --git a/tests/test-prog-go--classic-mode-hooks.el b/tests/test-prog-go--classic-mode-hooks.el
new file mode 100644
index 00000000..a9defe2e
--- /dev/null
+++ b/tests/test-prog-go--classic-mode-hooks.el
@@ -0,0 +1,46 @@
+;;; test-prog-go--classic-mode-hooks.el --- Classic go-mode hooks + gopls warn -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The config audit found the Go setup hooks attached to go-ts-mode
+;; only, so a fallback to classic go-mode silently lost indent, keys,
+;; and LSP. It also flagged gopls for the load-time missing-tool
+;; warning pyright/prettier already have; that warn is only honest if
+;; ~/go/bin is on `exec-path' at load time (it used to join only in
+;; go-mode's deferred :config, so gopls installed there read as
+;; missing).
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(require 'testutil-format-wiring)
+(format-test--ensure-packages-init)
+(require 'prog-go)
+
+(ert-deftest test-prog-go-hooks-cover-classic-go-mode ()
+ "Normal: classic go-mode runs the same setup + keybindings as go-ts-mode."
+ (dolist (hook '(go-mode-hook go-ts-mode-hook))
+ (should (memq #'cj/go-setup (symbol-value hook)))
+ (should (memq #'cj/go-mode-keybindings (symbol-value hook)))))
+
+(ert-deftest test-prog-go-bin-path-on-exec-path-at-load ()
+ "Normal: ~/go/bin joins `exec-path' at module load, not first go buffer.
+The load-time gopls warn resolves through `exec-path'; registering the
+Go bin directory only in go-mode's deferred :config would make gopls
+installed there warn as missing on every start."
+ (should (member go-bin-path exec-path)))
+
+(ert-deftest test-prog-go-warns-when-gopls-missing ()
+ "Error: loading the module without gopls on PATH warns about gopls."
+ (let ((warned '()))
+ (cl-letf (((symbol-function 'executable-find) (lambda (&rest _) nil))
+ ((symbol-function 'display-warning)
+ (lambda (_type msg &rest _) (push msg warned))))
+ (load (expand-file-name "modules/prog-go.el" user-emacs-directory) nil t))
+ (should (cl-some (lambda (m) (string-match-p "gopls" m)) warned))))
+
+(provide 'test-prog-go--classic-mode-hooks)
+;;; test-prog-go--classic-mode-hooks.el ends here
diff --git a/tests/test-prog-lsp--add-file-watch-ignored-extras.el b/tests/test-prog-lsp--add-file-watch-ignored-extras.el
deleted file mode 100644
index 9b71cab8..00000000
--- a/tests/test-prog-lsp--add-file-watch-ignored-extras.el
+++ /dev/null
@@ -1,116 +0,0 @@
-;;; test-prog-lsp--add-file-watch-ignored-extras.el --- Tests for cj/lsp--add-file-watch-ignored-extras -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; Tests for cj/lsp--add-file-watch-ignored-extras in prog-lsp.el.
-;; The function adds project-agnostic build/cache directory patterns to
-;; `lsp-file-watch-ignored-directories' without replacing lsp-mode's
-;; defaults. Patterns are sourced from `cj/lsp-file-watch-ignored-extras'.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-
-;; Declare lsp-mode's defcustom as a special variable so `let' binds it
-;; dynamically. Real definition is lsp-mode's; loaded only when use-package
-;; activates lsp-mode. In the test environment, this stub provides the value
-;; cell `add-to-list' needs.
-(defvar lsp-file-watch-ignored-directories nil)
-(defvar eldoc-documentation-functions nil)
-
-(defun lsp-eldoc-function (&rest _args)
- "Stub lsp-mode Eldoc function for tests.")
-
-(require 'prog-lsp)
-
-;;; Normal Cases
-
-(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-normal-adds-all-patterns ()
- "Normal: every entry from `cj/lsp-file-watch-ignored-extras' lands in the list."
- (let ((lsp-file-watch-ignored-directories nil))
- (cj/lsp--add-file-watch-ignored-extras)
- (should (= (length lsp-file-watch-ignored-directories)
- (length cj/lsp-file-watch-ignored-extras)))
- (dolist (pattern cj/lsp-file-watch-ignored-extras)
- (should (member pattern lsp-file-watch-ignored-directories)))))
-
-(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-normal-extends-not-replaces ()
- "Normal: pre-existing entries (lsp-mode defaults) are preserved."
- (let ((lsp-file-watch-ignored-directories
- '("[/\\\\]\\.git\\'" "[/\\\\]\\.svn\\'" "[/\\\\]\\.idea\\'")))
- (cj/lsp--add-file-watch-ignored-extras)
- (should (member "[/\\\\]\\.git\\'" lsp-file-watch-ignored-directories))
- (should (member "[/\\\\]\\.svn\\'" lsp-file-watch-ignored-directories))
- (should (member "[/\\\\]\\.idea\\'" lsp-file-watch-ignored-directories))
- (dolist (pattern cj/lsp-file-watch-ignored-extras)
- (should (member pattern lsp-file-watch-ignored-directories)))))
-
-(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-normal-key-patterns-present ()
- "Normal: specific expected directory names appear in the constant."
- (dolist (name '("node_modules" "target" "__pycache__" ".venv" "venv"
- "dist" "coverage" "test-results" "playwright-report"
- ".terraform" ".ruff_cache" ".pytest_cache" ".mypy_cache"))
- (should (cl-some (lambda (p) (string-match-p (regexp-quote name) p))
- cj/lsp-file-watch-ignored-extras))))
-
-;;; Boundary Cases
-
-(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-boundary-idempotent ()
- "Boundary: calling twice doesn't duplicate entries."
- (let ((lsp-file-watch-ignored-directories nil))
- (cj/lsp--add-file-watch-ignored-extras)
- (cj/lsp--add-file-watch-ignored-extras)
- (should (= (length lsp-file-watch-ignored-directories)
- (length cj/lsp-file-watch-ignored-extras)))))
-
-(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-boundary-all-patterns-non-empty ()
- "Boundary: every pattern is a non-empty string."
- (dolist (pattern cj/lsp-file-watch-ignored-extras)
- (should (stringp pattern))
- (should (not (string-empty-p pattern)))))
-
-(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-boundary-all-patterns-valid-regex ()
- "Boundary: every pattern compiles as a valid Emacs regex."
- (dolist (pattern cj/lsp-file-watch-ignored-extras)
- (condition-case err
- ;; string-match-p compiles the regex; invalid syntax raises invalid-regexp.
- (string-match-p pattern "/some/sample/path")
- (invalid-regexp
- (ert-fail (format "Invalid regex %S: %s"
- pattern (error-message-string err)))))))
-
-;;; Error Cases
-
-(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-error-non-list-target ()
- "Error: non-list target value triggers `add-to-list' wrong-type-argument."
- (let ((lsp-file-watch-ignored-directories "not-a-list"))
- (should-error (cj/lsp--add-file-watch-ignored-extras)
- :type 'wrong-type-argument)))
-
-(ert-deftest test-prog-lsp--remove-eldoc-provider-global-removes-from-default ()
- "Normal: remove lsp-mode's Eldoc provider from the global hook value.
-The per-buffer removal that this replaced raced lsp-mode's own buffer-
-local hook population; removing globally before any LSP buffer attaches
-makes the absence stick for every subsequent lsp-managed buffer."
- (let ((eldoc-documentation-functions
- '(lsp-eldoc-function eldoc-documentation-default)))
- (cj/lsp--remove-eldoc-provider-global)
- (should-not (memq #'lsp-eldoc-function eldoc-documentation-functions))
- (should (memq 'eldoc-documentation-default eldoc-documentation-functions))))
-
-(ert-deftest test-prog-lsp--remove-eldoc-provider-global-is-idempotent ()
- "Boundary: re-running the removal after the provider is gone is a no-op."
- (let ((eldoc-documentation-functions '(eldoc-documentation-default)))
- (cj/lsp--remove-eldoc-provider-global)
- (cj/lsp--remove-eldoc-provider-global)
- (should (equal eldoc-documentation-functions
- '(eldoc-documentation-default)))))
-
-(ert-deftest test-prog-lsp--module-no-obsolete-lsp-eldoc-hook-reference ()
- "Regression: prog-lsp should not reference obsolete `lsp-eldoc-hook'."
- (with-temp-buffer
- (insert-file-contents (expand-file-name "modules/prog-lsp.el" user-emacs-directory))
- (should-not (re-search-forward "\\_<lsp-eldoc-hook\\_>" nil t))))
-
-(provide 'test-prog-lsp--add-file-watch-ignored-extras)
-;;; test-prog-lsp--add-file-watch-ignored-extras.el ends here
diff --git a/tests/test-prog-lsp.el b/tests/test-prog-lsp.el
deleted file mode 100644
index 7e38111d..00000000
--- a/tests/test-prog-lsp.el
+++ /dev/null
@@ -1,66 +0,0 @@
-;;; test-prog-lsp.el --- Startup smoke test for LSP config resolution -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; A narrow smoke test of prog-lsp.el, the central LSP module. It pins the
-;; invariants that should hold the moment the config loads, before any server
-;; starts: lsp-enable-remote stays nil (so TRAMP files don't auto-start a slow
-;; LSP), the file-watch-ignore defaults live in one idempotent place, the eldoc
-;; provider is stripped from the global hook, and a mode never accrues a
-;; duplicate lsp-deferred entry. The generic :config defaults are deferred to
-;; lsp-mode's own load (see the make-test no-package-initialize note in
-;; CLAUDE.md), so this tests the top-level :init and helper surface, which runs.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-(require 'use-package)
-(require 'prog-lsp)
-
-;; lsp-mode's defcustom isn't loaded under make test, and prog-lsp's bare
-;; `(defvar lsp-file-watch-ignored-directories)' only marks it special within
-;; that file's unit. Declare it special here too so the `let' bindings below
-;; bind dynamically (the helper reads it through the symbol via add-to-list).
-(defvar lsp-file-watch-ignored-directories nil)
-
-(ert-deftest test-prog-lsp-enable-remote-nil ()
- "Normal: lsp-enable-remote is nil so LSP never auto-starts on TRAMP files."
- (should (boundp 'lsp-enable-remote))
- (should (null lsp-enable-remote)))
-
-(ert-deftest test-prog-lsp-file-watch-adds-extras ()
- "Normal: the build/cache ignore patterns get appended to lsp's watch-ignore list."
- (let ((lsp-file-watch-ignored-directories '("[/\\\\]\\.git\\'")))
- (cj/lsp--add-file-watch-ignored-extras)
- (dolist (pattern cj/lsp-file-watch-ignored-extras)
- (should (member pattern lsp-file-watch-ignored-directories)))
- (should (member "[/\\\\]\\.git\\'" lsp-file-watch-ignored-directories))))
-
-(ert-deftest test-prog-lsp-file-watch-idempotent ()
- "Boundary: adding the extras twice leaves each pattern present exactly once."
- (let ((lsp-file-watch-ignored-directories '()))
- (cj/lsp--add-file-watch-ignored-extras)
- (cj/lsp--add-file-watch-ignored-extras)
- (dolist (pattern cj/lsp-file-watch-ignored-extras)
- (should (= 1 (cl-count pattern lsp-file-watch-ignored-directories
- :test #'equal))))))
-
-(ert-deftest test-prog-lsp-eldoc-provider-removed-globally ()
- "Normal: the global eldoc provider is stripped so lsp can't reattach it."
- (let ((eldoc-documentation-functions
- (list #'lsp-eldoc-function #'ignore)))
- (cj/lsp--remove-eldoc-provider-global)
- (should-not (memq 'lsp-eldoc-function eldoc-documentation-functions))
- (should (memq 'ignore eldoc-documentation-functions))))
-
-(ert-deftest test-prog-lsp-no-duplicate-mode-hook ()
- "Boundary: a mode prog-lsp wires never holds more than one lsp-deferred entry.
-prog-lsp and the per-language modules both add lsp-deferred for some modes;
-add-hook dedups identical symbols, and this pins that invariant so a future
-non-symbol (lambda) addition that breaks it gets caught."
- (dolist (hook '(c-mode-hook python-mode-hook go-ts-mode-hook))
- (when (boundp hook)
- (should (>= 1 (cl-count 'lsp-deferred (symbol-value hook)))))))
-
-(provide 'test-prog-lsp)
-;;; test-prog-lsp.el ends here
diff --git a/tests/test-prog-python--lsp-guard.el b/tests/test-prog-python--lsp-guard.el
new file mode 100644
index 00000000..071e1ca9
--- /dev/null
+++ b/tests/test-prog-python--lsp-guard.el
@@ -0,0 +1,64 @@
+;;; test-prog-python--lsp-guard.el --- Python LSP guard tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The config audit found lsp-pyright's unguarded :hook lambda calling
+;; (require 'lsp-pyright) + (lsp-deferred) on every python-ts buffer, so
+;; pyright-less machines got the LSP attach prompt that cj/python-setup's
+;; guard exists to prevent. The guarded branch now owns the require and
+;; the attach, and both classic and treesit modes run the same setup.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'prog-python)
+
+(defmacro test-python-guard--setup (pyright-found &rest body)
+ "Run `cj/python-setup' with pyright presence set to PYRIGHT-FOUND.
+Records requires into `required' and lsp attaches into `attached';
+BODY sees both. Package minor modes are stubbed at their boundary."
+ (declare (indent 1))
+ `(let ((required '()) (attached nil))
+ (cl-letf (((symbol-function 'company-mode) #'ignore)
+ ((symbol-function 'flyspell-prog-mode) #'ignore)
+ ((symbol-function 'superword-mode) #'ignore)
+ ((symbol-function 'executable-find)
+ (lambda (&rest _) ,pyright-found))
+ ((symbol-function 'require)
+ (lambda (feature &rest _) (push feature required) feature))
+ ((symbol-function 'lsp-deferred)
+ (lambda () (setq attached t))))
+ (with-temp-buffer
+ (cj/python-setup))
+ ,@body)))
+
+(ert-deftest test-prog-python-setup-pyright-absent-no-lsp ()
+ "Error: without pyright, setup neither loads lsp-pyright nor attaches."
+ (test-python-guard--setup nil
+ (should-not (memq 'lsp-pyright required))
+ (should-not attached)))
+
+(ert-deftest test-prog-python-setup-pyright-present-attaches ()
+ "Normal: with pyright on PATH, setup loads lsp-pyright then attaches."
+ (test-python-guard--setup "/usr/bin/pyright"
+ (should (memq 'lsp-pyright required))
+ (should attached)))
+
+(ert-deftest test-prog-python-hooks-cover-both-mode-variants ()
+ "Normal: classic and treesit python modes both run the same setup."
+ (dolist (hook '(python-mode-hook python-ts-mode-hook))
+ (should (memq #'cj/python-setup (symbol-value hook)))
+ (should (memq #'cj/python-mode-keybindings (symbol-value hook)))))
+
+(ert-deftest test-prog-python-no-anonymous-hook-lambdas ()
+ "Boundary: no anonymous lambda remains on the python hooks.
+The audit's unguarded lsp-pyright lambda was anonymous; symbols only
+means every hook entry is a named, greppable function."
+ (dolist (hook '(python-mode-hook python-ts-mode-hook))
+ (dolist (fn (symbol-value hook))
+ (should (symbolp fn)))))
+
+(provide 'test-prog-python--lsp-guard)
+;;; test-prog-python--lsp-guard.el ends here
diff --git a/tests/test-prog-shell--tool-warnings.el b/tests/test-prog-shell--tool-warnings.el
new file mode 100644
index 00000000..37c1e559
--- /dev/null
+++ b/tests/test-prog-shell--tool-warnings.el
@@ -0,0 +1,34 @@
+;;; test-prog-shell--tool-warnings.el --- Load-time missing-tool warnings -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The config audit flagged bash-language-server, shfmt, and shellcheck
+;; for the load-time missing-tool warnings pyright/prettier already
+;; have. The shfmt and flycheck use-package blocks gate on `:if
+;; (executable-find ...)', which evaluates once at startup — an absent
+;; tool silently disables that setup until the next restart, so the
+;; warn is the only visible trace.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(require 'testutil-format-wiring)
+(format-test--ensure-packages-init)
+(require 'prog-shell)
+
+(ert-deftest test-prog-shell-warns-when-tools-missing ()
+ "Error: loading without the shell tools on PATH warns for each one."
+ (let ((warned '()))
+ (cl-letf (((symbol-function 'executable-find) (lambda (&rest _) nil))
+ ((symbol-function 'display-warning)
+ (lambda (_type msg &rest _) (push msg warned))))
+ (load (expand-file-name "modules/prog-shell.el" user-emacs-directory) nil t))
+ (dolist (tool '("bash-language-server" "shfmt" "shellcheck"))
+ (should (cl-some (lambda (m) (string-match-p (regexp-quote tool) m))
+ warned)))))
+
+(provide 'test-prog-shell--tool-warnings)
+;;; test-prog-shell--tool-warnings.el ends here
diff --git a/tests/test-prog-webdev--classic-and-web-mode-hooks.el b/tests/test-prog-webdev--classic-and-web-mode-hooks.el
new file mode 100644
index 00000000..20fc2fb8
--- /dev/null
+++ b/tests/test-prog-webdev--classic-and-web-mode-hooks.el
@@ -0,0 +1,78 @@
+;;; test-prog-webdev--classic-and-web-mode-hooks.el --- Classic js + web-mode setup hooks -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The config audit found the webdev setup hooks attached to the
+;; tree-sitter modes only, so a grammar-unavailable fallback to classic
+;; js-mode silently lost indent/keys/LSP, and web-mode got the format
+;; key but none of the promised setup (no company/flyspell/LSP in HTML
+;; buffers). These tests pin the classic js-mode hooks and the
+;; web-mode setup hook, plus the html-language-server guard that keeps
+;; LSP silent on machines without the server.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(require 'testutil-format-wiring)
+(format-test--ensure-packages-init)
+(require 'prog-webdev)
+
+(ert-deftest test-prog-webdev-hooks-cover-classic-js-mode ()
+ "Normal: classic js-mode runs the same setup + keybindings as js-ts-mode."
+ (should (memq #'cj/webdev-setup js-mode-hook))
+ (should (memq #'cj/webdev-keybindings js-mode-hook)))
+
+(ert-deftest test-prog-webdev-web-mode-hook-runs-setup ()
+ "Normal: web-mode runs `cj/web-mode-setup' alongside the keybindings."
+ (should (memq #'cj/web-mode-setup web-mode-hook))
+ (should (memq #'cj/webdev-keybindings web-mode-hook)))
+
+(ert-deftest test-prog-webdev-web-mode-setup-sets-buffer-local-preferences ()
+ "Normal: web-mode setup lands the shared webdev buffer preferences."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'company-mode) #'ignore)
+ ((symbol-function 'flyspell-prog-mode) #'ignore)
+ ((symbol-function 'superword-mode) #'ignore)
+ ((symbol-function 'electric-pair-local-mode) #'ignore)
+ ((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
+ (cj/web-mode-setup))
+ (should (= fill-column 100))
+ (should (= tab-width 2))
+ (should-not indent-tabs-mode)))
+
+(ert-deftest test-prog-webdev-web-mode-setup-starts-lsp-when-html-server-on-path ()
+ "Normal: with the html language server on PATH, `lsp-deferred' fires."
+ (with-temp-buffer
+ (let ((started nil))
+ (cl-letf (((symbol-function 'company-mode) #'ignore)
+ ((symbol-function 'flyspell-prog-mode) #'ignore)
+ ((symbol-function 'superword-mode) #'ignore)
+ ((symbol-function 'electric-pair-local-mode) #'ignore)
+ ((symbol-function 'lsp-deferred)
+ (lambda (&rest _) (setq started t)))
+ ((symbol-function 'executable-find)
+ (lambda (path &rest _)
+ (when (equal path html-language-server-path)
+ "/usr/bin/vscode-html-language-server"))))
+ (cj/web-mode-setup))
+ (should started))))
+
+(ert-deftest test-prog-webdev-web-mode-setup-skips-lsp-without-html-server ()
+ "Boundary: without the html language server, `lsp-deferred' is NOT called."
+ (with-temp-buffer
+ (let ((started nil))
+ (cl-letf (((symbol-function 'company-mode) #'ignore)
+ ((symbol-function 'flyspell-prog-mode) #'ignore)
+ ((symbol-function 'superword-mode) #'ignore)
+ ((symbol-function 'electric-pair-local-mode) #'ignore)
+ ((symbol-function 'lsp-deferred)
+ (lambda (&rest _) (setq started t)))
+ ((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
+ (cj/web-mode-setup))
+ (should-not started))))
+
+(provide 'test-prog-webdev--classic-and-web-mode-hooks)
+;;; test-prog-webdev--classic-and-web-mode-hooks.el ends here
diff --git a/tests/test-restclient-config--keymap.el b/tests/test-restclient-config--keymap.el
new file mode 100644
index 00000000..7265c4f3
--- /dev/null
+++ b/tests/test-restclient-config--keymap.el
@@ -0,0 +1,29 @@
+;;; test-restclient-config--keymap.el --- Tests for the restclient prefix keymap -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Pins the C-; R prefix wiring. The bindings must go through
+;; `cj/restclient-map' + `cj/register-prefix-map' (like the other C-;
+;; prefixes), not raw `global-set-key' calls that silently depend on
+;; keybindings.el having installed C-; first.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'restclient-config)
+
+;;; Normal Cases
+
+(ert-deftest test-restclient-keymap-registered-under-custom-prefix ()
+ "Normal: cj/restclient-map is bound at R inside cj/custom-keymap."
+ (should (boundp 'cj/restclient-map))
+ (should (eq cj/restclient-map (keymap-lookup cj/custom-keymap "R"))))
+
+(ert-deftest test-restclient-keymap-binds-new-buffer-and-open-file ()
+ "Normal: the prefix map carries the two restclient commands."
+ (should (eq #'cj/restclient-new-buffer (keymap-lookup cj/restclient-map "n")))
+ (should (eq #'cj/restclient-open-file (keymap-lookup cj/restclient-map "o"))))
+
+(provide 'test-restclient-config--keymap)
+;;; test-restclient-config--keymap.el ends here
diff --git a/tests/test-show-kill-ring--insert-item.el b/tests/test-show-kill-ring--insert-item.el
deleted file mode 100644
index a29ca75e..00000000
--- a/tests/test-show-kill-ring--insert-item.el
+++ /dev/null
@@ -1,73 +0,0 @@
-;;; test-show-kill-ring--insert-item.el --- Tests for show-kill-insert-item -*- lexical-binding: t -*-
-
-;;; Commentary:
-;; Tests for `show-kill-insert-item' in show-kill-ring.el — inserts a
-;; kill-ring entry into the current buffer, truncating to
-;; `show-kill-max-item-size' with an ellipsis when too long. The ellipsis
-;; sits inline for short items and on its own line for items wider than the
-;; frame. Frame width is read at runtime so the test is environment-stable.
-
-;;; Code:
-
-(require 'ert)
-(require 'show-kill-ring)
-
-;;; Normal Cases
-
-(ert-deftest test-show-kill-ring-insert-item-short-verbatim ()
- "Normal: an item shorter than the max is inserted unchanged."
- (let ((show-kill-max-item-size 1000))
- (with-temp-buffer
- (show-kill-insert-item "hello")
- (should (string= (buffer-string) "hello")))))
-
-(ert-deftest test-show-kill-ring-insert-item-inline-ellipsis ()
- "Normal: an over-max item narrower than the frame gets an inline ellipsis."
- (let* ((show-kill-max-item-size 5)
- (len (/ (frame-width) 2)) ; > max, < (frame-width - 5)
- (item (make-string len ?b)))
- (with-temp-buffer
- (show-kill-insert-item item)
- (should (string= (buffer-string) "bbbbb...")))))
-
-;;; Boundary Cases
-
-(ert-deftest test-show-kill-ring-insert-item-length-equals-max-truncates ()
- "Boundary: length exactly equal to max truncates — the guard is (< len max)."
- (let ((show-kill-max-item-size 5))
- (with-temp-buffer
- (show-kill-insert-item "hello") ; length 5, equals max
- (should (string= (buffer-string) "hello...")))))
-
-(ert-deftest test-show-kill-ring-insert-item-wide-newline-ellipsis ()
- "Boundary: an item wider than the frame puts the ellipsis on its own line."
- (let* ((show-kill-max-item-size 5)
- (item (make-string (+ (frame-width) 10) ?a)))
- (with-temp-buffer
- (show-kill-insert-item item)
- (should (string= (buffer-string) "aaaaa\n...")))))
-
-(ert-deftest test-show-kill-ring-insert-item-max-nil-verbatim ()
- "Boundary: a non-numeric max disables truncation."
- (let ((show-kill-max-item-size nil))
- (with-temp-buffer
- (show-kill-insert-item "anything long enough to exceed nothing")
- (should (string= (buffer-string)
- "anything long enough to exceed nothing")))))
-
-(ert-deftest test-show-kill-ring-insert-item-max-negative-verbatim ()
- "Boundary: a negative max disables truncation."
- (let ((show-kill-max-item-size -1))
- (with-temp-buffer
- (show-kill-insert-item "abc")
- (should (string= (buffer-string) "abc")))))
-
-(ert-deftest test-show-kill-ring-insert-item-empty-string ()
- "Boundary: an empty item inserts nothing and does not error."
- (let ((show-kill-max-item-size 1000))
- (with-temp-buffer
- (show-kill-insert-item "")
- (should (string= (buffer-string) "")))))
-
-(provide 'test-show-kill-ring--insert-item)
-;;; test-show-kill-ring--insert-item.el ends here
diff --git a/tests/test-signal-config-notify.el b/tests/test-signal-config-notify.el
deleted file mode 100644
index 1a772289..00000000
--- a/tests/test-signal-config-notify.el
+++ /dev/null
@@ -1,150 +0,0 @@
-;;; test-signal-config-notify.el --- Tests for the signal-config notification slice -*- lexical-binding: t -*-
-
-;;; Commentary:
-;; ERT tests for the notification slice of `signal-config': the pure
-;; body formatter (whitespace collapse + truncation to
-;; `cj/signal--notify-body-max') and `cj/signel--notify' routing (the
-;; suppression gate, the notify-script path with the sound flag, and
-;; the `notifications-notify' fallback). Spec: the "Notification
-;; slice" addendum in docs/specs/signal-client-spec-doing.org. No signal-cli or
-;; linked account needed.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-
-;; signel is the fork at ~/code/signel; signal-config wires it via
-;; use-package but these tests need the symbols available directly.
-(eval-and-compile
- (add-to-list 'load-path (expand-file-name "~/code/signel")))
-(require 'signel)
-
-(require 'signal-config)
-
-;;; cj/signal--format-notify-body
-
-(ert-deftest test-signal-config-format-notify-body-passthrough ()
- "Normal: short single-line text passes through unchanged."
- (should (equal (cj/signal--format-notify-body "lunch at noon?")
- "lunch at noon?")))
-
-(ert-deftest test-signal-config-format-notify-body-collapses-whitespace ()
- "Normal: newlines and whitespace runs collapse to single spaces."
- (should (equal (cj/signal--format-notify-body "two\nlines\n\nhere")
- "two lines here"))
- (should (equal (cj/signal--format-notify-body "tabs\t\tand spaces")
- "tabs and spaces")))
-
-(ert-deftest test-signal-config-format-notify-body-trims ()
- "Boundary: leading and trailing whitespace is trimmed."
- (should (equal (cj/signal--format-notify-body " hi ") "hi")))
-
-(ert-deftest test-signal-config-format-notify-body-empty ()
- "Boundary: the empty string stays empty."
- (should (equal (cj/signal--format-notify-body "") "")))
-
-(ert-deftest test-signal-config-format-notify-body-exact-limit ()
- "Boundary: a body exactly at the limit is untouched."
- (let ((s (make-string cj/signal--notify-body-max ?x)))
- (should (equal (cj/signal--format-notify-body s) s))))
-
-(ert-deftest test-signal-config-format-notify-body-truncates-over-limit ()
- "Boundary: over-limit text truncates to the limit, ending in an ellipsis."
- (let* ((s (make-string (1+ cj/signal--notify-body-max) ?x))
- (out (cj/signal--format-notify-body s)))
- (should (= (length out) cj/signal--notify-body-max))
- (should (string-suffix-p "…" out))))
-
-(ert-deftest test-signal-config-format-notify-body-unicode ()
- "Boundary: multibyte text truncates by characters, not bytes."
- (let* ((s (make-string (+ cj/signal--notify-body-max 10) ?é))
- (out (cj/signal--format-notify-body s)))
- (should (= (length out) cj/signal--notify-body-max))
- (should (string-suffix-p "…" out))))
-
-;;; cj/signel--notify routing
-
-(ert-deftest test-signal-config-notify-suppressed-when-viewing ()
- "Normal: nothing fires when the suppression predicate says no."
- (let (script-calls fallback-calls)
- (cl-letf (((symbol-function 'cj/signal--should-notify-p)
- (lambda (_chat-id) nil))
- ((symbol-function 'start-process)
- (lambda (&rest args) (push args script-calls) nil))
- ((symbol-function 'notifications-notify)
- (lambda (&rest args) (push args fallback-calls) nil)))
- (cj/signel--notify "+15551234567" "Alice" "hi"))
- (should-not script-calls)
- (should-not fallback-calls)))
-
-(ert-deftest test-signal-config-notify-script-silent-by-default ()
- "Normal: with the script present and sound off, runs notify info --silent."
- (let (script-calls)
- (cl-letf (((symbol-function 'cj/signal--should-notify-p)
- (lambda (_chat-id) t))
- ((symbol-function 'executable-find)
- (lambda (p &optional _remote)
- (when (equal p "notify") "/usr/bin/notify")))
- ((symbol-function 'start-process)
- (lambda (&rest args) (push args script-calls) nil))
- ((symbol-function 'notifications-notify)
- (lambda (&rest _)
- (error "Fallback must not fire when the script is present"))))
- (let ((cj/signel-notify-sound nil))
- (cj/signel--notify "+15551234567" "Alice" "hi")))
- (should (= (length script-calls) 1))
- ;; start-process args: (NAME BUFFER PROGRAM &rest PROGRAM-ARGS);
- ;; PROGRAM is the path executable-find resolved, not the bare name.
- (should (equal (nthcdr 2 (car script-calls))
- '("/usr/bin/notify" "info" "Signal: Alice" "hi" "--silent")))))
-
-(ert-deftest test-signal-config-notify-sound-enabled-drops-silent ()
- "Normal: with `cj/signel-notify-sound' non-nil, --silent is omitted."
- (let (script-calls)
- (cl-letf (((symbol-function 'cj/signal--should-notify-p)
- (lambda (_chat-id) t))
- ((symbol-function 'executable-find)
- (lambda (p &optional _remote)
- (when (equal p "notify") "/usr/bin/notify")))
- ((symbol-function 'start-process)
- (lambda (&rest args) (push args script-calls) nil)))
- (let ((cj/signel-notify-sound t))
- (cj/signel--notify "+15551234567" "Alice" "hi")))
- (should (equal (nthcdr 2 (car script-calls))
- '("/usr/bin/notify" "info" "Signal: Alice" "hi")))))
-
-(ert-deftest test-signal-config-notify-fallback-when-script-missing ()
- "Error: without the script on PATH, falls back to notifications-notify."
- (let (script-calls fallback-calls)
- (cl-letf (((symbol-function 'cj/signal--should-notify-p)
- (lambda (_chat-id) t))
- ((symbol-function 'executable-find)
- (lambda (_p &optional _remote) nil))
- ((symbol-function 'start-process)
- (lambda (&rest args) (push args script-calls) nil))
- ((symbol-function 'notifications-notify)
- (lambda (&rest args) (push args fallback-calls) nil)))
- (cj/signel--notify "+15551234567" "Alice" "hi"))
- (should-not script-calls)
- (should (= (length fallback-calls) 1))
- (let ((args (car fallback-calls)))
- (should (equal (plist-get args :title) "Signal: Alice"))
- (should (equal (plist-get args :body) "hi")))))
-
-(ert-deftest test-signal-config-notify-formats-body-before-send ()
- "Normal: the body runs through the formatter before reaching the script."
- (let (script-calls)
- (cl-letf (((symbol-function 'cj/signal--should-notify-p)
- (lambda (_chat-id) t))
- ((symbol-function 'executable-find)
- (lambda (p &optional _remote)
- (when (equal p "notify") "/usr/bin/notify")))
- ((symbol-function 'start-process)
- (lambda (&rest args) (push args script-calls) nil)))
- (let ((cj/signel-notify-sound nil))
- (cj/signel--notify "+15551234567" "Alice" "first line\nsecond line")))
- (should (equal (nth 5 (car script-calls)) "first line second line"))))
-
-(provide 'test-signal-config-notify)
-;;; test-signal-config-notify.el ends here
diff --git a/tests/test-signal-config.el b/tests/test-signal-config.el
deleted file mode 100644
index 7556efdb..00000000
--- a/tests/test-signal-config.el
+++ /dev/null
@@ -1,407 +0,0 @@
-;;; test-signal-config.el --- Tests for signal-config -*- lexical-binding: t -*-
-
-;;; Commentary:
-;; ERT tests for the pure helper layer of `signal-config': contact-list
-;; parsing for the contact picker, and the notify-when-not-viewing
-;; predicate. These need neither signal-cli nor a linked account.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-(require 'json)
-
-;; signel is the fork at ~/code/signel; signal-config wires it via
-;; use-package but the connection-guard/fetch tests need the symbols
-;; available directly.
-(eval-and-compile
- (add-to-list 'load-path (expand-file-name "~/code/signel")))
-(require 'signel)
-
-(require 'signal-config)
-
-;;; cj/signal--jstr
-
-(ert-deftest test-signal-config-jstr-string ()
- "Normal: a non-blank string passes through unchanged."
- (should (equal (cj/signal--jstr "hi") "hi")))
-
-(ert-deftest test-signal-config-jstr-rejects-nonstrings ()
- "Boundary/Error: nil, empty, whitespace, and non-string sentinels become nil."
- (should-not (cj/signal--jstr nil))
- (should-not (cj/signal--jstr ""))
- (should-not (cj/signal--jstr " "))
- (should-not (cj/signal--jstr :null))
- (should-not (cj/signal--jstr 42)))
-
-;;; cj/signal--contact-display-name
-;; Field priority: nickName, then nickGiven+nickFamily, then top-level name,
-;; then top-level given+family, then profile given+family, then username.
-
-(ert-deftest test-signal-config-display-name-prefers-name ()
- "Normal: the top-level combined name wins over the given/family parts."
- (should (equal (cj/signal--contact-display-name
- '((name . "Alice Anderson") (givenName . "Ali") (familyName . "A")))
- "Alice Anderson")))
-
-(ert-deftest test-signal-config-display-name-nickname-wins ()
- "Normal: a nickName overrides the contact name."
- (should (equal (cj/signal--contact-display-name
- '((nickName . "Edster") (name . "Eve Edwards")
- (givenName . "Eve") (familyName . "Edwards")))
- "Edster")))
-
-(ert-deftest test-signal-config-display-name-nickname-parts ()
- "Boundary: nickGivenName+nickFamilyName combine when nickName is unset."
- (should (equal (cj/signal--contact-display-name
- '((nickGivenName . "DJ") (nickFamilyName . "Cool") (name . "Daniel")))
- "DJ Cool")))
-
-(ert-deftest test-signal-config-display-name-toplevel-given-family ()
- "Boundary: with no name, top-level givenName+familyName combine."
- (should (equal (cj/signal--contact-display-name
- '((name) (givenName . "Bob") (familyName . "Brown")))
- "Bob Brown")))
-
-(ert-deftest test-signal-config-display-name-profile-fallback ()
- "Boundary: with no name or top-level parts, profile given/family is the fallback."
- (should (equal (cj/signal--contact-display-name
- '((name) (givenName) (familyName)
- (profile . ((givenName . "Carol") (familyName)))))
- "Carol")))
-
-(ert-deftest test-signal-config-display-name-username-fallback ()
- "Boundary: username is the last name source."
- (should (equal (cj/signal--contact-display-name
- '((name) (username . "dave.42")))
- "dave.42")))
-
-(ert-deftest test-signal-config-display-name-none ()
- "Error: no usable name yields nil."
- (should-not (cj/signal--contact-display-name
- '((name) (givenName) (familyName)
- (profile . ((givenName) (familyName))))))
- (should-not (cj/signal--contact-display-name '((number . "+15551112222")))))
-
-;;; cj/signal--parse-contacts
-
-(defconst test-signal-config--contacts-json
- "[
- {\"number\":\"+15551112222\",\"uuid\":\"uuid-a\",\"name\":\"Alice Anderson\",\"givenName\":\"Alice\",\"familyName\":\"Anderson\",\"nickName\":null,\"nickGivenName\":null,\"nickFamilyName\":null,\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}},
- {\"number\":\"+15553334444\",\"uuid\":null,\"name\":null,\"givenName\":\"Bob\",\"familyName\":\"Brown\",\"nickName\":null,\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}},
- {\"number\":\"+15555556666\",\"uuid\":\"uuid-c\",\"name\":null,\"givenName\":null,\"familyName\":null,\"nickName\":null,\"username\":null,\"profile\":{\"givenName\":\"Carol\",\"familyName\":null}},
- {\"number\":null,\"uuid\":\"uuid-d\",\"name\":null,\"givenName\":null,\"familyName\":null,\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}},
- {\"number\":\"+15557778888\",\"uuid\":\"uuid-e\",\"name\":\"Eve Edwards\",\"givenName\":\"Eve\",\"familyName\":\"Edwards\",\"nickName\":\"Edster\",\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}}
-]"
- "Synthetic fixture mirroring the signal-cli 0.14.4.1 `listContacts' shape:
-top-level name/givenName/familyName and nickName fields, with a profile
-sub-object whose name fields are usually null. Field layout was confirmed
-against a live linked account on 2026-05-26; the values here are fake.")
-
-(ert-deftest test-signal-config-parse-contacts-normal ()
- "Normal: top-level name, top-level parts, profile fallback, uuid fallback, nickname."
- (let* ((result (json-read-from-string test-signal-config--contacts-json))
- (pairs (cj/signal--parse-contacts result)))
- (should (equal pairs
- '(("Alice Anderson (+15551112222)" . "+15551112222")
- ("Bob Brown (+15553334444)" . "+15553334444")
- ("Carol (+15555556666)" . "+15555556666")
- ("Edster (+15557778888)" . "+15557778888")
- ("uuid-d" . "uuid-d"))))))
-
-(ert-deftest test-signal-config-parse-contacts-empty ()
- "Boundary: an empty result yields nil for both vector and nil input."
- (should-not (cj/signal--parse-contacts []))
- (should-not (cj/signal--parse-contacts nil)))
-
-(ert-deftest test-signal-config-parse-contacts-accepts-list-and-vector ()
- "Boundary: vector and list result sequences parse identically."
- (let ((entry '((number . "+15551112222") (name . "Al"))))
- (should (equal (cj/signal--parse-contacts (vector entry))
- (cj/signal--parse-contacts (list entry))))))
-
-(ert-deftest test-signal-config-parse-contacts-drops-recipientless ()
- "Error: a contact with neither number nor uuid is dropped."
- (should-not (cj/signal--parse-contacts
- (list '((name . "Ghost") (number) (uuid))))))
-
-;;; cj/signal--suppress-notify-p
-
-(ert-deftest test-signal-config-suppress-when-viewing-focused ()
- "Normal: viewing the chat buffer with focus suppresses the notification."
- (should (cj/signal--suppress-notify-p
- "+15551112222" "*Signel: +15551112222*" t)))
-
-(ert-deftest test-signal-config-no-suppress-other-buffer ()
- "Boundary: a different selected buffer does not suppress."
- (should-not (cj/signal--suppress-notify-p
- "+15551112222" "*scratch*" t)))
-
-(ert-deftest test-signal-config-no-suppress-unfocused ()
- "Boundary: viewing the chat but with the frame unfocused still notifies."
- (should-not (cj/signal--suppress-notify-p
- "+15551112222" "*Signel: +15551112222*" nil)))
-
-(ert-deftest test-signal-config-no-suppress-nil-viewing ()
- "Error: a nil viewing-buffer name does not suppress."
- (should-not (cj/signal--suppress-notify-p "+15551112222" nil t)))
-
-;;; cj/signel--ensure-started
-
-(ert-deftest test-signal-config-ensure-started-live-process-noop ()
- "Normal: with a live signel process, ensure-started returns without
-calling `signel-start' or the pre-warm fetch."
- (let ((start-called nil)
- (fetch-called nil))
- (cl-letf (((symbol-function 'process-live-p) (lambda (_) t))
- ((symbol-function 'get-process) (lambda (_) 'fake-proc))
- ((symbol-function 'signel-start)
- (lambda () (setq start-called t)))
- ((symbol-function 'cj/signel--fetch-contacts)
- (lambda (&rest _) (setq fetch-called t))))
- (cj/signel--ensure-started)
- (should-not start-called)
- (should-not fetch-called))))
-
-(ert-deftest test-signal-config-ensure-started-starts-when-account-set ()
- "Normal: with `signel-account' set and no live process, ensure-started
-calls `signel-start' to bring the daemon up."
- (let ((start-called nil)
- (signel-account "+15555550100"))
- (cl-letf (((symbol-function 'process-live-p) (lambda (_) nil))
- ((symbol-function 'get-process) (lambda (_) nil))
- ((symbol-function 'signel-start)
- (lambda () (setq start-called t)))
- ((symbol-function 'cj/signel--fetch-contacts)
- (lambda (&rest _) nil)))
- (cj/signel--ensure-started)
- (should start-called))))
-
-(ert-deftest test-signal-config-ensure-started-prewarms-on-start ()
- "Normal: when ensure-started actually starts the daemon, it triggers a
-pre-warm fetch so the picker cache is warm on first invocation."
- (let ((fetch-called nil)
- (signel-account "+15555550100"))
- (cl-letf (((symbol-function 'process-live-p) (lambda (_) nil))
- ((symbol-function 'get-process) (lambda (_) nil))
- ((symbol-function 'signel-start) (lambda () nil))
- ((symbol-function 'cj/signel--fetch-contacts)
- (lambda (&rest _) (setq fetch-called t))))
- (cj/signel--ensure-started)
- (should fetch-called))))
-
-(ert-deftest test-signal-config-ensure-started-errors-when-no-account ()
- "Error: with `signel-account' nil, ensure-started signals a user-error
-naming the remedy (set the account in the private config) instead of
-starting an account-less daemon."
- (let ((signel-account nil))
- (cl-letf (((symbol-function 'process-live-p) (lambda (_) nil))
- ((symbol-function 'get-process) (lambda (_) nil)))
- (should-error (cj/signel--ensure-started) :type 'user-error))))
-
-(ert-deftest test-signal-config-ensure-started-requires-signel-first ()
- "Error: ensure-started must `require' signel BEFORE reading any of
-its private variables, so a `void-variable' error cannot fire when
-called before signel has been autoloaded. Captures the bug where
-`signel--process-name' was forward-declared in signal-config but not
-yet bound at runtime because the `use-package' autoload had not fired.
-
-Asserts ordering, not just presence: a future refactor that moves the
-`require' below the `cond' would still execute the require eventually
-but the variable read in the cond would fire void-variable first.
-The test fails if `require' isn't the first call inside the function."
- (let ((call-order nil))
- (cl-letf (((symbol-function 'require)
- (lambda (feature &optional _filename _noerror)
- (push (list 'require feature) call-order)
- t))
- ((symbol-function 'process-live-p)
- (lambda (_) (push 'process-live-p call-order) t))
- ((symbol-function 'get-process)
- (lambda (_) (push 'get-process call-order) 'fake-proc)))
- (cj/signel--ensure-started)
- (should (equal (car (reverse call-order)) '(require signel))))))
-
-;;; cj/signel--fetch-contacts + cj/signel--contact-cache
-
-(ert-deftest test-signal-config-fetch-contacts-issues-list-contacts-rpc ()
- "Normal: fetch-contacts sends a `listContacts' RPC and registers a
-success callback so the response routes back."
- (let (sent-method sent-callback)
- (cl-letf (((symbol-function 'signel--send-rpc)
- (lambda (method _params _target callback)
- (setq sent-method method
- sent-callback callback)
- 1)))
- (cj/signel--fetch-contacts))
- (should (equal sent-method "listContacts"))
- (should (functionp sent-callback))))
-
-(ert-deftest test-signal-config-fetch-contacts-callback-populates-cache ()
- "Normal: on a successful result, the callback parses the contact list
-and stores the (LABEL . RECIPIENT) alist in `cj/signel--contact-cache'."
- (let (sent-callback)
- (cl-letf (((symbol-function 'signel--send-rpc)
- (lambda (_method _params _target callback)
- (setq sent-callback callback) 1)))
- (setq cj/signel--contact-cache nil)
- (cj/signel--fetch-contacts)
- (funcall sent-callback
- [((number . "+15555550100") (givenName . "Alice"))]))
- (should (equal cj/signel--contact-cache
- '(("Alice (+15555550100)" . "+15555550100"))))))
-
-(ert-deftest test-signal-config-fetch-contacts-empty-result-clears-cache ()
- "Boundary: an empty listContacts result populates the cache as nil,
-distinct from a failure path (which never invokes the success callback)."
- (let (sent-callback)
- (cl-letf (((symbol-function 'signel--send-rpc)
- (lambda (_method _params _target callback)
- (setq sent-callback callback) 1)))
- (setq cj/signel--contact-cache '(("stale" . "+10000000000")))
- (cj/signel--fetch-contacts)
- (funcall sent-callback []))
- (should-not cj/signel--contact-cache)))
-
-;;; cj/signel-refresh-contacts
-
-(ert-deftest test-signal-config-refresh-contacts-clears-and-refetches ()
- "Normal: `cj/signel-refresh-contacts' clears the cache and triggers a
-fresh fetch so a stale entry can't survive a user-driven refresh."
- (let ((fetch-called nil))
- (setq cj/signel--contact-cache '(("stale" . "+10000000000")))
- (cl-letf (((symbol-function 'cj/signel--fetch-contacts)
- (lambda (&rest _) (setq fetch-called t))))
- (cj/signel-refresh-contacts))
- (should-not cj/signel--contact-cache)
- (should fetch-called)))
-
-;;; cj/signel-message picker
-
-(ert-deftest test-signal-config-message-warm-cache-picks-contact ()
- "Normal: with a warm cache, picking a contact label opens that
-recipient's chat buffer."
- (let ((chosen-recipient nil)
- (signel-account "+15555550100"))
- (setq cj/signel--contact-cache
- '(("Alice (+15555550200)" . "+15555550200")))
- (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil))
- ((symbol-function 'completing-read)
- (lambda (&rest _) "Alice (+15555550200)"))
- ((symbol-function 'signel-chat)
- (lambda (r) (setq chosen-recipient r))))
- (cj/signel-message))
- (should (equal chosen-recipient "+15555550200"))))
-
-(ert-deftest test-signal-config-message-warm-cache-picks-note-to-self ()
- "Normal: the pinned `Note to Self' entry resolves to `signel-account'
-so a self-message lands in the Signal Note-to-Self thread."
- (let ((chosen-recipient nil)
- (signel-account "+15555550100"))
- (setq cj/signel--contact-cache
- '(("Alice (+15555550200)" . "+15555550200")))
- (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil))
- ((symbol-function 'completing-read)
- (lambda (&rest _) "Note to Self"))
- ((symbol-function 'signel-chat)
- (lambda (r) (setq chosen-recipient r))))
- (cj/signel-message))
- (should (equal chosen-recipient "+15555550100"))))
-
-(ert-deftest test-signal-config-message-cold-cache-fetch-resolves-in-time ()
- "Normal: cold cache, fetch's after-callback fires inside the bounded
-wait, picker proceeds with the now-warm cache."
- (let ((chosen-recipient nil)
- (signel-account "+15555550100")
- (cj/signel-fetch-timeout 1.0))
- (setq cj/signel--contact-cache nil)
- (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil))
- ((symbol-function 'cj/signel--fetch-contacts)
- (lambda (&optional after-cb)
- (setq cj/signel--contact-cache
- '(("Bob (+15555550300)" . "+15555550300")))
- (when after-cb (funcall after-cb))))
- ((symbol-function 'completing-read)
- (lambda (&rest _) "Bob (+15555550300)"))
- ((symbol-function 'signel-chat)
- (lambda (r) (setq chosen-recipient r))))
- (cj/signel-message))
- (should (equal chosen-recipient "+15555550300"))))
-
-(ert-deftest test-signal-config-message-cold-cache-timeout-errors ()
- "Error: cold cache, fetch never resolves, picker user-errors before
-the bounded wait would let Emacs hang on a dead daemon."
- (let ((signel-account "+15555550100")
- (cj/signel-fetch-timeout 0.1))
- (setq cj/signel--contact-cache nil)
- (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil))
- ((symbol-function 'cj/signel--fetch-contacts)
- (lambda (&rest _) nil)))
- (should-error (cj/signel-message) :type 'user-error))))
-
-;;; cj/signel-message-self
-
-(ert-deftest test-signal-config-message-self-calls-signel-chat-with-account ()
- "Normal: the direct self-message command opens a chat buffer addressed
-to `signel-account', skipping the picker entirely."
- (let ((chosen-recipient nil)
- (signel-account "+15555550100"))
- (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil))
- ((symbol-function 'signel-chat)
- (lambda (r) (setq chosen-recipient r))))
- (cj/signel-message-self))
- (should (equal chosen-recipient "+15555550100"))))
-
-;;; cj/signel-prefix-map (C-; M)
-
-(ert-deftest test-signal-config-prefix-map-has-expected-bindings ()
- "Normal: the signel C-; M prefix map binds m / s / d / q / SPC to the
-commands the workflow spec names."
- (should (eq (keymap-lookup cj/signel-prefix-map "m")
- #'cj/signel-message))
- (should (eq (keymap-lookup cj/signel-prefix-map "s")
- #'cj/signel-message-self))
- (should (eq (keymap-lookup cj/signel-prefix-map "d")
- #'signel-dashboard))
- (should (eq (keymap-lookup cj/signel-prefix-map "q")
- #'signel-stop))
- (should (eq (keymap-lookup cj/signel-prefix-map "SPC")
- #'cj/signel-connect)))
-
-(ert-deftest test-signal-config-prefix-map-registered-under-c-semi-m ()
- "Normal: loading signal-config registers `cj/signel-prefix-map' under
-`M' in `cj/custom-keymap', so C-; M reaches the signel prefix. Guards
-the wiring contract that the load-order bug broke: signal-config must
-register through `cj/register-prefix-map', not a boundp-guarded direct
-mutation that silently no-ops when keybindings loaded in a different
-order."
- (require 'keybindings)
- (should (eq (keymap-lookup cj/custom-keymap "M") cj/signel-prefix-map)))
-
-;;; display-buffer-alist entry for *Signel: ...* chat buffers
-
-(ert-deftest test-signal-config-chat-buffer-display-rule-uses-bottom-30 ()
- "Normal: signal-config registers a `display-buffer-alist' entry that
-matches `*Signel: <id>*' buffers, routes them through
-`display-buffer-at-bottom', and sets `window-height' to 0.3 so the
-chat docks to the bottom 30% of the frame."
- (let ((entry (seq-find (lambda (e) (equal (car e) "\\`\\*Signel: "))
- display-buffer-alist)))
- (should entry)
- (should (memq 'display-buffer-at-bottom (cadr entry)))
- (should (equal 0.3 (cdr (assq 'window-height (cddr entry)))))))
-
-(ert-deftest test-signal-config-chat-buffer-display-rule-matches-buffer-name ()
- "Boundary: the registered regex matches a realistic chat buffer name
-\(phone-number id and group-id) and does not match unrelated buffers."
- (let* ((entry (seq-find (lambda (e) (equal (car e) "\\`\\*Signel: "))
- display-buffer-alist))
- (regex (car entry)))
- (should regex)
- (should (string-match-p regex "*Signel: +15555550100*"))
- (should (string-match-p regex "*Signel: groupid-abc*"))
- (should-not (string-match-p regex "*signel-log*"))
- (should-not (string-match-p regex "scratch"))))
-
-(provide 'test-signal-config)
-;;; test-signal-config.el ends here
diff --git a/tests/test-signel-cancel-input.el b/tests/test-signel-cancel-input.el
deleted file mode 100644
index b2a7ef89..00000000
--- a/tests/test-signel-cancel-input.el
+++ /dev/null
@@ -1,74 +0,0 @@
-;;; test-signel-cancel-input.el --- Cancel-input contract for the signel fork -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; `signel--cancel-input' is the C-c C-k handler in `signel-chat-mode'.
-;; Its contract: clear any in-progress input between `signel--input-marker'
-;; and `point-max' (so the prompt is fresh on next visit), then dismiss
-;; the window via `quit-window' (the buffer stays alive so chat history
-;; survives revisits). These tests lock the contract; the binding test
-;; locks the keymap entry.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-
-(eval-and-compile
- (add-to-list 'load-path (expand-file-name "~/code/signel")))
-(require 'signel)
-
-(defmacro test-signel-cancel--with-chat-buffer (&rest body)
- "Set up a temp signel-chat-mode buffer with prompt drawn and run BODY."
- (declare (indent 0))
- `(with-temp-buffer
- (signel-chat-mode)
- (setq signel--chat-id "+15555550100")
- (signel--draw-prompt)
- ,@body))
-
-(ert-deftest test-signel-cancel-input-clears-pending-text ()
- "Normal: pending input from input-marker to point-max is cleared."
- (test-signel-cancel--with-chat-buffer
- (insert "abandoned-draft")
- (cl-letf (((symbol-function 'quit-window) (lambda (&rest _) nil)))
- (signel--cancel-input))
- (should-not (signel--pending-input))))
-
-(ert-deftest test-signel-cancel-input-empty-input-area-is-a-noop ()
- "Boundary: cancelling with no in-progress input is harmless."
- (test-signel-cancel--with-chat-buffer
- (cl-letf (((symbol-function 'quit-window) (lambda (&rest _) nil)))
- (signel--cancel-input))
- (should-not (signel--pending-input))))
-
-(ert-deftest test-signel-cancel-input-calls-quit-window ()
- "Normal: cancel dismisses the window via `quit-window'."
- (test-signel-cancel--with-chat-buffer
- (insert "abandoned-draft")
- (let ((called nil))
- (cl-letf (((symbol-function 'quit-window)
- (lambda (&rest _) (setq called t))))
- (signel--cancel-input))
- (should called))))
-
-(ert-deftest test-signel-cancel-input-preserves-buffer ()
- "Normal: cancel does not kill the buffer; chat history (prompt + prior
-content above the input marker) survives so reopening the contact lands
-in the same buffer."
- (test-signel-cancel--with-chat-buffer
- (insert "abandoned-draft")
- (let ((buf (current-buffer)))
- (cl-letf (((symbol-function 'quit-window) (lambda (&rest _) nil)))
- (signel--cancel-input))
- (should (buffer-live-p buf)))))
-
-(ert-deftest test-signel-chat-mode-binds-c-c-c-k-to-cancel ()
- "Normal: `signel-chat-mode' binds C-c C-k to `signel--cancel-input' so
-the documented cancel gesture reaches the handler."
- (with-temp-buffer
- (signel-chat-mode)
- (should (eq (lookup-key (current-local-map) (kbd "C-c C-k"))
- #'signel--cancel-input))))
-
-(provide 'test-signel-cancel-input)
-;;; test-signel-cancel-input.el ends here
diff --git a/tests/test-signel-input-preservation.el b/tests/test-signel-input-preservation.el
deleted file mode 100644
index e8ce4ddb..00000000
--- a/tests/test-signel-input-preservation.el
+++ /dev/null
@@ -1,68 +0,0 @@
-;;; test-signel-input-preservation.el --- Regression for signel #2 input clobber -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; signel-chat-mode buffers have an editable prompt area starting at
-;; `signel--input-marker'. Before this fix, both `signel--insert-msg' (the
-;; receive path) and `signel--insert-system-msg' (the RPC-error path)
-;; called `(delete-region (point) (point-max))' to clear the old prompt
-;; before redrawing it, which destroyed any text the user was mid-typing.
-;;
-;; These tests lock the preservation contract: a small `signel--pending-input'
-;; helper captures the in-progress input from the marker to `point-max', and
-;; both inserters restore it after the freshly drawn prompt. The chat-mode
-;; buffer is constructed in a temp buffer; `signel--insert-msg' is steered
-;; to it via a stub on `signel--get-buffer'.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-
-(eval-and-compile
- (add-to-list 'load-path (expand-file-name "~/code/signel")))
-(require 'signel)
-
-(defmacro test-signel--with-chat-buffer (&rest body)
- "Set up a temp signel-chat-mode buffer with prompt drawn and run BODY."
- (declare (indent 0))
- `(with-temp-buffer
- (signel-chat-mode)
- (setq signel--chat-id "+15555550100")
- (signel--draw-prompt)
- ,@body))
-
-(ert-deftest test-signel-input-pending-returns-typed-text ()
- "Normal: with text after the prompt marker, `signel--pending-input'
-returns the captured text."
- (test-signel--with-chat-buffer
- (insert "halfwritten")
- (should (equal (signel--pending-input) "halfwritten"))))
-
-(ert-deftest test-signel-input-pending-returns-nil-when-empty ()
- "Boundary: an empty input area returns nil so callers don't restore an
-empty string after the prompt."
- (test-signel--with-chat-buffer
- (should-not (signel--pending-input))))
-
-(ert-deftest test-signel-input-system-msg-preserves-pending-input ()
- "Regression for #2: `signel--insert-system-msg' redraws the prompt
-without clobbering text the user was mid-typing."
- (test-signel--with-chat-buffer
- (insert "halfwritten")
- (signel--insert-system-msg "An error happened" 'signel-error-face)
- (should (string-match-p "An error happened" (buffer-string)))
- (should (equal (signel--pending-input) "halfwritten"))))
-
-(ert-deftest test-signel-input-msg-preserves-pending-input ()
- "Regression for #2: `signel--insert-msg' (the receive path) redraws the
-prompt without clobbering the user's in-progress input."
- (test-signel--with-chat-buffer
- (insert "halfwritten")
- (cl-letf (((symbol-function 'signel--get-buffer)
- (lambda (_) (current-buffer))))
- (signel--insert-msg "+15555550100" "Alice" "Hi there" nil nil nil))
- (should (string-match-p "Hi there" (buffer-string)))
- (should (equal (signel--pending-input) "halfwritten"))))
-
-(provide 'test-signel-input-preservation)
-;;; test-signel-input-preservation.el ends here
diff --git a/tests/test-signel-notify-function.el b/tests/test-signel-notify-function.el
deleted file mode 100644
index e3d97af5..00000000
--- a/tests/test-signel-notify-function.el
+++ /dev/null
@@ -1,89 +0,0 @@
-;;; test-signel-notify-function.el --- Tests for signel's notify-function dispatch -*- lexical-binding: t -*-
-
-;;; Commentary:
-;; signel's receive handler (signel.el in the fork at ~/code/signel)
-;; raised notifications through a hardwired `notifications-notify'
-;; call. The notification slice (docs/specs/signal-client-spec-doing.org,
-;; "Notification slice" addendum) replaces that with
-;; `signel-notify-function', a customization point called with
-;; CHAT-ID, SENDER, and BODY so a config layer can add suppression or
-;; route through an external notifier. These tests cover the
-;; dispatch: text, sticker, and attachment bodies reach the function
-;; with the right arguments, and the default preserves the plain
-;; `notifications-notify' behavior.
-;;
-;; `signel--handle-receive' is exercised directly with synthetic
-;; envelope alists; buffer/dashboard side effects are stubbed. No
-;; live process needed.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-
-(eval-and-compile
- (add-to-list 'load-path (expand-file-name "~/code/signel")))
-(require 'signel)
-
-(defun test-signel-notify--receive (envelope)
- "Run `signel--handle-receive' on ENVELOPE, capturing notify calls.
-Returns the list of (CHAT-ID SENDER BODY) argument lists the handler
-passed to `signel-notify-function', oldest first. Buffer and
-dashboard side effects are stubbed out."
- (let (calls)
- (cl-letf (((symbol-function 'signel--insert-msg) (lambda (&rest _) nil))
- ((symbol-function 'signel--dashboard-refresh) (lambda () nil))
- ((symbol-function 'signel--get-buffer)
- (lambda (_) (current-buffer))))
- (let ((signel-notify-function
- (lambda (chat-id sender body)
- (push (list chat-id sender body) calls)))
- (signel-auto-open-buffer nil))
- (signel--handle-receive `((envelope . ,envelope)))))
- (nreverse calls)))
-
-(ert-deftest test-signel-notify-function-text-message ()
- "Normal: a text dataMessage calls the function with chat-id, sender, text."
- (should (equal (test-signel-notify--receive
- '((sourceNumber . "+15551234567")
- (sourceName . "Alice")
- (dataMessage . ((message . "hi there")))))
- '(("+15551234567" "Alice" "hi there")))))
-
-(ert-deftest test-signel-notify-function-sticker-placeholder ()
- "Boundary: a sticker with no text gets the [Sticker] placeholder body."
- (should (equal (test-signel-notify--receive
- '((sourceNumber . "+15551234567")
- (sourceName . "Alice")
- (dataMessage . ((sticker . ((packId . "p1")))))))
- '(("+15551234567" "Alice" "[Sticker]")))))
-
-(ert-deftest test-signel-notify-function-attachment-placeholder ()
- "Boundary: an attachment with no text gets the [Attachment] placeholder."
- (should (equal (test-signel-notify--receive
- '((sourceNumber . "+15551234567")
- (sourceName . "Alice")
- (dataMessage . ((attachments . [((id . "a1"))])))))
- '(("+15551234567" "Alice" "[Attachment]")))))
-
-(ert-deftest test-signel-notify-function-no-data-no-call ()
- "Boundary: an envelope with no dataMessage never calls the function."
- (should-not (test-signel-notify--receive
- '((sourceNumber . "+15551234567")
- (sourceName . "Alice")
- (typingMessage . ((action . "STARTED")))))))
-
-(ert-deftest test-signel-notify-function-default-preserves-behavior ()
- "Normal: the default value raises a plain notifications-notify toast."
- (should (eq signel-notify-function #'signel--notify-default))
- (let (calls)
- (cl-letf (((symbol-function 'notifications-notify)
- (lambda (&rest args) (push args calls) nil)))
- (signel--notify-default "+15551234567" "Alice" "hi"))
- (should (= (length calls) 1))
- (let ((args (car calls)))
- (should (equal (plist-get args :title) "Signel: Alice"))
- (should (equal (plist-get args :body) "hi")))))
-
-(provide 'test-signel-notify-function)
-;;; test-signel-notify-function.el ends here
diff --git a/tests/test-signel-rpc-dispatch.el b/tests/test-signel-rpc-dispatch.el
deleted file mode 100644
index 5ae023d6..00000000
--- a/tests/test-signel-rpc-dispatch.el
+++ /dev/null
@@ -1,94 +0,0 @@
-;;; test-signel-rpc-dispatch.el --- Tests for signel JSON-RPC success-result dispatch -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; signel's JSON-RPC dispatch (signel.el in the fork at ~/code/signel) routes
-;; incoming `receive' notifications and errors, but successful
-;; `((id . N) (result . VALUE))' responses had no path until this work added a
-;; request-callback table. These tests cover the new behavior: a registered
-;; callback fires with the result and is then removed; an error response
-;; also removes the handler so a retry starts clean; an unregistered id is a
-;; silent no-op; passing SUCCESS-CALLBACK to `signel--send-rpc' registers it
-;; under the returned id.
-;;
-;; The dispatch tests exercise `signel--dispatch' directly with synthetic JSON
-;; alists; no live process is needed. The send-rpc test stubs `get-process'
-;; and `process-send-string' so it doesn't require a running signal-cli.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-
-(eval-and-compile
- (add-to-list 'load-path (expand-file-name "~/code/signel")))
-(require 'signel)
-
-(defun test-signel-rpc--reset ()
- "Reset signel dispatch state to a clean baseline before each test."
- (clrhash signel--request-handler-map)
- (clrhash signel--request-buffer-map)
- (setq signel--rpc-id-counter 0))
-
-(ert-deftest test-signel-rpc-dispatch-result-invokes-callback ()
- "Normal: a result response with a registered id fires the callback with the
-result value and removes the handler."
- (test-signel-rpc--reset)
- (let ((captured nil))
- (puthash 7 (lambda (val) (setq captured val)) signel--request-handler-map)
- (signel--dispatch '((jsonrpc . "2.0") (id . 7)
- (result . ((contacts . [1 2 3])))))
- (should (equal captured '((contacts . [1 2 3]))))
- (should-not (gethash 7 signel--request-handler-map))))
-
-(ert-deftest test-signel-rpc-dispatch-unknown-id-is-noop ()
- "Boundary: a result response with an unregistered id is a silent no-op:
-neither receive nor error handler fires, and the handler map stays empty."
- (test-signel-rpc--reset)
- (let ((called nil))
- (cl-letf (((symbol-function 'signel--handle-error)
- (lambda (&rest _) (setq called 'error)))
- ((symbol-function 'signel--handle-receive)
- (lambda (&rest _) (setq called 'receive))))
- (signel--dispatch '((jsonrpc . "2.0") (id . 99) (result . "anything"))))
- (should-not called)
- (should (zerop (hash-table-count signel--request-handler-map)))))
-
-(ert-deftest test-signel-rpc-dispatch-error-cleans-up-handler ()
- "Error: an error response with a registered id removes the handler without
-firing the callback, leaving the map clean for a retry."
- (test-signel-rpc--reset)
- (let ((fired nil))
- (puthash 11 (lambda (&rest _) (setq fired t))
- signel--request-handler-map)
- (cl-letf (((symbol-function 'signel--handle-error) (lambda (&rest _) nil)))
- (signel--dispatch '((jsonrpc . "2.0") (id . 11)
- (error . ((code . -1) (message . "boom"))))))
- (should-not fired)
- (should-not (gethash 11 signel--request-handler-map))))
-
-(ert-deftest test-signel-rpc-send-rpc-registers-success-callback ()
- "Normal: passing a SUCCESS-CALLBACK to `signel--send-rpc' stores it under
-the returned id so the matching response can route to it."
- (test-signel-rpc--reset)
- (let ((cb (lambda (_) 'ok))
- (sent nil))
- (cl-letf (((symbol-function 'get-process) (lambda (&rest _) 'fake-proc))
- ((symbol-function 'process-send-string)
- (lambda (_ s) (setq sent s)))
- ((symbol-function 'signel--log) (lambda (&rest _) nil)))
- (let ((id (signel--send-rpc "listContacts" nil nil cb)))
- (should (eq cb (gethash id signel--request-handler-map)))
- (should (stringp sent))))))
-
-(ert-deftest test-signel-rpc-stop-clears-handler-map ()
- "Normal (reconnect-invalidation): `signel-stop' clears the handler map so a
-restart starts with no stale callbacks waiting for responses that will never
-arrive."
- (test-signel-rpc--reset)
- (puthash 13 (lambda (&rest _) nil) signel--request-handler-map)
- (cl-letf (((symbol-function 'get-process) (lambda (&rest _) nil)))
- (signel-stop))
- (should (zerop (hash-table-count signel--request-handler-map))))
-
-(provide 'test-signel-rpc-dispatch)
-;;; test-signel-rpc-dispatch.el ends here
diff --git a/tests/test-slack-config--notify.el b/tests/test-slack-config--notify.el
new file mode 100644
index 00000000..d830ae29
--- /dev/null
+++ b/tests/test-slack-config--notify.el
@@ -0,0 +1,113 @@
+;;; test-slack-config--notify.el --- Slack notification hardening tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The config audit found `cj/slack-notify' missing signel's hardening: no
+;; body truncation (giant toasts), no whitespace collapse, no sound gating,
+;; and no `notifications-notify' fallback when the notify script is absent
+;; (the raw `start-process' error was swallowed by the condition-case, so
+;; the notification silently vanished). This mirrors signel's shape in
+;; place; the shared cj/messenger-notify extraction belongs to the
+;; messenger-unification task.
+;;
+;; The slack package's own predicates (`slack-im-p', `slack-message-minep',
+;; `slack-message-mentioned-p') are package boundaries and are mocked; the
+;; formatter and routing logic run real.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'slack-config)
+
+;;; ------------------------------ body formatter -----------------------------
+
+(ert-deftest test-slack-config-format-notify-body-collapses-whitespace ()
+ "Normal: whitespace runs (including newlines) become single spaces."
+ (should (equal (cj/slack--format-notify-body "a b\nc\t\td")
+ "a b c d")))
+
+(ert-deftest test-slack-config-format-notify-body-truncates-long ()
+ "Boundary: text over the max truncates to max length ending in an ellipsis."
+ (let ((long (make-string 500 ?x)))
+ (let ((formatted (cj/slack--format-notify-body long)))
+ (should (= (length formatted) cj/slack--notify-body-max))
+ (should (string-suffix-p "…" formatted)))))
+
+(ert-deftest test-slack-config-format-notify-body-empty ()
+ "Boundary: empty and whitespace-only input format to the empty string."
+ (should (equal (cj/slack--format-notify-body "") ""))
+ (should (equal (cj/slack--format-notify-body " \n\t ") "")))
+
+;;; ----------------------------- delivery routing ----------------------------
+
+(ert-deftest test-slack-config-send-notification-script-silent-by-default ()
+ "Normal: with the notify script on PATH and sound off, --silent is passed."
+ (let ((argv nil) (cj/slack-notify-sound nil))
+ (cl-letf (((symbol-function 'executable-find)
+ (lambda (prog &rest _) (when (equal prog "notify") "/bin/notify")))
+ ((symbol-function 'start-process)
+ (lambda (_name _buf &rest args) (setq argv args))))
+ (cj/slack--send-notification "Slack: general" "hello"))
+ (should (equal argv '("/bin/notify" "info" "Slack: general" "hello" "--silent")))))
+
+(ert-deftest test-slack-config-send-notification-sound-enabled ()
+ "Boundary: with sound enabled, --silent is not passed."
+ (let ((argv nil) (cj/slack-notify-sound t))
+ (cl-letf (((symbol-function 'executable-find)
+ (lambda (prog &rest _) (when (equal prog "notify") "/bin/notify")))
+ ((symbol-function 'start-process)
+ (lambda (_name _buf &rest args) (setq argv args))))
+ (cj/slack--send-notification "Slack: general" "hello"))
+ (should (equal argv '("/bin/notify" "info" "Slack: general" "hello")))))
+
+(ert-deftest test-slack-config-send-notification-fallback-without-script ()
+ "Error: with no notify script, delivery falls back to notifications-notify."
+ (let ((fallback nil))
+ (cl-letf (((symbol-function 'executable-find) (lambda (&rest _) nil))
+ ((symbol-function 'notifications-notify)
+ (lambda (&rest args) (setq fallback args))))
+ (cj/slack--send-notification "Slack: general" "hello"))
+ (should (equal (plist-get fallback :title) "Slack: general"))
+ (should (equal (plist-get fallback :body) "hello"))))
+
+;;; --------------------------- predicate wiring ------------------------------
+
+(defmacro test-slack-notify--with-message (minep im-p mentioned-p &rest body)
+ "Run BODY with the slack package predicates mocked to the given values.
+Also mocks room/body accessors and captures delivery into `sent'."
+ (declare (indent 3))
+ `(let ((sent nil))
+ (cl-letf (((symbol-function 'slack-message-minep) (lambda (&rest _) ,minep))
+ ((symbol-function 'slack-im-p) (lambda (&rest _) ,im-p))
+ ((symbol-function 'slack-message-mentioned-p) (lambda (&rest _) ,mentioned-p))
+ ((symbol-function 'slack-room-display-name) (lambda (&rest _) "general"))
+ ((symbol-function 'slack-message-body) (lambda (&rest _) "the message"))
+ ((symbol-function 'cj/slack--send-notification)
+ (lambda (title body) (setq sent (list title body)))))
+ (cj/slack-notify 'msg 'room 'team)
+ ,@body)))
+
+(ert-deftest test-slack-config-notify-dm-notifies ()
+ "Normal: a DM from someone else raises a notification."
+ (test-slack-notify--with-message nil t nil
+ (should (equal sent '("Slack: general" "the message")))))
+
+(ert-deftest test-slack-config-notify-mention-notifies ()
+ "Normal: an @mention in a channel raises a notification."
+ (test-slack-notify--with-message nil nil t
+ (should (equal sent '("Slack: general" "the message")))))
+
+(ert-deftest test-slack-config-notify-own-message-silent ()
+ "Boundary: your own message never notifies, even in a DM."
+ (test-slack-notify--with-message t t t
+ (should-not sent)))
+
+(ert-deftest test-slack-config-notify-plain-channel-silent ()
+ "Boundary: a channel message with no mention stays silent."
+ (test-slack-notify--with-message nil nil nil
+ (should-not sent)))
+
+(provide 'test-slack-config--notify)
+;;; test-slack-config--notify.el ends here
diff --git a/tests/test-system-lib--ensure-marginalia-align.el b/tests/test-system-lib--ensure-marginalia-align.el
new file mode 100644
index 00000000..33ff2ba2
--- /dev/null
+++ b/tests/test-system-lib--ensure-marginalia-align.el
@@ -0,0 +1,57 @@
+;;; test-system-lib--ensure-marginalia-align.el --- Tests for marginalia category registration -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Custom completion categories (cj-music-file, cj-radio-station, and every
+;; category passed to the system-lib table helpers) bypass marginalia, so
+;; their annotations never get its right-alignment even with marginalia-align
+;; set. The registration helper adds a builtin entry per category so the
+;; table's own annotation function renders through marginalia's aligned field.
+
+;;; Code:
+
+(require 'ert)
+
+;; The module's bare defvar marks this special only file-locally; declare it
+;; here too so `let' binds dynamically (the scope-shadowing trap).
+(defvar marginalia-annotator-registry)
+
+(require 'system-lib)
+
+;;; Normal Cases
+
+(ert-deftest test-system-lib-ensure-marginalia-align-registers-category ()
+ "Normal: an unregistered category gains a builtin registry entry."
+ (let ((marginalia-annotator-registry '((file some-annotator builtin none))))
+ (cj/completion-ensure-marginalia-align 'cj-test-category)
+ (should (equal (assq 'cj-test-category marginalia-annotator-registry)
+ '(cj-test-category builtin none)))))
+
+(ert-deftest test-system-lib-ensure-marginalia-align-idempotent ()
+ "Normal: registering the same category twice leaves one entry."
+ (let ((marginalia-annotator-registry '()))
+ (cj/completion-ensure-marginalia-align 'cj-test-category)
+ (cj/completion-ensure-marginalia-align 'cj-test-category)
+ (should (= 1 (length marginalia-annotator-registry)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-system-lib-ensure-marginalia-align-preserves-existing-entry ()
+ "Boundary: a category with an existing (possibly custom) entry is untouched."
+ (let ((marginalia-annotator-registry '((cj-test-category my-custom-annotator))))
+ (cj/completion-ensure-marginalia-align 'cj-test-category)
+ (should (equal (assq 'cj-test-category marginalia-annotator-registry)
+ '(cj-test-category my-custom-annotator)))))
+
+;;; Error Cases
+
+(ert-deftest test-system-lib-ensure-marginalia-align-marginalia-absent-noop ()
+ "Error: without marginalia loaded (registry void) the helper is a silent
+no-op -- annotations just stay unaligned, nothing breaks."
+ ;; marginalia is not loadable in the batch environment, so the global
+ ;; registry is genuinely void outside the `let's above.
+ (should-not (cj/completion-ensure-marginalia-align 'cj-test-category)))
+
+(provide 'test-system-lib--ensure-marginalia-align)
+;;; test-system-lib--ensure-marginalia-align.el ends here
diff --git a/tests/test-test-runner--nil-global-directory.el b/tests/test-test-runner--nil-global-directory.el
new file mode 100644
index 00000000..c70de5bd
--- /dev/null
+++ b/tests/test-test-runner--nil-global-directory.el
@@ -0,0 +1,51 @@
+;;; test-test-runner--nil-global-directory.el --- Tests for the no-test-directory path -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Outside a Projectile project, `cj/test--get-test-directory' falls back
+;; to `cj/test-global-directory', which defaults to nil. These tests pin
+;; the contract for that nil case: discovery helpers return nil instead
+;; of crashing on (file-directory-p nil), and the interactive commands
+;; signal `user-error' instead of wrong-type-argument.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'test-runner)
+
+(defmacro test-runner-nil-dir--outside-project (&rest body)
+ "Run BODY with no project root and a nil `cj/test-global-directory'."
+ (declare (indent 0))
+ `(let ((cj/test-global-directory nil))
+ (cl-letf (((symbol-function 'cj/test--project-root)
+ (lambda () nil)))
+ ,@body)))
+
+;;; Boundary Cases
+
+(ert-deftest test-test-runner-nil-dir-get-test-directory-returns-nil ()
+ "Boundary: no project and no global dir yields nil, not an error."
+ (test-runner-nil-dir--outside-project
+ (should (null (cj/test--get-test-directory)))))
+
+(ert-deftest test-test-runner-nil-dir-get-test-files-returns-nil ()
+ "Boundary: file discovery returns nil instead of crashing on a nil dir."
+ (test-runner-nil-dir--outside-project
+ (should (null (cj/test--get-test-files)))))
+
+;;; Error Cases
+
+(ert-deftest test-test-runner-nil-dir-load-all-signals-user-error ()
+ "Error: `cj/test-load-all' signals `user-error', not wrong-type-argument."
+ (test-runner-nil-dir--outside-project
+ (should-error (cj/test-load-all) :type 'user-error)))
+
+(ert-deftest test-test-runner-nil-dir-focus-add-signals-user-error ()
+ "Error: `cj/test-focus-add' signals `user-error', not wrong-type-argument."
+ (test-runner-nil-dir--outside-project
+ (should-error (cj/test-focus-add) :type 'user-error)))
+
+(provide 'test-test-runner--nil-global-directory)
+;;; test-test-runner--nil-global-directory.el ends here
diff --git a/tests/test-test-runner.el b/tests/test-test-runner.el
index 0ff66f7f..6854b72e 100644
--- a/tests/test-test-runner.el
+++ b/tests/test-test-runner.el
@@ -152,6 +152,25 @@ FILES is an alist of relative test filenames to file contents."
(should (eq (car result) 'not-in-testdir)))
(test-testrunner-teardown))
+(ert-deftest test-testrunner-focus-add-file-shared-prefix-sibling-rejected ()
+ "Boundary: a sibling directory sharing the test dir's name prefix is outside it.
+`/tmp/x/tests-old/f.el' starts with `/tmp/x/tests' as a string, but it is not
+in `/tmp/x/tests'. A raw `string-prefix-p' on the two truenames accepts it;
+comparing against the directory with a trailing slash rejects it."
+ (test-testrunner-setup)
+ (let* ((testdir (file-truename test-testrunner--temp-dir))
+ (sibling (concat (directory-file-name testdir) "-old"))
+ (filepath (expand-file-name "test-foo.el" sibling)))
+ (unwind-protect
+ (progn
+ (make-directory sibling t)
+ (with-temp-file filepath (insert ";; not in the test dir\n"))
+ (let ((result (cj/test--do-focus-add-file
+ filepath test-testrunner--temp-dir '())))
+ (should (eq (car result) 'not-in-testdir))))
+ (when (file-directory-p sibling) (delete-directory sibling t))))
+ (test-testrunner-teardown))
+
(ert-deftest test-testrunner-focus-add-file-already-focused ()
"Should detect already focused file."
(test-testrunner-setup)
diff --git a/tests/test-text-config.el b/tests/test-text-config.el
index 96935e1b..82dfe05e 100644
--- a/tests/test-text-config.el
+++ b/tests/test-text-config.el
@@ -37,5 +37,18 @@ standard boundary check."
(should (eq (cj/prettify-compose-block-markers-p start end "lambda")
(prettify-symbols-default-compose-p start end "lambda"))))))
+(ert-deftest test-text-config-edit-indirect-bound-on-reachable-key ()
+ "Error/regression: edit-indirect-region is bound on M-I -- the event
+Meta+Shift+i actually produces -- not the unreachable M-S-i, which no
+keypress generates so it silently fell through to M-i tab-to-tab-stop."
+ (should (eq (key-binding (kbd "M-I")) #'edit-indirect-region))
+ (should-not (eq (key-binding (kbd "M-S-i")) #'edit-indirect-region)))
+
+(ert-deftest test-text-config-accent-uses-completion-agnostic-backend ()
+ "Regression: C-` invokes accent-menu, which reads through the minibuffer
+and so survives a Company->Corfu migration, rather than accent-company,
+whose company backend would break silently once Company is gone."
+ (should (eq (key-binding (kbd "C-`")) #'accent-menu)))
+
(provide 'test-text-config)
;;; test-text-config.el ends here
diff --git a/tests/test-ui-theme-persistence.el b/tests/test-ui-theme-persistence.el
index 02bb105a..250b606b 100644
--- a/tests/test-ui-theme-persistence.el
+++ b/tests/test-ui-theme-persistence.el
@@ -32,6 +32,19 @@
"modus-vivendi")))
(delete-file file))))
+(ert-deftest test-ui-theme-write-file-contents-creates-missing-parent-dir ()
+ "Boundary: writing into a not-yet-existing directory creates it first.
+On a fresh machine `persist/' does not exist, and `file-writable-p' returns nil
+for a file inside a missing directory, so the write must create the parent."
+ (let* ((sandbox (make-temp-file "ui-theme-sandbox-" t))
+ (file (expand-file-name "persist/emacs-theme" sandbox)))
+ (unwind-protect
+ (progn
+ (should-not (file-directory-p (file-name-directory file)))
+ (should (cj/theme-write-file-contents "modus-vivendi" file))
+ (should (equal (cj/theme-read-file-contents file) "modus-vivendi")))
+ (delete-directory sandbox t))))
+
(ert-deftest test-ui-theme-write-file-contents-uses-write-region ()
"Theme persistence should write directly instead of visiting the file."
(let ((file (make-temp-file "ui-theme-write-region-"))
diff --git a/tests/test-undead-buffers-kill-other-window.el b/tests/test-undead-buffers-kill-other-window.el
index e9371a0f..000ada9b 100644
--- a/tests/test-undead-buffers-kill-other-window.el
+++ b/tests/test-undead-buffers-kill-other-window.el
@@ -66,8 +66,9 @@
;;; Boundary Cases
-(ert-deftest test-kill-other-window-single-window-should-only-kill-buffer ()
- "With single window, should only kill the current buffer."
+(ert-deftest test-kill-other-window-single-window-signals-no-other-window ()
+ "Error: with a single window, signal `user-error' and kill nothing.
+There is no other window, so acting would kill the buffer being viewed."
(test-kill-other-window-setup)
(unwind-protect
(let ((buf (generate-new-buffer "*test-single-other*")))
@@ -75,9 +76,9 @@
(progn
(switch-to-buffer buf)
(should (one-window-p))
- (cj/kill-other-window)
+ (should-error (cj/kill-other-window) :type 'user-error)
(should (one-window-p))
- (should-not (buffer-live-p buf)))
+ (should (buffer-live-p buf)))
(when (buffer-live-p buf) (kill-buffer buf))))
(test-kill-other-window-teardown)))
diff --git a/tests/test-vc-config--git-clone.el b/tests/test-vc-config--git-clone.el
index 3b39ece2..46ce3d40 100644
--- a/tests/test-vc-config--git-clone.el
+++ b/tests/test-vc-config--git-clone.el
@@ -2,9 +2,11 @@
;;; Commentary:
;; Unit tests for cj/--git-clone-dir-name (robust repo-dir derivation across
-;; HTTPS, scp-style SSH, ssh:// and local URLs) and for cj/git-clone-clipboard-url
-;; reporting a failed clone from the process exit status instead of silently
-;; assuming the directory appeared.
+;; HTTPS, scp-style SSH, ssh:// and local URLs), for the async clone process
+;; wiring in cj/git-clone-clipboard-url (make-process argv, no shell), and
+;; for the sentinel built by cj/--git-clone-make-sentinel (open on success,
+;; surface the process buffer on failure). Sentinel tests drive real
+;; short-lived processes rather than mocking process primitives.
;;; Code:
@@ -14,6 +16,18 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'vc-config)
+(defun test-vc-clone--run-sentinel (command sentinel buffer)
+ "Run COMMAND with SENTINEL and BUFFER; wait for process exit."
+ (let ((proc (make-process :name "test-vc-clone"
+ :buffer buffer
+ :command command
+ :sentinel sentinel)))
+ (while (process-live-p proc)
+ (accept-process-output proc 0.05))
+ ;; Give the sentinel a chance to run after exit.
+ (accept-process-output nil 0.05)
+ proc))
+
;;; cj/--git-clone-dir-name — Normal Cases
(ert-deftest test-vc-git-clone-dir-name-https-with-git-suffix ()
@@ -36,7 +50,7 @@
(should (equal "repo"
(cj/--git-clone-dir-name "ssh://git@example.com/user/repo.git"))))
-;;; Boundary Cases
+;;; cj/--git-clone-dir-name — Boundary Cases
(ert-deftest test-vc-git-clone-dir-name-ssh-scp-without-user ()
"Boundary: scp-style SSH with no user path (host:repo.git) still works.
@@ -60,29 +74,110 @@ since there is no `/' separator."
(should (equal "repo"
(cj/--git-clone-dir-name " https://example.com/user/repo.git\n"))))
-;;; cj/git-clone-clipboard-url — Error Cases
+;;; cj/--git-clone-open — Normal / Boundary Cases
-(ert-deftest test-vc-git-clone-clipboard-url-reports-clone-failure ()
- "Error: a nonzero git exit status surfaces a user-error, not silence.
-Uses a real writable temp dir as the target (so the file predicates run
-for real) and mocks only the clone process to fail."
- (let ((target (make-temp-file "cj-clone-fail-" t)))
+(ert-deftest test-vc-git-clone-open-finds-readme ()
+ "Normal: a README in the clone is opened."
+ (let ((dir (make-temp-file "cj-clone-open-" t))
+ (opened nil))
+ (unwind-protect
+ (progn
+ (with-temp-file (expand-file-name "README.md" dir) (insert "hi"))
+ (cl-letf (((symbol-function 'find-file)
+ (lambda (f &rest _) (setq opened f)))
+ ((symbol-function 'dired) #'ignore))
+ (cj/--git-clone-open dir)
+ (should (equal (expand-file-name "README.md" dir) opened))))
+ (delete-directory dir t))))
+
+(ert-deftest test-vc-git-clone-open-no-readme-dires ()
+ "Boundary: with no README, the clone directory is dired."
+ (let ((dir (make-temp-file "cj-clone-open-" t))
+ (dired-dir nil))
+ (unwind-protect
+ (cl-letf (((symbol-function 'find-file)
+ (lambda (&rest _) (error "find-file should not run")))
+ ((symbol-function 'dired)
+ (lambda (d &rest _) (setq dired-dir d))))
+ (cj/--git-clone-open dir)
+ (should (equal dir dired-dir)))
+ (delete-directory dir t))))
+
+;;; cj/--git-clone-make-sentinel — Normal / Error Cases
+
+(ert-deftest test-vc-git-clone-sentinel-success-opens-clone ()
+ "Normal: a zero-exit clone process opens the clone directory."
+ (let ((buffer (generate-new-buffer " *test-clone-ok*"))
+ (opened nil))
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--git-clone-open)
+ (lambda (d) (setq opened d)))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (test-vc-clone--run-sentinel
+ '("true") (cj/--git-clone-make-sentinel "url" "/tmp/clone-dst") buffer)
+ (should (equal "/tmp/clone-dst" opened)))
+ (kill-buffer buffer))))
+
+(ert-deftest test-vc-git-clone-sentinel-failure-pops-process-buffer ()
+ "Error: a nonzero exit surfaces the process buffer, never opens the clone."
+ (let ((buffer (generate-new-buffer " *test-clone-fail*"))
+ (opened nil)
+ (popped nil))
(unwind-protect
- (cl-letf (((symbol-function 'call-process) (lambda (&rest _) 128))
- ((symbol-function 'pop-to-buffer) #'ignore)
- ((symbol-function 'message) #'ignore))
- (should-error
- (cj/git-clone-clipboard-url "https://example.com/user/repo.git" target)
- :type 'user-error))
+ (cl-letf (((symbol-function 'cj/--git-clone-open)
+ (lambda (d) (setq opened d)))
+ ((symbol-function 'pop-to-buffer)
+ (lambda (b &rest _) (setq popped b)))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (test-vc-clone--run-sentinel
+ '("false") (cj/--git-clone-make-sentinel "url" "/tmp/clone-dst") buffer)
+ (should-not opened)
+ (should (eq buffer popped)))
+ (kill-buffer buffer))))
+
+;;; cj/git-clone-clipboard-url — Normal / Error Cases
+
+(ert-deftest test-vc-git-clone-clipboard-url-spawns-async-argv ()
+ "Normal: the clone runs as an async process with a plain argv, no shell.
+The `--' separator must precede the URL so a leading-dash URL cannot be
+read as a git flag."
+ (let ((target (make-temp-file "cj-clone-async-" t))
+ (spawned nil))
+ (unwind-protect
+ (cl-letf (((symbol-function 'make-process)
+ (lambda (&rest args)
+ (setq spawned (plist-get args :command))
+ nil))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (cj/git-clone-clipboard-url "https://example.com/user/repo.git" target)
+ (should (equal (list "git" "clone" "--"
+ "https://example.com/user/repo.git"
+ (expand-file-name "repo" target))
+ spawned)))
(delete-directory target t))))
(ert-deftest test-vc-git-clone-clipboard-url-empty-clipboard-errors ()
"Error: an empty clipboard URL aborts before any clone attempt."
- (let ((cloned nil))
- (cl-letf (((symbol-function 'call-process)
- (lambda (&rest _) (setq cloned t) 0)))
+ (let ((spawned nil))
+ (cl-letf (((symbol-function 'make-process)
+ (lambda (&rest _) (setq spawned t) nil)))
(should-error (cj/git-clone-clipboard-url " " "/tmp") :type 'user-error))
- (should-not cloned)))
+ (should-not spawned)))
+
+(ert-deftest test-vc-git-clone-clipboard-url-existing-destination-errors ()
+ "Error: an existing clone destination aborts before any clone attempt."
+ (let ((target (make-temp-file "cj-clone-exists-" t))
+ (spawned nil))
+ (unwind-protect
+ (progn
+ (make-directory (expand-file-name "repo" target))
+ (cl-letf (((symbol-function 'make-process)
+ (lambda (&rest _) (setq spawned t) nil)))
+ (should-error
+ (cj/git-clone-clipboard-url "https://example.com/user/repo.git" target)
+ :type 'user-error))
+ (should-not spawned))
+ (delete-directory target t))))
(provide 'test-vc-config--git-clone)
;;; test-vc-config--git-clone.el ends here
diff --git a/tests/test-vc-config--gutter-hunk-candidates.el b/tests/test-vc-config--gutter-hunk-candidates.el
new file mode 100644
index 00000000..65db0c3c
--- /dev/null
+++ b/tests/test-vc-config--gutter-hunk-candidates.el
@@ -0,0 +1,69 @@
+;;; test-vc-config--gutter-hunk-candidates.el --- Tests for cj/--git-gutter-hunk-candidates -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Unit tests for cj/--git-gutter-hunk-candidates, the pure helper that
+;; builds completion candidates (label . line) from git-gutter hunk start
+;; lines against the current buffer's text. The interactive wrapper
+;; cj/goto-git-gutter-diff-hunks maps git-gutter:diffinfos onto start
+;; lines and delegates here.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'vc-config)
+
+;;; Normal Cases
+
+(ert-deftest test-vc-gutter-hunk-candidates-labels-carry-line-text ()
+ "Normal: each candidate label contains the hunk line's text."
+ (with-temp-buffer
+ (insert "alpha\nbravo\ncharlie\n")
+ (let ((candidates (cj/--git-gutter-hunk-candidates '(2 3))))
+ (should (= 2 (length candidates)))
+ (should (string-match-p "bravo" (car (nth 0 candidates))))
+ (should (string-match-p "charlie" (car (nth 1 candidates)))))))
+
+(ert-deftest test-vc-gutter-hunk-candidates-cdr-is-start-line ()
+ "Normal: each candidate's cdr is the hunk's start line number."
+ (with-temp-buffer
+ (insert "alpha\nbravo\ncharlie\n")
+ (let ((candidates (cj/--git-gutter-hunk-candidates '(1 3))))
+ (should (equal '(1 3) (mapcar #'cdr candidates))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-vc-gutter-hunk-candidates-empty-input-returns-nil ()
+ "Boundary: no hunks produce no candidates."
+ (with-temp-buffer
+ (insert "alpha\n")
+ (should (null (cj/--git-gutter-hunk-candidates '())))))
+
+(ert-deftest test-vc-gutter-hunk-candidates-first-line ()
+ "Boundary: a hunk on line 1 resolves to the first line's text."
+ (with-temp-buffer
+ (insert "alpha\nbravo\n")
+ (let ((candidates (cj/--git-gutter-hunk-candidates '(1))))
+ (should (string-match-p "alpha" (caar candidates)))
+ (should (= 1 (cdar candidates))))))
+
+(ert-deftest test-vc-gutter-hunk-candidates-unicode-line-text ()
+ "Boundary: line text with unicode survives into the label."
+ (with-temp-buffer
+ (insert "naïve — 日本語\n")
+ (let ((candidates (cj/--git-gutter-hunk-candidates '(1))))
+ (should (string-match-p "日本語" (caar candidates))))))
+
+;;; Error Cases
+
+(ert-deftest test-vc-goto-git-gutter-diff-hunks-no-hunks-user-error ()
+ "Error: the command signals `user-error' when the buffer has no hunks."
+ (with-temp-buffer
+ (setq-local git-gutter:diffinfos nil)
+ (cl-letf (((symbol-function 'require) (lambda (&rest _) nil)))
+ (should-error (cj/goto-git-gutter-diff-hunks) :type 'user-error))))
+
+(provide 'test-vc-config--gutter-hunk-candidates)
+;;; test-vc-config--gutter-hunk-candidates.el ends here
diff --git a/tests/test-vc-config--timemachine-commands.el b/tests/test-vc-config--timemachine-commands.el
new file mode 100644
index 00000000..36a71695
--- /dev/null
+++ b/tests/test-vc-config--timemachine-commands.el
@@ -0,0 +1,36 @@
+;;; test-vc-config--timemachine-commands.el --- Tests for git-timemachine command wiring -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Guards the git-timemachine autoload surface in vc-config.el. The
+;; upstream package defines no `git-timemachine-show-selected-revision';
+;; an autoload for it in :commands creates a phantom M-x command that
+;; errors after loading the package. The real selector lives in the
+;; cj/ namespace.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'vc-config)
+
+;;; Normal Cases
+
+(ert-deftest test-vc-timemachine-selected-revision-cj-command-defined ()
+ "Normal: the cj/ selected-revision selector is defined by vc-config."
+ (should (fboundp 'cj/git-timemachine-show-selected-revision)))
+
+(ert-deftest test-vc-timemachine-entry-command-defined ()
+ "Normal: the cj/git-timemachine entry command is defined."
+ (should (commandp 'cj/git-timemachine)))
+
+;;; Error Cases
+
+(ert-deftest test-vc-timemachine-no-phantom-package-autoload ()
+ "Error: no autoload exists for a function the package never defines.
+An autoload stub for `git-timemachine-show-selected-revision' would
+surface in M-x and signal void-function after the package loads."
+ (should-not (fboundp 'git-timemachine-show-selected-revision)))
+
+(provide 'test-vc-config--timemachine-commands)
+;;; test-vc-config--timemachine-commands.el ends here
diff --git a/tests/test-video-audio-recording--build-video-command.el b/tests/test-video-audio-recording--build-video-command.el
index 4f290978..1ffce95b 100644
--- a/tests/test-video-audio-recording--build-video-command.el
+++ b/tests/test-video-audio-recording--build-video-command.el
@@ -27,6 +27,14 @@
(should (string-match-p "-i pipe:0" cmd))
(should (string-match-p "-c:v copy" cmd))))))
+(ert-deftest test-video-audio-recording--build-video-command-normal-wayland-keeps-wf-recorder-stderr ()
+ "Wayland command does not discard wf-recorder stderr, so a failed grab is diagnosable."
+ (let ((cj/recording-mic-boost 2.0)
+ (cj/recording-system-volume 1.0))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_prog &rest _) t)))
+ (let ((cmd (cj/recording--build-video-command "mic" "sys" "/tmp/out.mkv" t)))
+ (should-not (string-match-p "2>/dev/null" cmd))))))
+
(ert-deftest test-video-audio-recording--build-video-command-normal-x11-uses-x11grab ()
"X11 command uses ffmpeg with x11grab, no wf-recorder."
(let ((cj/recording-mic-boost 2.0)
diff --git a/tests/test-video-audio-recording--start-race.el b/tests/test-video-audio-recording--start-race.el
new file mode 100644
index 00000000..36ea8595
--- /dev/null
+++ b/tests/test-video-audio-recording--start-race.el
@@ -0,0 +1,56 @@
+;;; test-video-audio-recording--start-race.el --- start-race fix tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the wf-recorder start-race fix: the poll that waits for a dying
+;; wf-recorder to release the compositor capture before launching a new one, and
+;; the fail-fast timing predicate that tells a 0.5s failed start from a real
+;; recording. The pgrep wrapper and the live start/stop wiring are exercised in
+;; the daemon, not here; the poll is tested with an injected predicate so no real
+;; process is needed.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'video-audio-recording)
+
+(declare-function cj/recording--start-failed-p "video-audio-recording-capture" (elapsed threshold))
+(declare-function cj/recording--wait-for-no-wf-recorder "video-audio-recording-capture" (timeout-secs &optional running-p))
+
+;;; ------------------------- cj/recording--start-failed-p ---------------------
+
+(ert-deftest test-recording-start-failed-p-short-exit-is-failure ()
+ "Normal: an exit well before the threshold is a failed start."
+ (should (cj/recording--start-failed-p 0.5 1.5)))
+
+(ert-deftest test-recording-start-failed-p-long-run-is-not-failure ()
+ "Normal: a long-running recording that ends is not a failed start."
+ (should-not (cj/recording--start-failed-p 30.0 1.5)))
+
+(ert-deftest test-recording-start-failed-p-at-threshold-is-not-failure ()
+ "Boundary: an exit exactly at the threshold is not counted as failed."
+ (should-not (cj/recording--start-failed-p 1.5 1.5)))
+
+;;; -------------------- cj/recording--wait-for-no-wf-recorder ------------------
+
+(ert-deftest test-recording-wait-for-no-wf-recorder-clears ()
+ "Normal: returns t once the injected predicate reports wf-recorder gone."
+ (let ((n 0))
+ (should (cj/recording--wait-for-no-wf-recorder
+ 2.0
+ (lambda () (setq n (1+ n)) (< n 3))))))
+
+(ert-deftest test-recording-wait-for-no-wf-recorder-already-clear ()
+ "Boundary: an already-clear predicate returns t immediately."
+ (should (cj/recording--wait-for-no-wf-recorder 2.0 (lambda () nil))))
+
+(ert-deftest test-recording-wait-for-no-wf-recorder-times-out ()
+ "Error: a predicate that never clears returns nil at the timeout."
+ (should-not (cj/recording--wait-for-no-wf-recorder 0.15 (lambda () t))))
+
+(provide 'test-video-audio-recording--start-race)
+;;; test-video-audio-recording--start-race.el ends here
diff --git a/tests/test-video-audio-recording-process-sentinel.el b/tests/test-video-audio-recording-process-sentinel.el
index 92fb3f0d..d733e46f 100644
--- a/tests/test-video-audio-recording-process-sentinel.el
+++ b/tests/test-video-audio-recording-process-sentinel.el
@@ -190,5 +190,157 @@
(should (null cj/audio-recording-ffmpeg-process))))
(test-sentinel-teardown)))
+;;; Failed-Start Stub Deletion
+;;
+;; On Wayland a wf-recorder that fails to grab the compositor capture
+;; still writes a ~500KB, ~0.5s stub .mkv before dying. The sentinel
+;; detects the failed start (exit sooner than
+;; `cj/recording-start-fail-threshold' without a user stop); these tests
+;; pin that it also deletes the stub file stamped on the process as the
+;; `cj-output-file' property — and that normal stops and user stops
+;; never delete anything.
+
+(defun test-sentinel--make-exited-process ()
+ "Return a real process that has already exited.
+Drives the sentinel with a genuinely dead process so `process-status'
+and the process plist behave for real instead of through mocks."
+ (let ((proc (make-process :name "test-sentinel-exited"
+ :command '("true")
+ :sentinel #'ignore)))
+ (while (process-live-p proc)
+ (accept-process-output proc 0.05))
+ proc))
+
+(defun test-sentinel--make-stub-file ()
+ "Create and return a temp file standing in for the stub .mkv."
+ (make-temp-file "test-sentinel-stub-" nil ".mkv" "stub-content"))
+
+(ert-deftest test-video-audio-recording-process-sentinel-normal-failed-start-deletes-stub ()
+ "Normal: a failed video start deletes the stub output file."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((proc (test-sentinel--make-exited-process))
+ (stub (test-sentinel--make-stub-file)))
+ (unwind-protect
+ (progn
+ (setq cj/video-recording-ffmpeg-process proc)
+ ;; Exited immediately after its start time — a failed start.
+ (process-put proc 'cj-start-time (float-time))
+ (process-put proc 'cj-output-file stub)
+ (cj/recording-process-sentinel proc "exited abnormally\n")
+ (should-not (file-exists-p stub)))
+ (when (file-exists-p stub) (delete-file stub))))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-normal-failed-start-still-messages ()
+ "Normal: the failed-start branch still reports the failure to the user."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((proc (test-sentinel--make-exited-process))
+ (stub (test-sentinel--make-stub-file))
+ (failure-messaged nil))
+ (unwind-protect
+ (progn
+ (setq cj/video-recording-ffmpeg-process proc)
+ (process-put proc 'cj-start-time (float-time))
+ (process-put proc 'cj-output-file stub)
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (let ((msg (apply #'format fmt args)))
+ (when (string-match-p "failed to start" msg)
+ (setq failure-messaged t))))))
+ (cj/recording-process-sentinel proc "exited abnormally\n"))
+ (should failure-messaged))
+ (when (file-exists-p stub) (delete-file stub))))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-normal-long-run-keeps-file ()
+ "Normal: a recording that ran past the threshold keeps its output file."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((proc (test-sentinel--make-exited-process))
+ (stub (test-sentinel--make-stub-file)))
+ (unwind-protect
+ (progn
+ (setq cj/video-recording-ffmpeg-process proc)
+ ;; Ran well past the fail threshold — a real recording.
+ (process-put proc 'cj-start-time
+ (- (float-time)
+ (* 10 cj/recording-start-fail-threshold)))
+ (process-put proc 'cj-output-file stub)
+ (cj/recording-process-sentinel proc "finished\n")
+ (should (file-exists-p stub)))
+ (when (file-exists-p stub) (delete-file stub))))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-boundary-user-stop-keeps-file ()
+ "Boundary: a quick user stop (cj-stopping) never deletes the file."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((proc (test-sentinel--make-exited-process))
+ (stub (test-sentinel--make-stub-file)))
+ (unwind-protect
+ (progn
+ (setq cj/video-recording-ffmpeg-process proc)
+ ;; Quick exit, but the user asked for it.
+ (process-put proc 'cj-start-time (float-time))
+ (process-put proc 'cj-stopping t)
+ (process-put proc 'cj-output-file stub)
+ (cj/recording-process-sentinel proc "finished\n")
+ (should (file-exists-p stub)))
+ (when (file-exists-p stub) (delete-file stub))))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-boundary-missing-stub-no-error ()
+ "Boundary: failed start whose stub never hit disk signals no error."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((proc (test-sentinel--make-exited-process)))
+ (setq cj/video-recording-ffmpeg-process proc)
+ (process-put proc 'cj-start-time (float-time))
+ (process-put proc 'cj-output-file "/nonexistent/dir/never-written.mkv")
+ ;; Must not signal even though the file is absent.
+ (cj/recording-process-sentinel proc "exited abnormally\n")
+ (should (null cj/video-recording-ffmpeg-process)))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-error-nil-output-property-no-error ()
+ "Error: failed start with no cj-output-file property signals no error.
+Covers processes started before the property existed (a live daemon
+mid-upgrade) — the sentinel degrades to the old message-only path."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((proc (test-sentinel--make-exited-process)))
+ (setq cj/video-recording-ffmpeg-process proc)
+ (process-put proc 'cj-start-time (float-time))
+ ;; No cj-output-file property at all.
+ (cj/recording-process-sentinel proc "exited abnormally\n")
+ (should (null cj/video-recording-ffmpeg-process)))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-normal-start-stamps-output-file ()
+ "Normal: `cj/ffmpeg-record-video' stamps cj-output-file on the process."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((cj/recording-mic-device "test-mic-device")
+ (cj/recording-system-device "test-monitor-device")
+ (cj/recording-mic-boost 2.0)
+ (cj/recording-system-volume 1.0))
+ (cl-letf (((symbol-function 'cj/recording--wayland-p) (lambda () nil))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil))
+ ((symbol-function 'start-process-shell-command)
+ (lambda (_name _buffer _command)
+ (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (cj/ffmpeg-record-video "/tmp/video-recordings/")
+ (let ((output-file (process-get cj/video-recording-ffmpeg-process
+ 'cj-output-file)))
+ (should (stringp output-file))
+ (should (string-suffix-p ".mkv" output-file))
+ (should (string-prefix-p "/tmp/video-recordings/" output-file))))
+ (when cj/video-recording-ffmpeg-process
+ (ignore-errors (delete-process cj/video-recording-ffmpeg-process))))
+ (test-sentinel-teardown)))
+
(provide 'test-video-audio-recording-process-sentinel)
;;; test-video-audio-recording-process-sentinel.el ends here