diff options
Diffstat (limited to 'tests')
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)) |
