aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/specs/keybinding-console-safety-spec.org5
-rw-r--r--modules/elfeed-config.el24
-rw-r--r--modules/music-config.el15
-rw-r--r--modules/org-agenda-frame.el96
-rw-r--r--modules/selection-framework.el2
-rw-r--r--modules/system-commands.el17
-rw-r--r--tests/test-calendar-sync--expand-monthly.el4
-rw-r--r--tests/test-elfeed-config-helpers.el39
-rw-r--r--tests/test-music-config--header-text.el17
-rw-r--r--tests/test-music-config-create-radio-station.el7
-rw-r--r--tests/test-org-agenda-frame.el112
-rw-r--r--tests/test-org-roam-config-format.el22
-rw-r--r--tests/test-system-commands-resolve-and-run.el32
13 files changed, 295 insertions, 97 deletions
diff --git a/docs/specs/keybinding-console-safety-spec.org b/docs/specs/keybinding-console-safety-spec.org
index b29309b8..5fd7d52c 100644
--- a/docs/specs/keybinding-console-safety-spec.org
+++ b/docs/specs/keybinding-console-safety-spec.org
@@ -4,14 +4,15 @@
#+TODO: TODO | DONE
#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
-* DOING Keymap Consolidation — Spec
+* READY Keymap Consolidation — Spec
:PROPERTIES:
:ID: 540bf06b-16b8-46c6-b459-c40d1b9c795d
:END:
+- 2026-07-21 Tue @ 07:07 -0500 — DOING → READY: the DOING was a legacy :STATUS: retrofit with no build tasks ever decomposed, and the primary work has not started (the key-translation layer is still live in keyboard-compat.el, M-S- bindings global). READY is the honest state: design settled per Path 2, awaiting decomposition. Flipped in the 2026-07-21 board review with Craig.
- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DOING from existing :STATUS: doing
* Metadata
-| Status | doing |
+| Status | ready |
|----------+--------------------------------------------------------------------|
| Owner | Craig Jennings |
|----------+--------------------------------------------------------------------|
diff --git a/modules/elfeed-config.el b/modules/elfeed-config.el
index dbc7e4a4..f4feef3a 100644
--- a/modules/elfeed-config.el
+++ b/modules/elfeed-config.el
@@ -142,30 +142,6 @@
(elfeed-search-update--force)
(goto-char (point-min)))
-;; ----------------------------- Extract Stream URL ----------------------------
-;; TASK: Is this method reused anywhere here or in another file?
-
-(defun cj/extract-stream-url (url format)
- "Extract the direct stream URL from URL using yt-dlp with FORMAT.
-Returns the stream URL or nil on failure."
- (unless (executable-find "yt-dlp")
- (error "The program yt-dlp is not installed or not in PATH"))
- (let* ((format-args (if format
- (list "-f" format)
- nil))
- (cmd-args (append '("yt-dlp" "-q" "-g")
- format-args
- (list url)))
- (output (with-temp-buffer
- (let ((exit-code (apply #'call-process
- (car cmd-args) nil t nil
- (cdr cmd-args))))
- (if (zerop exit-code)
- (string-trim (buffer-string))
- nil)))))
- (when (and output (string-match-p "^https?://" output))
- output)))
-
;; -------------------------- Elfeed Core Processing ---------------------------
(defun cj/elfeed-process-entries (action-fn action-name &optional skip-error-handling)
diff --git a/modules/music-config.el b/modules/music-config.el
index d33c8c45..a7ec62c8 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -1377,12 +1377,12 @@ The rule uses a resize-safe :align-to span, not a hardcoded character count."
(propertize "Mode " 'face 'cj/music-header-face)
(propertize " : " 'face 'cj/music-header-face)
(funcall mode-indicator "r" "repeat" (bound-and-true-p emms-repeat-playlist)) " "
- (funcall mode-indicator "s" "single" (bound-and-true-p emms-repeat-track)) " "
+ (funcall mode-indicator "1" "single" (bound-and-true-p emms-repeat-track)) " "
(funcall mode-indicator "z" "random" (bound-and-true-p emms-random-playlist)) " "
(funcall mode-indicator "x" "consume" cj/music-consume-mode) "\n"
(propertize "Keys " 'face 'cj/music-header-face)
(propertize " : " 'face 'cj/music-header-face)
- (propertize "a:add c:clear L:load v:save D:delete S:stop SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
+ (propertize "a:add c:clear L:load s:save D:delete SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
'face 'cj/music-keyhint-face) "\n"
(propertize "Radio " 'face 'cj/music-header-face)
(propertize " : " 'face 'cj/music-header-face)
@@ -1614,7 +1614,6 @@ unless fancy."
;; Playback
("p" . emms-playlist-mode-go)
("SPC" . emms-pause)
- ("s" . emms-stop)
("n" . cj/music-next)
(">" . cj/music-next)
("P" . cj/music-previous)
@@ -1640,7 +1639,6 @@ unless fancy."
("D" . cj/music-delete-playlist)
("E" . cj/music-playlist-edit)
("g" . cj/music-playlist-reload)
- ("v" . cj/music-playlist-save)
;; Track reordering
("S-<up>" . emms-playlist-mode-shift-track-up)
("S-<down>" . emms-playlist-mode-shift-track-down)
@@ -2094,14 +2092,15 @@ when there is nothing to fetch."
(message "Cleared music art cache: %s" cj/music-art-cache-dir))
;; Radio row in the playlist buffer: n = search by name, t = search by tag,
-;; m = enter a station by hand. This moves the "single" mode toggle off t to s
-;; and emms-stop off s to S (see the header's Mode/Keys/Radio rows).
+;; m = enter a station by hand. Single-track mode is on 1 and s saves the
+;; playlist; stop was dropped (SPC/pause covers it). These run after
+;; use-package's :map, so they win (see the header's Mode/Keys/Radio rows).
(with-eval-after-load 'emms
(keymap-set emms-playlist-mode-map "n" #'cj/music-radio-search-by-name)
(keymap-set emms-playlist-mode-map "t" #'cj/music-radio-search-by-tag)
(keymap-set emms-playlist-mode-map "m" #'cj/music-create-radio-station)
- (keymap-set emms-playlist-mode-map "s" #'emms-toggle-repeat-track)
- (keymap-set emms-playlist-mode-map "S" #'emms-stop))
+ (keymap-set emms-playlist-mode-map "1" #'emms-toggle-repeat-track)
+ (keymap-set emms-playlist-mode-map "s" #'cj/music-playlist-save))
(provide 'music-config)
;;; music-config.el ends here
diff --git a/modules/org-agenda-frame.el b/modules/org-agenda-frame.el
index d7fc3d4d..b783e358 100644
--- a/modules/org-agenda-frame.el
+++ b/modules/org-agenda-frame.el
@@ -31,6 +31,7 @@
;; `with-eval-after-load' so a batch test-load runs no org-agenda side effects.
(defvar org-agenda-custom-commands)
(defvar org-agenda-finalize-hook)
+(defvar org-agenda-mode-map)
(defvar org-agenda-sticky)
(defvar org-agenda-window-setup)
(declare-function cj/build-org-agenda-list "org-agenda-config" (&optional force-rebuild))
@@ -95,9 +96,18 @@ The existing top-level key is `d' (org-agenda-config.el:344), so `F' is
collision-free. The sticky buffer derives its name from this key
\(*Org Agenda(F)*).")
+(defvar cj/--agenda-frame-span 7
+ "Span, in days, of the agenda-frame view.
+The `F' custom command reads this via its `org-agenda-span' setting, which
+Org evaluates on every build and every redo, so `cj/--agenda-frame-day-view'
+\(span 1) and `cj/--agenda-frame-week-view' (span 7) change it and the next
+redo picks up the new span. Reset to 7 on each spawn so a fresh frame opens
+at the documented default.")
+
(defun cj/--agenda-frame-command ()
"Return the `org-agenda-custom-commands' entry for the agenda frame.
-A one-block agenda: a seven-day span anchored to today rather than
+A one-block agenda: a `cj/--agenda-frame-span'-day span anchored to today
+rather than
Monday (`org-agenda-list' otherwise anchors any seven-day span to the
week start in Org 9.7.11), rendered in the frame's sole window
\(`current-window', so Org's default `reorganize-frame' can't split it),
@@ -105,7 +115,7 @@ as its own sticky *Org Agenda(F)* buffer, with follow-mode forced off so
a non-nil global default can't open a second window at build time."
`(,cj/--agenda-frame-command-key "Agenda frame: 7-day today-anchored"
((agenda ""
- ((org-agenda-span 7)
+ ((org-agenda-span cj/--agenda-frame-span)
(org-agenda-start-day "0d")
(org-agenda-start-on-weekday nil)
(org-agenda-start-with-follow-mode nil)
@@ -195,7 +205,7 @@ frame stranded on a non-agenda buffer."
"Shown when a mutating or buffer-opening command is denied in the frame.")
(defconst cj/--agenda-frame-fixed-view-message
- "Agenda frame is fixed to the 7-day view"
+ "Agenda frame shows only the day (d) and week (w) views"
"Shown when a view-changing command is denied in the frame.")
(defun cj/--agenda-frame-denied-readonly ()
@@ -253,6 +263,14 @@ The default binding for every key not on the allowlist."
(define-key map (kbd "Q") #'cj/--agenda-frame-close)
(define-key map (kbd "x") #'cj/--agenda-frame-close)
(define-key map (kbd "r") #'cj/--agenda-frame-safe-redo)
+ ;; g is the muscle-memory agenda refresh; keep it working here (the
+ ;; frame-scoped safe redo, same as r) rather than denying it as a
+ ;; view-change. C-M-<f8> stays the force-rescan.
+ (define-key map (kbd "g") #'cj/--agenda-frame-safe-redo)
+ ;; d / w toggle the span (today's day vs the seven-day view) in place; the
+ ;; other view-changers stay denied to keep the today-anchored frame stable.
+ (define-key map (kbd "d") #'cj/--agenda-frame-day-view)
+ (define-key map (kbd "w") #'cj/--agenda-frame-week-view)
(define-key map (kbd "S-<f8>") #'cj/agenda-frame-toggle)
(define-key map (kbd "C-M-<f8>") #'cj/org-agenda-refresh-files)
;; C-x C-c means "close this frame" here. The global
@@ -272,8 +290,17 @@ The default binding for every key not on the allowlist."
[mouse-1] [down-mouse-1] [drag-mouse-1]
(kbd "C-h")))
(define-key map key nil))
- ;; View-changers get the distinct fixed-view message, not the read-only one.
- (dolist (key '("w" "d" "y" "f" "b" "j" "g"))
+ ;; (e) Global chords that would pull focus out of the frame must be
+ ;; denied *explicitly*. The [t] catch-all can't reach them: a keymap's
+ ;; default binding does not shadow an *explicit* binding in a
+ ;; lower-priority map, and these are bound in the global map (M-SPC /
+ ;; M-S-SPC swap ai-term agents). Left to the catch-all, M-SPC follows
+ ;; its global binding and escapes the read-only frame into ai-term.
+ (dolist (key '("M-SPC" "M-S-SPC"))
+ (define-key map (kbd key) #'cj/--agenda-frame-denied-readonly))
+ ;; The remaining view-changers get the distinct fixed-view message, not the
+ ;; read-only one. d/w are handled above (they toggle the span in place).
+ (dolist (key '("y" "f" "b" "j"))
(define-key map (kbd key) #'cj/--agenda-frame-denied-fixed-view))
map)
"Keymap for `cj/agenda-frame-mode'.
@@ -292,6 +319,47 @@ of contract."
:lighter " AgendaFrame"
:keymap cj/agenda-frame-mode-map)
+(defun cj/--agenda-frame-shadow-mutations (&optional source-map prefix)
+ "Deny every SOURCE-MAP key sequence not on the frame map's allowlist.
+Walk SOURCE-MAP (default `org-agenda-mode-map') recursively. For each
+sequence it binds to a command, if `cj/agenda-frame-mode-map' doesn't already
+bind that sequence to a command or manage it as a prefix, add an explicit
+read-only deny.
+
+This closes the default-deny hole: a keymap's `[t]' default never shadows an
+explicit binding in a lower-priority map, so a single `[t]' catch-all denies
+only keys that are unbound everywhere. Every key `org-agenda-mode-map' binds
+\(t, I, k, z, s, ., the C-c mutators, C-x C-s, ...) would otherwise sail
+through the catch-all and mutate source files from the read-only frame.
+Explicitly denying each non-allowlisted sequence makes the catch-all's intent
+actually hold.
+
+PREFIX is the accumulated key vector during recursion (internal). Idempotent:
+re-running rebinds the same denials. Runs from `with-eval-after-load' once
+`org-agenda-mode-map' exists."
+ (let ((source (or source-map org-agenda-mode-map))
+ (prefix (or prefix [])))
+ (map-keymap
+ (lambda (event binding)
+ (unless (or (eq event t) (eq event 'menu-bar) (eq event 'remap)
+ (consp event))
+ (let ((seq (vconcat prefix (vector event))))
+ (cond
+ ((keymapp binding)
+ (cj/--agenda-frame-shadow-mutations binding seq))
+ ((commandp binding)
+ (let ((ours (lookup-key cj/agenda-frame-mode-map seq)))
+ ;; A command we allowlisted or a prefix we manage: leave it.
+ ;; Anything else (only the `[t]' default, or unbound under a
+ ;; shared prefix) escapes to org's command -- deny it here.
+ (unless (or (commandp ours) (keymapp ours))
+ (define-key cj/agenda-frame-mode-map seq
+ #'cj/--agenda-frame-denied-readonly))))))))
+ source)))
+
+(with-eval-after-load 'org-agenda
+ (cj/--agenda-frame-shadow-mutations))
+
(defun cj/--agenda-frame-maybe-enable-mode ()
"Re-enable `cj/agenda-frame-mode' after an agenda build in the agenda frame.
Added to `org-agenda-finalize-hook'. `org-agenda-redo' rebuilds through
@@ -416,6 +484,9 @@ Returns the new agenda frame on success."
(condition-case err
(progn
(setq cj/--agenda-frame-launch-frame launch)
+ ;; A fresh frame opens at the documented seven-day default, even if a
+ ;; prior session left the span on the day view (d).
+ (setq cj/--agenda-frame-span 7)
(setq frame (make-frame (cj/--agenda-frame-make-parameters)))
(select-frame-set-input-focus frame)
;; Cached, non-forced: a frame spawned early after daemon startup
@@ -704,6 +775,21 @@ out-of-range window-start nor steals focus."
(when (window-live-p prev-window)
(select-window prev-window t)))))))
+(defun cj/--agenda-frame-day-view ()
+ "Shrink the Full Agenda frame to today's single-day view.
+Sets the span to 1 and refreshes. The redo re-evaluates the span, so the
+day view survives the wall-clock refresh tick until `w' widens it again."
+ (interactive)
+ (setq cj/--agenda-frame-span 1)
+ (cj/--agenda-frame-safe-redo))
+
+(defun cj/--agenda-frame-week-view ()
+ "Restore the Full Agenda frame to the seven-day today-anchored view.
+Sets the span back to 7 and refreshes."
+ (interactive)
+ (setq cj/--agenda-frame-span 7)
+ (cj/--agenda-frame-safe-redo))
+
(defun cj/--agenda-frame-start-timer (frame)
"Start FRAME's five-minute wall-clock refresh timer, unless one exists.
Idempotent: a frame already carrying a live timer keeps it (no duplicate).
diff --git a/modules/selection-framework.el b/modules/selection-framework.el
index 8e3ee252..47fbf7c7 100644
--- a/modules/selection-framework.el
+++ b/modules/selection-framework.el
@@ -225,7 +225,7 @@
("C-p" . company-select-previous))
:custom
(company-backends '(company-capf company-files company-keywords))
- (company-idle-delay 2)
+ (company-idle-delay 4)
(company-minimum-prefix-length 2)
(company-show-numbers t)
(company-tooltip-align-annotations t)
diff --git a/modules/system-commands.el b/modules/system-commands.el
index de5e8853..edc6339d 100644
--- a/modules/system-commands.el
+++ b/modules/system-commands.el
@@ -115,8 +115,21 @@ actions like shutdown and reboot), nil for no confirmation."
;; directly: logind emits the Lock signal, hypridle catches it and runs its
;; lock_cmd (hyprlock), the same path idle/before-sleep locking already uses.
;; X11 machines keep slock.
-(cj/defsystem-command cj/system-cmd-lock lockscreen-cmd
- (if (env-wayland-p) "loginctl lock-session" "slock"))
+;;
+;; Unlike its siblings, the locker is resolved at COMMAND time, not baked into
+;; the defvar at load: a daemon started before WAYLAND_DISPLAY reaches its
+;; environment would freeze the locker to slock forever, and Lock would then
+;; fail silently on Wayland. `lockscreen-cmd' stays as an override knob.
+(defvar lockscreen-cmd nil
+ "Explicit lock command, overriding session-type resolution when non-nil.")
+
+(defun cj/system-cmd-lock ()
+ "Lock the session, resolving the locker from the live session type.
+Runs `lockscreen-cmd' when set; otherwise `loginctl lock-session' on
+Wayland and slock on X11, decided per call via `env-wayland-p'."
+ (interactive)
+ (cj/system-cmd (or lockscreen-cmd
+ (if (env-wayland-p) "loginctl lock-session" "slock"))))
(cj/defsystem-command cj/system-cmd-suspend suspend-cmd "systemctl suspend" t)
(cj/defsystem-command cj/system-cmd-shutdown shutdown-cmd "systemctl poweroff" strong)
(cj/defsystem-command cj/system-cmd-reboot reboot-cmd "systemctl reboot" strong)
diff --git a/tests/test-calendar-sync--expand-monthly.el b/tests/test-calendar-sync--expand-monthly.el
index 75404937..41b196a7 100644
--- a/tests/test-calendar-sync--expand-monthly.el
+++ b/tests/test-calendar-sync--expand-monthly.el
@@ -278,8 +278,10 @@ instead of the RFC 5545 skip."
:start '(2030 1 31 10 0)
:end '(2030 1 31 11 0)))
(rrule (list :freq 'monthly :interval 1))
+ ;; End the range past Dec 31: the range end is midnight, so ending
+ ;; ON the 31st would exclude that day's 10:00 occurrence.
(range (list (calendar-sync--date-to-time '(2030 1 1))
- (calendar-sync--date-to-time '(2030 12 31))))
+ (calendar-sync--date-to-time '(2031 1 1))))
(occurrences (calendar-sync--expand-monthly base-event rrule range))
(months (mapcar (lambda (o) (nth 1 (plist-get o :start))) occurrences))
(days (mapcar (lambda (o) (nth 2 (plist-get o :start))) occurrences)))
diff --git a/tests/test-elfeed-config-helpers.el b/tests/test-elfeed-config-helpers.el
index 16cbb744..95a98e83 100644
--- a/tests/test-elfeed-config-helpers.el
+++ b/tests/test-elfeed-config-helpers.el
@@ -1,10 +1,7 @@
;;; test-elfeed-config-helpers.el --- Tests for elfeed stream/process helpers -*- lexical-binding: t; -*-
;;; Commentary:
-;; Coverage for two elfeed-config helpers that were untested:
-;; - cj/extract-stream-url: runs yt-dlp -g to resolve a direct stream URL,
-;; returning the URL, nil on non-URL / nonzero exit, or signalling when
-;; yt-dlp is absent.
+;; Coverage for the elfeed-config entry-processing helper:
;; - cj/elfeed-process-entries: applies an action to each selected entry,
;; marking them read; errors when nothing is selected, skips entries with
;; no link, and (by default) catches per-entry action errors.
@@ -34,40 +31,6 @@
(require 'elfeed-config)
(require 'elfeed nil t)
-;;; cj/extract-stream-url
-
-(ert-deftest test-elfeed-extract-stream-url-normal-returns-url ()
- "Normal: a successful yt-dlp run returns the trimmed https stream URL."
- (cl-letf (((symbol-function 'executable-find)
- (lambda (p &rest _) (and (equal p "yt-dlp") "/usr/bin/yt-dlp")))
- ((symbol-function 'cj/log-silently) #'ignore)
- ((symbol-function 'call-process)
- (lambda (_prog _infile _dest _disp &rest _args)
- (insert "https://stream.example/abc\n") 0)))
- (should (equal "https://stream.example/abc"
- (cj/extract-stream-url "https://youtube.com/watch?v=x" "best")))))
-
-(ert-deftest test-elfeed-extract-stream-url-boundary-non-url-output-is-nil ()
- "Boundary: output that is not an http(s) URL yields nil, not the raw text."
- (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/yt-dlp"))
- ((symbol-function 'cj/log-silently) #'ignore)
- ((symbol-function 'call-process)
- (lambda (_p _i _d _disp &rest _) (insert "ERROR: unavailable\n") 0)))
- (should (null (cj/extract-stream-url "u" nil)))))
-
-(ert-deftest test-elfeed-extract-stream-url-boundary-nonzero-exit-is-nil ()
- "Boundary: a nonzero yt-dlp exit code yields nil."
- (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/yt-dlp"))
- ((symbol-function 'cj/log-silently) #'ignore)
- ((symbol-function 'call-process)
- (lambda (_p _i _d _disp &rest _) (insert "boom") 1)))
- (should (null (cj/extract-stream-url "u" nil)))))
-
-(ert-deftest test-elfeed-extract-stream-url-error-without-yt-dlp ()
- "Error: a missing yt-dlp signals before attempting the call."
- (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
- (should-error (cj/extract-stream-url "u" "best") :type 'error)))
-
;;; cj/elfeed-process-entries
(defun cj/test--elfeed-entry (link)
diff --git a/tests/test-music-config--header-text.el b/tests/test-music-config--header-text.el
index 8de97350..c860c6d4 100644
--- a/tests/test-music-config--header-text.el
+++ b/tests/test-music-config--header-text.el
@@ -139,6 +139,23 @@
(should (string-match-p "consume" plain))))
(test-header--teardown)))
+(ert-deftest test-music-config--header-text-boundary-key-hints-single-save-stop ()
+ "Header key hints: single is on 1, save is on s, and stop (S) is gone.
+SPC/pause covers stop, so the S:stop hint and the [s] single / v:save hints
+are retired."
+ (unwind-protect
+ (progn
+ (test-header--setup-playlist-buffer '("/music/a.mp3"))
+ (let* ((header (with-current-buffer cj/music-playlist-buffer-name
+ (cj/music--header-text)))
+ (plain (test-header--strip-properties header)))
+ (should (string-match-p "\\[1\\] single" plain))
+ (should (string-match-p "s:save" plain))
+ (should-not (string-match-p "\\[s\\] single" plain))
+ (should-not (string-match-p "v:save" plain))
+ (should-not (string-match-p "S:stop" plain))))
+ (test-header--teardown)))
+
;;; Error Cases
(ert-deftest test-music-config--header-text-error-empty-playlist-shows-zero-count ()
diff --git a/tests/test-music-config-create-radio-station.el b/tests/test-music-config-create-radio-station.el
index 7d9adbed..4f49f49b 100644
--- a/tests/test-music-config-create-radio-station.el
+++ b/tests/test-music-config-create-radio-station.el
@@ -106,15 +106,16 @@
(ert-deftest test-music-config-menu-map-lowercase-keys ()
"Normal: the menu's former uppercase keys live on lowercase homes, and the
-playlist buffer saves on v."
+playlist buffer saves on s (single on 1, old save key v unbound)."
(should (eq (lookup-key cj/music-map "v") 'cj/music-playlist-show))
(should (eq (lookup-key cj/music-map "u") 'emms-shuffle))
(should (eq (lookup-key cj/music-map "l") 'emms-toggle-repeat-playlist))
(should-not (lookup-key cj/music-map "R"))
(should-not (lookup-key cj/music-map "M"))
(should-not (lookup-key cj/music-map "Z"))
- (should (eq (lookup-key emms-playlist-mode-map "v") 'cj/music-playlist-save))
- (should-not (eq (lookup-key emms-playlist-mode-map "w") 'cj/music-playlist-save)))
+ (should (eq (lookup-key emms-playlist-mode-map "s") 'cj/music-playlist-save))
+ (should (eq (lookup-key emms-playlist-mode-map "1") 'emms-toggle-repeat-track))
+ (should-not (lookup-key emms-playlist-mode-map "v")))
;;; Error Cases
diff --git a/tests/test-org-agenda-frame.el b/tests/test-org-agenda-frame.el
index 21842600..fd8bd839 100644
--- a/tests/test-org-agenda-frame.el
+++ b/tests/test-org-agenda-frame.el
@@ -121,14 +121,50 @@
(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."
+ "Normal: the default span is seven days anchored to today, not Monday.
+The span is the frame-span variable (default 7), evaluated the way org
+evaluates custom-command settings."
(let ((s (test-org-agenda-frame--block-settings)))
- (should (equal (cadr (assq 'org-agenda-span s)) 7))
+ (should (equal (default-value 'cj/--agenda-frame-span) 7))
+ (should (equal (eval (cadr (assq 'org-agenda-span s)) t)
+ (default-value 'cj/--agenda-frame-span)))
(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-span-follows-variable ()
+ "Normal: the block span reads `cj/--agenda-frame-span', so d/w can change it
+and a redo -- which re-evaluates the lprops -- picks up the new span."
+ (let ((cj/--agenda-frame-span 1))
+ (should (equal (eval (cadr (assq 'org-agenda-span
+ (test-org-agenda-frame--block-settings)))
+ t)
+ 1)))
+ (let ((cj/--agenda-frame-span 7))
+ (should (equal (eval (cadr (assq 'org-agenda-span
+ (test-org-agenda-frame--block-settings)))
+ t)
+ 7))))
+
+(ert-deftest test-org-agenda-frame-day-view-sets-span-1-and-redoes ()
+ "Normal: d sets the span to one day and refreshes via the safe redo."
+ (let ((cj/--agenda-frame-span 7) redone)
+ (cl-letf (((symbol-function 'cj/--agenda-frame-safe-redo)
+ (lambda (&rest _) (setq redone t))))
+ (cj/--agenda-frame-day-view)
+ (should (equal cj/--agenda-frame-span 1))
+ (should redone))))
+
+(ert-deftest test-org-agenda-frame-week-view-sets-span-7-and-redoes ()
+ "Normal: w restores the seven-day span and refreshes."
+ (let ((cj/--agenda-frame-span 1) redone)
+ (cl-letf (((symbol-function 'cj/--agenda-frame-safe-redo)
+ (lambda (&rest _) (setq redone t))))
+ (cj/--agenda-frame-week-view)
+ (should (equal cj/--agenda-frame-span 7))
+ (should redone))))
+
(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
@@ -212,7 +248,7 @@ the spawn wrapper, where it names the buffer."
(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))))
+ (should (string-match-p "day (d) and week (w)" captured))))
;;; Default-deny policy — the keymap
@@ -255,19 +291,58 @@ graphical frame and RET would be denied instead of opening the item."
'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."
+ "Normal: q/Q/x close the frame; r and g take the safe-redo path.
+g is the muscle-memory agenda refresh; it must refresh, not hit the
+fixed-view deny handler."
(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)))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "r")) 'cj/--agenda-frame-safe-redo))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "g")) '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"))
+ (dolist (key '("y" "f" "b" "j"))
(should (eq (lookup-key cj/agenda-frame-mode-map (kbd key))
'cj/--agenda-frame-denied-fixed-view))))
+(ert-deftest test-org-agenda-frame-map-day-week-view-keys ()
+ "Normal: d and w toggle the span (day / week) rather than being denied.
+d shrinks the frame to the current day, w restores the seven-day span."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "d"))
+ 'cj/--agenda-frame-day-view))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "w"))
+ 'cj/--agenda-frame-week-view)))
+
+(ert-deftest test-org-agenda-frame-shadow-denies-org-mutations-preserves-allowlist ()
+ "Boundary: with the frame mode active over `org-agenda-mode-map', mutating
+org-agenda keys are denied and the allowlist still works.
+The `[t]' default cannot shadow org-agenda-mode-map's explicit bindings, so
+the shadow walk must add explicit denies for every non-allowlisted key --
+single keys (t/I/k/z/s/.), the C-c mutators (schedule/deadline/clock), and
+C-x (save-all) -- while leaving the allowlist (navigation, engage, refresh,
+d/w, C-c C-o) intact."
+ (require 'org-agenda)
+ (cj/--agenda-frame-shadow-mutations)
+ (with-temp-buffer
+ (use-local-map org-agenda-mode-map)
+ (cj/agenda-frame-mode 1)
+ ;; escaping mutators are now explicitly denied (not org's commands)
+ (dolist (chord '("t" "I" "k" "z" "s" "." "C-c C-s" "C-c C-d"
+ "C-c C-x C-i" "C-x C-s"))
+ (should (eq (key-binding (kbd chord))
+ 'cj/--agenda-frame-denied-readonly)))
+ ;; the allowlist survives the walk
+ (should (eq (key-binding (kbd "n")) 'org-agenda-next-line))
+ (should (eq (key-binding (kbd "p")) 'org-agenda-previous-line))
+ (should (eq (key-binding (kbd "RET")) 'cj/--agenda-frame-engage-open))
+ (should (eq (key-binding (kbd "g")) 'cj/--agenda-frame-safe-redo))
+ (should (eq (key-binding (kbd "d")) 'cj/--agenda-frame-day-view))
+ (should (eq (key-binding (kbd "w")) 'cj/--agenda-frame-week-view))
+ (should (eq (key-binding (kbd "C-c C-o")) 'cj/--agenda-frame-open-link))
+ (should (eq (key-binding (kbd "q")) 'cj/--agenda-frame-close))))
+
(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
@@ -301,6 +376,16 @@ scroll spams the deny message."
;; 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-global-escape-chords-denied ()
+ "Boundary: global chords bound elsewhere (M-SPC / M-S-SPC swap ai-term
+agents) are denied by an explicit binding, not left to the [t] catch-all.
+A keymap's default binding does not shadow an explicit binding in a
+lower-priority map, so without an explicit deny here M-SPC follows its
+global binding and escapes the read-only frame into ai-term."
+ (dolist (key '("M-SPC" "M-S-SPC"))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd key))
+ 'cj/--agenda-frame-denied-readonly))))
+
(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)
@@ -931,6 +1016,21 @@ showing the launch buffer."
(cj/--agenda-frame-spawn)
(should collapsed))))
+(ert-deftest test-org-agenda-frame-spawn-resets-span-to-7 ()
+ "Normal: a fresh spawn opens at the documented seven-day default, even when a
+prior frame's session left `cj/--agenda-frame-span' at the day view."
+ (let ((cj/--agenda-frame-span 1))
+ (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 _) 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 cj/--agenda-frame-span 7)))))
+
(ert-deftest test-org-agenda-frame-spawn-starts-timer ()
"Normal: a successful spawn starts the refresh timer for the new frame."
(let (timed)
diff --git a/tests/test-org-roam-config-format.el b/tests/test-org-roam-config-format.el
index caafe563..16370ca1 100644
--- a/tests/test-org-roam-config-format.el
+++ b/tests/test-org-roam-config-format.el
@@ -147,13 +147,21 @@ Returns the formatted file content."
(let ((result (test-format "Title" "id" "* Content")))
(should (string-match-p "#\\+FILETAGS: Topic\n\n\\* Content" result))))
-(ert-deftest test-org-roam-config-capture-templates-declared-special ()
- "Normal: org-roam-capture-templates is declared special in the module.
-cj/org-roam-node-insert-immediate let-binds it; without the defvar the
-byte-compiled let is a dead lexical binding and :immediate-finish never
-reaches org-roam-node-insert -- the \"immediate\" insert opens a capture
-buffer."
- (should (special-variable-p 'org-roam-capture-templates)))
+(defvar org-roam-capture-templates)
+
+(ert-deftest test-org-roam-config-immediate-insert-binding-is-dynamic ()
+ "Normal: the immediate-insert template binding reaches the insert call.
+Without the module's defvar, the byte-compiled let is a dead lexical
+binding: org-roam-node-insert would see the untouched global templates
+and :immediate-finish never applies -- the \"immediate\" insert opens a
+capture buffer. Asserted behaviorally (not via special-variable-p,
+whose answer differs between the package-loaded and bare batch envs)."
+ (let ((org-roam-capture-templates '(("d" "default")))
+ seen)
+ (cl-letf (((symbol-function 'org-roam-node-insert)
+ (lambda (&rest _) (setq seen org-roam-capture-templates))))
+ (cj/org-roam-node-insert-immediate nil))
+ (should (equal seen '(("d" "default" :immediate-finish t))))))
(provide 'test-org-roam-config-format)
;;; test-org-roam-config-format.el ends here
diff --git a/tests/test-system-commands-resolve-and-run.el b/tests/test-system-commands-resolve-and-run.el
index 9d92c5d6..7e5146b1 100644
--- a/tests/test-system-commands-resolve-and-run.el
+++ b/tests/test-system-commands-resolve-and-run.el
@@ -230,5 +230,37 @@ kill-emacs directly (the service owns the daemon lifecycle)."
(cj/system-command-menu))
(should (eq called 'cj/system-cmd-lock))))
+;;; Lock command resolves the locker at call time
+
+(defun test-system-cmd--run-lock-capturing ()
+ "Run the lock command; return the shell command line it launched."
+ (let (cmd-line)
+ (cl-letf (((symbol-function 'start-process-shell-command)
+ (lambda (_name _buf c) (setq cmd-line c) 'fake-proc))
+ ((symbol-function 'set-process-query-on-exit-flag) #'ignore)
+ ((symbol-function 'set-process-sentinel) #'ignore)
+ ((symbol-function 'message) #'ignore))
+ (cj/system-cmd-lock))
+ cmd-line))
+
+(ert-deftest test-system-cmd-lock-follows-session-type-at-call-time ()
+ "Normal: the locker tracks the live session type, not the load-time bake.
+A daemon started before WAYLAND_DISPLAY was imported used to freeze the
+locker to slock forever; Lock then failed silently on Wayland."
+ (let ((lockscreen-cmd nil))
+ (cl-letf (((symbol-function 'env-wayland-p) (lambda () t)))
+ (should (string-match-p "loginctl lock-session"
+ (test-system-cmd--run-lock-capturing))))
+ (cl-letf (((symbol-function 'env-wayland-p) (lambda () nil)))
+ (should (string-match-p "slock"
+ (test-system-cmd--run-lock-capturing))))))
+
+(ert-deftest test-system-cmd-lock-explicit-override-wins ()
+ "Boundary: a user-set lockscreen-cmd overrides the session-type resolution."
+ (let ((lockscreen-cmd "my-locker --now"))
+ (cl-letf (((symbol-function 'env-wayland-p) (lambda () t)))
+ (should (string-match-p "my-locker --now"
+ (test-system-cmd--run-lock-capturing))))))
+
(provide 'test-system-commands-resolve-and-run)
;;; test-system-commands-resolve-and-run.el ends here