aboutsummaryrefslogtreecommitdiff
path: root/tests/test-org-agenda-frame.el
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test-org-agenda-frame.el')
-rw-r--r--tests/test-org-agenda-frame.el999
1 files changed, 999 insertions, 0 deletions
diff --git a/tests/test-org-agenda-frame.el b/tests/test-org-agenda-frame.el
new file mode 100644
index 00000000..21842600
--- /dev/null
+++ b/tests/test-org-agenda-frame.el
@@ -0,0 +1,999 @@
+;;; test-org-agenda-frame.el --- Tests for the fullscreen agenda frame -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Phase 1 of the org-agenda fullscreen frame (spec:
+;; docs/specs/2026-07-17-org-agenda-fullscreen-frame-spec.org). Frame lookup
+;; is mocked (frame-list / frame-live-p / frame-parameter), the house pattern
+;; from test-dirvish-config-popup.el, since --batch can't create real frames.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-agenda-frame)
+
+;; org-agenda isn't loaded in batch (no package-initialize), so declare the
+;; command list special and bound for the registration tests to let-bind.
+(defvar org-agenda-custom-commands nil)
+
+;;; cj/--agenda-frame — locate the marked frame
+
+(ert-deftest test-org-agenda-frame-find-returns-marked-live-frame ()
+ "Normal: returns the live frame carrying the `cj/agenda-frame' marker."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fb fc)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'fb)))))
+ (should (eq (cj/--agenda-frame) 'fb))))
+
+(ert-deftest test-org-agenda-frame-find-nil-when-none-marked ()
+ "Boundary: no frame carries the marker -> nil."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fc)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter) (lambda (_f _p) nil)))
+ (should (null (cj/--agenda-frame)))))
+
+(ert-deftest test-org-agenda-frame-find-ignores-dead-marked-frame ()
+ "Error: a marked but dead frame is not returned."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fb)))
+ ((symbol-function 'frame-live-p) (lambda (f) (not (eq f 'fb))))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'fb)))))
+ (should (null (cj/--agenda-frame)))))
+
+;;; cj/--agenda-frame-p — is FRAME a live agenda frame
+
+(ert-deftest test-org-agenda-frame-p-true-for-live-marked ()
+ "Normal: a live marked frame is an agenda frame."
+ (cl-letf (((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'fa)))))
+ (should (cj/--agenda-frame-p 'fa))))
+
+(ert-deftest test-org-agenda-frame-p-nil-for-unmarked ()
+ "Boundary: a live unmarked frame is not an agenda frame."
+ (cl-letf (((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter) (lambda (_f _p) nil)))
+ (should (null (cj/--agenda-frame-p 'fa)))))
+
+(ert-deftest test-org-agenda-frame-p-nil-for-dead-marked ()
+ "Error: a dead marked frame is not an agenda frame."
+ (cl-letf (((symbol-function 'frame-live-p) (lambda (_f) nil))
+ ((symbol-function 'frame-parameter)
+ (lambda (_f p) (eq p 'cj/agenda-frame))))
+ (should (null (cj/--agenda-frame-p 'fa)))))
+
+;;; cj/--agenda-frame-working-frame — route source files to a non-agenda frame
+
+(ert-deftest test-org-agenda-working-frame-returns-non-agenda-frame ()
+ "Normal: with no recorded launch frame, returns the first live non-agenda frame."
+ (let ((cj/--agenda-frame-launch-frame nil))
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(agenda work)))
+ ((symbol-function 'frame-live-p) (lambda (f) (memq f '(agenda work))))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'agenda)))))
+ (should (eq (cj/--agenda-frame-working-frame) 'work)))))
+
+(ert-deftest test-org-agenda-working-frame-prefers-live-launch-frame ()
+ "Normal: the recorded launch frame wins when live and non-agenda."
+ (let ((cj/--agenda-frame-launch-frame 'w2))
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(agenda w1 w2)))
+ ((symbol-function 'frame-live-p) (lambda (f) (memq f '(agenda w1 w2))))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'agenda)))))
+ (should (eq (cj/--agenda-frame-working-frame) 'w2)))))
+
+(ert-deftest test-org-agenda-working-frame-falls-back-when-launch-dead ()
+ "Boundary: a dead recorded launch frame falls back to another non-agenda frame."
+ (let ((cj/--agenda-frame-launch-frame 'gone))
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(agenda work)))
+ ((symbol-function 'frame-live-p) (lambda (f) (memq f '(agenda work))))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'agenda)))))
+ (should (eq (cj/--agenda-frame-working-frame) 'work)))))
+
+(ert-deftest test-org-agenda-working-frame-nil-when-only-agenda ()
+ "Error: the agenda frame is the only live frame -> nil (caller creates one)."
+ (let ((cj/--agenda-frame-launch-frame nil))
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(agenda)))
+ ((symbol-function 'frame-live-p) (lambda (f) (eq f 'agenda)))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'agenda)))))
+ (should (null (cj/--agenda-frame-working-frame))))))
+
+;;; cj/--agenda-frame-command — the dedicated F seven-day view
+
+(defun test-org-agenda-frame--block-settings ()
+ "Return the per-block settings alist of the agenda-frame command."
+ (let* ((cmd (cj/--agenda-frame-command))
+ (blocks (nth 2 cmd))
+ (agenda-block (car blocks)))
+ (nth 2 agenda-block)))
+
+(defun test-org-agenda-frame--general-settings ()
+ "Return the general (view-wide) settings alist of the agenda-frame command."
+ (nth 3 (cj/--agenda-frame-command)))
+
+(ert-deftest test-org-agenda-frame-command-key-is-F ()
+ "Normal: the command's key is F (collision-free with the existing d)."
+ (should (equal (nth 0 (cj/--agenda-frame-command)) "F")))
+
+(ert-deftest test-org-agenda-frame-command-today-anchored-7-day ()
+ "Normal: the span is seven days anchored to today, not Monday."
+ (let ((s (test-org-agenda-frame--block-settings)))
+ (should (equal (cadr (assq 'org-agenda-span s)) 7))
+ (should (equal (cadr (assq 'org-agenda-start-day s)) "0d"))
+ ;; start-on-weekday nil is what un-anchors the span from Monday.
+ (should (assq 'org-agenda-start-on-weekday s))
+ (should (null (cadr (assq 'org-agenda-start-on-weekday s))))))
+
+(ert-deftest test-org-agenda-frame-command-tight-prefix-format ()
+ "Normal: the view sets its own prefix format with a narrow category column.
+Without it the global agenda format applies, whose 25-char category pad
+leaves a wide blank gutter between the source name and the item."
+ (let ((s (test-org-agenda-frame--block-settings)))
+ (should (assq 'org-agenda-prefix-format s))
+ (should (string-match-p "%-10:c"
+ (cadr (assq 'org-agenda-prefix-format s))))))
+
+(ert-deftest test-org-agenda-frame-do-redo-leaves-sticky-alone ()
+ "Normal: the redo binds current-window but never touches sticky.
+org-agenda-redo handles the in-place rebuild itself (it binds sticky nil
+and redirects the buffer name); a sticky t reaching org-agenda-prepare
+mid-redo makes it throw \\='exit with no catch and the tick fails."
+ (let ((org-agenda-sticky nil)
+ seen-sticky seen-setup (params '()))
+ (with-temp-buffer
+ (insert "agenda line\n")
+ (cl-letf (((symbol-function 'org-agenda-redo)
+ (lambda (&rest _)
+ (setq seen-sticky org-agenda-sticky
+ seen-setup org-agenda-window-setup)))
+ ((symbol-function 'frame-parameter) (lambda (_f p) (alist-get p params)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setf (alist-get p params) v))))
+ (cj/--agenda-frame-do-redo 'af (current-buffer) nil)
+ (should (null seen-sticky))
+ (should (eq seen-setup 'current-window))))))
+
+(ert-deftest test-org-agenda-frame-command-follow-mode-off ()
+ "Boundary: follow-mode is forced off locally so a global default can't split."
+ (let ((s (test-org-agenda-frame--block-settings)))
+ (should (assq 'org-agenda-start-with-follow-mode s))
+ (should (null (cadr (assq 'org-agenda-start-with-follow-mode s))))))
+
+(ert-deftest test-org-agenda-frame-command-sticky-and-current-window ()
+ "Normal: current-window in the settings; sticky deliberately NOT there.
+The general settings are baked into the buffer's series-redo-cmd and
+re-applied on every redo; a sticky t there makes org-agenda-use-sticky-p
+true mid-redo (the buffer exists), and org-agenda-prepare throws \\='exit
+with no catch -- every refresh tick fails. Stickiness belongs only in
+the spawn wrapper, where it names the buffer."
+ (let ((g (test-org-agenda-frame--general-settings)))
+ (should-not (assq 'org-agenda-sticky g))
+ ;; org evaluates custom-command setting values via org-let, so the stored
+ ;; form is (quote current-window); eval it the way org would.
+ (should (eq (eval (cadr (assq 'org-agenda-window-setup g)) t)
+ 'current-window))))
+
+;;; cj/--agenda-frame-register-command — idempotent registration
+
+(ert-deftest test-org-agenda-frame-register-adds-entry ()
+ "Normal: registration inserts the F entry into org-agenda-custom-commands."
+ (let ((org-agenda-custom-commands '(("d" "Daily" nil))))
+ (cj/--agenda-frame-register-command)
+ (should (assoc "F" org-agenda-custom-commands))
+ (should (assoc "d" org-agenda-custom-commands))))
+
+(ert-deftest test-org-agenda-frame-register-is-idempotent ()
+ "Boundary: registering twice leaves exactly one F entry."
+ (let ((org-agenda-custom-commands nil))
+ (cj/--agenda-frame-register-command)
+ (cj/--agenda-frame-register-command)
+ (should (= 1 (seq-count (lambda (e) (equal (car e) "F"))
+ org-agenda-custom-commands)))))
+
+;;; Default-deny policy — denial handlers
+
+(ert-deftest test-org-agenda-frame-denied-readonly-messages ()
+ "Normal: the read-only denial shows the read-only message and acts on nothing."
+ (let (captured)
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (setq captured (apply #'format fmt args)))))
+ (cj/--agenda-frame-denied-readonly))
+ (should (string-match-p "read-only" captured))
+ (should (string-match-p "working frame" captured))))
+
+(ert-deftest test-org-agenda-frame-denied-fixed-view-messages ()
+ "Normal: the view-change denial shows the fixed-view message."
+ (let (captured)
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (setq captured (apply #'format fmt args)))))
+ (cj/--agenda-frame-denied-fixed-view))
+ (should (string-match-p "7-day view" captured))))
+
+;;; Default-deny policy — the keymap
+
+(ert-deftest test-org-agenda-frame-map-catch-all-is-readonly-deny ()
+ "Normal: the [t] default binding denies with the read-only handler."
+ (should (eq (lookup-key cj/agenda-frame-mode-map [t])
+ 'cj/--agenda-frame-denied-readonly)))
+
+(ert-deftest test-org-agenda-frame-map-navigation-allowed ()
+ "Normal: navigation keys resolve to their org-agenda commands."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "n")) 'org-agenda-next-line))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "p")) 'org-agenda-previous-line))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-g")) 'keyboard-quit)))
+
+(ert-deftest test-org-agenda-frame-map-point-motion-and-isearch-allowed ()
+ "Normal: read-only point motion and isearch work in the frame.
+C-a/C-e/C-f/C-b move point and C-s/C-r search; all are read-only and
+must not hit the deny catch-all."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-a")) 'move-beginning-of-line))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-e")) 'move-end-of-line))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-f")) 'forward-char))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-b")) 'backward-char))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-s")) 'isearch-forward))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-r")) 'isearch-backward)))
+
+(ert-deftest test-org-agenda-frame-map-engage-routed ()
+ "Normal: RET and TAB route to the working-frame engage command."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "RET"))
+ 'cj/--agenda-frame-engage-open))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "TAB"))
+ 'cj/--agenda-frame-engage-open)))
+
+(ert-deftest test-org-agenda-frame-map-engage-gui-function-keys ()
+ "Boundary: the GUI [return]/[tab] events engage too, not the [t] deny handler.
+Without these, the catch-all suppresses their translation to RET/TAB in a
+graphical frame and RET would be denied instead of opening the item."
+ (should (eq (lookup-key cj/agenda-frame-mode-map [return])
+ 'cj/--agenda-frame-engage-open))
+ (should (eq (lookup-key cj/agenda-frame-mode-map [tab])
+ 'cj/--agenda-frame-engage-open)))
+
+(ert-deftest test-org-agenda-frame-map-lifecycle-keys ()
+ "Normal: q/Q/x close the frame; r takes the safe-redo path."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "q")) 'cj/--agenda-frame-close))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "Q")) 'cj/--agenda-frame-close))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "x")) 'cj/--agenda-frame-close))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "r")) 'cj/--agenda-frame-safe-redo)))
+
+(ert-deftest test-org-agenda-frame-map-view-changers-fixed-view-deny ()
+ "Boundary: view-changing keys are explicitly denied with the fixed-view message,
+not caught by the read-only catch-all."
+ (dolist (key '("w" "d" "y" "f" "b" "j" "g"))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd key))
+ 'cj/--agenda-frame-denied-fixed-view))))
+
+(ert-deftest test-org-agenda-frame-map-frame-controls-bound ()
+ "Normal: the frame's own controls work from inside the frame.
+S-<f8> must close/toggle and C-M-<f8> must force-rescan; unbound, the
+catch-all denies them and the frame can't be closed by its own key."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "S-<f8>"))
+ 'cj/agenda-frame-toggle))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-M-<f8>"))
+ 'cj/org-agenda-refresh-files)))
+
+(ert-deftest test-org-agenda-frame-map-C-x-C-c-closes-frame ()
+ "Normal: C-x C-c in the agenda frame closes the frame, not the daemon.
+The global save-buffers-kill-terminal would kill Emacs itself here (a
+make-frame frame has no client), so the intuitive close gesture must be
+remapped to the frame close."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-x C-c"))
+ 'cj/--agenda-frame-close)))
+
+(ert-deftest test-org-agenda-frame-map-machinery-punched-through ()
+ "Boundary: input machinery is punched through the [t] catch-all.
+switch-frame events, mouse-wheel scrolling, mouse-1 clicks, and the help
+prefix must fall through to their global bindings (an explicit nil shadows
+the default in this map); otherwise every frame-focus change and every
+scroll spams the deny message."
+ (dolist (key (list [switch-frame]
+ [wheel-up] [wheel-down] [wheel-left] [wheel-right]
+ [double-wheel-up] [double-wheel-down]
+ [triple-wheel-up] [triple-wheel-down]
+ [mouse-1] [down-mouse-1] [drag-mouse-1]
+ (kbd "C-h")))
+ ;; accept-default t: a punched key returns nil (falls through to the
+ ;; global map); an unpunched key returns the catch-all deny handler.
+ (should (null (lookup-key cj/agenda-frame-mode-map key t)))))
+
+(ert-deftest test-org-agenda-frame-map-unpunched-still-denied ()
+ "Normal: an ordinary unbound key still hits the catch-all after the punches."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "t") t)
+ 'cj/--agenda-frame-denied-readonly)))
+
+(ert-deftest test-org-agenda-frame-maybe-enable-readds-kill-buffer-hook ()
+ "Normal: the finalize re-enable also re-adds the buffer-local kill hook.
+org-agenda-redo's kill-all-local-variables strips the hook installed at
+spawn; without the re-add, killing the buffer after the first refresh tick
+orphans the frame."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'agenda))
+ ((symbol-function 'get-buffer-window) (lambda (_b _f) 'win)))
+ (cj/--agenda-frame-maybe-enable-mode)
+ (should (memq 'cj/--agenda-frame-on-kill-buffer
+ (buffer-local-value 'kill-buffer-hook (current-buffer)))))))
+
+(ert-deftest test-org-agenda-frame-overlay-removable-after-local-var-wipe ()
+ "Error: the failure overlay is found by property, not a buffer-local var.
+kill-all-local-variables (every redo) wipes buffer-local vars while the
+overlay object survives erase-buffer, so a var-held overlay could never be
+removed after a later success -- the failure banner would stick forever."
+ (with-temp-buffer
+ (insert "x\n")
+ (cj/--agenda-frame-show-failure-overlay (current-buffer))
+ ;; Simulate the org-agenda-mode reset between failure and success.
+ (kill-all-local-variables)
+ (cj/--agenda-frame-remove-overlay (current-buffer))
+ ;; The visible banner (a before-string overlay) must be gone.
+ (should (= 0 (seq-count (lambda (o) (overlay-get o 'before-string))
+ (overlays-in (point-min) (point-max)))))))
+
+(ert-deftest test-org-agenda-frame-map-mutation-keys-not-explicitly-bound ()
+ "Boundary: a mutation key (t = org-agenda-todo) is not explicitly bound, so the
+[t] catch-all denies it as read-only."
+ (should (null (lookup-key cj/agenda-frame-mode-map (kbd "t")))))
+
+;;; Default-deny policy — the minor mode + finalize re-enable
+
+(ert-deftest test-org-agenda-frame-mode-toggles ()
+ "Normal: the minor mode turns on and off in a buffer."
+ (with-temp-buffer
+ (cj/agenda-frame-mode 1)
+ (should cj/agenda-frame-mode)
+ (cj/agenda-frame-mode -1)
+ (should-not cj/agenda-frame-mode)))
+
+(ert-deftest test-org-agenda-frame-maybe-enable-in-agenda-frame ()
+ "Normal: after a build in the agenda frame, the policy is re-enabled."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'agenda))
+ ((symbol-function 'get-buffer-window) (lambda (_b _f) 'win)))
+ (cj/--agenda-frame-maybe-enable-mode)
+ (should cj/agenda-frame-mode))))
+
+(ert-deftest test-org-agenda-frame-maybe-enable-skips-other-buffers ()
+ "Boundary: a build not shown in the agenda frame leaves the policy off."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'agenda))
+ ((symbol-function 'get-buffer-window) (lambda (_b _f) nil)))
+ (cj/--agenda-frame-maybe-enable-mode)
+ (should-not cj/agenda-frame-mode))))
+
+(ert-deftest test-org-agenda-frame-maybe-enable-no-frame ()
+ "Boundary: no agenda frame at all -> policy stays off, no error."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () nil)))
+ (cj/--agenda-frame-maybe-enable-mode)
+ (should-not cj/agenda-frame-mode))))
+
+;;; Engage routing — frame target + open
+
+(ert-deftest test-org-agenda-frame-target-frame-uses-working-frame ()
+ "Normal: the engage target is the working frame when one exists."
+ (cl-letf (((symbol-function 'cj/--agenda-frame-working-frame) (lambda () 'work))
+ ((symbol-function 'make-frame) (lambda (&rest _) (error "should not create"))))
+ (should (eq (cj/--agenda-frame-target-frame) 'work))))
+
+(ert-deftest test-org-agenda-frame-target-frame-creates-when-none ()
+ "Boundary: no working frame -> a normal frame is created."
+ (cl-letf (((symbol-function 'cj/--agenda-frame-working-frame) (lambda () nil))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'new)))
+ (should (eq (cj/--agenda-frame-target-frame) 'new))))
+
+(ert-deftest test-org-agenda-frame-engage-open-no-item-errors ()
+ "Error: engaging on a line with no source item signals a user-error."
+ (cl-letf (((symbol-function 'cj/--agenda-frame-item-marker) (lambda () nil)))
+ (should-error (cj/--agenda-frame-engage-open) :type 'user-error)))
+
+(ert-deftest test-org-agenda-frame-engage-open-routes-to-source ()
+ "Normal: engage opens the item's source buffer at the item's position."
+ (let ((source (generate-new-buffer " *frame-engage-source*")))
+ (unwind-protect
+ (progn
+ (with-current-buffer source (insert "line one\nline two\nline three\n"))
+ (let ((marker (set-marker (make-marker) 10 source))
+ focused opened)
+ (cl-letf (((symbol-function 'cj/--agenda-frame-item-marker) (lambda () marker))
+ ((symbol-function 'cj/--agenda-frame-target-frame) (lambda () 'work))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f &rest _) (setq focused f)))
+ ((symbol-function 'pop-to-buffer-same-window)
+ (lambda (b &rest _) (setq opened b) (set-buffer b)))
+ ((symbol-function 'org-fold-show-context) (lambda (&rest _) nil)))
+ (cj/--agenda-frame-engage-open)
+ (should (eq focused 'work))
+ (should (eq opened source))
+ (should (eq (current-buffer) source))
+ (should (= (point) (line-beginning-position))))))
+ (kill-buffer source))))
+
+;;; Frame lifecycle — sticky buffer, timer-cancel, teardown cleanup
+
+(ert-deftest test-org-agenda-frame-sticky-buffer-name ()
+ "Normal: the sticky buffer is *Org Agenda(F)*; nil when absent."
+ (should (null (cj/--agenda-frame-sticky-buffer)))
+ (let ((buf (get-buffer-create "*Org Agenda(F)*")))
+ (unwind-protect
+ (should (eq (cj/--agenda-frame-sticky-buffer) buf))
+ (kill-buffer buf))))
+
+(ert-deftest test-org-agenda-frame-cancel-timer-safe-when-none ()
+ "Boundary: cancelling with no timer set does nothing and does not error."
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () nil)))
+ (should-not (cj/--agenda-frame-cancel-timer))))
+
+;;; Frame lifecycle — toggle dispatch
+
+(ert-deftest test-org-agenda-frame-toggle-spawns-when-none ()
+ "Normal: with no agenda frame, toggle spawns one."
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-spawn) (lambda () 'spawned)))
+ (should (eq (cj/--agenda-frame-toggle) 'spawned))))
+
+(ert-deftest test-org-agenda-frame-toggle-deletes-when-selected ()
+ "Normal: toggle from within the agenda frame deletes it."
+ (let (deleted)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'selected-frame) (lambda () 'af))
+ ((symbol-function 'cj/--agenda-frame-delete)
+ (lambda () (setq deleted t))))
+ (cj/--agenda-frame-toggle)
+ (should deleted))))
+
+(ert-deftest test-org-agenda-frame-toggle-raises-when-unfocused ()
+ "Normal: toggle from a working frame raises the existing agenda frame."
+ (let (raised)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'selected-frame) (lambda () 'work))
+ ((symbol-function 'cj/--agenda-frame-raise)
+ (lambda (f) (setq raised f))))
+ (cj/--agenda-frame-toggle)
+ (should (eq raised 'af)))))
+
+;;; Frame lifecycle — delete
+
+(ert-deftest test-org-agenda-frame-delete-deletes-live-frame ()
+ "Normal: delete removes the live agenda frame."
+ (let (deleted)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'frame-live-p) (lambda (f) (eq f 'af)))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--agenda-frame-delete)
+ (should (eq deleted 'af)))))
+
+(ert-deftest test-org-agenda-frame-delete-noop-when-none ()
+ "Boundary: delete with no agenda frame does nothing."
+ (let (called)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () nil))
+ ((symbol-function 'delete-frame) (lambda (_f &rest _) (setq called t))))
+ (cj/--agenda-frame-delete)
+ (should-not called))))
+
+;;; Frame lifecycle — cleanup on frame death and buffer kill
+
+(ert-deftest test-org-agenda-frame-on-delete-cancels-and-kills-buffer ()
+ "Normal: deleting the agenda frame cancels its timer and kills the sticky buffer."
+ (let ((buf (get-buffer-create "*Org Agenda(F)*"))
+ cancelled)
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--agenda-frame-p) (lambda (_f) t))
+ ((symbol-function 'cj/--agenda-frame-cancel-timer)
+ (lambda (&optional _f) (setq cancelled t))))
+ (cj/--agenda-frame-on-delete-frame 'af)
+ (should cancelled)
+ (should-not (buffer-live-p buf)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest test-org-agenda-frame-on-delete-ignores-non-agenda-frame ()
+ "Boundary: a non-agenda frame deletion triggers no cleanup."
+ (let (cancelled)
+ (cl-letf (((symbol-function 'cj/--agenda-frame-p) (lambda (_f) nil))
+ ((symbol-function 'cj/--agenda-frame-cancel-timer)
+ (lambda (&optional _f) (setq cancelled t))))
+ (cj/--agenda-frame-on-delete-frame 'work)
+ (should-not cancelled))))
+
+(ert-deftest test-org-agenda-frame-on-kill-buffer-deletes-frame ()
+ "Normal: killing the dedicated buffer deletes the frame."
+ (let (deleted)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'frame-live-p) (lambda (f) (eq f 'af)))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--agenda-frame-on-kill-buffer)
+ (should (eq deleted 'af)))))
+
+(ert-deftest test-org-agenda-frame-on-kill-buffer-guarded-during-teardown ()
+ "Boundary: during a teardown the buffer-kill hook does not re-delete the frame."
+ (let (deleted (cj/--agenda-frame-tearing-down t))
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--agenda-frame-on-kill-buffer)
+ (should-not deleted))))
+
+;;; Auto-dim suspension while the agenda frame lives
+
+(ert-deftest test-org-agenda-frame-spawn-suspends-auto-dim ()
+ "Normal: spawning the frame turns auto-dim off and remembers it was on.
+The refresh tick's selection swing marks the working window non-selected;
+auto-dim's debounced dim then lands after the tick and the working frame
+visibly dims every five minutes."
+ (defvar auto-dim-other-buffers-mode)
+ (let ((auto-dim-other-buffers-mode t)
+ (cj/--agenda-frame-dim-was-on nil)
+ calls)
+ (cl-letf (((symbol-function 'auto-dim-other-buffers-mode)
+ (lambda (arg) (push arg calls)))
+ ((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'af))
+ ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda) (lambda (&rest _) nil))
+ ((symbol-function 'delete-other-windows) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-start-timer) (lambda (_f) nil)))
+ (cj/--agenda-frame-spawn)
+ (should (equal calls '(-1)))
+ (should cj/--agenda-frame-dim-was-on))))
+
+(ert-deftest test-org-agenda-frame-spawn-leaves-auto-dim-when-off ()
+ "Boundary: auto-dim already off -> spawn doesn't touch it, no restore later."
+ (defvar auto-dim-other-buffers-mode)
+ (let ((auto-dim-other-buffers-mode nil)
+ (cj/--agenda-frame-dim-was-on nil)
+ calls)
+ (cl-letf (((symbol-function 'auto-dim-other-buffers-mode)
+ (lambda (arg) (push arg calls)))
+ ((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'af))
+ ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda) (lambda (&rest _) nil))
+ ((symbol-function 'delete-other-windows) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-start-timer) (lambda (_f) nil)))
+ (cj/--agenda-frame-spawn)
+ (should (null calls))
+ (should-not cj/--agenda-frame-dim-was-on))))
+
+(ert-deftest test-org-agenda-frame-on-delete-restores-auto-dim ()
+ "Normal: closing the frame restores auto-dim when spawn had turned it off."
+ (let ((cj/--agenda-frame-dim-was-on t)
+ calls)
+ (cl-letf (((symbol-function 'auto-dim-other-buffers-mode)
+ (lambda (arg) (push arg calls)))
+ ((symbol-function 'cj/--agenda-frame-p) (lambda (_f) t))
+ ((symbol-function 'cj/--agenda-frame-cancel-timer)
+ (lambda (&optional _f) nil)))
+ (cj/--agenda-frame-on-delete-frame 'af)
+ (should (equal calls '(1)))
+ (should-not cj/--agenda-frame-dim-was-on))))
+
+(ert-deftest test-org-agenda-frame-on-delete-no-dim-restore-when-untouched ()
+ "Boundary: closing without a suspended auto-dim doesn't enable it."
+ (let ((cj/--agenda-frame-dim-was-on nil)
+ calls)
+ (cl-letf (((symbol-function 'auto-dim-other-buffers-mode)
+ (lambda (arg) (push arg calls)))
+ ((symbol-function 'cj/--agenda-frame-p) (lambda (_f) t))
+ ((symbol-function 'cj/--agenda-frame-cancel-timer)
+ (lambda (&optional _f) nil)))
+ (cj/--agenda-frame-on-delete-frame 'af)
+ (should (null calls)))))
+
+;;; Frame lifecycle — transactional spawn rollback
+
+(ert-deftest test-org-agenda-frame-spawn-rolls-back-on-failure ()
+ "Error: a failure after make-frame deletes the partial frame, restores the
+working frame, and signals a user-error."
+ (let (deleted focus)
+ (cl-letf (((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'pf))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f &rest _) (setq focus f)))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda) (lambda (&rest _) (error "boom")))
+ ((symbol-function 'frame-live-p) (lambda (f) (memq f '(pf launch))))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (should-error (cj/--agenda-frame-spawn) :type 'user-error)
+ (should (eq deleted 'pf))
+ (should (eq focus 'launch)))))
+
+;;; Phase 2 — wall-clock alignment
+
+(ert-deftest test-org-agenda-frame-seconds-to-next-mark-aligned ()
+ "Normal: a time exactly on a 5-minute boundary yields a full period."
+ ;; 1000000200 is divisible by 300 (a :00/:05 wall-clock mark).
+ (should (= (cj/--agenda-frame-seconds-to-next-mark 1000000200 300) 300)))
+
+(ert-deftest test-org-agenda-frame-seconds-to-next-mark-midway ()
+ "Boundary: partway through a period returns the remainder to the next mark."
+ ;; 1000000200 + 120 -> 180 seconds remain to the next 300 mark.
+ (should (= (cj/--agenda-frame-seconds-to-next-mark 1000000320 300) 180)))
+
+;;; Phase 2 — deterministic point restoration
+
+(defun test-org-agenda-frame--make-agenda-buffer (lines source)
+ "Insert LINES into the current buffer; each is (TEXT . SRC-POS).
+A non-nil SRC-POS puts an org-marker into SOURCE at that position on the line."
+ (dolist (spec lines)
+ (let ((start (point)))
+ (insert (car spec) "\n")
+ (when (cdr spec)
+ (put-text-property start (1+ start) 'org-marker
+ (set-marker (make-marker) (cdr spec) source))))))
+
+(ert-deftest test-org-agenda-frame-restore-point-duplicate-nearest ()
+ "Normal: a source marker occurring twice restores the occurrence nearest the old line."
+ (let ((src (generate-new-buffer " *rp-src*")))
+ (unwind-protect
+ (with-temp-buffer
+ (with-current-buffer src (insert "aaaaaaaaaa\n"))
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("header" . nil) ("item @2" . 3) ("filler" . nil)
+ ("filler" . nil) ("item @5" . 3))
+ src)
+ (let ((old (set-marker (make-marker) 3 src)))
+ (cj/--agenda-frame-restore-point old 4))
+ ;; lines 2 and 5 both point at src pos 3; nearest to old-line 4 is line 5.
+ (should (= (line-number-at-pos) 5)))
+ (kill-buffer src))))
+
+(ert-deftest test-org-agenda-frame-restore-point-missing-clamps ()
+ "Boundary: a gone marker clamps the old line into range and lands on an item."
+ (let ((src (generate-new-buffer " *rp-src*")))
+ (unwind-protect
+ (with-temp-buffer
+ (with-current-buffer src (insert "aaaaaaaaaa\n"))
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("header" . nil) ("item" . 3) ("item" . 5))
+ src)
+ ;; old-line 99 is past the end; clamp to last line (an item).
+ (let ((gone (set-marker (make-marker) 99 src)))
+ (cj/--agenda-frame-restore-point gone 99))
+ (should (get-text-property (line-beginning-position) 'org-marker)))
+ (kill-buffer src))))
+
+(ert-deftest test-org-agenda-frame-restore-point-header-goes-to-first-item ()
+ "Boundary: with no marker match, a header line moves to the first item."
+ (let ((src (generate-new-buffer " *rp-src*")))
+ (unwind-protect
+ (with-temp-buffer
+ (with-current-buffer src (insert "aaaaaaaaaa\n"))
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("header" . nil) ("item one" . 3) ("item two" . 5))
+ src)
+ (cj/--agenda-frame-restore-point nil 1) ; line 1 is the header
+ (should (= (line-number-at-pos) 2))
+ (should (get-text-property (line-beginning-position) 'org-marker)))
+ (kill-buffer src))))
+
+(ert-deftest test-org-agenda-frame-restore-point-empty-buffer-start ()
+ "Error: an empty (item-less) view leaves point at buffer start."
+ (with-temp-buffer
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("only a header" . nil) ("no items here" . nil)) nil)
+ (cj/--agenda-frame-restore-point nil 2)
+ (should (= (point) (point-min)))))
+
+;;; Phase 2 — snapshot marker cloning
+
+(ert-deftest test-org-agenda-frame-clone-and-reinstall-markers ()
+ "Normal: cloned markers survive nulling the originals and reinstall live."
+ (let ((src (generate-new-buffer " *clone-src*")))
+ (unwind-protect
+ (let (clones)
+ (with-temp-buffer
+ (with-current-buffer src (insert "0123456789\n"))
+ (test-org-agenda-frame--make-agenda-buffer '(("item" . 4)) src)
+ (setq clones (cj/--agenda-frame-snapshot-markers (current-buffer)))
+ ;; Simulate org-agenda-reset-markers nulling the buffer's originals.
+ (let ((orig (get-text-property (point-min) 'org-marker)))
+ (set-marker orig nil)))
+ ;; Reinstall into a fresh buffer copy; the clone must still be live.
+ (with-temp-buffer
+ (test-org-agenda-frame--make-agenda-buffer '(("item" . nil)) nil)
+ (cj/--agenda-frame-reinstall-markers (current-buffer) clones)
+ (let ((m (get-text-property (point-min) 'org-marker)))
+ (should (markerp m))
+ (should (eq (marker-buffer m) src))
+ (should (= (marker-position m) 4)))))
+ (kill-buffer src))))
+
+;;; Phase 2 — failure latch (report once per consecutive-failure run)
+
+(ert-deftest test-org-agenda-frame-failure-latch-reports-once ()
+ "Normal: the first failure of a run reports; subsequent ones stay silent."
+ (let ((params '()))
+ (cl-letf (((symbol-function 'frame-parameter)
+ (lambda (_f p) (alist-get p params)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setf (alist-get p params) v))))
+ (should (cj/--agenda-frame-record-failure 'af)) ; 0 -> 1, report
+ (should-not (cj/--agenda-frame-record-failure 'af)) ; 1 -> 2, silent
+ (cj/--agenda-frame-clear-failure 'af)
+ (should (cj/--agenda-frame-record-failure 'af))))) ; reset -> report again
+
+;;; Phase 2 — timer start + duplicate prevention
+
+(ert-deftest test-org-agenda-frame-start-timer-sets-timer ()
+ "Normal: start-timer schedules and stores a timer when none exists."
+ (let (stored)
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (_f _p) nil))
+ ((symbol-function 'run-at-time) (lambda (&rest _) 'the-timer))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f _p v) (setq stored v))))
+ (should (eq (cj/--agenda-frame-start-timer 'af) 'the-timer))
+ (should (eq stored 'the-timer)))))
+
+(ert-deftest test-org-agenda-frame-start-timer-no-duplicate ()
+ "Boundary: a frame already carrying a live timer is not given a second one."
+ (let (called)
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (_f _p) 'existing))
+ ((symbol-function 'timerp) (lambda (x) (eq x 'existing)))
+ ((symbol-function 'run-at-time) (lambda (&rest _) (setq called t) 'new)))
+ (cj/--agenda-frame-start-timer 'af)
+ (should-not called))))
+
+;;; Phase 2 — do-redo orchestration (success and failure branches)
+
+(ert-deftest test-org-agenda-frame-do-redo-success-clears-and-releases ()
+ "Normal: a successful redo clears the failure latch and releases the snapshot."
+ (let ((params (list (cons 'cj/agenda-frame-fail-count 3)))
+ released)
+ (with-temp-buffer
+ (insert "agenda line\n")
+ (cl-letf (((symbol-function 'org-agenda-redo) (lambda (&rest _) nil))
+ ((symbol-function 'frame-parameter) (lambda (_f p) (alist-get p params)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setf (alist-get p params) v)))
+ ((symbol-function 'cj/--agenda-frame-release-snapshot)
+ (lambda (_s) (setq released t))))
+ (cj/--agenda-frame-do-redo 'af (current-buffer) nil)
+ (should (equal (alist-get 'cj/agenda-frame-fail-count params) 0))
+ (should released)))))
+
+(ert-deftest test-org-agenda-frame-do-redo-error-restores-reenables-reports ()
+ "Error: a redo that fails mid-rebuild restores the last-good buffer verbatim,
+re-enables the policy, shows one overlay, and reports once."
+ (let ((params '()) msgs)
+ (with-temp-buffer
+ (insert "good agenda content\n")
+ (unwind-protect
+ (cl-letf (((symbol-function 'org-agenda-redo)
+ (lambda (&rest _) (erase-buffer) (insert "PARTIAL") (error "boom")))
+ ((symbol-function 'frame-parameter) (lambda (_f p) (alist-get p params)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setf (alist-get p params) v)))
+ ((symbol-function 'message)
+ (lambda (fmt &rest a) (push (apply #'format fmt a) msgs))))
+ (cj/--agenda-frame-do-redo 'af (current-buffer) nil)
+ (should cj/agenda-frame-mode) ; policy re-enabled
+ (should (= 1 (length (cj/--agenda-frame-failure-overlays
+ (current-buffer))))) ; overlay shown
+ (should (string-match-p "good agenda content" (buffer-string))) ; restored
+ (should-not (string-match-p "PARTIAL" (buffer-string)))
+ (should (= 1 (seq-count (lambda (m) (string-match-p "refresh failed" m))
+ msgs))))
+ (cj/agenda-frame-mode -1)
+ (cj/--agenda-frame-remove-overlay (current-buffer))))))
+
+(ert-deftest test-org-agenda-frame-do-redo-follows-item-across-shift ()
+ "Normal: on a successful redo, point follows the same source item even when
+lines shift and the buffer's own markers are nulled (the reset-markers case).
+Guards against restoring by raw line number after the item moved."
+ (let ((src (generate-new-buffer " *shift-src*"))
+ (params '()))
+ (unwind-protect
+ (with-temp-buffer
+ (with-current-buffer src (insert "0123456789\n"))
+ ;; Before: item B (src pos 5) sits on line 3, and point is on it.
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("header" . nil) ("item A" . 3) ("item B" . 5)) src)
+ (goto-char (point-min)) (forward-line 2) ; line 3, item B
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (_f p) (alist-get p params)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setf (alist-get p params) v)))
+ ((symbol-function 'org-agenda-redo)
+ (lambda (&rest _)
+ ;; Null the buffer's originals (as org-agenda-reset-markers
+ ;; does), then rebuild with item B shifted to line 4.
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (let ((m (get-text-property (line-beginning-position) 'org-marker)))
+ (when (markerp m) (set-marker m nil)))
+ (forward-line 1)))
+ (erase-buffer)
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("header" . nil) ("new item" . 1) ("item A" . 3) ("item B" . 5))
+ src))))
+ (cj/--agenda-frame-do-redo 'af (current-buffer) nil))
+ ;; Point should be on the rebuilt item B (src pos 5), now line 4 --
+ ;; not clamped to old line 3 (which is now item A, src pos 3).
+ (let ((m (get-text-property (line-beginning-position) 'org-marker)))
+ (should (markerp m))
+ (should (= (marker-position m) 5))))
+ (kill-buffer src))))
+
+;;; Phase 2 — snapshot round-trip, release, safe-redo window contract, overlay
+
+(ert-deftest test-org-agenda-frame-restore-snapshot-round-trip ()
+ "Normal: snapshot then restore reinstates text and a live cloned marker."
+ (let ((src (generate-new-buffer " *ss-src*")))
+ (unwind-protect
+ (with-temp-buffer
+ (with-current-buffer src (insert "0123456789\n"))
+ (test-org-agenda-frame--make-agenda-buffer '(("item alpha" . 4)) src)
+ (let ((snap (cj/--agenda-frame-snapshot (current-buffer) nil)))
+ (erase-buffer)
+ (insert "CORRUPT")
+ (cj/--agenda-frame-restore-snapshot (current-buffer) snap nil)
+ (should (string-match-p "item alpha" (buffer-string)))
+ (let ((m (get-text-property (point-min) 'org-marker)))
+ (should (markerp m))
+ (should (eq (marker-buffer m) src))
+ (should (= (marker-position m) 4)))
+ (cj/--agenda-frame-release-snapshot snap)
+ (should-not (marker-buffer (cdr (car (plist-get snap :markers)))))))
+ (kill-buffer src))))
+
+(ert-deftest test-org-agenda-frame-safe-redo-selects-and-restores-window ()
+ "Normal: the active tick runs the redo and restores the prior window (no focus theft)."
+ (let (redone (prev (selected-window)))
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer)
+ (lambda () (current-buffer)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'get-buffer-window) (lambda (&rest _) prev))
+ ((symbol-function 'cj/--agenda-frame-do-redo)
+ (lambda (&rest _) (setq redone t))))
+ (cj/--agenda-frame-safe-redo)
+ (should redone)
+ (should (eq (selected-window) prev)))))
+
+(ert-deftest test-org-agenda-frame-overlay-idempotent ()
+ "Boundary: showing the failure overlay twice keeps a single overlay."
+ (with-temp-buffer
+ (insert "x\n")
+ (unwind-protect
+ (progn
+ (cj/--agenda-frame-show-failure-overlay (current-buffer))
+ (let ((first (car (cj/--agenda-frame-failure-overlays (current-buffer)))))
+ (cj/--agenda-frame-show-failure-overlay (current-buffer))
+ (should (equal (cj/--agenda-frame-failure-overlays (current-buffer))
+ (list first)))
+ (should (= 1 (seq-count (lambda (o) (overlay-get o 'before-string))
+ (overlays-in (point-min) (point-max)))))))
+ (cj/--agenda-frame-remove-overlay (current-buffer)))))
+
+;;; Phase 2 — public command + F8-family rebind
+
+(ert-deftest test-org-agenda-frame-public-toggle-wraps-private ()
+ "Normal: the interactive command delegates to the private toggle."
+ (let (called)
+ (cl-letf (((symbol-function 'cj/--agenda-frame-toggle)
+ (lambda () (setq called t))))
+ (call-interactively 'cj/agenda-frame-toggle)
+ (should called))))
+
+(ert-deftest test-org-agenda-frame-parameters-normal-tiled-frame ()
+ "Boundary: the agenda frame is a normal frame (the tiling WM places it
+side by side with the working frame), carrying the marker and a distinct name."
+ (let ((params (cj/--agenda-frame-make-parameters)))
+ (should (assq cj/--agenda-frame-parameter params))
+ (should (equal (cdr (assq 'name params)) "Full Agenda"))
+ ;; No fullscreen request -- a fullboth frame would cover the whole output
+ ;; instead of tiling beside the working frame.
+ (should-not (assq 'fullscreen params))))
+
+(ert-deftest test-org-agenda-frame-spawn-binds-sticky-and-current-window ()
+ "Normal: spawn dynamically binds sticky + current-window around the render.
+The custom command's own settings apply too late to name the buffer, so
+without these bindings the buffer is plain *Org Agenda* -- which matches
+the 0.75 below-selected display rule and splits the new frame with the
+working buffer left on top."
+ (let (seen-sticky seen-setup)
+ (cl-letf (((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'af))
+ ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda)
+ (lambda (&rest _)
+ (setq seen-sticky org-agenda-sticky
+ seen-setup org-agenda-window-setup)))
+ ((symbol-function 'delete-other-windows) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-start-timer) (lambda (_f) nil)))
+ (cj/--agenda-frame-spawn)
+ (should (eq seen-sticky t))
+ (should (eq seen-setup 'current-window)))))
+
+(ert-deftest test-org-agenda-frame-spawn-forces-single-window ()
+ "Boundary: spawn collapses the frame to one window after rendering.
+A display rule that still splits the frame must not leave a second window
+showing the launch buffer."
+ (let (collapsed)
+ (cl-letf (((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'af))
+ ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda) (lambda (&rest _) nil))
+ ((symbol-function 'delete-other-windows)
+ (lambda (&rest _) (setq collapsed t)))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-start-timer) (lambda (_f) nil)))
+ (cj/--agenda-frame-spawn)
+ (should collapsed))))
+
+(ert-deftest test-org-agenda-frame-spawn-starts-timer ()
+ "Normal: a successful spawn starts the refresh timer for the new frame."
+ (let (timed)
+ (cl-letf (((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'af))
+ ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-start-timer)
+ (lambda (f) (setq timed f))))
+ (should (eq (cj/--agenda-frame-spawn) 'af))
+ (should (eq timed 'af)))))
+
+(ert-deftest test-org-agenda-frame-safe-redo-inhibits-redisplay ()
+ "Normal: the tick runs with redisplay inhibited.
+The rebuild takes visible time; without this, the agenda window is the
+selected window for the whole rebuild and the user watches their cursor
+go hollow every five minutes -- indistinguishable from focus theft."
+ (let (seen (prev (selected-window)))
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer)
+ (lambda () (current-buffer)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'get-buffer-window) (lambda (&rest _) prev))
+ ((symbol-function 'cj/--agenda-frame-do-redo)
+ (lambda (&rest _) (setq seen inhibit-redisplay))))
+ (cj/--agenda-frame-safe-redo)
+ (should (eq seen t)))))
+
+(ert-deftest test-org-agenda-frame-safe-redo-skips-during-minibuffer ()
+ "Boundary: a tick while a minibuffer is active is skipped entirely.
+Reselecting windows under an active minibuffer session can break it; the
+next tick catches up."
+ (let (redone (prev (selected-window)))
+ (cl-letf (((symbol-function 'active-minibuffer-window) (lambda () 'mini))
+ ((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer)
+ (lambda () (current-buffer)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'get-buffer-window) (lambda (&rest _) prev))
+ ((symbol-function 'cj/--agenda-frame-do-redo)
+ (lambda (&rest _) (setq redone t))))
+ (cj/--agenda-frame-safe-redo)
+ (should-not redone))))
+
+(ert-deftest test-org-agenda-frame-safe-redo-noop-when-not-shown ()
+ "Boundary: a tick with the buffer not shown in the frame does not redo or error."
+ (let (redone)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'get-buffer-window) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--agenda-frame-do-redo)
+ (lambda (&rest _) (setq redone t))))
+ (cj/--agenda-frame-safe-redo)
+ (should-not redone))))
+
+(ert-deftest test-org-agenda-frame-install-keys-rebinds-f8-family ()
+ "Normal: S-<f8> toggles the frame; the force-rescan moves to C-M-<f8>."
+ (let ((map (make-sparse-keymap)))
+ (cj/--agenda-frame-install-keys map)
+ (should (eq (lookup-key map (kbd "S-<f8>")) 'cj/agenda-frame-toggle))
+ (should (eq (lookup-key map (kbd "C-M-<f8>")) 'cj/org-agenda-refresh-files))))
+
+(provide 'test-org-agenda-frame)
+;;; test-org-agenda-frame.el ends here