aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-08 19:21:26 -0500
committerCraig Jennings <c@cjennings.net>2026-05-08 19:21:26 -0500
commiteab070e5b542f525340ee7f07ea0560944639721 (patch)
tree09a0ce76e38821ecfa2ed8bfcdf50057096fe794 /tests
parent1d93e1a6569e4193c2b078a3d5df0bf47eeba9df (diff)
downloaddotemacs-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.el64
-rw-r--r--tests/test-ai-vterm--claude-buffers.el63
-rw-r--r--tests/test-ai-vterm--dispatch.el70
-rw-r--r--tests/test-ai-vterm--display-saved.el95
-rw-r--r--tests/test-ai-vterm--displayed-claude-window.el64
-rw-r--r--tests/test-ai-vterm--pick-buffer-candidates.el84
-rw-r--r--tests/test-ai-vterm--reuse-existing-claude.el103
-rw-r--r--tests/test-ai-vterm--show-or-create.el39
-rw-r--r--tests/test-ai-vterm--window-geometry.el85
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