aboutsummaryrefslogtreecommitdiff
path: root/tests/test-ai-term--project-color.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-02 10:33:34 -0400
committerCraig Jennings <c@cjennings.net>2026-07-02 10:33:34 -0400
commit75cf36183811a1a9208baf6d75b56c274debebca (patch)
tree5b81eae8299a603cfc2c19fd416f456ca4e39121 /tests/test-ai-term--project-color.el
parent82b195278b551839314edf1bfab37d5f54c432cb (diff)
downloaddotemacs-75cf36183811a1a9208baf6d75b56c274debebca.tar.gz
dotemacs-75cf36183811a1a9208baf6d75b56c274debebca.zip
feat(ai-term): auto-set each project's session color on fresh launch
Every project now maps to a stable Claude Code session color: an override alist wins, else a character-sum hash of the project basename picks one of the eight names. When a fresh tmux session is created (never on reattach), a poller waits for the TUI to boot and types /color <name> itself, with the Enter deferred a beat so the slash-command menu can't swallow it. Two refusals keep the injection safe: the bypass banner must be on screen (a bare shell never gets typed into) and the prompt line must still be empty (typed-ahead input is never corrupted).
Diffstat (limited to 'tests/test-ai-term--project-color.el')
-rw-r--r--tests/test-ai-term--project-color.el203
1 files changed, 203 insertions, 0 deletions
diff --git a/tests/test-ai-term--project-color.el b/tests/test-ai-term--project-color.el
new file mode 100644
index 00000000..244d91ee
--- /dev/null
+++ b/tests/test-ai-term--project-color.el
@@ -0,0 +1,203 @@
+;;; test-ai-term--project-color.el --- Tests for per-project session colors -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests the per-project /color auto-assignment: the project -> color-name
+;; mapping (override alist + deterministic hash fallback), the TUI-ready
+;; detector, the two-step /color send, the polling scheduler, and the
+;; show-or-create wiring (fresh sessions get a color; tmux reattaches are
+;; never injected into). eat, tmux discovery, and timers are stubbed.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(declare-function cj/--ai-term-project-color "ai-term" (dir))
+(declare-function cj/--ai-term-color-ready-p "ai-term-backend-eat" (buffer))
+(declare-function cj/--ai-term-send-color "ai-term-backend-eat" (buffer color))
+(declare-function cj/--ai-term-schedule-color "ai-term-backend-eat" (buffer color))
+(declare-function cj/--ai-term-show-or-create "ai-term-backend-eat" (dir name))
+
+(defvar cj/ai-term-project-colors)
+(defvar cj/--ai-term-color-names)
+(defvar cj/--ai-term-mru)
+(defvar eat-buffer-name)
+
+;; eat isn't loaded in batch -- provide a stub so cl-letf has an override.
+(unless (fboundp 'eat)
+ (defun eat (&optional _program _arg) nil))
+
+;;; ------------------------ cj/--ai-term-project-color ------------------------
+
+(ert-deftest test-ai-term-project-color-is-deterministic ()
+ "Normal: the same project dir always maps to the same color name."
+ (let ((cj/ai-term-project-colors nil))
+ (should (equal (cj/--ai-term-project-color "/home/u/code/someproj")
+ (cj/--ai-term-project-color "/home/u/code/someproj")))))
+
+(ert-deftest test-ai-term-project-color-returns-a-known-name ()
+ "Normal: the hash fallback lands on one of Claude Code's color names."
+ (let ((cj/ai-term-project-colors nil))
+ (should (seq-contains-p cj/--ai-term-color-names
+ (cj/--ai-term-project-color "/home/u/code/xyz")
+ #'equal))))
+
+(ert-deftest test-ai-term-project-color-alist-override-wins ()
+ "Normal: an explicit project entry beats the hash."
+ (let ((cj/ai-term-project-colors '(("myproj" . "purple"))))
+ (should (equal (cj/--ai-term-project-color "/home/u/code/myproj") "purple"))))
+
+(ert-deftest test-ai-term-project-color-trailing-slash-insensitive ()
+ "Boundary: a trailing slash on the dir does not change the color."
+ (let ((cj/ai-term-project-colors nil))
+ (should (equal (cj/--ai-term-project-color "/home/u/code/proj")
+ (cj/--ai-term-project-color "/home/u/code/proj/")))))
+
+;;; ----------------------- cj/--ai-term-color-ready-p -------------------------
+
+(defun test-ai-term-pc--buffer-with (content)
+ "Make a temp buffer holding CONTENT and return `cj/--ai-term-color-ready-p' on it."
+ (with-temp-buffer
+ (insert content)
+ (cj/--ai-term-color-ready-p (current-buffer))))
+
+(ert-deftest test-ai-term-color-ready-detects-idle-tui ()
+ "Normal: banner present + untouched prompt line reads as ready."
+ (should (test-ai-term-pc--buffer-with
+ "───────\n❯ \n───────\n ⏵⏵ bypass permissions on (shift+tab to cycle)\n")))
+
+(ert-deftest test-ai-term-color-ready-rejects-typed-prompt ()
+ "Boundary: text already on the prompt line means never inject."
+ (should-not (test-ai-term-pc--buffer-with
+ "───────\n❯ fix the bug\n───────\n ⏵⏵ bypass permissions on\n")))
+
+(ert-deftest test-ai-term-color-ready-rejects-plain-shell ()
+ "Error: a shell without the Claude banner is not ready (fail-safe: no injection)."
+ (should-not (test-ai-term-pc--buffer-with "cjennings@velox ~ $ \n")))
+
+(ert-deftest test-ai-term-color-ready-rejects-dead-buffer ()
+ "Error: a killed buffer is never ready."
+ (let ((buf (generate-new-buffer "pc-dead")))
+ (kill-buffer buf)
+ (should-not (cj/--ai-term-color-ready-p buf))))
+
+;;; ------------------------ cj/--ai-term-send-color ---------------------------
+
+(ert-deftest test-ai-term-send-color-two-step ()
+ "Normal: the command text goes first; the CR follows via a timer (menu race guard)."
+ (let ((sent nil) (timer-fns nil))
+ (cl-letf (((symbol-function 'cj/--ai-term-send-string)
+ (lambda (_buf s) (push s sent)))
+ ((symbol-function 'run-at-time)
+ (lambda (_time _repeat fn &rest args)
+ (push (cons fn args) timer-fns)
+ 'fake-timer)))
+ (with-temp-buffer
+ (cj/--ai-term-send-color (current-buffer) "purple"))
+ (should (equal sent '("/color purple")))
+ (should (= (length timer-fns) 1))
+ ;; Fire the deferred CR.
+ (apply (caar timer-fns) (cdar timer-fns))
+ (should (equal sent '("\r" "/color purple"))))))
+
+;;; ---------------------- cj/--ai-term-schedule-color -------------------------
+
+(ert-deftest test-ai-term-schedule-color-sends-when-ready ()
+ "Normal: the poll sends once the TUI is ready, then cancels its timer."
+ (let ((poll-fn nil) (cancelled nil) (sent nil))
+ (cl-letf (((symbol-function 'run-at-time)
+ (lambda (_time _repeat fn &rest args)
+ (setq poll-fn (lambda () (apply fn args)))
+ 'fake-timer))
+ ((symbol-function 'cancel-timer)
+ (lambda (&rest _) (setq cancelled t)))
+ ((symbol-function 'cj/--ai-term-send-color)
+ (lambda (_buf color) (push color sent))))
+ (with-temp-buffer
+ (rename-buffer "pc-sched-ready" t)
+ (cj/--ai-term-schedule-color (current-buffer) "green")
+ (should poll-fn)
+ ;; Not ready yet: nothing sent, timer stays.
+ (funcall poll-fn)
+ (should-not sent)
+ (should-not cancelled)
+ ;; Becomes ready: sends and cancels.
+ (insert "❯ \n⏵⏵ bypass permissions on\n")
+ (funcall poll-fn)
+ (should (equal sent '("green")))
+ (should cancelled)))))
+
+(ert-deftest test-ai-term-schedule-color-gives-up-on-dead-buffer ()
+ "Error: a killed buffer cancels the poll without sending."
+ (let ((poll-fn nil) (cancelled nil) (sent nil)
+ (buf (generate-new-buffer "pc-sched-dead")))
+ (cl-letf (((symbol-function 'run-at-time)
+ (lambda (_time _repeat fn &rest args)
+ (setq poll-fn (lambda () (apply fn args)))
+ 'fake-timer))
+ ((symbol-function 'cancel-timer)
+ (lambda (&rest _) (setq cancelled t)))
+ ((symbol-function 'cj/--ai-term-send-color)
+ (lambda (_buf color) (push color sent))))
+ (cj/--ai-term-schedule-color buf "red")
+ (kill-buffer buf)
+ (funcall poll-fn)
+ (should-not sent)
+ (should cancelled))))
+
+;;; ------------------------- show-or-create wiring ----------------------------
+
+(defmacro test-ai-term-pc--with-create-mocks (sessions scheduled &rest body)
+ "Run BODY with the create path mocked; live tmux SESSIONS list injected.
+SCHEDULED captures (BUFFER-NAME . COLOR) pairs from the schedule call."
+ (declare (indent 2) (debug t))
+ `(cl-letf (((symbol-function 'eat)
+ (lambda (&optional _program _arg)
+ (get-buffer-create eat-buffer-name)))
+ ((symbol-function 'cj/--ai-term-send-string)
+ (lambda (_buf _s) nil))
+ ((symbol-function 'display-buffer)
+ (lambda (&rest _) nil))
+ ((symbol-function 'cj/--ai-term-apply-accent)
+ (lambda (_buffer) nil))
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda () ,sessions))
+ ((symbol-function 'cj/--ai-term-schedule-color)
+ (lambda (buffer color)
+ (push (cons (buffer-name buffer) color) ,scheduled))))
+ ,@body))
+
+(ert-deftest test-ai-term-show-or-create-schedules-color-on-fresh-session ()
+ "Normal: a project with no live tmux session gets a scheduled /color."
+ (let ((name "agent [pc-fresh-test]")
+ (cj/--ai-term-mru nil)
+ (scheduled nil))
+ (when (get-buffer name) (kill-buffer name))
+ (unwind-protect
+ (test-ai-term-pc--with-create-mocks nil scheduled
+ (cj/--ai-term-show-or-create "/tmp/pc-fresh-test" name)
+ (should (= (length scheduled) 1))
+ (should (equal (caar scheduled) name))
+ (should (equal (cdar scheduled)
+ (cj/--ai-term-project-color "/tmp/pc-fresh-test"))))
+ (when (get-buffer name) (kill-buffer name)))))
+
+(ert-deftest test-ai-term-show-or-create-skips-color-on-reattach ()
+ "Boundary: a live tmux session (reattach) is never injected with /color."
+ (let* ((dir "/tmp/pc-reattach-test")
+ (name "agent [pc-reattach-test]")
+ (cj/--ai-term-mru nil)
+ (scheduled nil)
+ (live (list (cj/--ai-term-tmux-session-name dir))))
+ (when (get-buffer name) (kill-buffer name))
+ (unwind-protect
+ (test-ai-term-pc--with-create-mocks live scheduled
+ (cj/--ai-term-show-or-create dir name)
+ (should-not scheduled))
+ (when (get-buffer name) (kill-buffer name)))))
+
+(provide 'test-ai-term--project-color)
+;;; test-ai-term--project-color.el ends here