diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-08 19:21:26 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-08 19:21:26 -0500 |
| commit | eab070e5b542f525340ee7f07ea0560944639721 (patch) | |
| tree | 09a0ce76e38821ecfa2ed8bfcdf50057096fe794 /tests | |
| parent | 1d93e1a6569e4193c2b078a3d5df0bf47eeba9df (diff) | |
| download | dotemacs-eab070e5b542f525340ee7f07ea0560944639721.tar.gz dotemacs-eab070e5b542f525340ee7f07ea0560944639721.zip | |
feat(ai-vterm): F9 toggle/redisplay/pick + persistent split geometry
F9 was a single command that always opened the project picker. Three small frustrations stacked up. With one claude buffer open and not visible, F9 was a redundant prompt to pick a project that already had a session. With claude visible, there was no way to bury it without M-x quit-window. With two projects' buffers alive, swapping between them was a buffer-switch chore.
F9 is now a dispatch:
- Claude visible in this frame: quit the window (toggle off) and capture the geometry first.
- Exactly one claude buffer alive but hidden: re-display it (DWIM single-buffer case).
- Zero or two-plus alive: fall through to the project picker.
C-F9 is the always-pick-project entry point for explicit project switches. M-F9 is a buffer picker over the alive claude buffers. If a claude window is currently shown, the picked buffer replaces it in that window so the split orientation and size carry over. The shown buffer sorts last in the picker with a [shown] marker so RET picks "the other one."
Split geometry persists across toggles. Two module-level vars (cj/--ai-vterm-last-direction, cj/--ai-vterm-last-size) capture at toggle-off and feed a custom display action. After M-S-t flips claude from right to bottom, F9 toggle-off-then-on returns it at the bottom. After a mouse resize, the next toggle restores that fraction. State is per-session. Restarts reset to default right/0.5.
Two display-buffer fixes came out of testing:
- save-window-excursion around (vterm name) keeps the dashboard from being buried on a fresh F9 at startup. vterm calls pop-to-buffer-same-window internally, which would otherwise replace the selected window's buffer before the alist could route the new one.
- The action chain swaps display-buffer-use-some-window for a more specific cj/--ai-vterm-reuse-existing-claude. The generic version stole non-claude windows on C-F9 when the user was focused inside claude (claude on bottom, code on top -> new project landed in the code window). The specific version only reuses windows that already show a claude buffer.
I reclaimed C-F9 from the gptel toggle in ai-config.el. C-; a t still binds gptel.
I added eight new test files (claude-buffers, displayed-claude-window, dispatch, pick-buffer-candidates, window-geometry, capture-state, display-saved, reuse-existing-claude) plus a regression test on cj/--ai-vterm-show-or-create for the dashboard-preservation fix. All 73 ai-vterm tests pass and the full make test suite is green.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test-ai-vterm--capture-state.el | 64 | ||||
| -rw-r--r-- | tests/test-ai-vterm--claude-buffers.el | 63 | ||||
| -rw-r--r-- | tests/test-ai-vterm--dispatch.el | 70 | ||||
| -rw-r--r-- | tests/test-ai-vterm--display-saved.el | 95 | ||||
| -rw-r--r-- | tests/test-ai-vterm--displayed-claude-window.el | 64 | ||||
| -rw-r--r-- | tests/test-ai-vterm--pick-buffer-candidates.el | 84 | ||||
| -rw-r--r-- | tests/test-ai-vterm--reuse-existing-claude.el | 103 | ||||
| -rw-r--r-- | tests/test-ai-vterm--show-or-create.el | 39 | ||||
| -rw-r--r-- | tests/test-ai-vterm--window-geometry.el | 85 |
9 files changed, 667 insertions, 0 deletions
diff --git a/tests/test-ai-vterm--capture-state.el b/tests/test-ai-vterm--capture-state.el new file mode 100644 index 00000000..cecb3ab8 --- /dev/null +++ b/tests/test-ai-vterm--capture-state.el @@ -0,0 +1,64 @@ +;;; test-ai-vterm--capture-state.el --- Tests for cj/--ai-vterm-capture-state -*- lexical-binding: t; -*- + +;;; Commentary: +;; The capture helper writes WINDOW's direction and size to module- +;; level state vars `cj/--ai-vterm-last-direction' and +;; `cj/--ai-vterm-last-size'. Called from `cj/ai-vterm''s toggle-off +;; branch so the next F9 display can restore the user's chosen +;; orientation and size. No-op on a dead window. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-vterm) + +(ert-deftest test-ai-vterm--capture-state-right-split-sets-direction () + "Normal: right-split window -> direction=right, size in (0.4, 0.6)." + (save-window-excursion + (delete-other-windows) + (let ((right (split-window (selected-window) nil 'right)) + (cj/--ai-vterm-last-direction nil) + (cj/--ai-vterm-last-size nil)) + (cj/--ai-vterm-capture-state right) + (should (eq cj/--ai-vterm-last-direction 'right)) + (should (numberp cj/--ai-vterm-last-size)) + (should (and (> cj/--ai-vterm-last-size 0.4) + (< cj/--ai-vterm-last-size 0.6)))))) + +(ert-deftest test-ai-vterm--capture-state-below-split-sets-direction () + "Normal: below-split window -> direction=below, size in (0.4, 0.6)." + (save-window-excursion + (delete-other-windows) + (let ((below (split-window (selected-window) nil 'below)) + (cj/--ai-vterm-last-direction nil) + (cj/--ai-vterm-last-size nil)) + (cj/--ai-vterm-capture-state below) + (should (eq cj/--ai-vterm-last-direction 'below)) + (should (and (> cj/--ai-vterm-last-size 0.4) + (< cj/--ai-vterm-last-size 0.6)))))) + +(ert-deftest test-ai-vterm--capture-state-noop-on-dead-window () + "Boundary: nil window -> state remains unchanged." + (let ((cj/--ai-vterm-last-direction 'sentinel-dir) + (cj/--ai-vterm-last-size 0.123)) + (cj/--ai-vterm-capture-state nil) + (should (eq cj/--ai-vterm-last-direction 'sentinel-dir)) + (should (= cj/--ai-vterm-last-size 0.123)))) + +(ert-deftest test-ai-vterm--capture-state-noop-on-deleted-window () + "Boundary: deleted window -> state remains unchanged." + (let ((cj/--ai-vterm-last-direction 'sentinel-dir) + (cj/--ai-vterm-last-size 0.123) + (dead-win (save-window-excursion + (delete-other-windows) + (let ((w (split-window (selected-window) nil 'right))) + (delete-window w) + w)))) + (cj/--ai-vterm-capture-state dead-win) + (should (eq cj/--ai-vterm-last-direction 'sentinel-dir)) + (should (= cj/--ai-vterm-last-size 0.123)))) + +(provide 'test-ai-vterm--capture-state) +;;; test-ai-vterm--capture-state.el ends here diff --git a/tests/test-ai-vterm--claude-buffers.el b/tests/test-ai-vterm--claude-buffers.el new file mode 100644 index 00000000..56668ca1 --- /dev/null +++ b/tests/test-ai-vterm--claude-buffers.el @@ -0,0 +1,63 @@ +;;; test-ai-vterm--claude-buffers.el --- Tests for cj/--ai-vterm-claude-buffers -*- lexical-binding: t; -*- + +;;; Commentary: +;; The helper returns the list of buffers whose names start with the +;; literal prefix "claude [". Order is the same order `buffer-list' +;; gives them (most-recently-selected first). Non-claude buffers and +;; buffers whose names merely contain the prefix as a substring are +;; excluded. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-vterm) + +(defun test-ai-vterm--claude-buffers-cleanup () + "Kill any leftover claude-prefixed buffers before/after a test." + (dolist (b (buffer-list)) + (when (string-prefix-p "claude [" (buffer-name b)) + (kill-buffer b)))) + +(ert-deftest test-ai-vterm--claude-buffers-empty-when-none-exist () + "Boundary: no claude-prefixed buffers anywhere -> empty list." + (test-ai-vterm--claude-buffers-cleanup) + (unwind-protect + (should (null (cj/--ai-vterm-claude-buffers))) + (test-ai-vterm--claude-buffers-cleanup))) + +(ert-deftest test-ai-vterm--claude-buffers-returns-only-claude-buffers () + "Normal: filters to only claude-prefixed buffers, leaves others alone." + (test-ai-vterm--claude-buffers-cleanup) + (let ((c1 (get-buffer-create "claude [a]")) + (c2 (get-buffer-create "claude [b]")) + (other (get-buffer-create "regular-buffer"))) + (unwind-protect + (let ((result (cj/--ai-vterm-claude-buffers))) + (should (memq c1 result)) + (should (memq c2 result)) + (should-not (memq other result)) + (should (= (length result) 2))) + (kill-buffer c1) + (kill-buffer c2) + (kill-buffer other)))) + +(ert-deftest test-ai-vterm--claude-buffers-anchors-prefix-not-substring () + "Boundary: 'foo claude [bar]' is not a claude buffer -- prefix anchored." + (test-ai-vterm--claude-buffers-cleanup) + (let ((not-claude (get-buffer-create "foo claude [bar]"))) + (unwind-protect + (should-not (memq not-claude (cj/--ai-vterm-claude-buffers))) + (kill-buffer not-claude)))) + +(ert-deftest test-ai-vterm--claude-buffers-bare-claude-not-included () + "Boundary: 'claude' alone (no bracket) doesn't match the 'claude [' prefix." + (test-ai-vterm--claude-buffers-cleanup) + (let ((bare (get-buffer-create "claude"))) + (unwind-protect + (should-not (memq bare (cj/--ai-vterm-claude-buffers))) + (kill-buffer bare)))) + +(provide 'test-ai-vterm--claude-buffers) +;;; test-ai-vterm--claude-buffers.el ends here diff --git a/tests/test-ai-vterm--dispatch.el b/tests/test-ai-vterm--dispatch.el new file mode 100644 index 00000000..3c0ae766 --- /dev/null +++ b/tests/test-ai-vterm--dispatch.el @@ -0,0 +1,70 @@ +;;; test-ai-vterm--dispatch.el --- Tests for cj/--ai-vterm-dispatch -*- lexical-binding: t; -*- + +;;; Commentary: +;; The dispatch helper is a pure decision function used by F9. +;; Returns one of (toggle-off . WIN), (redisplay-single . BUF), +;; or (pick-project) based on whether a claude buffer is currently +;; displayed and how many alive claude buffers exist. Tests mock the +;; two underlying helpers so the dispatch logic can be exercised +;; without touching real windows. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-vterm) + +(defun test-ai-vterm--dispatch-cleanup () + "Kill any leftover claude-prefixed buffers." + (dolist (b (buffer-list)) + (when (string-prefix-p "claude [" (buffer-name b)) + (kill-buffer b)))) + +(ert-deftest test-ai-vterm--dispatch-window-displayed-returns-toggle-off () + "Normal: displayed claude window -> (toggle-off . WIN)." + (let ((sentinel-win 'fake-window)) + (cl-letf (((symbol-function 'cj/--ai-vterm-displayed-claude-window) + (lambda (&optional _frame) sentinel-win))) + (should (equal (cj/--ai-vterm-dispatch) + (cons 'toggle-off sentinel-win)))))) + +(ert-deftest test-ai-vterm--dispatch-no-window-single-buffer-returns-redisplay () + "Normal: no displayed claude, exactly one alive buffer -> redisplay-single." + (test-ai-vterm--dispatch-cleanup) + (let ((b1 (get-buffer-create "claude [single]"))) + (unwind-protect + (cl-letf (((symbol-function 'cj/--ai-vterm-displayed-claude-window) + (lambda (&optional _frame) nil)) + ((symbol-function 'cj/--ai-vterm-claude-buffers) + (lambda () (list b1)))) + (should (equal (cj/--ai-vterm-dispatch) + (cons 'redisplay-single b1)))) + (kill-buffer b1)))) + +(ert-deftest test-ai-vterm--dispatch-no-window-multiple-buffers-returns-pick-project () + "Normal: no displayed claude, 2+ alive buffers -> pick-project." + (test-ai-vterm--dispatch-cleanup) + (let ((b1 (get-buffer-create "claude [a]")) + (b2 (get-buffer-create "claude [b]"))) + (unwind-protect + (cl-letf (((symbol-function 'cj/--ai-vterm-displayed-claude-window) + (lambda (&optional _frame) nil)) + ((symbol-function 'cj/--ai-vterm-claude-buffers) + (lambda () (list b1 b2)))) + (should (equal (cj/--ai-vterm-dispatch) '(pick-project)))) + (kill-buffer b1) + (kill-buffer b2)))) + +(ert-deftest test-ai-vterm--dispatch-no-window-zero-buffers-returns-pick-project () + "Boundary: no displayed claude, zero alive buffers -> pick-project." + (test-ai-vterm--dispatch-cleanup) + (cl-letf (((symbol-function 'cj/--ai-vterm-displayed-claude-window) + (lambda (&optional _frame) nil)) + ((symbol-function 'cj/--ai-vterm-claude-buffers) + (lambda () nil))) + (should (equal (cj/--ai-vterm-dispatch) '(pick-project))))) + +(provide 'test-ai-vterm--dispatch) +;;; test-ai-vterm--dispatch.el ends here diff --git a/tests/test-ai-vterm--display-saved.el b/tests/test-ai-vterm--display-saved.el new file mode 100644 index 00000000..9cb3521c --- /dev/null +++ b/tests/test-ai-vterm--display-saved.el @@ -0,0 +1,95 @@ +;;; test-ai-vterm--display-saved.el --- Tests for the display-saved action -*- lexical-binding: t; -*- + +;;; Commentary: +;; The action reads `cj/--ai-vterm-last-direction' + +;; `cj/--ai-vterm-last-size' (with default fallbacks), builds an +;; alist with direction + the matching size key, strips any +;; conflicting entries that came in via the rule, and delegates to +;; `display-buffer-in-direction'. +;; +;; Tests stub `display-buffer-in-direction' to capture the alist +;; that would have reached it. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-vterm) + +(ert-deftest test-ai-vterm--display-saved-uses-defaults-when-state-nil () + "Normal: nil state -> direction=right, size=cj/ai-vterm-window-width." + (let (received-buf received-alist + (cj/--ai-vterm-last-direction nil) + (cj/--ai-vterm-last-size nil) + (cj/ai-vterm-window-width 0.5)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (b a) + (setq received-buf b received-alist a) + 'fake-window))) + (cj/--ai-vterm-display-saved 'fake-buf '((inhibit-same-window . t)))) + (should (eq received-buf 'fake-buf)) + (should (eq (cdr (assq 'direction received-alist)) 'right)) + (should (= (cdr (assq 'window-width received-alist)) 0.5)) + (should (eq (cdr (assq 'inhibit-same-window received-alist)) t)))) + +(ert-deftest test-ai-vterm--display-saved-uses-saved-direction-and-size-below () + "Normal: saved direction=below, size=0.4 -> below + window-height 0.4." + (let (received-alist + (cj/--ai-vterm-last-direction 'below) + (cj/--ai-vterm-last-size 0.4)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (_b a) (setq received-alist a) 'fake-window))) + (cj/--ai-vterm-display-saved 'fake-buf nil)) + (should (eq (cdr (assq 'direction received-alist)) 'below)) + (should (= (cdr (assq 'window-height received-alist)) 0.4)) + (should-not (assq 'window-width received-alist)))) + +(ert-deftest test-ai-vterm--display-saved-uses-saved-direction-and-size-right () + "Normal: saved direction=right, size=0.7 -> right + window-width 0.7." + (let (received-alist + (cj/--ai-vterm-last-direction 'right) + (cj/--ai-vterm-last-size 0.7)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (_b a) (setq received-alist a) 'fake-window))) + (cj/--ai-vterm-display-saved 'fake-buf nil)) + (should (eq (cdr (assq 'direction received-alist)) 'right)) + (should (= (cdr (assq 'window-width received-alist)) 0.7)) + (should-not (assq 'window-height received-alist)))) + +(ert-deftest test-ai-vterm--display-saved-strips-conflicting-alist-entries () + "Boundary: caller-supplied direction/size are stripped, saved values win." + (let (received-alist + (cj/--ai-vterm-last-direction 'right) + (cj/--ai-vterm-last-size 0.7)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (_b a) (setq received-alist a) 'fake-window))) + (cj/--ai-vterm-display-saved + 'fake-buf + '((direction . below) + (window-width . 0.2) + (window-height . 0.3) + (inhibit-same-window . t)))) + (should (eq (cdr (assq 'direction received-alist)) 'right)) + (should (= (cdr (assq 'window-width received-alist)) 0.7)) + (should (eq (cdr (assq 'inhibit-same-window received-alist)) t)) + ;; window-height should not be in the alist when direction is right + ;; -- the action picks the matching size key based on direction. + (let ((wh-cells (cl-remove-if-not + (lambda (cell) (eq (car-safe cell) 'window-height)) + received-alist))) + (should (null wh-cells))))) + +(ert-deftest test-ai-vterm--display-saved-passes-buffer-through () + "Normal: BUFFER argument reaches display-buffer-in-direction unchanged." + (let (received-buf + (cj/--ai-vterm-last-direction 'right) + (cj/--ai-vterm-last-size 0.5)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (b _a) (setq received-buf b) 'fake-window))) + (cj/--ai-vterm-display-saved 'sentinel-buffer nil)) + (should (eq received-buf 'sentinel-buffer)))) + +(provide 'test-ai-vterm--display-saved) +;;; test-ai-vterm--display-saved.el ends here diff --git a/tests/test-ai-vterm--displayed-claude-window.el b/tests/test-ai-vterm--displayed-claude-window.el new file mode 100644 index 00000000..283a1b3c --- /dev/null +++ b/tests/test-ai-vterm--displayed-claude-window.el @@ -0,0 +1,64 @@ +;;; test-ai-vterm--displayed-claude-window.el --- Tests for the displayed-window helper -*- lexical-binding: t; -*- + +;;; Commentary: +;; The helper returns a window in the selected frame whose buffer +;; satisfies `cj/--ai-vterm-buffer-p', or nil when no such window +;; exists. Used by F9 dispatch and M-F9 in-place replacement. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-vterm) + +(defun test-ai-vterm--displayed-cleanup () + "Kill any leftover claude-prefixed buffers." + (dolist (b (buffer-list)) + (when (string-prefix-p "claude [" (buffer-name b)) + (kill-buffer b)))) + +(ert-deftest test-ai-vterm--displayed-claude-window-no-buffers-returns-nil () + "Boundary: no claude buffers anywhere -> nil." + (test-ai-vterm--displayed-cleanup) + (save-window-excursion + (delete-other-windows) + (should-not (cj/--ai-vterm-displayed-claude-window)))) + +(ert-deftest test-ai-vterm--displayed-claude-window-not-displayed-returns-nil () + "Boundary: claude buffer exists but not in any window -> nil." + (test-ai-vterm--displayed-cleanup) + (let ((b1 (get-buffer-create "claude [hidden]"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (should-not (cj/--ai-vterm-displayed-claude-window))) + (kill-buffer b1)))) + +(ert-deftest test-ai-vterm--displayed-claude-window-returns-window-when-displayed () + "Normal: claude buffer in a window -> returns that window." + (test-ai-vterm--displayed-cleanup) + (let ((b1 (get-buffer-create "claude [shown]"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((win (split-window-right))) + (set-window-buffer win b1) + (let ((result (cj/--ai-vterm-displayed-claude-window))) + (should (windowp result)) + (should (eq (window-buffer result) b1))))) + (kill-buffer b1)))) + +(ert-deftest test-ai-vterm--displayed-claude-window-ignores-non-claude-windows () + "Boundary: only a non-claude buffer is displayed -> nil." + (test-ai-vterm--displayed-cleanup) + (let ((other (get-buffer-create "regular-displayed-buffer"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (set-window-buffer (selected-window) other) + (should-not (cj/--ai-vterm-displayed-claude-window))) + (kill-buffer other)))) + +(provide 'test-ai-vterm--displayed-claude-window) +;;; test-ai-vterm--displayed-claude-window.el ends here diff --git a/tests/test-ai-vterm--pick-buffer-candidates.el b/tests/test-ai-vterm--pick-buffer-candidates.el new file mode 100644 index 00000000..99ef7325 --- /dev/null +++ b/tests/test-ai-vterm--pick-buffer-candidates.el @@ -0,0 +1,84 @@ +;;; test-ai-vterm--pick-buffer-candidates.el --- Tests for the M-F9 candidate builder -*- lexical-binding: t; -*- + +;;; Commentary: +;; The candidate builder is a pure function: given an MRU list of +;; alive AI-vterm buffers and the currently-displayed buffer (or +;; nil), it returns an alist of (DISPLAY-NAME . BUFFER) cells. +;; +;; Sort rule: non-shown buffers come first in their input order, +;; then the shown buffer (if it's in the list) appears last with a +;; \" [shown]\" suffix. The intent is that the default `completing- +;; read' selection lands on a non-shown candidate so RET means +;; \"give me the other one\". + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-vterm) + +(defun test-ai-vterm--pbc-cleanup () + "Kill any leftover claude-prefixed buffers." + (dolist (b (buffer-list)) + (when (string-prefix-p "claude [" (buffer-name b)) + (kill-buffer b)))) + +(ert-deftest test-ai-vterm--pick-buffer-candidates-empty-buffers () + "Boundary: empty buffer list -> empty alist regardless of shown." + (test-ai-vterm--pbc-cleanup) + (should (null (cj/--ai-vterm-pick-buffer-candidates nil nil))) + (should (null (cj/--ai-vterm-pick-buffer-candidates nil 'sentinel)))) + +(ert-deftest test-ai-vterm--pick-buffer-candidates-shown-nil () + "Normal: shown is nil -> straight alist in input order, no marker." + (test-ai-vterm--pbc-cleanup) + (let ((b1 (get-buffer-create "claude [a]")) + (b2 (get-buffer-create "claude [b]"))) + (unwind-protect + (let ((result (cj/--ai-vterm-pick-buffer-candidates (list b1 b2) nil))) + (should (equal result `(("claude [a]" . ,b1) + ("claude [b]" . ,b2))))) + (kill-buffer b1) + (kill-buffer b2)))) + +(ert-deftest test-ai-vterm--pick-buffer-candidates-shown-promotes-non-shown () + "Normal: shown buffer sorts last with [shown] suffix; others first." + (test-ai-vterm--pbc-cleanup) + (let ((b1 (get-buffer-create "claude [a]")) + (b2 (get-buffer-create "claude [b]")) + (b3 (get-buffer-create "claude [c]"))) + (unwind-protect + (let ((result (cj/--ai-vterm-pick-buffer-candidates + (list b1 b2 b3) b1))) + (should (equal result + `(("claude [b]" . ,b2) + ("claude [c]" . ,b3) + ("claude [a] [shown]" . ,b1))))) + (kill-buffer b1) + (kill-buffer b2) + (kill-buffer b3)))) + +(ert-deftest test-ai-vterm--pick-buffer-candidates-shown-only-buffer () + "Boundary: shown is the only entry -> single cell with [shown] marker." + (test-ai-vterm--pbc-cleanup) + (let ((b1 (get-buffer-create "claude [a]"))) + (unwind-protect + (let ((result (cj/--ai-vterm-pick-buffer-candidates (list b1) b1))) + (should (equal result `(("claude [a] [shown]" . ,b1))))) + (kill-buffer b1)))) + +(ert-deftest test-ai-vterm--pick-buffer-candidates-shown-not-in-buffers () + "Boundary: stale shown buffer not in list -> all cells are non-shown." + (test-ai-vterm--pbc-cleanup) + (let ((b1 (get-buffer-create "claude [a]")) + (b-stale (get-buffer-create "claude [stale]"))) + (unwind-protect + (let ((result (cj/--ai-vterm-pick-buffer-candidates + (list b1) b-stale))) + (should (equal result `(("claude [a]" . ,b1))))) + (kill-buffer b1) + (kill-buffer b-stale)))) + +(provide 'test-ai-vterm--pick-buffer-candidates) +;;; test-ai-vterm--pick-buffer-candidates.el ends here diff --git a/tests/test-ai-vterm--reuse-existing-claude.el b/tests/test-ai-vterm--reuse-existing-claude.el new file mode 100644 index 00000000..4668188d --- /dev/null +++ b/tests/test-ai-vterm--reuse-existing-claude.el @@ -0,0 +1,103 @@ +;;; test-ai-vterm--reuse-existing-claude.el --- Tests for reuse-existing-claude action -*- lexical-binding: t; -*- + +;;; Commentary: +;; The action looks for any window in the selected frame whose buffer +;; satisfies `cj/--ai-vterm-buffer-p'. When found, swaps that +;; window's buffer for the one being displayed and returns the +;; window. When not found, returns nil so the next action in the +;; chain runs. +;; +;; This is the action that keeps C-F9 (project-switch) from stealing +;; a non-claude window when the user is focused inside claude. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-vterm) + +(defun test-ai-vterm--reuse-cleanup () + "Kill any leftover claude-prefixed buffers." + (dolist (b (buffer-list)) + (when (string-prefix-p "claude [" (buffer-name b)) + (kill-buffer b)))) + +(ert-deftest test-ai-vterm--reuse-existing-claude-swaps-buffer-when-window-exists () + "Normal: a claude window exists -> swap its buffer, return the window." + (test-ai-vterm--reuse-cleanup) + (save-window-excursion + (delete-other-windows) + (let ((existing (get-buffer-create "claude [existing]")) + (new-buf (get-buffer-create "claude [new]")) + (split (split-window (selected-window) nil 'right))) + (unwind-protect + (progn + (set-window-buffer split existing) + (let ((result (cj/--ai-vterm-reuse-existing-claude new-buf nil))) + (should (eq result split)) + (should (eq (window-buffer split) new-buf)))) + (kill-buffer existing) + (kill-buffer new-buf))))) + +(ert-deftest test-ai-vterm--reuse-existing-claude-returns-nil-when-no-claude-window () + "Boundary: no claude window in frame -> nil (chain continues to next action)." + (test-ai-vterm--reuse-cleanup) + (save-window-excursion + (delete-other-windows) + (let ((new-buf (get-buffer-create "claude [no-existing]"))) + (unwind-protect + (should (null (cj/--ai-vterm-reuse-existing-claude new-buf nil))) + (kill-buffer new-buf))))) + +(ert-deftest test-ai-vterm--reuse-existing-claude-leaves-non-claude-windows-alone () + "Boundary: only non-claude windows in frame -> nil; other windows untouched." + (test-ai-vterm--reuse-cleanup) + (save-window-excursion + (delete-other-windows) + (let ((code-buf (get-buffer-create "*test-code-buffer*")) + (new-claude (get-buffer-create "claude [new-here]")) + (other-win (split-window (selected-window) nil 'right))) + (unwind-protect + (progn + (set-window-buffer (selected-window) code-buf) + (set-window-buffer other-win code-buf) + (let ((result (cj/--ai-vterm-reuse-existing-claude + new-claude nil))) + (should (null result)) + (should (eq (window-buffer (selected-window)) code-buf)) + (should (eq (window-buffer other-win) code-buf)))) + (kill-buffer code-buf) + (kill-buffer new-claude))))) + +(ert-deftest test-ai-vterm--reuse-existing-claude-preserves-non-claude-window-when-swapping () + "Normal: swap claude window only; the other window keeps its buffer. + +This is the C-F9-from-claude regression: with claude at the bottom +and code on top, switching projects must replace the bottom window's +buffer, not the top window's." + (test-ai-vterm--reuse-cleanup) + (save-window-excursion + (delete-other-windows) + (let* ((code-buf (get-buffer-create "*test-code-top*")) + (claude-a (get-buffer-create "claude [a]")) + (claude-b (get-buffer-create "claude [b]")) + (top-win (selected-window)) + (bottom-win (split-window top-win nil 'below))) + (unwind-protect + (progn + (set-window-buffer top-win code-buf) + (set-window-buffer bottom-win claude-a) + ;; Focus the claude window -- this is the regression scenario. + (select-window bottom-win) + (let ((result (cj/--ai-vterm-reuse-existing-claude + claude-b nil))) + (should (eq result bottom-win)) + (should (eq (window-buffer bottom-win) claude-b)) + (should (eq (window-buffer top-win) code-buf)))) + (kill-buffer code-buf) + (kill-buffer claude-a) + (kill-buffer claude-b))))) + +(provide 'test-ai-vterm--reuse-existing-claude) +;;; test-ai-vterm--reuse-existing-claude.el ends here diff --git a/tests/test-ai-vterm--show-or-create.el b/tests/test-ai-vterm--show-or-create.el index 3faf5f03..3fee4883 100644 --- a/tests/test-ai-vterm--show-or-create.el +++ b/tests/test-ai-vterm--show-or-create.el @@ -105,6 +105,45 @@ VARS is a plist of capture variable names: :calls, :strings, :returns, (should-not (buffer-live-p stale))))) (test-ai-vterm--cleanup name)))) +(ert-deftest test-ai-vterm--show-or-create-preserves-selected-window () + "Regression: vterm's pop-to-buffer-same-window must not bury the dashboard. + +Real `vterm' replaces the selected window's buffer as a side-effect of +construction. On a fresh-boot frame (one window showing the dashboard), +that side-effect previously left the original window pointing at the new +claude buffer; the dashboard was buried, the alist-routed split then +created a second window also showing claude. The wrapper must restore +the original window state before `display-buffer' fires so dashboard +stays put and the alist places claude into a fresh right-side split. + +This test stubs `vterm' to mimic the pop-to-buffer-same-window side-effect +and asserts the originally-selected window still shows its original buffer +after `cj/--ai-vterm-show-or-create' returns." + (let ((claude-name "claude [preserve-window-test]") + (orig-name "*test-original-buffer*")) + (test-ai-vterm--cleanup claude-name) + (when (get-buffer orig-name) (kill-buffer orig-name)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((orig-buf (get-buffer-create orig-name)) + (orig-win (selected-window))) + (set-window-buffer orig-win orig-buf) + (cl-letf + (((symbol-function 'vterm) + (lambda (&optional name) + (let ((buf (get-buffer-create name))) + (set-window-buffer (selected-window) buf) + buf))) + ((symbol-function 'vterm-send-string) + (lambda (_s &optional _) nil)) + ((symbol-function 'vterm-send-return) + (lambda () nil))) + (cj/--ai-vterm-show-or-create "/tmp/preserve" claude-name) + (should (eq (window-buffer orig-win) orig-buf))))) + (test-ai-vterm--cleanup claude-name) + (when (get-buffer orig-name) (kill-buffer orig-name))))) + (ert-deftest test-ai-vterm--show-or-create-returns-buffer () "Normal: return value is the vterm buffer." (let ((name "claude [return-test]")) diff --git a/tests/test-ai-vterm--window-geometry.el b/tests/test-ai-vterm--window-geometry.el new file mode 100644 index 00000000..62b78baf --- /dev/null +++ b/tests/test-ai-vterm--window-geometry.el @@ -0,0 +1,85 @@ +;;; test-ai-vterm--window-geometry.el --- Tests for direction + fraction helpers -*- lexical-binding: t; -*- + +;;; Commentary: +;; Two pure helpers used by F9's geometry-preservation feature: +;; +;; - `cj/--ai-vterm-window-direction' classifies a window's position +;; relative to its frame as right / below / left / above (with a +;; right fallback when the window fills the frame). +;; +;; - `cj/--ai-vterm-window-fraction' returns the window's size on +;; the matching axis as a fraction of the frame. +;; +;; Tests use real window splits in `save-window-excursion' rather +;; than mocking, since the helpers consume `window-edges' and +;; `frame-width' / `frame-height' directly. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-vterm) + +(ert-deftest test-ai-vterm--window-direction-right-split () + "Normal: 2-window vertical split, right-side window -> right." + (save-window-excursion + (delete-other-windows) + (let ((right (split-window (selected-window) nil 'right))) + (should (eq (cj/--ai-vterm-window-direction right) 'right))))) + +(ert-deftest test-ai-vterm--window-direction-left-split () + "Normal: 2-window vertical split, left-side window -> left." + (save-window-excursion + (delete-other-windows) + (split-window (selected-window) nil 'right) + (should (eq (cj/--ai-vterm-window-direction (selected-window)) 'left)))) + +(ert-deftest test-ai-vterm--window-direction-below-split () + "Normal: 2-window horizontal split, bottom window -> below." + (save-window-excursion + (delete-other-windows) + (let ((below (split-window (selected-window) nil 'below))) + (should (eq (cj/--ai-vterm-window-direction below) 'below))))) + +(ert-deftest test-ai-vterm--window-direction-above-split () + "Normal: 2-window horizontal split, top window -> above." + (save-window-excursion + (delete-other-windows) + (split-window (selected-window) nil 'below) + (should (eq (cj/--ai-vterm-window-direction (selected-window)) 'above)))) + +(ert-deftest test-ai-vterm--window-direction-single-window-fallback () + "Boundary: single-window frame -> default right." + (save-window-excursion + (delete-other-windows) + (should (eq (cj/--ai-vterm-window-direction (selected-window)) 'right)))) + +(ert-deftest test-ai-vterm--window-fraction-right-split-half () + "Normal: right window of equal vertical split -> ~0.5 width fraction." + (save-window-excursion + (delete-other-windows) + (let* ((right (split-window (selected-window) nil 'right)) + (frac (cj/--ai-vterm-window-fraction right 'right))) + (should (and (> frac 0.4) (< frac 0.6)))))) + +(ert-deftest test-ai-vterm--window-fraction-below-split-half () + "Normal: bottom window of equal horizontal split -> ~0.5 height fraction." + (save-window-excursion + (delete-other-windows) + (let* ((below (split-window (selected-window) nil 'below)) + (frac (cj/--ai-vterm-window-fraction below 'below))) + (should (and (> frac 0.4) (< frac 0.6)))))) + +(ert-deftest test-ai-vterm--window-fraction-narrow-right-split () + "Normal: right window at 1/4 width -> fraction within that range." + (save-window-excursion + (delete-other-windows) + (let* ((frame-w (frame-width)) + (target-cols (/ frame-w 4)) + (right (split-window (selected-window) (- target-cols) 'right)) + (frac (cj/--ai-vterm-window-fraction right 'right))) + (should (and (> frac 0.15) (< frac 0.35)))))) + +(provide 'test-ai-vterm--window-geometry) +;;; test-ai-vterm--window-geometry.el ends here |
