diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-09 11:03:10 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-09 11:03:10 -0500 |
| commit | 26e97633c2141051dee418aff5d8993700cf39b2 (patch) | |
| tree | 7dbe6fa685c0b3093f6e7952a340bb61e44ccbea /tests | |
| parent | 1945df4bf5f34256908fdf221da8a6f7767ad427 (diff) | |
| download | dotemacs-26e97633c2141051dee418aff5d8993700cf39b2.tar.gz dotemacs-26e97633c2141051dee418aff5d8993700cf39b2.zip | |
fix(ai-vterm): harden F9 toggle across multi-window and buffer-move
Live-testing surfaced four edge-case failures in the F9 toggle geometry preservation. Each gets a dedicated regression test.
- Multi-window squeeze: a captured fraction-of-frame replayed at the wrong size in 3+ window layouts because `display-buffer-in-direction` interprets float widths as fractions of the new window's parent, not the frame. In a flat 3-window layout the parent is the root, but in nested splits it's a sub-tree, and the captured fraction blew the layout up. I switched to absolute integer body-cols and body-lines as the captured unit. The unit is layout-independent.
- One-col peek: a claude window captured rightmost (no right divider, body=total) replayed into a middle position (with divider, body=total-1) showed 1 col of the sibling buffer peeking through where claude should have ended. I wrap the integer size in a `(body-columns . N)` / `(body-lines . N)` cons so `display-buffer-in-direction` sets the body explicitly, divider-independent.
- Position swap and compounding gap: `direction=right` in `display-buffer-in-direction` splits the selected window, not the frame edge. In multi-window layouts the new claude landed mid-frame instead of where it came from. Each toggle compounded a 1-col loss because the new position picked up a divider the original lacked. I map the cardinal direction to its frame-edge variant (`right` -> `rightmost`, `below` -> `bottom`, etc.) so claude always returns to the captured edge.
- Extra window after buffer-move: buffer-move (C-M-arrows) doesn't update the claude window's `quit-restore` parameter, so `quit-window` falls through to bury rather than delete. The window stays alive showing some other buffer. Toggle-on doesn't recognize it and creates a fresh side window, landing at N+1 windows. I switched to `delete-window` with a `one-window-p` guard for the single-window-frame case.
One tradeoff: in a layout where claude was deliberately in a middle position (e.g. agenda | claude | todo), the next toggle pulls it to the frame edge rather than the middle. The side-panel pattern is the design intent and the common case.
7 new regression tests covering each scenario. 80 ai-vterm tests pass. Full make test green.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test-ai-vterm--capture-state.el | 13 | ||||
| -rw-r--r-- | tests/test-ai-vterm--display-saved.el | 289 | ||||
| -rw-r--r-- | tests/test-ai-vterm--window-geometry.el | 27 |
3 files changed, 303 insertions, 26 deletions
diff --git a/tests/test-ai-vterm--capture-state.el b/tests/test-ai-vterm--capture-state.el index cecb3ab8..88a7784b 100644 --- a/tests/test-ai-vterm--capture-state.el +++ b/tests/test-ai-vterm--capture-state.el @@ -15,7 +15,7 @@ (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)." + "Normal: right-split window -> direction=right, integer body-cols matching window." (save-window-excursion (delete-other-windows) (let ((right (split-window (selected-window) nil 'right)) @@ -23,12 +23,11 @@ (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)))))) + (should (integerp cj/--ai-vterm-last-size)) + (should (= cj/--ai-vterm-last-size (window-body-width right)))))) (ert-deftest test-ai-vterm--capture-state-below-split-sets-direction () - "Normal: below-split window -> direction=below, size in (0.4, 0.6)." + "Normal: below-split window -> direction=below, integer body-lines matching window." (save-window-excursion (delete-other-windows) (let ((below (split-window (selected-window) nil 'below)) @@ -36,8 +35,8 @@ (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)))))) + (should (integerp cj/--ai-vterm-last-size)) + (should (= cj/--ai-vterm-last-size (window-body-height below)))))) (ert-deftest test-ai-vterm--capture-state-noop-on-dead-window () "Boundary: nil window -> state remains unchanged." diff --git a/tests/test-ai-vterm--display-saved.el b/tests/test-ai-vterm--display-saved.el index 9cb3521c..aa0dc5ee 100644 --- a/tests/test-ai-vterm--display-saved.el +++ b/tests/test-ai-vterm--display-saved.el @@ -19,7 +19,10 @@ (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." + "Normal: nil state -> direction=rightmost, size=cj/ai-vterm-window-width. +The cardinal `right' default maps to the frame-edge variant +`rightmost' so claude lands at the frame's right edge regardless of +which window is selected." (let (received-buf received-alist (cj/--ai-vterm-last-direction nil) (cj/--ai-vterm-last-size nil) @@ -30,31 +33,31 @@ '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 (eq (cdr (assq 'direction received-alist)) 'rightmost)) (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." + "Normal: saved direction=below maps to bottom edge; size=0.4 passes through." (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 (eq (cdr (assq 'direction received-alist)) 'bottom)) (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." + "Normal: saved direction=right maps to rightmost edge; size=0.7 passes through." (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 (eq (cdr (assq 'direction received-alist)) 'rightmost)) (should (= (cdr (assq 'window-width received-alist)) 0.7)) (should-not (assq 'window-height received-alist)))) @@ -71,7 +74,7 @@ (window-width . 0.2) (window-height . 0.3) (inhibit-same-window . t)))) - (should (eq (cdr (assq 'direction received-alist)) 'right)) + (should (eq (cdr (assq 'direction received-alist)) 'rightmost)) (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 @@ -91,5 +94,277 @@ (cj/--ai-vterm-display-saved 'sentinel-buffer nil)) (should (eq received-buf 'sentinel-buffer)))) +(defun test-ai-vterm--display-saved-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--display-saved-3window-roundtrip-preserves-body-width () + "Regression: capture+delete+display in a 3-window layout preserves body-width. + +Reproduces Craig's `peeking ~1 col' report from 2026-05-09: when +the new claude lands at a different position than the captured one +(rightmost vs middle), `window-total-width' differs by 1 because +of the right divider. `window-body-width' is divider-independent +and is what the user actually sees, so the assertion locks down +the body match." + (test-ai-vterm--display-saved-cleanup) + (let ((claude-name "claude [3win-roundtrip]") + (left-name "*test-3win-left*") + (right-name "*test-3win-right*")) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((left-buf (get-buffer-create left-name)) + (right-buf (get-buffer-create right-name)) + (claude-buf (get-buffer-create claude-name))) + ;; Build: left | claude | right. Selected window starts as + ;; the only window. Split right twice to get three windows. + (set-window-buffer (selected-window) left-buf) + (let* ((right-win (split-window (selected-window) nil 'right)) + (_ (set-window-buffer right-win right-buf)) + (claude-win (split-window (selected-window) nil 'right))) + (set-window-buffer claude-win claude-buf) + ;; Capture claude's state. + (cj/--ai-vterm-capture-state claude-win) + (let ((captured-size cj/--ai-vterm-last-size) + (captured-direction cj/--ai-vterm-last-direction)) + ;; Simulate quit-window on claude. + (delete-window claude-win) + ;; Now route a fresh display through the actual rule. + (let* ((display-buffer-alist (cj/--ai-vterm-display-rule-list)) + (new-win (display-buffer claude-buf))) + (should (windowp new-win)) + (should (eq (window-buffer new-win) claude-buf)) + ;; The captured size should be replayed exactly. + (should (= (window-body-width new-win) + captured-size)) + ;; Direction should also match. + (should (eq captured-direction 'right))))))) + (when (get-buffer left-name) (kill-buffer left-name)) + (when (get-buffer right-name) (kill-buffer right-name)) + (test-ai-vterm--display-saved-cleanup)))) + +(ert-deftest test-ai-vterm--display-saved-3window-claude-rightmost-roundtrip () + "Round-trip when claude is the rightmost window (no right divider)." + (test-ai-vterm--display-saved-cleanup) + (let ((claude-name "claude [rightmost]") + (left-name "*test-rm-left*") + (mid-name "*test-rm-mid*")) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((left-buf (get-buffer-create left-name)) + (mid-buf (get-buffer-create mid-name)) + (claude-buf (get-buffer-create claude-name))) + ;; Build: left | mid | claude (claude rightmost) + (set-window-buffer (selected-window) left-buf) + (let* ((mid-win (split-window (selected-window) nil 'right)) + (claude-win (split-window mid-win nil 'right))) + (set-window-buffer mid-win mid-buf) + (set-window-buffer claude-win claude-buf) + (cj/--ai-vterm-capture-state claude-win) + (let ((captured-size cj/--ai-vterm-last-size)) + (delete-window claude-win) + (let* ((display-buffer-alist (cj/--ai-vterm-display-rule-list)) + (new-win (display-buffer claude-buf))) + (should (windowp new-win)) + (should (= (window-body-width new-win) captured-size))))))) + (when (get-buffer left-name) (kill-buffer left-name)) + (when (get-buffer mid-name) (kill-buffer mid-name)) + (test-ai-vterm--display-saved-cleanup)))) + +(ert-deftest test-ai-vterm--display-saved-3window-after-mouse-resize () + "Round-trip after a deliberate mid-window resize (mimics mouse-drag)." + (test-ai-vterm--display-saved-cleanup) + (let ((claude-name "claude [mouse-resize]") + (left-name "*test-mr-left*") + (right-name "*test-mr-right*")) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((left-buf (get-buffer-create left-name)) + (right-buf (get-buffer-create right-name)) + (claude-buf (get-buffer-create claude-name))) + (set-window-buffer (selected-window) left-buf) + (let* ((right-win (split-window (selected-window) nil 'right)) + (claude-win (split-window (selected-window) nil 'right))) + (set-window-buffer right-win right-buf) + (set-window-buffer claude-win claude-buf) + ;; Resize claude smaller to mimic the user dragging the + ;; divider. Shrink claude by 5 cols, give to left. + (let ((delta -5)) + (when (window--resizable-p claude-win delta t) + (window-resize claude-win delta t))) + (cj/--ai-vterm-capture-state claude-win) + (let ((captured-size cj/--ai-vterm-last-size)) + (delete-window claude-win) + (let* ((display-buffer-alist (cj/--ai-vterm-display-rule-list)) + (new-win (display-buffer claude-buf))) + (should (windowp new-win)) + (should (= (window-body-width new-win) captured-size))))))) + (when (get-buffer left-name) (kill-buffer left-name)) + (when (get-buffer right-name) (kill-buffer right-name)) + (test-ai-vterm--display-saved-cleanup)))) + +(ert-deftest test-ai-vterm--display-saved-roundtrip-via-cj/ai-vterm-toggle () + "End-to-end: toggle-off via dispatch then redisplay -- preserves size." + (test-ai-vterm--display-saved-cleanup) + (let ((claude-name "claude [toggle-roundtrip]") + (left-name "*test-tr-left*") + (right-name "*test-tr-right*")) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((left-buf (get-buffer-create left-name)) + (right-buf (get-buffer-create right-name)) + (claude-buf (get-buffer-create claude-name))) + (set-window-buffer (selected-window) left-buf) + (let* ((right-win (split-window (selected-window) nil 'right)) + (claude-win (split-window (selected-window) nil 'right))) + (set-window-buffer right-win right-buf) + (set-window-buffer claude-win claude-buf) + (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) + ;; Focus claude (mimics `M-x cj/ai-vterm' from inside claude). + (select-window claude-win) + (let ((before-size (window-body-width claude-win))) + ;; Toggle off via the actual command -- captures + quit-window. + (cj/ai-vterm) + (should-not (cj/--ai-vterm-displayed-claude-window)) + ;; Toggle on -- single-buffer DWIM redisplay path. + (cj/ai-vterm) + (let* ((new-win (cj/--ai-vterm-displayed-claude-window)) + (new-size (window-body-width new-win))) + (should (windowp new-win)) + (should (= new-size before-size)))))))) + (when (get-buffer left-name) (kill-buffer left-name)) + (when (get-buffer right-name) (kill-buffer right-name)) + (test-ai-vterm--display-saved-cleanup)))) + +(ert-deftest test-ai-vterm--display-saved-two-toggle-cycles-stable () + "Two consecutive toggle-off+toggle-on cycles -- no compounding error." + (test-ai-vterm--display-saved-cleanup) + (let ((claude-name "claude [two-cycle]") + (left-name "*test-2c-left*") + (right-name "*test-2c-right*")) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((left-buf (get-buffer-create left-name)) + (right-buf (get-buffer-create right-name)) + (claude-buf (get-buffer-create claude-name))) + (set-window-buffer (selected-window) left-buf) + (let* ((right-win (split-window (selected-window) nil 'right)) + (claude-win (split-window (selected-window) nil 'right))) + (set-window-buffer right-win right-buf) + (set-window-buffer claude-win claude-buf) + (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list)) + (initial-size (window-body-width claude-win))) + (select-window claude-win) + ;; Cycle 1 + (cj/ai-vterm) ; off + (cj/ai-vterm) ; on + (let ((cycle1-size (window-body-width + (cj/--ai-vterm-displayed-claude-window)))) + (should (= cycle1-size initial-size)) + (select-window (cj/--ai-vterm-displayed-claude-window)) + ;; Cycle 2 + (cj/ai-vterm) ; off + (cj/ai-vterm) ; on + (let ((cycle2-size (window-body-width + (cj/--ai-vterm-displayed-claude-window)))) + (should (= cycle2-size initial-size)))))))) + (when (get-buffer left-name) (kill-buffer left-name)) + (when (get-buffer right-name) (kill-buffer right-name)) + (test-ai-vterm--display-saved-cleanup)))) + +(ert-deftest test-ai-vterm--display-saved-craig-c-x-3-roundtrip () + "Reproduces Craig's repro from 2026-05-09: +launch -> F9 -> dashboard splits via C-x 3 -> toggle off -> toggle on. +Expected: new claude lands at the same total-width it had before." + (test-ai-vterm--display-saved-cleanup) + (let ((claude-name "claude [c-x-3-repro]") + (dash-name "*test-cx3-dashboard*")) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((dash-buf (get-buffer-create dash-name)) + (claude-buf (get-buffer-create claude-name))) + (set-window-buffer (selected-window) dash-buf) + (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) + ;; Step 1: F9 displays claude. Layout: dashboard | claude. + (let ((claude-win-1 (display-buffer claude-buf))) + (should (windowp claude-win-1))) + ;; Step 2: focus dashboard, C-x 3 (split-window-right). + (let ((dash-win (get-buffer-window dash-buf))) + (select-window dash-win) + (split-window-right)) + ;; Layout now: dashboard1 | dashboard2 | claude + ;; Capture claude's pre-toggle body width for later assertion. + (let* ((claude-win-2 (cj/--ai-vterm-displayed-claude-window)) + (size-before (window-body-width claude-win-2))) + ;; Step 3: F9 toggles claude off (selected is dashboard). + (cj/ai-vterm) + (should-not (cj/--ai-vterm-displayed-claude-window)) + ;; Step 4: F9 toggles claude on -- redisplay-single path. + (cj/ai-vterm) + (let* ((claude-win-3 (cj/--ai-vterm-displayed-claude-window)) + (size-after (window-body-width claude-win-3))) + (should (windowp claude-win-3)) + (should (= size-after size-before))))))) + (when (get-buffer dash-name) (kill-buffer dash-name)) + (test-ai-vterm--display-saved-cleanup)))) + +(ert-deftest test-ai-vterm--toggle-after-buffer-move-no-extra-window () + "Regression: toggle-off must remove claude's window even when buffer-move +has cleared its `quit-restore' parameter. + +Reproduces Craig's repro from 2026-05-09: 3 windows, user uses +buffer-move (C-M-arrows) to relocate claude. buffer-move swaps +buffers between windows and leaves the receiving window with no +record that it was created for the claude buffer. `quit-window' +respects that history and only buries -- the window stays with +some other buffer in it. The next toggle-on then doesn't recognize +that window as a claude home and creates a fresh one alongside, +landing the user at N+1 windows instead of N. + +Assertion: after toggle-off+toggle-on, the window count is back to +its pre-cycle value, regardless of `quit-restore' state." + (test-ai-vterm--display-saved-cleanup) + (let ((claude-name "claude [buffer-move-toggle]") + (left-name "*test-bm-left*") + (right-name "*test-bm-right*")) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((left-buf (get-buffer-create left-name)) + (right-buf (get-buffer-create right-name)) + (claude-buf (get-buffer-create claude-name))) + (set-window-buffer (selected-window) left-buf) + (let* ((right-win (split-window (selected-window) nil 'right)) + (claude-win (split-window (selected-window) nil 'right))) + (set-window-buffer right-win right-buf) + (set-window-buffer claude-win claude-buf) + ;; Mimic buffer-move's effect: claude lives in this + ;; window but quit-restore says nothing about it. + (set-window-parameter claude-win 'quit-restore nil) + (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list)) + (window-count-before (count-windows))) + (select-window claude-win) + (cj/ai-vterm) ; off + (cj/ai-vterm) ; on + (should (= (count-windows) window-count-before)) + ;; Claude must be displayed exactly once. + (let ((claude-windows + (seq-filter + (lambda (w) + (eq (window-buffer w) claude-buf)) + (window-list)))) + (should (= (length claude-windows) 1))))))) + (when (get-buffer left-name) (kill-buffer left-name)) + (when (get-buffer right-name) (kill-buffer right-name)) + (test-ai-vterm--display-saved-cleanup)))) + (provide 'test-ai-vterm--display-saved) ;;; test-ai-vterm--display-saved.el ends here diff --git a/tests/test-ai-vterm--window-geometry.el b/tests/test-ai-vterm--window-geometry.el index 62b78baf..c2200273 100644 --- a/tests/test-ai-vterm--window-geometry.el +++ b/tests/test-ai-vterm--window-geometry.el @@ -55,31 +55,34 @@ (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." +(ert-deftest test-ai-vterm--window-size-right-split-returns-body-cols () + "Normal: right window -> integer body-cols matching window-body-width." (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)))))) + (size (cj/--ai-vterm-window-size right 'right))) + (should (integerp size)) + (should (= size (window-body-width right)))))) -(ert-deftest test-ai-vterm--window-fraction-below-split-half () - "Normal: bottom window of equal horizontal split -> ~0.5 height fraction." +(ert-deftest test-ai-vterm--window-size-below-split-returns-body-lines () + "Normal: bottom window -> integer body-lines matching window-body-height." (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)))))) + (size (cj/--ai-vterm-window-size below 'below))) + (should (integerp size)) + (should (= size (window-body-height below)))))) -(ert-deftest test-ai-vterm--window-fraction-narrow-right-split () - "Normal: right window at 1/4 width -> fraction within that range." +(ert-deftest test-ai-vterm--window-size-narrow-right-split () + "Normal: deliberately narrow right window -> matching body-col count." (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)))))) + (size (cj/--ai-vterm-window-size right 'right))) + (should (integerp size)) + (should (= size (window-body-width right)))))) (provide 'test-ai-vterm--window-geometry) ;;; test-ai-vterm--window-geometry.el ends here |
