aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test-ai-term--active-agent-dirs.el50
-rw-r--r--tests/test-ai-term--collapse-split.el14
-rw-r--r--tests/test-ai-term--f9-in-term.el58
-rw-r--r--tests/test-ai-term--keybindings.el59
-rw-r--r--tests/test-ai-term--live-count.el60
-rw-r--r--tests/test-ai-term--next-agent-buffer.el73
-rw-r--r--tests/test-ai-term--next-agent-dir.el48
-rw-r--r--tests/test-ai-term--next-no-agents.el34
-rw-r--r--tests/test-ai-term--quit.el65
-rw-r--r--tests/test-ai-term--shutdown-countdown.el73
-rw-r--r--tests/test-calendar-sync--apply-single-exception.el37
-rw-r--r--tests/test-calendar-sync--robustness.el70
-rw-r--r--tests/test-calendar-sync.el9
-rw-r--r--tests/test-dashboard-config-launchers.el3
-rw-r--r--tests/test-dirvish-config-wallpaper-program.el4
-rw-r--r--tests/test-erc-config-connected-servers.el9
-rw-r--r--tests/test-face-diagnostic.el25
-rw-r--r--tests/test-google-keep-config.el142
-rw-r--r--tests/test-init-module-headers.el1
-rw-r--r--tests/test-latex-config--latexmk-wiring.el62
-rw-r--r--tests/test-nerd-icons-config--apply-tint.el63
-rw-r--r--tests/test-nerd-icons-config--color-dir.el15
-rw-r--r--tests/test-org-agenda-config--base-files.el57
-rw-r--r--tests/test-org-config-keymap-ownership.el6
-rw-r--r--tests/test-org-refile-config-scan-targets.el11
-rw-r--r--tests/test-prog-lsp.el66
-rw-r--r--tests/test-term-tmux-history.el95
-rw-r--r--tests/test-transcription-video.el22
28 files changed, 1000 insertions, 231 deletions
diff --git a/tests/test-ai-term--active-agent-dirs.el b/tests/test-ai-term--active-agent-dirs.el
new file mode 100644
index 000000000..86e557b42
--- /dev/null
+++ b/tests/test-ai-term--active-agent-dirs.el
@@ -0,0 +1,50 @@
+;;; test-ai-term--active-agent-dirs.el --- Tests for cj/--ai-term-active-agent-dirs -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The queue `cj/ai-term-next' steps through: project dirs with an active
+;; agent, which is either a live agent buffer (attached) or a live tmux session
+;; with no Emacs buffer (detached). Folding detached sessions in is what lets
+;; the step key reach and attach a session that isn't currently on screen.
+;; 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--active-agent-dirs-includes-attached-and-detached ()
+ "Normal: dirs with a live buffer OR a live session are active and sorted by
+name; dirs with neither are excluded."
+ (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); beta/delta neither.
+ (should (equal '("/p/alpha" "/p/gamma") (cj/--ai-term-active-agent-dirs))))
+ (kill-buffer buf))))
+
+(ert-deftest test-ai-term--active-agent-dirs-detached-only ()
+ "Normal: a dir with only a live session (no buffer) is included -- the detached case."
+ (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 (equal '("/p/solo") (cj/--ai-term-active-agent-dirs)))))
+
+(ert-deftest test-ai-term--active-agent-dirs-empty-when-none-active ()
+ "Boundary: no live buffers and no sessions -> an empty queue."
+ (cl-letf (((symbol-function 'cj/--ai-term-candidates) (lambda (&rest _) '("/p/a" "/p/b")))
+ ((symbol-function 'cj/--ai-term-agent-buffers) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions) (lambda (&rest _) nil)))
+ (should (null (cj/--ai-term-active-agent-dirs)))))
+
+(provide 'test-ai-term--active-agent-dirs)
+;;; test-ai-term--active-agent-dirs.el ends here
diff --git a/tests/test-ai-term--collapse-split.el b/tests/test-ai-term--collapse-split.el
index d7b4ee17f..a09af5598 100644
--- a/tests/test-ai-term--collapse-split.el
+++ b/tests/test-ai-term--collapse-split.el
@@ -59,7 +59,12 @@ different agent (stale quit-restore after slot reuse)."
(agent-a (get-buffer-create "agent [collapse-a]"))
(agent-b (get-buffer-create "agent [collapse-b]"))
(agent-c (get-buffer-create "agent [collapse-c]"))
- (cj/--ai-term-last-was-bury nil))
+ (cj/--ai-term-last-was-bury nil)
+ ;; Isolate the layout-capture globals cj/ai-term writes on toggle-off,
+ ;; so this test doesn't leak last-direction/last-size into others -- the
+ ;; display-rule test splits via display-saved, which reads them.
+ (cj/--ai-term-last-direction nil)
+ (cj/--ai-term-last-size nil))
(unwind-protect
(save-window-excursion
(delete-other-windows)
@@ -89,7 +94,12 @@ to a NON-agent buffer (the working file), never another agent. Before the fix,
(let ((work (get-buffer-create "*test-collapse-sw-work*"))
(agent-a (get-buffer-create "agent [collapse-sw-a]"))
(agent-b (get-buffer-create "agent [collapse-sw-b]"))
- (cj/--ai-term-last-was-bury nil))
+ (cj/--ai-term-last-was-bury nil)
+ ;; Isolate the layout-capture globals cj/ai-term writes on toggle-off,
+ ;; so this test doesn't leak last-direction/last-size into others -- the
+ ;; display-rule test splits via display-saved, which reads them.
+ (cj/--ai-term-last-direction nil)
+ (cj/--ai-term-last-size nil))
(unwind-protect
(save-window-excursion
(delete-other-windows)
diff --git a/tests/test-ai-term--f9-in-term.el b/tests/test-ai-term--f9-in-term.el
deleted file mode 100644
index 0477f2517..000000000
--- a/tests/test-ai-term--f9-in-term.el
+++ /dev/null
@@ -1,58 +0,0 @@
-;;; test-ai-term--f9-in-term.el --- F9 reaches Emacs from inside an agent buffer -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; ghostel's semi-char mode forwards keys not in `ghostel-keymap-exceptions' to
-;; the terminal program, so a plain <f9> typed while point is in an agent
-;; buffer would be sent to the program instead of toggling the agent -- exactly
-;; the case when the agent buffer fills the frame. `ai-term.el' re-binds the F9
-;; family in `ghostel-mode-map'. These tests require ghostel (which defines
-;; `ghostel-mode-map' and lets ai-term's `with-eval-after-load' fire) BEFORE
-;; ai-term, then confirm the bindings landed (and the global ones are intact).
-;; `(require 'ghostel)' does not load the native module, so this stays light.
-
-;;; Code:
-
-(require 'ert)
-(require 'package)
-
-(setq package-user-dir (expand-file-name "elpa" user-emacs-directory))
-(package-initialize)
-(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'ghostel)
-(require 'ai-term)
-
-(ert-deftest test-ai-term-f9-bound-in-ghostel-mode-map ()
- "Normal: <f9> in `ghostel-mode-map' runs the agent toggle."
- (should (eq (keymap-lookup ghostel-mode-map "<f9>") #'cj/ai-term)))
-
-(ert-deftest test-ai-term-f9-family-bound-in-ghostel-mode-map ()
- "Normal: the C-/s-/M- F9 variants are bound in `ghostel-mode-map' too.
-`s-<f9>' steps to the next agent; `M-<f9>' closes an agent via
-`cj/ai-term-close'."
- (should (eq (keymap-lookup ghostel-mode-map "C-<f9>") #'cj/ai-term-pick-project))
- (should (eq (keymap-lookup ghostel-mode-map "s-<f9>") #'cj/ai-term-next))
- (should (eq (keymap-lookup ghostel-mode-map "M-<f9>") #'cj/ai-term-close)))
-
-(ert-deftest test-ai-term-f9-still-bound-globally ()
- "Normal: the global F9 family bindings are intact.
-`<f9>' toggles the ai-term agent window; `C-<f9>' picks a project
-agent; `s-<f9>' steps to the next agent; `M-<f9>' closes an agent
-via `cj/ai-term-close'."
- (should (eq (lookup-key (current-global-map) (kbd "<f9>")) #'cj/ai-term))
- (should (eq (lookup-key (current-global-map) (kbd "C-<f9>")) #'cj/ai-term-pick-project))
- (should (eq (lookup-key (current-global-map) (kbd "s-<f9>")) #'cj/ai-term-next))
- (should (eq (lookup-key (current-global-map) (kbd "M-<f9>")) #'cj/ai-term-close)))
-
-(ert-deftest test-ai-term-f9-family-in-keymap-exceptions ()
- "Regression: the F9 family is in `ghostel-keymap-exceptions' so semi-char
-mode lets it reach Emacs instead of forwarding it to the terminal program.
-Binding in `ghostel-mode-map' alone is not enough -- the semi-char map outranks
-it and forwards any key not in the exceptions to the pty."
- (dolist (key '("<f9>" "C-<f9>" "s-<f9>" "M-<f9>"))
- (should (member key ghostel-keymap-exceptions)))
- ;; The rebuilt semi-char map must no longer forward <f9> to the pty.
- (should-not (eq (keymap-lookup ghostel-semi-char-mode-map "<f9>")
- 'ghostel--send-event)))
-
-(provide 'test-ai-term--f9-in-term)
-;;; test-ai-term--f9-in-term.el ends here
diff --git a/tests/test-ai-term--keybindings.el b/tests/test-ai-term--keybindings.el
new file mode 100644
index 000000000..a8b92ffa8
--- /dev/null
+++ b/tests/test-ai-term--keybindings.el
@@ -0,0 +1,59 @@
+;;; test-ai-term--keybindings.el --- ai-term keybinding placement -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; ai-term lives under the C-; a prefix (vacated when gptel was archived), with
+;; the frequent "swap to the next agent" also on M-SPC for a fast chord. M-SPC
+;; must reach Emacs from inside an agent buffer, so it is bound in
+;; `ghostel-mode-map' and added to `ghostel-keymap-exceptions' (the semi-char
+;; map otherwise forwards it to the pty). C-; is already an exception via
+;; term-config, so the C-; a family resolves through the global prefix. These
+;; tests require ghostel (so ai-term's `with-eval-after-load' fires) before
+;; ai-term, then confirm the bindings landed and the old F9 family is gone.
+;; `(require 'ghostel)' does not load the native module, so this stays light.
+
+;;; Code:
+
+(require 'ert)
+(require 'package)
+
+(setq package-user-dir (expand-file-name "elpa" user-emacs-directory))
+(package-initialize)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ghostel)
+(require 'ai-term)
+
+(ert-deftest test-ai-term-keymap-leaf-bindings ()
+ "Normal: the ai-term keymap binds toggle/select/next/kill on a/s/n/k."
+ (should (eq (keymap-lookup cj/ai-term-keymap "a") #'cj/ai-term))
+ (should (eq (keymap-lookup cj/ai-term-keymap "s") #'cj/ai-term-pick-project))
+ (should (eq (keymap-lookup cj/ai-term-keymap "n") #'cj/ai-term-next))
+ (should (eq (keymap-lookup cj/ai-term-keymap "k") #'cj/ai-term-close)))
+
+(ert-deftest test-ai-term-keymap-registered-under-custom-prefix ()
+ "Normal: the ai-term keymap is registered under C-; a."
+ (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)))
+
+(ert-deftest test-ai-term-meta-space-bound-in-ghostel-mode-map ()
+ "Normal: M-SPC is bound in `ghostel-mode-map' so swap works inside an agent."
+ (should (eq (keymap-lookup ghostel-mode-map "M-SPC") #'cj/ai-term-next)))
+
+(ert-deftest test-ai-term-meta-space-in-keymap-exceptions ()
+ "Regression: M-SPC is in `ghostel-keymap-exceptions' so semi-char mode lets it
+reach Emacs instead of forwarding it to the pty."
+ (should (member "M-SPC" ghostel-keymap-exceptions))
+ (should-not (eq (keymap-lookup ghostel-semi-char-mode-map "M-SPC")
+ 'ghostel--send-event)))
+
+(ert-deftest test-ai-term-f9-family-removed-globally ()
+ "Regression: the old F9 family no longer binds the ai-term commands globally."
+ (should-not (eq (lookup-key (current-global-map) (kbd "<f9>")) #'cj/ai-term))
+ (should-not (eq (lookup-key (current-global-map) (kbd "C-<f9>")) #'cj/ai-term-pick-project))
+ (should-not (eq (lookup-key (current-global-map) (kbd "s-<f9>")) #'cj/ai-term-next))
+ (should-not (eq (lookup-key (current-global-map) (kbd "M-<f9>")) #'cj/ai-term-close)))
+
+(provide 'test-ai-term--keybindings)
+;;; test-ai-term--keybindings.el ends here
diff --git a/tests/test-ai-term--live-count.el b/tests/test-ai-term--live-count.el
new file mode 100644
index 000000000..1432599cc
--- /dev/null
+++ b/tests/test-ai-term--live-count.el
@@ -0,0 +1,60 @@
+;;; test-ai-term--live-count.el --- Tests for cj/ai-term-live-count -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The shutdown safety gate: the integer count of live AI-term (aiv-*) tmux
+;; sessions, read by the rulesets wrap-it-up workflow via emacsclient -e. No
+;; server / no sessions is 0, not an error.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(defmacro test-ai-term-live-count--with-tmux (exit-code output &rest body)
+ "Run BODY with `process-file' mocked to a tmux list-sessions response.
+EXIT-CODE is returned (or the symbol `error' to signal); OUTPUT is written to
+the stdout destination buffer."
+ (declare (indent 2))
+ `(cl-letf (((symbol-function 'process-file)
+ (lambda (_program _infile destination _display &rest _args)
+ (when (eq ,exit-code 'error) (error "tmux: command not found"))
+ (let ((buffer (cond ((eq destination t) (current-buffer))
+ ((bufferp destination) destination)
+ ((consp destination)
+ (and (eq (car destination) t) (current-buffer))))))
+ (when (bufferp buffer)
+ (with-current-buffer buffer (insert ,output))))
+ ,exit-code)))
+ (let ((cj/ai-term-tmux-session-prefix "aiv-"))
+ ,@body)))
+
+(ert-deftest test-ai-term-live-count-counts-matching-sessions ()
+ "Normal: two aiv-* sessions among others count as 2."
+ (test-ai-term-live-count--with-tmux 0 "aiv-foo\nrandom\naiv-bar\n"
+ (should (= (cj/ai-term-live-count) 2))))
+
+(ert-deftest test-ai-term-live-count-single-session ()
+ "Boundary: a sole aiv-* session counts as 1."
+ (test-ai-term-live-count--with-tmux 0 "aiv-only\nother\n"
+ (should (= (cj/ai-term-live-count) 1))))
+
+(ert-deftest test-ai-term-live-count-no-matching-sessions ()
+ "Boundary: a running server with no aiv-* sessions is 0."
+ (test-ai-term-live-count--with-tmux 0 "other-a\nother-b\n"
+ (should (= (cj/ai-term-live-count) 0))))
+
+(ert-deftest test-ai-term-live-count-no-server ()
+ "Error: tmux exits non-zero (no server) -> 0, not a signal."
+ (test-ai-term-live-count--with-tmux 1 "no server running\n"
+ (should (= (cj/ai-term-live-count) 0))))
+
+(ert-deftest test-ai-term-live-count-tmux-missing ()
+ "Error: tmux not installed -> 0."
+ (test-ai-term-live-count--with-tmux 'error ""
+ (should (= (cj/ai-term-live-count) 0))))
+
+(provide 'test-ai-term--live-count)
+;;; test-ai-term--live-count.el ends here
diff --git a/tests/test-ai-term--next-agent-buffer.el b/tests/test-ai-term--next-agent-buffer.el
deleted file mode 100644
index 330714a92..000000000
--- a/tests/test-ai-term--next-agent-buffer.el
+++ /dev/null
@@ -1,73 +0,0 @@
-;;; test-ai-term--next-agent-buffer.el --- Tests for cj/--ai-term-next-agent-buffer -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; The pure decision helper behind `cj/ai-term-next' (s-F9). Given the
-;; current agent buffer and the ordered list of live agent buffers, it
-;; returns the next buffer in the queue, wrapping after the last. A nil
-;; or non-member CURRENT returns the first; an empty list returns nil.
-;; No buffer or window side effects -- list logic only.
-
-;;; Code:
-
-(require 'ert)
-
-(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'ai-term)
-
-(ert-deftest test-ai-term--next-agent-buffer-advances-from-first ()
- "Normal: current is the first element -> returns the second."
- (let ((a (get-buffer-create "agent [a]"))
- (b (get-buffer-create "agent [b]"))
- (c (get-buffer-create "agent [c]")))
- (unwind-protect
- (should (eq b (cj/--ai-term-next-agent-buffer a (list a b c))))
- (mapc #'kill-buffer (list a b c)))))
-
-(ert-deftest test-ai-term--next-agent-buffer-advances-from-middle ()
- "Normal: current in the middle -> returns the following element."
- (let ((a (get-buffer-create "agent [a]"))
- (b (get-buffer-create "agent [b]"))
- (c (get-buffer-create "agent [c]")))
- (unwind-protect
- (should (eq c (cj/--ai-term-next-agent-buffer b (list a b c))))
- (mapc #'kill-buffer (list a b c)))))
-
-(ert-deftest test-ai-term--next-agent-buffer-wraps-after-last ()
- "Boundary: current is the last element -> wraps to the first."
- (let ((a (get-buffer-create "agent [a]"))
- (b (get-buffer-create "agent [b]"))
- (c (get-buffer-create "agent [c]")))
- (unwind-protect
- (should (eq a (cj/--ai-term-next-agent-buffer c (list a b c))))
- (mapc #'kill-buffer (list a b c)))))
-
-(ert-deftest test-ai-term--next-agent-buffer-single-element-returns-itself ()
- "Boundary: a one-agent queue wraps current back to itself."
- (let ((a (get-buffer-create "agent [a]")))
- (unwind-protect
- (should (eq a (cj/--ai-term-next-agent-buffer a (list a))))
- (kill-buffer a))))
-
-(ert-deftest test-ai-term--next-agent-buffer-nil-current-returns-first ()
- "Boundary: nil current (no agent displayed) -> returns the first."
- (let ((a (get-buffer-create "agent [a]"))
- (b (get-buffer-create "agent [b]")))
- (unwind-protect
- (should (eq a (cj/--ai-term-next-agent-buffer nil (list a b))))
- (mapc #'kill-buffer (list a b)))))
-
-(ert-deftest test-ai-term--next-agent-buffer-non-member-current-returns-first ()
- "Error: current not in the queue -> returns the first rather than nil."
- (let ((a (get-buffer-create "agent [a]"))
- (b (get-buffer-create "agent [b]"))
- (stray (get-buffer-create "agent [stray]")))
- (unwind-protect
- (should (eq a (cj/--ai-term-next-agent-buffer stray (list a b))))
- (mapc #'kill-buffer (list a b stray)))))
-
-(ert-deftest test-ai-term--next-agent-buffer-empty-queue-returns-nil ()
- "Boundary: an empty queue returns nil (nothing to switch to)."
- (should (null (cj/--ai-term-next-agent-buffer nil '()))))
-
-(provide 'test-ai-term--next-agent-buffer)
-;;; test-ai-term--next-agent-buffer.el ends here
diff --git a/tests/test-ai-term--next-agent-dir.el b/tests/test-ai-term--next-agent-dir.el
new file mode 100644
index 000000000..b5cf1cdf5
--- /dev/null
+++ b/tests/test-ai-term--next-agent-dir.el
@@ -0,0 +1,48 @@
+;;; test-ai-term--next-agent-dir.el --- Tests for cj/--ai-term-next-agent-dir -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The pure decision helper behind `cj/ai-term-next'. Given the current
+;; active-agent project dir and the ordered list of active-agent dirs, it
+;; returns the next dir in the queue, wrapping after the last. A nil or
+;; non-member CURRENT returns the first; an empty list returns nil. Dirs are
+;; matched with `member' (string equality). No side effects -- list logic only.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(defconst test-ai-term--dirs '("/p/a" "/p/b" "/p/c"))
+
+(ert-deftest test-ai-term--next-agent-dir-advances-from-first ()
+ "Normal: current is the first element -> returns the second."
+ (should (equal "/p/b" (cj/--ai-term-next-agent-dir "/p/a" test-ai-term--dirs))))
+
+(ert-deftest test-ai-term--next-agent-dir-advances-from-middle ()
+ "Normal: current in the middle -> returns the following element."
+ (should (equal "/p/c" (cj/--ai-term-next-agent-dir "/p/b" test-ai-term--dirs))))
+
+(ert-deftest test-ai-term--next-agent-dir-wraps-after-last ()
+ "Boundary: current is the last element -> wraps to the first."
+ (should (equal "/p/a" (cj/--ai-term-next-agent-dir "/p/c" test-ai-term--dirs))))
+
+(ert-deftest test-ai-term--next-agent-dir-single-element-returns-itself ()
+ "Boundary: a one-agent queue wraps current back to itself."
+ (should (equal "/p/a" (cj/--ai-term-next-agent-dir "/p/a" '("/p/a")))))
+
+(ert-deftest test-ai-term--next-agent-dir-nil-current-returns-first ()
+ "Boundary: nil current (no agent displayed) -> returns the first."
+ (should (equal "/p/a" (cj/--ai-term-next-agent-dir nil '("/p/a" "/p/b")))))
+
+(ert-deftest test-ai-term--next-agent-dir-non-member-current-returns-first ()
+ "Error: current not in the queue -> returns the first rather than nil."
+ (should (equal "/p/a" (cj/--ai-term-next-agent-dir "/p/stray" '("/p/a" "/p/b")))))
+
+(ert-deftest test-ai-term--next-agent-dir-empty-queue-returns-nil ()
+ "Boundary: an empty queue returns nil (nothing to switch to)."
+ (should (null (cj/--ai-term-next-agent-dir nil '()))))
+
+(provide 'test-ai-term--next-agent-dir)
+;;; test-ai-term--next-agent-dir.el ends here
diff --git a/tests/test-ai-term--next-no-agents.el b/tests/test-ai-term--next-no-agents.el
new file mode 100644
index 000000000..59132df8e
--- /dev/null
+++ b/tests/test-ai-term--next-no-agents.el
@@ -0,0 +1,34 @@
+;;; test-ai-term--next-no-agents.el --- cj/ai-term-next no-agents fallback -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; When no agent buffers are open, `cj/ai-term-next' (bound to M-SPC) launches
+;; the project picker (`cj/ai-term-pick-project') to start the first agent,
+;; instead of signalling a `user-error'. The swap key thus doubles as a
+;; "start an agent" key when there is nothing to swap to.
+
+;;; 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-next-no-agents-launches-picker ()
+ "Error: no agents open -> launches the picker instead of erroring."
+ (let ((picked 0))
+ (cl-letf (((symbol-function 'cj/--ai-term-active-agent-dirs) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--ai-term-displayed-agent-window) (lambda (&rest _) nil))
+ ((symbol-function 'cj/ai-term-pick-project) (lambda (&rest _) (setq picked (1+ picked)))))
+ (cj/ai-term-next)
+ (should (= picked 1)))))
+
+(ert-deftest test-ai-term-next-no-agents-does-not-signal ()
+ "Error: no agents open -> returns normally, no user-error raised."
+ (cl-letf (((symbol-function 'cj/--ai-term-active-agent-dirs) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--ai-term-displayed-agent-window) (lambda (&rest _) nil))
+ ((symbol-function 'cj/ai-term-pick-project) (lambda (&rest _) nil)))
+ (should (progn (cj/ai-term-next) t))))
+
+(provide 'test-ai-term--next-no-agents)
+;;; test-ai-term--next-no-agents.el ends here
diff --git a/tests/test-ai-term--quit.el b/tests/test-ai-term--quit.el
new file mode 100644
index 000000000..55ace81db
--- /dev/null
+++ b/tests/test-ai-term--quit.el
@@ -0,0 +1,65 @@
+;;; test-ai-term--quit.el --- Tests for cj/ai-term-quit -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Headless teardown of a project's AI-term: kill the aiv-<name> tmux session,
+;; then the agent buffer. Driven by the rulesets Stop hook via emacsclient -e,
+;; keyed by project basename. Must be idempotent (a no-op when already gone).
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(defmacro test-ai-term-quit--with-tmux (calls-var &rest body)
+ "Run BODY with `process-file' mocked to record arg lists into CALLS-VAR (0 exit)."
+ (declare (indent 1))
+ `(cl-letf (((symbol-function 'process-file)
+ (lambda (_program _infile _destination _display &rest args)
+ (push args ,calls-var) 0)))
+ ,@body))
+
+(ert-deftest test-ai-term-quit-kills-session-and-buffer ()
+ "Normal: quit kills the project's aiv- session and its agent buffer."
+ (let ((buf (get-buffer-create "agent [myproj]"))
+ (calls nil))
+ (unwind-protect
+ (test-ai-term-quit--with-tmux calls
+ (cj/ai-term-quit "myproj")
+ (should (member '("kill-session" "-t" "aiv-myproj") calls))
+ (should-not (buffer-live-p buf)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest test-ai-term-quit-sanitizes-dotted-basename ()
+ "Boundary: a dotted basename maps to the sanitized session tmux really uses."
+ (let ((buf (get-buffer-create "agent [.emacs.d]"))
+ (calls nil))
+ (unwind-protect
+ (test-ai-term-quit--with-tmux calls
+ (cj/ai-term-quit ".emacs.d")
+ (should (member '("kill-session" "-t" "aiv-_emacs_d") 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))
+ (test-ai-term-quit--with-tmux calls
+ ;; No buffer named "agent [ghost]" exists; session kill is a no-op in tmux.
+ (should (stringp (cj/ai-term-quit "ghost")))
+ (should (member '("kill-session" "-t" "aiv-ghost") calls)))))
+
+(ert-deftest test-ai-term-quit-leaves-non-agent-buffers ()
+ "Error: a same-named-but-non-agent buffer is never killed (prefix guard)."
+ (let ((buf (get-buffer-create "notes-myproj"))
+ (calls nil))
+ (unwind-protect
+ (test-ai-term-quit--with-tmux calls
+ (cj/ai-term-quit "myproj")
+ (should (buffer-live-p buf)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(provide 'test-ai-term--quit)
+;;; test-ai-term--quit.el ends here
diff --git a/tests/test-ai-term--shutdown-countdown.el b/tests/test-ai-term--shutdown-countdown.el
new file mode 100644
index 000000000..6500e9634
--- /dev/null
+++ b/tests/test-ai-term--shutdown-countdown.el
@@ -0,0 +1,73 @@
+;;; test-ai-term--shutdown-countdown.el --- Tests for the shutdown countdown -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The "wrap it up and shutdown" countdown. The testable logic is the safety
+;; gate (abort when more than one aiv-* session is live) and the cancel/timer
+;; bookkeeping; the tick rendering and the actual shutdown side effect are
+;; manual (see the spec). shell-command is stubbed throughout so no test can
+;; power the machine off, and timers are cancelled rather than allowed to fire.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(defmacro test-ai-term-shutdown--with (live-count shell-var &rest body)
+ "Run BODY with `cj/ai-term-live-count' mocked to LIVE-COUNT and `shell-command'
+recording its argument into SHELL-VAR; the timer is cleared before and after."
+ (declare (indent 2))
+ `(progn
+ (cj/--ai-term-shutdown-clear-timer)
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/ai-term-live-count) (lambda () ,live-count))
+ ((symbol-function 'shell-command)
+ (lambda (cmd &rest _) (setq ,shell-var cmd) 0)))
+ ,@body)
+ (cj/--ai-term-shutdown-clear-timer))))
+
+(ert-deftest test-ai-term-shutdown-aborts-when-other-sessions-live ()
+ "Normal: more than one live session aborts -- no timer, no shutdown."
+ (let ((shell nil))
+ (test-ai-term-shutdown--with 2 shell
+ (should-not (cj/ai-term-shutdown-countdown 3))
+ (should-not cj/--ai-term-shutdown-timer)
+ (should-not shell))))
+
+(ert-deftest test-ai-term-shutdown-schedules-timer-when-sole-session ()
+ "Normal: the sole live session schedules the countdown timer (does not fire here)."
+ (let ((shell nil))
+ (test-ai-term-shutdown--with 1 shell
+ (cj/ai-term-shutdown-countdown 3)
+ (should (timerp cj/--ai-term-shutdown-timer))
+ ;; The timer has not ticked (no event loop in batch), so no shutdown yet.
+ (should-not shell))))
+
+(ert-deftest test-ai-term-shutdown-cancel-clears-the-timer ()
+ "Normal: cancel stops an in-progress countdown."
+ (let ((shell nil))
+ (test-ai-term-shutdown--with 1 shell
+ (cj/ai-term-shutdown-countdown 5)
+ (should (timerp cj/--ai-term-shutdown-timer))
+ (cj/ai-term-shutdown-cancel)
+ (should-not cj/--ai-term-shutdown-timer)
+ (should-not shell))))
+
+(ert-deftest test-ai-term-shutdown-tick-fires-shutdown-at-zero ()
+ "Boundary: invoking the timer function at zero remaining runs the shutdown
+command and clears the timer. Drives the tick directly rather than waiting."
+ (let ((shell nil))
+ (test-ai-term-shutdown--with 1 shell
+ (cj/ai-term-shutdown-countdown 1)
+ (let ((fn (timer--function cj/--ai-term-shutdown-timer)))
+ ;; remaining starts at 1: first call renders, second call hits zero.
+ (funcall fn)
+ (should-not shell)
+ (funcall fn)
+ (should (equal shell cj/ai-term-shutdown-command))
+ (should-not cj/--ai-term-shutdown-timer)))))
+
+(provide 'test-ai-term--shutdown-countdown)
+;;; test-ai-term--shutdown-countdown.el ends here
diff --git a/tests/test-calendar-sync--apply-single-exception.el b/tests/test-calendar-sync--apply-single-exception.el
index 3d2342708..f23104d98 100644
--- a/tests/test-calendar-sync--apply-single-exception.el
+++ b/tests/test-calendar-sync--apply-single-exception.el
@@ -105,5 +105,42 @@
(plist-get (calendar-sync--apply-single-exception occ exc)
:url)))))
+;;; Status re-derivation from overridden attendees (chime handoff 2026-06-24)
+
+(ert-deftest test-calendar-sync--apply-single-exception-declined-occurrence-rederives-status ()
+ "Normal: a declined single occurrence re-derives :status from the override attendees."
+ (let ((calendar-sync-user-emails '("craig@example.com"))
+ (occ (list :start '(2026 6 24 16 0) :status "accepted" :uid "abc"))
+ (exc (list :start '(2026 6 24 16 0)
+ :attendees (list (list :email "craig@example.com" :partstat "DECLINED")))))
+ (should (equal "declined"
+ (plist-get (calendar-sync--apply-single-exception occ exc) :status)))))
+
+(ert-deftest test-calendar-sync--apply-single-exception-no-attendee-override-keeps-status ()
+ "Boundary: an exception with no attendee block leaves the inherited :status intact."
+ (let ((calendar-sync-user-emails '("craig@example.com"))
+ (occ (list :start '(2026 6 24 16 0) :status "accepted" :uid "abc"))
+ (exc (list :start '(2026 6 24 16 0) :summary "Moved")))
+ (should (equal "accepted"
+ (plist-get (calendar-sync--apply-single-exception occ exc) :status)))))
+
+(ert-deftest test-calendar-sync--apply-single-exception-accepted-override-stays-accepted ()
+ "Normal: an accepted attendee override keeps :status accepted."
+ (let ((calendar-sync-user-emails '("craig@example.com"))
+ (occ (list :start '(2026 6 24 16 0) :status "accepted" :uid "abc"))
+ (exc (list :start '(2026 6 24 16 0)
+ :attendees (list (list :email "craig@example.com" :partstat "ACCEPTED")))))
+ (should (equal "accepted"
+ (plist-get (calendar-sync--apply-single-exception occ exc) :status)))))
+
+(ert-deftest test-calendar-sync--apply-single-exception-override-without-user-keeps-status ()
+ "Boundary: override attendees that don't include the user leave :status intact."
+ (let ((calendar-sync-user-emails '("craig@example.com"))
+ (occ (list :start '(2026 6 24 16 0) :status "accepted" :uid "abc"))
+ (exc (list :start '(2026 6 24 16 0)
+ :attendees (list (list :email "someone@else.com" :partstat "DECLINED")))))
+ (should (equal "accepted"
+ (plist-get (calendar-sync--apply-single-exception occ exc) :status)))))
+
(provide 'test-calendar-sync--apply-single-exception)
;;; test-calendar-sync--apply-single-exception.el ends here
diff --git a/tests/test-calendar-sync--robustness.el b/tests/test-calendar-sync--robustness.el
new file mode 100644
index 000000000..2c044b013
--- /dev/null
+++ b/tests/test-calendar-sync--robustness.el
@@ -0,0 +1,70 @@
+;;; test-calendar-sync--robustness.el --- Tests for sync robustness fixes -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for two robustness fixes:
+;; - calendar-sync--parse-ics distinguishes a healthy zero-event calendar
+;; (a real iCalendar with no in-window events -> non-nil header) from
+;; garbage (no BEGIN:VCALENDAR -> nil), so a near-empty calendar no longer
+;; reports "parse failed".
+;; - calendar-sync--write-file writes atomically (temp file + rename), so a
+;; reader never sees a half-written calendar and no temp file is left behind.
+;; (The curl --fail change is in the make-process command list and is exercised
+;; against the live feed, not unit-tested here.)
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+
+;;; calendar-sync--parse-ics: zero-event vs garbage
+
+(ert-deftest test-calendar-sync--parse-ics-valid-zero-events-non-nil ()
+ "Normal: a real iCalendar with no in-window events returns a non-nil empty
+calendar, not a parse failure."
+ (let ((result (calendar-sync--parse-ics "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR\n")))
+ (should result)
+ (should (string-match-p "Calendar Events" result))))
+
+(ert-deftest test-calendar-sync--parse-ics-garbage-nil ()
+ "Error: non-iCalendar content (no BEGIN:VCALENDAR, e.g. an HTML error page)
+returns nil -- a genuine failure."
+ (should-not (calendar-sync--parse-ics "HTTP 404 Not Found\n<html><body>error</body></html>")))
+
+;;; calendar-sync--write-file: atomic
+
+(ert-deftest test-calendar-sync--write-file-writes-content ()
+ "Normal: the content lands in the target file."
+ (let* ((dir (make-temp-file "cal-sync-test-" t))
+ (file (expand-file-name "agenda.org" dir)))
+ (unwind-protect
+ (progn
+ (calendar-sync--write-file "# Calendar Events\n\nhello\n" file)
+ (should (equal "# Calendar Events\n\nhello\n"
+ (with-temp-buffer (insert-file-contents file)
+ (buffer-string)))))
+ (delete-directory dir t))))
+
+(ert-deftest test-calendar-sync--write-file-leaves-no-temp ()
+ "Boundary: the temp file is renamed into place, not left in the directory."
+ (let* ((dir (make-temp-file "cal-sync-test-" t))
+ (file (expand-file-name "agenda.org" dir)))
+ (unwind-protect
+ (progn
+ (calendar-sync--write-file "x" file)
+ ;; only the target file remains -- no leftover .calendar-sync-* temp
+ (should (equal '("agenda.org")
+ (directory-files dir nil "\\`[^.]"))))
+ (delete-directory dir t))))
+
+(ert-deftest test-calendar-sync--write-file-creates-parent-dir ()
+ "Boundary: a missing parent directory is created."
+ (let* ((root (make-temp-file "cal-sync-test-" t))
+ (file (expand-file-name "sub/nested/agenda.org" root)))
+ (unwind-protect
+ (progn
+ (calendar-sync--write-file "y" file)
+ (should (file-exists-p file)))
+ (delete-directory root t))))
+
+(provide 'test-calendar-sync--robustness)
+;;; test-calendar-sync--robustness.el ends here
diff --git a/tests/test-calendar-sync.el b/tests/test-calendar-sync.el
index 62b00aba1..f562cfc61 100644
--- a/tests/test-calendar-sync.el
+++ b/tests/test-calendar-sync.el
@@ -471,11 +471,14 @@ Earlier events should appear first in the output."
(should (string-match-p "\\* Event 1" org-content))
(should (string-match-p "\\* Event 2" org-content))))
-(ert-deftest test-calendar-sync--parse-ics-boundary-empty-calendar-returns-nil ()
- "Test parsing empty calendar (no events)."
+(ert-deftest test-calendar-sync--parse-ics-boundary-empty-calendar-returns-header ()
+ "A valid but empty iCalendar (no events) is a healthy zero-event calendar:
+it returns a non-nil header so the sync reports success, not a parse failure.
+Garbage with no BEGIN:VCALENDAR still returns nil (covered elsewhere)."
(let* ((ics "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR")
(org-content (calendar-sync--parse-ics ics)))
- (should (null org-content))))
+ (should org-content)
+ (should (string-match-p "Calendar Events" org-content))))
(ert-deftest test-calendar-sync--parse-ics-error-malformed-ics-returns-nil ()
"Test that malformed .ics returns nil and sets error."
diff --git a/tests/test-dashboard-config-launchers.el b/tests/test-dashboard-config-launchers.el
index e7e8d2f33..a9a871979 100644
--- a/tests/test-dashboard-config-launchers.el
+++ b/tests/test-dashboard-config-launchers.el
@@ -56,7 +56,8 @@ Slack, Linear, and Signal sharing the last row."
(cl-letf (((symbol-function 'nerd-icons-faicon) (lambda (n &rest _) (concat "I:" n)))
((symbol-function 'nerd-icons-devicon) (lambda (n &rest _) (concat "I:" n)))
((symbol-function 'nerd-icons-mdicon) (lambda (n &rest _) (concat "I:" n)))
- ((symbol-function 'nerd-icons-octicon) (lambda (n &rest _) (concat "I:" n))))
+ ((symbol-function 'nerd-icons-octicon) (lambda (n &rest _) (concat "I:" n)))
+ ((symbol-function 'nerd-icons-codicon) (lambda (n &rest _) (concat "I:" n))))
(let ((rows (cj/dashboard--navigator-rows)))
(should (= 4 (length rows)))
(should (equal '(4 4 3 3) (mapcar #'length rows)))
diff --git a/tests/test-dirvish-config-wallpaper-program.el b/tests/test-dirvish-config-wallpaper-program.el
index 556c13100..41d2ad8b2 100644
--- a/tests/test-dirvish-config-wallpaper-program.el
+++ b/tests/test-dirvish-config-wallpaper-program.el
@@ -28,9 +28,9 @@
'("feh" "--bg-fill"))))
(ert-deftest test-cj--wallpaper-program-for-wayland ()
- "Normal: wayland dispatches to swww with the img subcommand."
+ "Normal: wayland dispatches to the set-wallpaper script (awww backend + waypaper persist)."
(should (equal (cj/--wallpaper-program-for 'wayland)
- '("swww" "img"))))
+ '("set-wallpaper"))))
(ert-deftest test-cj--wallpaper-program-for-unknown-returns-nil ()
"Boundary: an unknown environment returns nil so the wrapper can fall back."
diff --git a/tests/test-erc-config-connected-servers.el b/tests/test-erc-config-connected-servers.el
index 7d4540d68..394367c3e 100644
--- a/tests/test-erc-config-connected-servers.el
+++ b/tests/test-erc-config-connected-servers.el
@@ -5,8 +5,9 @@
;; process. The original test compared a buffer's own erc-server-process to the
;; same buffer-local value inside `with-current-buffer', which is always true, so
;; it returned every ERC buffer (channels, queries, dead connections). These
-;; tests stub `erc-buffer-list' and the two ERC predicates so the classification
-;; is exercised without a real IRC connection.
+;; tests stub `erc-buffer-list' and the two ERC predicates
+;; (`erc-server-or-unjoined-channel-buffer-p' and `erc-server-process-alive')
+;; so the classification is exercised without a real IRC connection.
;;; Code:
@@ -25,7 +26,7 @@ returned; a channel buffer and a dead-connection server buffer are excluded."
(unwind-protect
(cl-letf (((symbol-function 'erc-buffer-list)
(lambda (&rest _) (list b-server b-channel b-dead)))
- ((symbol-function 'erc-server-buffer-p)
+ ((symbol-function 'erc-server-or-unjoined-channel-buffer-p)
(lambda (&rest _) (memq (current-buffer) (list b-server b-dead))))
((symbol-function 'erc-server-process-alive)
(lambda (&rest _) (eq (current-buffer) b-server))))
@@ -39,7 +40,7 @@ returned; a channel buffer and a dead-connection server buffer are excluded."
(unwind-protect
(cl-letf (((symbol-function 'erc-buffer-list)
(lambda (&rest _) (list b-channel)))
- ((symbol-function 'erc-server-buffer-p) (lambda (&rest _) nil))
+ ((symbol-function 'erc-server-or-unjoined-channel-buffer-p) (lambda (&rest _) nil))
((symbol-function 'erc-server-process-alive) (lambda (&rest _) nil)))
(should (null (cj/erc-connected-servers))))
(kill-buffer b-channel))))
diff --git a/tests/test-face-diagnostic.el b/tests/test-face-diagnostic.el
index 241425fc5..32595b464 100644
--- a/tests/test-face-diagnostic.el
+++ b/tests/test-face-diagnostic.el
@@ -286,6 +286,31 @@
(should (string-match-p "Real font" report))
(should (string-match-p "Provenance" report)))))
+(ert-deftest test-face-diag-face-button-real-face-is-button ()
+ "Normal: a real face renders as a `describe-face' button carrying the face.
+Visible label is unchanged; the button data is the face so RET/mouse opens it."
+ (let ((s (cj/--face-diag-face-button 'bold)))
+ (should (equal (substring-no-properties s) "bold"))
+ (should (get-text-property 0 'button s))
+ (should (eq (get-text-property 0 'button-data s) 'bold))))
+
+(ert-deftest test-face-diag-face-button-non-face-is-plain ()
+ "Boundary: a symbol that is not a face stays plain text, no button."
+ (let ((s (cj/--face-diag-face-button 'cj-not-a-real-face-xyz)))
+ (should (equal s "cj-not-a-real-face-xyz"))
+ (should-not (get-text-property 0 'button s))))
+
+(ert-deftest test-face-diag-face-button-anonymous-spec-is-plain ()
+ "Error: an anonymous (:attr val ...) spec is not a face, so no button."
+ (let ((s (cj/--face-diag-face-button '(:foreground "red"))))
+ (should-not (get-text-property 0 'button s))))
+
+(ert-deftest test-face-diag-render-faces-buttonizes-real-face ()
+ "Normal: a real face in the stack render carries a button property."
+ (let ((s (cj/--face-diag-render-faces '(bold))))
+ (should (string-match-p "bold" s))
+ (should (get-text-property 0 'button s))))
+
(ert-deftest test-face-diag-render-banner-out-of-scope ()
"Boundary: a terminal classification renders a banner naming the ANSI source."
(should (string-match-p "terminal" (cj/--face-diag-render-banner 'terminal-ansi)))
diff --git a/tests/test-google-keep-config.el b/tests/test-google-keep-config.el
new file mode 100644
index 000000000..690355506
--- /dev/null
+++ b/tests/test-google-keep-config.el
@@ -0,0 +1,142 @@
+;;; test-google-keep-config.el --- Tests for google-keep-config -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the pure JSON-to-org core of google-keep-config.el (the part that
+;; later extracts to a package) plus the parse-render-write chain. The bridge
+;; subprocess + auth are the IO boundary, exercised live once the token is set.
+
+;;; Code:
+
+(require 'ert)
+(require 'google-keep-config)
+
+(defun test-google-keep--note (&rest overrides)
+ "Build a note alist (parse-shaped) with OVERRIDES merged in."
+ (let ((base (list (cons 'id "abc")
+ (cons 'title "Groceries")
+ (cons 'text "milk\neggs")
+ (cons 'labels '("shopping" "home"))
+ (cons 'pinned nil)
+ (cons 'archived nil)
+ (cons 'color "WHITE")
+ (cons 'updated "2026-06-25T04:00:00Z"))))
+ (dolist (pair overrides base)
+ (setf (alist-get (car pair) base) (cdr pair)))))
+
+;;; cj/keep--parse-json
+
+(ert-deftest test-google-keep-parse-json-array ()
+ "Normal: a JSON array parses to a list of note alists."
+ (let ((notes (cj/keep--parse-json
+ "[{\"id\":\"a\",\"title\":\"T\",\"labels\":[\"x\"],\"pinned\":true}]")))
+ (should (= 1 (length notes)))
+ (should (equal "a" (alist-get 'id (car notes))))
+ (should (equal '("x") (alist-get 'labels (car notes))))
+ (should (eq t (alist-get 'pinned (car notes))))))
+
+(ert-deftest test-google-keep-parse-json-empty ()
+ "Boundary: an empty Keep ([]) parses to an empty list."
+ (should (null (cj/keep--parse-json "[]"))))
+
+;;; cj/keep--label-to-tag
+
+(ert-deftest test-google-keep-label-to-tag-plain ()
+ "Normal: an alphanumeric label is unchanged."
+ (should (equal "shopping" (cj/keep--label-to-tag "shopping"))))
+
+(ert-deftest test-google-keep-label-to-tag-sanitizes ()
+ "Boundary: spaces and punctuation become underscores (valid org tag chars)."
+ (should (equal "to_do_list_" (cj/keep--label-to-tag "to do/list!"))))
+
+;;; cj/keep--note-tags
+
+(ert-deftest test-google-keep-note-tags-labels ()
+ "Normal: labels render as a trailing org-tag string."
+ (should (equal " :shopping:home:" (cj/keep--note-tags (test-google-keep--note)))))
+
+(ert-deftest test-google-keep-note-tags-archived ()
+ "Normal: an archived note gains the archived tag."
+ (should (equal " :shopping:home:archived:"
+ (cj/keep--note-tags (test-google-keep--note (cons 'archived t))))))
+
+(ert-deftest test-google-keep-note-tags-none ()
+ "Boundary: no labels and not archived yields an empty tag string."
+ (should (equal "" (cj/keep--note-tags
+ (test-google-keep--note (cons 'labels nil))))))
+
+;;; cj/keep--note-heading
+
+(ert-deftest test-google-keep-note-heading-full ()
+ "Normal: a full note renders heading, properties, link, and body."
+ (let ((s (cj/keep--note-heading (test-google-keep--note))))
+ (should (string-match-p "\\`\\* Groceries :shopping:home:\n" s))
+ (should (string-match-p ":KEEP_ID: abc\n" s))
+ (should (string-match-p ":UPDATED: 2026-06-25T04:00:00Z\n" s))
+ (should (string-match-p "\\[\\[https://keep.google.com/#NOTE/abc\\]\\[open in Keep\\]\\]" s))
+ (should (string-match-p "milk\neggs\n" s))))
+
+(ert-deftest test-google-keep-note-heading-untitled ()
+ "Boundary: an empty title falls back to (untitled)."
+ (let ((s (cj/keep--note-heading (test-google-keep--note (cons 'title "")))))
+ (should (string-match-p "\\`\\* (untitled)" s))))
+
+(ert-deftest test-google-keep-note-heading-empty-text ()
+ "Boundary: an empty body emits no trailing text block."
+ (let ((s (cj/keep--note-heading
+ (test-google-keep--note (cons 'text "") (cons 'labels nil)))))
+ (should-not (string-match-p "open in Keep\\]\\]\n.+[^\n]" s))))
+
+;;; cj/keep--sort-pinned-first
+
+(ert-deftest test-google-keep-sort-pinned-first ()
+ "Normal: pinned notes come first, order otherwise preserved."
+ (let* ((a (test-google-keep--note (cons 'id "a") (cons 'pinned nil)))
+ (b (test-google-keep--note (cons 'id "b") (cons 'pinned t)))
+ (c (test-google-keep--note (cons 'id "c") (cons 'pinned nil)))
+ (sorted (cj/keep--sort-pinned-first (list a b c))))
+ (should (equal '("b" "a" "c") (mapcar (lambda (n) (alist-get 'id n)) sorted)))))
+
+;;; cj/keep--render
+
+(ert-deftest test-google-keep-render-header-and-notes ()
+ "Normal: the page carries the read-only header and a heading per note."
+ (let ((s (cj/keep--render (list (test-google-keep--note)) "2026-06-25 04:00")))
+ (should (string-match-p "read-only view" s))
+ (should (string-match-p "Last refresh: 2026-06-25 04:00" s))
+ (should (string-match-p "^\\* Groceries" s))))
+
+(ert-deftest test-google-keep-render-empty ()
+ "Boundary: no notes still produces a valid header-only page."
+ (let ((s (cj/keep--render nil)))
+ (should (string-match-p "#\\+TITLE: Google Keep" s))
+ (should-not (string-match-p "^\\* " s))))
+
+;;; cj/keep--write-atomically + the parse-render-write chain
+
+(ert-deftest test-google-keep-write-atomically ()
+ "Normal: content lands in the target file via temp + rename."
+ (let* ((dir (make-temp-file "keep-test-" t))
+ (file (expand-file-name "keep.org" dir)))
+ (unwind-protect
+ (progn
+ (cj/keep--write-atomically "hello\n" file)
+ (should (equal "hello\n"
+ (with-temp-buffer (insert-file-contents file)
+ (buffer-string)))))
+ (delete-directory dir t))))
+
+(ert-deftest test-google-keep-write-notes-chain ()
+ "Normal: JSON in, a rendered org file out, with the note count returned."
+ (let* ((dir (make-temp-file "keep-test-" t))
+ (keep-file (expand-file-name "keep.org" dir)))
+ (unwind-protect
+ (let ((n (cj/keep--write-notes
+ "[{\"id\":\"a\",\"title\":\"One\",\"labels\":[],\"pinned\":false,\"archived\":false,\"color\":\"WHITE\",\"updated\":\"2026-06-25T04:00:00Z\"}]")))
+ (should (= 1 n))
+ (should (string-match-p "^\\* One"
+ (with-temp-buffer (insert-file-contents keep-file)
+ (buffer-string)))))
+ (delete-directory dir t))))
+
+(provide 'test-google-keep-config)
+;;; test-google-keep-config.el ends here
diff --git a/tests/test-init-module-headers.el b/tests/test-init-module-headers.el
index 4b6ac05c4..478819b89 100644
--- a/tests/test-init-module-headers.el
+++ b/tests/test-init-module-headers.el
@@ -105,6 +105,7 @@
"erc-config"
"eshell-config"
"eww-config"
+ "face-diagnostic"
"flyspell-and-abbrev"
"games-config"
"gloss-config"
diff --git a/tests/test-latex-config--latexmk-wiring.el b/tests/test-latex-config--latexmk-wiring.el
new file mode 100644
index 000000000..30b8f29de
--- /dev/null
+++ b/tests/test-latex-config--latexmk-wiring.el
@@ -0,0 +1,62 @@
+;;; test-latex-config--latexmk-wiring.el --- latexmk activation guards -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Guards the two breaks that kept the latexmk workflow from activating:
+;; 1. The :hook entry that sets `TeX-command-default' must target the real
+;; `TeX-mode-hook'. use-package appends "-hook" to any hook symbol not
+;; ending in "-mode", so the mode name `TeX-mode' is required; the literal
+;; `TeX-mode-hook' expands to the nonexistent `TeX-mode-hook-hook'.
+;; 2. `auctex-latexmk' must load so `auctex-latexmk-setup' runs. `:defer t'
+;; with no trigger never fires; `:after tex' loads it when AUCTeX loads.
+;;
+;; The forms are read from the source and macroexpanded, so the test fails the
+;; way the live config failed -- against the actual declaration.
+
+;;; Code:
+
+(require 'ert)
+(require 'seq)
+(require 'use-package)
+
+(defun test-latex-config--forms ()
+ "Return the top-level forms in latex-config.el."
+ (let ((file (expand-file-name "modules/latex-config.el" user-emacs-directory))
+ (forms '()))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (goto-char (point-min))
+ (condition-case nil
+ (while t (push (read (current-buffer)) forms))
+ (end-of-file nil)))
+ (nreverse forms)))
+
+(defun test-latex-config--use-package-form (package)
+ "Return the (use-package PACKAGE ...) top-level form from latex-config.el."
+ (seq-find (lambda (form)
+ (and (consp form)
+ (eq (car form) 'use-package)
+ (eq (cadr form) package)))
+ (test-latex-config--forms)))
+
+(ert-deftest test-latex-config-tex-hook-targets-real-hook ()
+ "Regression: the latexmk-default :hook expands to `TeX-mode-hook', not the
+unbound `TeX-mode-hook-hook' use-package builds from a non-mode hook symbol."
+ (let* ((form (test-latex-config--use-package-form 'tex))
+ (expansion (format "%S" (macroexpand-all form))))
+ (should form)
+ ;; The hook symbol is followed by whitespace before its lambda, so anchor
+ ;; on that to distinguish `TeX-mode-hook' from the broken `...-hook-hook'.
+ (should (string-match-p "TeX-mode-hook[ )]" expansion))
+ (should-not (string-match-p "TeX-mode-hook-hook" expansion))))
+
+(ert-deftest test-latex-config-auctex-latexmk-loads-after-tex ()
+ "Regression: auctex-latexmk uses `:after tex' so `auctex-latexmk-setup' runs;
+a bare `:defer t' with no trigger would never load it."
+ (let ((form (test-latex-config--use-package-form 'auctex-latexmk)))
+ (should form)
+ (should (member :after form))
+ (should (eq (cadr (member :after form)) 'tex))
+ (should-not (member :defer form))))
+
+(provide 'test-latex-config--latexmk-wiring)
+;;; test-latex-config--latexmk-wiring.el ends here
diff --git a/tests/test-nerd-icons-config--apply-tint.el b/tests/test-nerd-icons-config--apply-tint.el
deleted file mode 100644
index ef723352c..000000000
--- a/tests/test-nerd-icons-config--apply-tint.el
+++ /dev/null
@@ -1,63 +0,0 @@
-;;; test-nerd-icons-config--apply-tint.el --- Tests for cj/nerd-icons-apply-tint -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; Tests for the bulk-tint helper. Mocks `set-face-foreground' and `facep'
-;; at the framework boundary so the tests don't depend on nerd-icons being
-;; loaded — only on the symbol list and the dispatch logic.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-
-(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'nerd-icons-config)
-
-(defmacro test-nerd-icons-config--capture-set-face-foreground (calls-var &rest body)
- "Run BODY with `set-face-foreground' and `facep' stubbed.
-Each (face color) pair gets pushed onto CALLS-VAR. `facep' returns t
-for every symbol so all faces in the list count as defined."
- (declare (indent 1) (debug t))
- `(cl-letf (((symbol-function 'set-face-foreground)
- (lambda (face color &rest _) (push (cons face color) ,calls-var)))
- ((symbol-function 'facep)
- (lambda (_) t)))
- ,@body))
-
-(ert-deftest test-nerd-icons-config--apply-tint-covers-every-face ()
- "Normal: apply-tint calls set-face-foreground once per face in the list."
- (let ((calls nil))
- (test-nerd-icons-config--capture-set-face-foreground calls
- (cj/nerd-icons-apply-tint "test-color"))
- (should (= (length calls) (length cj/--nerd-icons-color-faces)))
- (dolist (face cj/--nerd-icons-color-faces)
- (should (assq face calls)))))
-
-(ert-deftest test-nerd-icons-config--apply-tint-passes-color-arg ()
- "Normal: apply-tint forwards COLOR to every set-face-foreground call."
- (let ((calls nil))
- (test-nerd-icons-config--capture-set-face-foreground calls
- (cj/nerd-icons-apply-tint "rebeccapurple"))
- (dolist (call calls)
- (should (equal (cdr call) "rebeccapurple")))))
-
-(ert-deftest test-nerd-icons-config--apply-tint-defaults-to-customvar ()
- "Normal: with no COLOR arg, uses `cj/nerd-icons-tint-color'."
- (let ((calls nil))
- (test-nerd-icons-config--capture-set-face-foreground calls
- (let ((cj/nerd-icons-tint-color "default-test-color"))
- (cj/nerd-icons-apply-tint)))
- (should (cl-every (lambda (call) (equal (cdr call) "default-test-color")) calls))))
-
-(ert-deftest test-nerd-icons-config--apply-tint-skips-undefined-faces ()
- "Boundary: faces that fail `facep' are silently skipped, not errored."
- (let ((calls nil))
- (cl-letf (((symbol-function 'set-face-foreground)
- (lambda (face color &rest _) (push (cons face color) calls)))
- ((symbol-function 'facep)
- (lambda (_) nil)))
- (cj/nerd-icons-apply-tint "any"))
- (should (null calls))))
-
-(provide 'test-nerd-icons-config--apply-tint)
-;;; test-nerd-icons-config--apply-tint.el ends here
diff --git a/tests/test-nerd-icons-config--color-dir.el b/tests/test-nerd-icons-config--color-dir.el
index 808c0dc34..2ae64a810 100644
--- a/tests/test-nerd-icons-config--color-dir.el
+++ b/tests/test-nerd-icons-config--color-dir.el
@@ -53,5 +53,20 @@ renders would stack `nerd-icons-yellow' over and over on the cached string."
(yellows (cl-count 'nerd-icons-yellow specs)))
(should (= yellows 1)))))
+(ert-deftest test-nerd-icons-config--color-dir-precedence-over-completion-face ()
+ "Normal: when the dir icon already carries nerd-icons-completion-dir-face
+\(what `nerd-icons-completion-get-icon' passes), the advice prepends
+nerd-icons-yellow so it is first in the face list and wins the merge. Locks
+the dir-precedence decision: the prepended advice face outranks the package's
+:face, even though that face lives in a different package."
+ (let* ((icon (propertize "X" 'face 'nerd-icons-completion-dir-face))
+ (result (cj/--nerd-icons-color-dir icon))
+ (faces (ensure-list (get-text-property 0 'face result))))
+ (should (memq 'nerd-icons-yellow faces))
+ (should (memq 'nerd-icons-completion-dir-face faces))
+ (should (= 0 (cl-position 'nerd-icons-yellow faces)))
+ (should (< (cl-position 'nerd-icons-yellow faces)
+ (cl-position 'nerd-icons-completion-dir-face faces)))))
+
(provide 'test-nerd-icons-config--color-dir)
;;; test-nerd-icons-config--color-dir.el ends here
diff --git a/tests/test-org-agenda-config--base-files.el b/tests/test-org-agenda-config--base-files.el
index c6939b4d7..bd202a195 100644
--- a/tests/test-org-agenda-config--base-files.el
+++ b/tests/test-org-agenda-config--base-files.el
@@ -3,8 +3,11 @@
;;; Commentary:
;; cj/--org-agenda-base-files is the single source of the fixed agenda base list
;; (inbox, schedule, and the three calendars) that was previously spelled out as
-;; a literal in three places. The path vars are special (defvar'd in
-;; user-constants), so they can be dynamically bound here.
+;; a literal in three places. It now drops files that do not exist so org-agenda
+;; never prompts to create a missing path (the hang class). The path vars are
+;; special (defvar'd in user-constants), so they can be dynamically bound; tests
+;; use real temp files for "exists" rather than mocking the `file-exists-p'
+;; primitive.
;;; Code:
@@ -13,24 +16,44 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'org-agenda-config)
-(ert-deftest test-org-agenda-base-files-returns-fixed-list-in-order ()
- "Normal: returns inbox, schedule, gcal, pcal, dcal in that order."
- (let ((inbox-file "/i")
- (schedule-file "/s")
- (gcal-file "/g")
- (pcal-file "/p")
- (dcal-file "/d"))
- (should (equal (cj/--org-agenda-base-files)
- '("/i" "/s" "/g" "/p" "/d")))))
+(defun test-oa-base--tmp ()
+ "Return a fresh existing temp file path."
+ (make-temp-file "oa-base-"))
+
+(ert-deftest test-org-agenda-base-files-returns-existing-in-order ()
+ "Normal: returns inbox, schedule, gcal, pcal, dcal (all existing) in order."
+ (let* ((i (test-oa-base--tmp)) (s (test-oa-base--tmp)) (g (test-oa-base--tmp))
+ (p (test-oa-base--tmp)) (d (test-oa-base--tmp))
+ (inbox-file i) (schedule-file s) (gcal-file g) (pcal-file p) (dcal-file d))
+ (unwind-protect
+ (should (equal (cj/--org-agenda-base-files) (list i s g p d)))
+ (dolist (f (list i s g p d)) (ignore-errors (delete-file f))))))
(ert-deftest test-org-agenda-base-files-reflects-current-values ()
"Boundary: the helper reads the vars at call time (not a captured snapshot)."
- (let ((inbox-file "first")
- (schedule-file "x") (gcal-file "x") (pcal-file "x") (dcal-file "x"))
- (should (equal (car (cj/--org-agenda-base-files)) "first"))
- (setq inbox-file "second")
- (should (equal (car (cj/--org-agenda-base-files)) "second"))
- (should (= (length (cj/--org-agenda-base-files)) 5))))
+ (let* ((a (test-oa-base--tmp)) (b (test-oa-base--tmp))
+ (inbox-file a) (schedule-file b) (gcal-file b) (pcal-file b) (dcal-file b))
+ (unwind-protect
+ (progn
+ (should (equal (car (cj/--org-agenda-base-files)) a))
+ (setq inbox-file b)
+ (should (equal (car (cj/--org-agenda-base-files)) b))
+ (should (= (length (cj/--org-agenda-base-files)) 5)))
+ (ignore-errors (delete-file a))
+ (ignore-errors (delete-file b)))))
+
+(ert-deftest test-org-agenda-base-files-drops-missing-files ()
+ "Boundary/Error: files that do not exist are dropped, so a fresh machine
+without synced calendars never hands org-agenda a path it would prompt to create."
+ (let* ((i (test-oa-base--tmp)) (s (test-oa-base--tmp))
+ (inbox-file i) (schedule-file s)
+ (gcal-file "/no/such/gcal.org")
+ (pcal-file "/no/such/pcal.org")
+ (dcal-file "/no/such/dcal.org"))
+ (unwind-protect
+ (should (equal (cj/--org-agenda-base-files) (list i s)))
+ (ignore-errors (delete-file i))
+ (ignore-errors (delete-file s)))))
(provide 'test-org-agenda-config--base-files)
;;; test-org-agenda-config--base-files.el ends here
diff --git a/tests/test-org-config-keymap-ownership.el b/tests/test-org-config-keymap-ownership.el
index 729d497cb..81f1ccd46 100644
--- a/tests/test-org-config-keymap-ownership.el
+++ b/tests/test-org-config-keymap-ownership.el
@@ -60,14 +60,14 @@ at the top level."
"Sparse-tree commands sit directly under `C-; O' (flat).
Lowercase creates, capital of the same letter cancels: `s' /
`S' for match-sparse-tree, `t' / `T' for show-todo-tree. Both
-capitals resolve to `org-show-all' -- the user's mental model is
+capitals resolve to `org-fold-show-all' -- the user's mental model is
\"capital cancels the lowercase I just ran\" without having to
remember which letter the cancel actually lives on. `R' is
`org-reveal' (no lowercase pair -- `r' is the table-row sub-prefix)."
(should (eq (keymap-lookup cj/org-map "s") #'org-match-sparse-tree))
- (should (eq (keymap-lookup cj/org-map "S") #'org-show-all))
+ (should (eq (keymap-lookup cj/org-map "S") #'org-fold-show-all))
(should (eq (keymap-lookup cj/org-map "t") #'org-show-todo-tree))
- (should (eq (keymap-lookup cj/org-map "T") #'org-show-all))
+ (should (eq (keymap-lookup cj/org-map "T") #'org-fold-show-all))
(should (eq (keymap-lookup cj/org-map "R") #'org-reveal)))
(ert-deftest test-org-config-keymap-ownership-regression-no-duplicate-org-keymap ()
diff --git a/tests/test-org-refile-config-scan-targets.el b/tests/test-org-refile-config-scan-targets.el
index 71451a29a..6123d3262 100644
--- a/tests/test-org-refile-config-scan-targets.el
+++ b/tests/test-org-refile-config-scan-targets.el
@@ -101,9 +101,10 @@ maxlevel rules when no roam tags and no code/projects todo files exist."
(should (= 1 hits)))
(delete-directory tmp t))))
-(ert-deftest test-org-refile-scan-targets-includes-roam-project-and-topic-files ()
- "Normal: when the roam helpers are available, Project and Topic files
-become additional refile targets."
+(ert-deftest test-org-refile-scan-targets-includes-roam-topic-not-project ()
+ "Normal: roam Topic files become refile targets; Project files do NOT.
+Project notes were dropped as refile targets (2026-06-24) -- roam Projects are
+no longer scanned for refile."
(let* ((tmp (file-name-as-directory (make-temp-file "cj-refile-roam-" t)))
(inbox-file "/tmp/test-inbox.org")
(reference-file "/tmp/test-reference.org")
@@ -121,8 +122,8 @@ become additional refile targets."
(lambda () nil)))
(let* ((result (cj/--org-refile-scan-targets))
(paths (mapcar #'car result)))
- (should (member "/notes/alpha.org" paths))
- (should (member "/notes/topic.org" paths))))
+ (should (member "/notes/topic.org" paths))
+ (should-not (member "/notes/alpha.org" paths))))
(delete-directory tmp t))))
(ert-deftest test-org-refile-scan-targets-survives-permission-denied ()
diff --git a/tests/test-prog-lsp.el b/tests/test-prog-lsp.el
new file mode 100644
index 000000000..7e38111d0
--- /dev/null
+++ b/tests/test-prog-lsp.el
@@ -0,0 +1,66 @@
+;;; 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-term-tmux-history.el b/tests/test-term-tmux-history.el
index 4ad7fb79d..0ea7cf37d 100644
--- a/tests/test-term-tmux-history.el
+++ b/tests/test-term-tmux-history.el
@@ -355,5 +355,100 @@ Emacs region gets stuck in the ghostel buffer and tmux copy-mode's
begin-selection never starts."
(should (eq (keymap-lookup ghostel-mode-map "C-SPC") #'cj/term-send-C-SPC)))
+;; ----------------------------- copy-mode scroll ------------------------------
+
+(ert-deftest test-term-copy-mode-up-tmux-enters-then-scrolls-up ()
+ "Normal: from a live (non-copy) tmux pane, C-<up> enters copy-mode then sends
+the up-arrow, so one stroke both enters copy-mode and scrolls up."
+ (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]"))
+ (sent nil))
+ (unwind-protect
+ (with-current-buffer agent
+ (cl-letf (((symbol-function 'get-buffer-process)
+ (lambda (_buffer) 'fake-process))
+ ((symbol-function 'process-tty-name)
+ (lambda (_process &rest _) "/dev/pts/8"))
+ ((symbol-function 'ghostel-send-string)
+ (lambda (s) (push s sent))))
+ (test-term-tmux-history--with-tmux-mock
+ '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0
+ "/dev/pts/8\t%8\n")
+ (("display-message" "-p" "-t" "%8" "#{pane_in_mode}") 0 "0\n"))
+ (cj/term-copy-mode-up)
+ (should (equal (reverse sent) '("\C-b[\C-a" "\e[A"))))))
+ (when (buffer-live-p agent)
+ (kill-buffer agent)))))
+
+(ert-deftest test-term-copy-mode-up-tmux-already-in-mode-just-scrolls ()
+ "Normal: when the tmux pane is already in copy-mode, C-<up> only sends the
+up-arrow -- it does not re-enter (which would reset the cursor)."
+ (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]"))
+ (sent nil))
+ (unwind-protect
+ (with-current-buffer agent
+ (cl-letf (((symbol-function 'get-buffer-process)
+ (lambda (_buffer) 'fake-process))
+ ((symbol-function 'process-tty-name)
+ (lambda (_process &rest _) "/dev/pts/8"))
+ ((symbol-function 'ghostel-send-string)
+ (lambda (s) (push s sent))))
+ (test-term-tmux-history--with-tmux-mock
+ '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0
+ "/dev/pts/8\t%8\n")
+ (("display-message" "-p" "-t" "%8" "#{pane_in_mode}") 0 "1\n"))
+ (cj/term-copy-mode-up)
+ (should (equal (reverse sent) '("\e[A"))))))
+ (when (buffer-live-p agent)
+ (kill-buffer agent)))))
+
+(ert-deftest test-term-copy-mode-up-nontmux-enters-then-moves-up ()
+ "Boundary: without tmux and not yet in copy-mode, C-<up> enters
+ghostel-copy-mode then moves point up a line, sending nothing to the pty."
+ (with-temp-buffer
+ (insert "abc\ndef\nghi\n")
+ (goto-char (point-min))
+ (forward-line 2) ; land on line 3
+ (let ((sent nil) (entered nil))
+ (cl-letf (((symbol-function 'ghostel-send-string) (lambda (s) (push s sent)))
+ ((symbol-function 'ghostel-copy-mode) (lambda () (setq entered t))))
+ (cj/term-copy-mode-up)
+ (should entered)
+ (should-not sent)
+ (should (= (line-number-at-pos) 2))))))
+
+(ert-deftest test-term-copy-mode-up-nontmux-already-in-copy-just-moves ()
+ "Normal: when ghostel is already in copy-mode, C-<up> just moves point up --
+it does not call `ghostel-copy-mode' again (which would toggle copy-mode off)."
+ (with-temp-buffer
+ (insert "abc\ndef\nghi\n")
+ (goto-char (point-min))
+ (forward-line 2) ; land on line 3
+ (setq-local ghostel--input-mode 'copy)
+ (let ((sent nil) (entered nil))
+ (cl-letf (((symbol-function 'ghostel-send-string) (lambda (s) (push s sent)))
+ ((symbol-function 'ghostel-copy-mode) (lambda () (setq entered t))))
+ (cj/term-copy-mode-up)
+ (should-not entered)
+ (should-not sent)
+ (should (= (line-number-at-pos) 2))))))
+
+(ert-deftest test-term-copy-mode-only-c-up-bound ()
+ "Normal/Regression: only C-<up> enters copy-mode in ghostel-mode-map; the
+other arrows are not bound to it, so they pass through to the terminal."
+ (should (eq (keymap-lookup ghostel-mode-map "C-<up>") #'cj/term-copy-mode-up))
+ (dolist (key '("C-<down>" "C-<left>" "C-<right>"
+ "M-<up>" "M-<down>" "M-<left>" "M-<right>"))
+ (should-not (eq (keymap-lookup ghostel-mode-map key) #'cj/term-copy-mode-up))))
+
+(ert-deftest test-term-copy-mode-only-c-up-in-keymap-exceptions ()
+ "Regression (C-arrow copy-mode bug): only C-<up> is in
+`ghostel-keymap-exceptions'. C-<left>/<right>/<down> are readline word-motion
+at the shell prompt and the M-arrows have no copy-mode role, so none are
+exceptions -- they reach the terminal program instead of Emacs."
+ (should (member "C-<up>" ghostel-keymap-exceptions))
+ (dolist (key '("C-<down>" "C-<left>" "C-<right>"
+ "M-<up>" "M-<down>" "M-<left>" "M-<right>"))
+ (should-not (member key ghostel-keymap-exceptions))))
+
(provide 'test-term-tmux-history)
;;; test-term-tmux-history.el ends here
diff --git a/tests/test-transcription-video.el b/tests/test-transcription-video.el
index 8327fa326..aa8383d12 100644
--- a/tests/test-transcription-video.el
+++ b/tests/test-transcription-video.el
@@ -128,6 +128,28 @@ goes through `cj/--start-transcription-process' with a cleanup hint."
;; deleted after transcription completes).
(should (equal (nth 1 extract-args) (cadr worker-call)))))
+(ert-deftest test-tx-transcribe-media-video-output-base-is-the-source ()
+ "Regression: a video's transcript derives from the VIDEO path (alongside the
+source), not the temp /tmp audio. The worker gets the video as its output base
+\(third arg), so cj/--transcription-output-files lands talk.mp4 -> talk.txt
+beside the video instead of in /tmp."
+ (let* ((tmp (make-temp-file "cj-tx-vid-" nil ".mp4"))
+ worker-call)
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--extract-audio-from-video)
+ (lambda (_vid _out cb) (funcall cb)))
+ ((symbol-function 'cj/--start-transcription-process)
+ (lambda (file &rest rest)
+ (setq worker-call (cons file rest))
+ 'fake-proc)))
+ (cj/transcribe-media tmp))
+ (delete-file tmp))
+ ;; the output base (third arg) is the source video, not the temp audio
+ (should (equal (nth 2 worker-call) tmp))
+ ;; so the derived transcript sits beside the video, not in /tmp
+ (should (equal (car (cj/--transcription-output-files (nth 2 worker-call)))
+ (concat (file-name-sans-extension tmp) ".txt")))))
+
(ert-deftest test-tx-transcribe-media-rejects-non-media ()
"Error: non-media paths get rejected up front."
(should-error (cj/transcribe-media "/notes/readme.txt") :type 'user-error))