From a9736bcf1f1d683656d7222bdda26c1a7ef73986 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 14 Jul 2026 01:12:21 -0500 Subject: refactor(signal): retire the in-Emacs signel client to the archive Agents drive Signal via signal-cli and signal-mcp, so the interactive client earns no keep. I moved signal-config.el and its seven test files to archive/ per the pasture convention, dropped the require from init.el, removed the dashboard's Signal launcher (row sizes now 5 4 3 2, tests updated), and unregistered the C-; M prefix from the running daemon. The ~/code/signel fork repo is untouched. The spec record stays IMPLEMENTED with a retirement history line, and the messenger-unification draft carries a premise-shift note. --- archive/README.org | 7 + archive/modules/signal-config.el | 380 +++++++++++++++++++ archive/tests/test-signal-config--contact-cache.el | 61 +++ archive/tests/test-signal-config-notify.el | 150 ++++++++ archive/tests/test-signal-config.el | 410 +++++++++++++++++++++ archive/tests/test-signel-cancel-input.el | 74 ++++ archive/tests/test-signel-input-preservation.el | 68 ++++ archive/tests/test-signel-notify-function.el | 89 +++++ archive/tests/test-signel-rpc-dispatch.el | 94 +++++ docs/specs/messenger-unification-spec.org | 1 + docs/specs/signal-client-spec.org | 1 + init.el | 1 - modules/dashboard-config.el | 11 +- modules/signal-config.el | 380 ------------------- tests/test-dashboard-config-launchers.el | 23 +- tests/test-signal-config--contact-cache.el | 61 --- tests/test-signal-config-notify.el | 150 -------- tests/test-signal-config.el | 410 --------------------- tests/test-signel-cancel-input.el | 74 ---- tests/test-signel-input-preservation.el | 68 ---- tests/test-signel-notify-function.el | 89 ----- tests/test-signel-rpc-dispatch.el | 94 ----- 22 files changed, 1351 insertions(+), 1345 deletions(-) create mode 100644 archive/modules/signal-config.el create mode 100644 archive/tests/test-signal-config--contact-cache.el create mode 100644 archive/tests/test-signal-config-notify.el create mode 100644 archive/tests/test-signal-config.el create mode 100644 archive/tests/test-signel-cancel-input.el create mode 100644 archive/tests/test-signel-input-preservation.el create mode 100644 archive/tests/test-signel-notify-function.el create mode 100644 archive/tests/test-signel-rpc-dispatch.el delete mode 100644 modules/signal-config.el delete mode 100644 tests/test-signal-config--contact-cache.el delete mode 100644 tests/test-signal-config-notify.el delete mode 100644 tests/test-signal-config.el delete mode 100644 tests/test-signel-cancel-input.el delete mode 100644 tests/test-signel-input-preservation.el delete mode 100644 tests/test-signel-notify-function.el delete mode 100644 tests/test-signel-rpc-dispatch.el diff --git a/archive/README.org b/archive/README.org index bbc30a14..e292065a 100644 --- a/archive/README.org +++ b/archive/README.org @@ -46,6 +46,13 @@ Retired files keep their origin directory so a restore is a straight move back: - =mu4e-org-contacts-setup.el= — nothing requires it; its featurep gate would be nil at init in any case. The live mu4e contacts wiring lives in =mu4e-org-contacts-integration.el=. +- =signal-config.el= — the in-Emacs Signal client (forked signel at + =~/code/signel= + signal-cli), retired whole on Craig's call 2026-07-14: + agents drive Signal via signal-cli / signal-mcp, so the interactive Emacs + client earns no keep. The fork repo itself is untouched. Its seven test + files moved to =archive/tests/= with it (=test-signal-config*.el=, + =test-signel-*.el=). The spec record is + =docs/specs/signal-client-spec.org= (IMPLEMENTED, then retired). ** archive/custom/ diff --git a/archive/modules/signal-config.el b/archive/modules/signal-config.el new file mode 100644 index 00000000..a1ec7933 --- /dev/null +++ b/archive/modules/signal-config.el @@ -0,0 +1,380 @@ +;;; signal-config.el --- Signal client (forked signel) configuration -*- lexical-binding: t -*- + +;;; Commentary: +;; cj/-namespaced configuration and helpers layered on the forked `signel' +;; package, a Signal client that drives signal-cli over JSON-RPC. +;; +;; This file currently holds the pure, signal-cli-independent helper layer +;; that the fork edits and `use-package' wiring build on: +;; - contact-list parsing for a completing-read contact picker, and +;; - the predicate that suppresses a notification for the chat the user +;; is actively viewing. +;; Both are unit-tested without a linked account. The use-package wiring, +;; keybindings, and the signel fork edits that call these helpers land once +;; signal-cli is installed and the device is linked. + +;;; Code: + +(require 'seq) +(require 'keybindings) ;; provides cj/custom-keymap + cj/register-prefix-map +(require 'system-lib) ;; for cj/executable-find-or-warn + +(declare-function notifications-notify "notifications") + +(defun cj/signal--jstr (value) + "Return VALUE if it is a non-blank string, else nil. +Normalizes a JSON field that may arrive as nil, the empty string, or a +null sentinel symbol into a plain string-or-nil." + (and (stringp value) + (not (string-empty-p (string-trim value))) + value)) + +(defun cj/signal--combine-name (given family) + "Join GIVEN and FAMILY name parts into a trimmed full name, or nil. +Either part may be nil, the empty string, or a JSON null sentinel." + (let ((parts (delq nil (list (cj/signal--jstr given) (cj/signal--jstr family))))) + (cj/signal--jstr (mapconcat #'identity parts " ")))) + +(defun cj/signal--contact-display-name (contact) + "Return a display name for CONTACT, or nil when none is set. +CONTACT is one entry alist from signal-cli `listContacts'. Picks the +first set source in priority order: the nickname (combined nickName, or +nickGivenName+nickFamilyName), the stored contact name, the top-level +givenName+familyName, the profile givenName+familyName, then username. +signal-cli 0.14 puts givenName/familyName at the top level; the profile +sub-object's name fields are usually null, so it is the deeper fallback." + (let ((profile (alist-get 'profile contact))) + (seq-find + #'cj/signal--jstr + (list (cj/signal--jstr (alist-get 'nickName contact)) + (cj/signal--combine-name (alist-get 'nickGivenName contact) + (alist-get 'nickFamilyName contact)) + (cj/signal--jstr (alist-get 'name contact)) + (cj/signal--combine-name (alist-get 'givenName contact) + (alist-get 'familyName contact)) + (cj/signal--combine-name (alist-get 'givenName profile) + (alist-get 'familyName profile)) + (cj/signal--jstr (alist-get 'username contact)))))) + +(defun cj/signal--parse-contacts (result) + "Parse RESULT from signal-cli `listContacts' into a completing-read alist. +RESULT is the JSON-RPC result value: a sequence (list or vector) of +contact alists. Returns an alist of (LABEL . RECIPIENT) sorted by LABEL, +where RECIPIENT is the contact's phone number (falling back to its UUID) +and LABEL is \"Name (recipient)\" when a name is known, or the bare +recipient otherwise. Contacts with no usable recipient are dropped." + (let (pairs) + (dolist (contact (append result nil)) + (let ((recipient (or (cj/signal--jstr (alist-get 'number contact)) + (cj/signal--jstr (alist-get 'uuid contact)))) + (name (cj/signal--contact-display-name contact))) + (when recipient + (push (cons (if name (format "%s (%s)" name recipient) recipient) + recipient) + pairs)))) + (sort pairs (lambda (a b) (string-lessp (car a) (car b)))))) + +(defun cj/signal--chat-buffer-name (id) + "Return the chat buffer name `signel' uses for chat ID." + (format "*Signel: %s*" id)) + +(defun cj/signal--suppress-notify-p (chat-id viewing-buffer-name frame-focused) + "Return non-nil when a notification for CHAT-ID should be suppressed. +Suppress only while the user is actively viewing that chat: the chat +buffer named by `cj/signal--chat-buffer-name' is VIEWING-BUFFER-NAME and +FRAME-FOCUSED is non-nil. A nil VIEWING-BUFFER-NAME or an unfocused +frame never suppresses." + (and frame-focused + (stringp viewing-buffer-name) + (string= viewing-buffer-name (cj/signal--chat-buffer-name chat-id)))) + +(defun cj/signal--frame-focused-p () + "Return non-nil when the selected frame currently has input focus. +Treats an unknown focus state as focused." + (if (fboundp 'frame-focus-state) + (let ((state (frame-focus-state))) + (if (eq state 'unknown) t state)) + t)) + +(defun cj/signal--should-notify-p (chat-id) + "Return non-nil when an incoming message for CHAT-ID should notify. +Notify unless the user is actively viewing that chat in the selected +window of a focused frame." + (not (cj/signal--suppress-notify-p + chat-id + (buffer-name (window-buffer (selected-window))) + (cj/signal--frame-focused-p)))) + +;;; Notifications + +(defcustom cj/signel-notify-sound nil + "When non-nil, incoming-message notifications play the notify script's sound. +Nil (the default) passes --silent so the toast is visual only." + :type 'boolean + :group 'signel) + +(defconst cj/signal--notify-body-max 120 + "Maximum character length of a desktop-notification body. +Longer message text truncates to this length ending in an ellipsis; +the full text is always in the chat buffer.") + +(defun cj/signal--format-notify-body (text) + "Collapse whitespace in TEXT and truncate it for a notification body. +Whitespace runs (including newlines) become single spaces, the result +is trimmed, and anything over `cj/signal--notify-body-max' characters +truncates to that length with a trailing ellipsis." + (let ((flat (string-trim (replace-regexp-in-string "[ \t\n\r]+" " " text)))) + (if (<= (length flat) cj/signal--notify-body-max) + flat + (concat (substring flat 0 (1- cj/signal--notify-body-max)) "…")))) + +(defun cj/signel--notify (chat-id sender body) + "Raise a desktop notification for an incoming Signal message. +Suppressed via `cj/signal--should-notify-p' when the user is actively +viewing CHAT-ID. Routes through the external notify script when it is +on PATH (type info, sound gated by `cj/signel-notify-sound'), falling +back to `notifications-notify' otherwise. SENDER names the title; +BODY is formatted by `cj/signal--format-notify-body'. Installed as +`signel-notify-function' in the use-package :config below." + (when (cj/signal--should-notify-p chat-id) + (let ((title (format "Signal: %s" sender)) + (text (cj/signal--format-notify-body body)) + (script (executable-find "notify"))) + (if script + (apply #'start-process "signel-notify" nil script "info" title text + (unless cj/signel-notify-sound (list "--silent"))) + ;; notifications.el is not autoloaded; load it on the first fallback. + (unless (fboundp 'notifications-notify) + (require 'notifications)) + (notifications-notify :title title :body text))))) + +;;; signel — fork integration + +(defcustom cj/signal-private-config-file + (expand-file-name "signal-config.local.el" user-emacs-directory) + "Private signal-config file, loaded when readable. +This is the place to set `signel-account' to the linked phone number so +the number stays out of the version-controlled (and publicly mirrored) +config. A phone number is an identifier rather than a credential, so it +lives here rather than in authinfo, which avoids a GPG prompt at connect +time." + :type 'file + :group 'signel) + +(use-package signel + :load-path "~/code/signel" + :ensure nil + :commands (signel-start signel-stop signel-chat signel-dashboard) + :custom + ;; Don't let an incoming message steal a window by auto-popping its chat + ;; buffer; surface arrivals through notifications instead (see child task + ;; "Notify only for the unviewed conversation"). + (signel-auto-open-buffer nil) + :config + (when (file-readable-p cj/signal-private-config-file) + (load cj/signal-private-config-file nil t)) + ;; Route incoming-message notifications through cj/signel--notify + ;; (suppression + notify script + truncation); warn once at load when + ;; the script is missing — the runtime path still falls back to + ;; notifications-notify, so messages are never silently dropped. + (setq signel-notify-function #'cj/signel--notify) + (cj/executable-find-or-warn "notify" "Signal desktop notifications via the notify script (falling back to notifications-notify)" 'signal-config)) + +;; Chat buffers (named `*Signel: *') open in the bottom 30% of the +;; frame rather than wherever display-buffer's fallback rule picks. +;; The fork's `signel-chat' uses `pop-to-buffer', so this entry applies. +(add-to-list + 'display-buffer-alist + '("\\`\\*Signel: " + (display-buffer-reuse-window display-buffer-at-bottom) + (window-height . 0.3) + (reusable-frames . nil))) + +;;; Connection guard, contact fetch, and cache + +;; Forward declarations: signel.el is loaded by the use-package above (with +;; :load-path on the fork), but the byte-compiler doesn't see those symbols +;; statically. Declaring them keeps the compile clean without changing +;; runtime behavior. +(defvar signel-account) +(defvar signel--process-name) +(declare-function signel-start "signel" ()) +(declare-function signel--send-rpc "signel" (method params &optional target-buffer success-callback)) + +(defvar cj/signel--contact-cache nil + "Contact-picker cache: nil (cold), `empty', or a `(LABEL . RECIPIENT)' alist. +Populated by `cj/signel--fetch-contacts' on first invocation (or after a +`cj/signel-refresh-contacts'). A fetched-and-empty account caches the +symbol `empty' rather than nil, so it reads as warm and the picker does +not re-run its blocking fetch on every open -- read through +`cj/signel--cached-contacts'. Cleared back to cold by +`cj/signel--clear-contact-cache', advised onto `signel-stop' so a stale +list can't survive a reconnect. In-memory only.") + +(defun cj/signel--clear-contact-cache (&rest _) + "Return the contact cache to cold (nil) so the next picker refetches. +Advised `:after' `signel-stop': a relink or reconnect may change the +contact list, so a cache from the previous connection must not survive." + (setq cj/signel--contact-cache nil)) + +(advice-add 'signel-stop :after #'cj/signel--clear-contact-cache) + +(defun cj/signel--cached-contacts () + "Return the cached contact alist, treating the `empty' sentinel as none." + (unless (eq cj/signel--contact-cache 'empty) + cj/signel--contact-cache)) + +(defcustom cj/signel-fetch-timeout 3.0 + "Seconds the picker blocks on `accept-process-output' for a cold-cache fetch. +On warm cache the picker opens instantly; on cold cache it kicks off a +fetch and waits up to this many seconds for the RPC result before +reporting a `user-error' so a dead or wedged daemon can't hang Emacs." + :type 'number + :group 'signel) + +(defun cj/signel--ensure-started () + "Ensure the signel daemon is live, starting it if needed. +Three branches: +- The process is already live -- no-op, return nil. +- `signel-account' is set but no live process exists -- call `signel-start' + and pre-warm the contact cache with a background `listContacts' fetch so + the picker is instant on first use. +- `signel-account' is nil -- `user-error' naming the remedy (set the + account in `cj/signal-private-config-file'). + +If startup launches but the RPC handshake exits before the first response, +the subsequent `signel--send-rpc' call (in the pre-warm or any later +fetch) signals through its own error path; check =*signel-log*= and +=*signel-stderr*= for detail and link the account manually. + +Loads the `signel' feature explicitly before reading any of its +private variables: the use-package above autoloads only on +`signel-start' / `signel-stop' / `signel-chat' / `signel-dashboard', +so without this require the first branch's read of `signel--process-name' +fires a void-variable error before the autoload would trigger." + (require 'signel) + (cond + ((process-live-p (get-process signel--process-name)) + nil) + ((null signel-account) + (user-error + "signel-account is unset. Set it in %s (or your private config) and link the device manually with `signal-cli link', then retry" + cj/signal-private-config-file)) + (t + (signel-start) + (cj/signel--fetch-contacts)))) + +(defun cj/signel--fetch-contacts (&optional after-callback) + "Fetch the contact list from signal-cli and populate `cj/signel--contact-cache'. +Issues a `listContacts' RPC and registers a success callback that runs +the result through `cj/signal--parse-contacts' (the verified parser) and +stores the resulting `(LABEL . RECIPIENT)' alist in the cache. An empty +result caches the `empty' sentinel -- nil would read as a cold cache and +re-run the picker's blocking fetch on every open. A failure goes +through the dispatch error path and never invokes the callback, so the +prior cache survives. + +AFTER-CALLBACK, when non-nil, is invoked with no arguments after the +cache has been populated -- the picker uses this to unblock its +bounded-wait on cold caches." + (signel--send-rpc + "listContacts" nil nil + (lambda (result) + (setq cj/signel--contact-cache + (or (cj/signal--parse-contacts result) 'empty)) + (when after-callback (funcall after-callback))))) + +(defun cj/signel-refresh-contacts () + "Clear the picker's contact cache and refetch it from signal-cli. +Use when a contact added or renamed on the phone hasn't shown up in the +picker yet; this forces a fresh `listContacts' rather than reading the +cached snapshot." + (interactive) + (setq cj/signel--contact-cache nil) + (cj/signel--fetch-contacts)) + +;;; Picker, self-message, and connect + +(declare-function signel-chat "signel" (recipient)) +(declare-function signel-dashboard "signel" ()) +(declare-function signel-stop "signel" ()) + +(defun cj/signel-connect () + "Connect to signal-cli, starting the daemon if needed. +Thin interactive wrapper around `cj/signel--ensure-started' so the +keymap has a friendly verb to bind." + (interactive) + (cj/signel--ensure-started) + (message "Signel connected.")) + +(defun cj/signel-message () + "Pick a Signal contact by name and open the chat buffer. +Ensures the daemon is connected first (auto-starts and pre-warms on +cold start, or errors with the remedy if the account isn't set). Uses +the cached contact list when warm; on a cold cache, kicks off a fetch +and waits up to `cj/signel-fetch-timeout' seconds for the result before +raising a `user-error' so a dead daemon can't hang Emacs. The picker +offers a pinned \"Note to Self\" entry plus every Signal contact, and +opens the chosen recipient in `signel-chat'." + (interactive) + (cj/signel--ensure-started) + (unless cj/signel--contact-cache + (let ((done nil) + (deadline (+ (float-time) cj/signel-fetch-timeout))) + (cj/signel--fetch-contacts (lambda () (setq done t))) + (while (and (not done) (< (float-time) deadline)) + (accept-process-output nil 0.1)) + (unless done + (user-error + "Signal contact fetch timed out after %.1fs; try again or run M-x cj/signel-refresh-contacts (see *signel-log* for detail)" + cj/signel-fetch-timeout)))) + (let* ((note-self (cons "Note to Self" signel-account)) + (candidates (cons note-self (cj/signel--cached-contacts))) + (table (lambda (string pred action) + (if (eq action 'metadata) + `(metadata + (category . signal-contact) + (annotation-function + . ,(lambda (cand) + (let ((r (cdr (assoc cand candidates)))) + (when r + (concat " " (propertize r 'face 'completions-annotations)))))) + (display-sort-function . identity) + (cycle-sort-function . identity)) + (complete-with-action action candidates string pred)))) + (label (completing-read "Signal recipient: " table nil t)) + (recipient (cdr (assoc label candidates)))) + (when recipient + (signel-chat recipient)))) + +(defun cj/signel-message-self () + "Open a Signal chat buffer addressed to Note to Self. +Resolves to `signel-account' (the linked phone number). Sending to it +lands in the Signal Note-to-Self thread on the phone; manual-verify +that on first use." + (interactive) + (cj/signel--ensure-started) + (unless signel-account + (user-error "signel-account is unset; cannot send to self")) + (signel-chat signel-account)) + +(defvar cj/signel-prefix-map + (let ((map (make-sparse-keymap))) + (keymap-set map "m" #'cj/signel-message) + (keymap-set map "s" #'cj/signel-message-self) + (keymap-set map "d" #'signel-dashboard) + (keymap-set map "q" #'signel-stop) + (keymap-set map "SPC" #'cj/signel-connect) + map) + "Signel \"Messages\" prefix keymap, bound under `C-; M'. +Leaves =l= unbound for now -- the future =cj/signel-link= command lands +in a later pass. See =docs/specs/signal-client-spec-doing.org= scope summary.") + +;; Register the messages prefix under C-; M via the documented helper. +;; keybindings.el owns cj/custom-keymap; the (require 'keybindings) above +;; guarantees it is loaded before this runs, so no load-order guard is +;; needed. This is the same pattern every other feature module uses. +(cj/register-prefix-map "M" cj/signel-prefix-map "signal messages") + +(provide 'signal-config) +;;; signal-config.el ends here diff --git a/archive/tests/test-signal-config--contact-cache.el b/archive/tests/test-signal-config--contact-cache.el new file mode 100644 index 00000000..720e0c3a --- /dev/null +++ b/archive/tests/test-signal-config--contact-cache.el @@ -0,0 +1,61 @@ +;;; test-signal-config--contact-cache.el --- Contact-cache lifecycle tests -*- lexical-binding: t; -*- + +;;; Commentary: +;; The picker's contact cache has two lifecycle bugs from the 2026-06 config +;; audit: (1) its docstring promised clearing on signel-stop but nothing +;; cleared it, so a stale list survived a relink/reconnect; (2) a +;; fetched-and-empty list was cached as nil, indistinguishable from a cold +;; cache, so a zero-contact account re-ran the blocking fetch (up to +;; `cj/signel-fetch-timeout') on every picker open. The fix names the empty +;; state with an `empty' sentinel and clears the cache via a named function +;; advised onto `signel-stop'. +;; +;; Boundary mocks only (the RPC send, the prompt); the cache logic runs +;; real. The ensure-started branches the audit called untested were +;; already covered in test-signal-config.el -- that claim was stale. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'signal-config) + +;;; ------------------------- clear-on-stop (F1) ------------------------------ + +(ert-deftest test-signal-config-clear-contact-cache-resets () + "Normal: the clear function empties the cache back to cold (nil)." + (let ((cj/signel--contact-cache '(("Al (+1)" . "+1")))) + (cj/signel--clear-contact-cache) + (should (null cj/signel--contact-cache)))) + +(ert-deftest test-signal-config-clear-contact-cache-advises-stop () + "Normal: `signel-stop' carries the clear advice, so the docstring's +\"cleared on stop/restart\" promise is real." + (should (advice-member-p #'cj/signel--clear-contact-cache 'signel-stop))) + +;;; ------------------------- empty sentinel (F2) ------------------------------ + +(ert-deftest test-signal-config-fetch-empty-caches-sentinel () + "Boundary: a fetched-and-empty list caches the `empty' sentinel, not nil. +nil means cold cache; without the sentinel a zero-contact account re-ran +the blocking fetch on every picker open." + (let ((cj/signel--contact-cache nil) + (captured-callback nil)) + (cl-letf (((symbol-function 'signel--send-rpc) + (lambda (_method _params _buf callback) + (setq captured-callback callback)))) + (cj/signel--fetch-contacts) + (funcall captured-callback '())) + (should (eq cj/signel--contact-cache 'empty)))) + +(ert-deftest test-signal-config-cached-contacts-unwraps-sentinel () + "Normal: the cache reader returns the alist, and nil for the sentinel." + (let ((cj/signel--contact-cache '(("Al (+1)" . "+1")))) + (should (equal (cj/signel--cached-contacts) '(("Al (+1)" . "+1"))))) + (let ((cj/signel--contact-cache 'empty)) + (should (null (cj/signel--cached-contacts))))) + +(provide 'test-signal-config--contact-cache) +;;; test-signal-config--contact-cache.el ends here diff --git a/archive/tests/test-signal-config-notify.el b/archive/tests/test-signal-config-notify.el new file mode 100644 index 00000000..1a772289 --- /dev/null +++ b/archive/tests/test-signal-config-notify.el @@ -0,0 +1,150 @@ +;;; test-signal-config-notify.el --- Tests for the signal-config notification slice -*- lexical-binding: t -*- + +;;; Commentary: +;; ERT tests for the notification slice of `signal-config': the pure +;; body formatter (whitespace collapse + truncation to +;; `cj/signal--notify-body-max') and `cj/signel--notify' routing (the +;; suppression gate, the notify-script path with the sound flag, and +;; the `notifications-notify' fallback). Spec: the "Notification +;; slice" addendum in docs/specs/signal-client-spec-doing.org. No signal-cli or +;; linked account needed. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +;; signel is the fork at ~/code/signel; signal-config wires it via +;; use-package but these tests need the symbols available directly. +(eval-and-compile + (add-to-list 'load-path (expand-file-name "~/code/signel"))) +(require 'signel) + +(require 'signal-config) + +;;; cj/signal--format-notify-body + +(ert-deftest test-signal-config-format-notify-body-passthrough () + "Normal: short single-line text passes through unchanged." + (should (equal (cj/signal--format-notify-body "lunch at noon?") + "lunch at noon?"))) + +(ert-deftest test-signal-config-format-notify-body-collapses-whitespace () + "Normal: newlines and whitespace runs collapse to single spaces." + (should (equal (cj/signal--format-notify-body "two\nlines\n\nhere") + "two lines here")) + (should (equal (cj/signal--format-notify-body "tabs\t\tand spaces") + "tabs and spaces"))) + +(ert-deftest test-signal-config-format-notify-body-trims () + "Boundary: leading and trailing whitespace is trimmed." + (should (equal (cj/signal--format-notify-body " hi ") "hi"))) + +(ert-deftest test-signal-config-format-notify-body-empty () + "Boundary: the empty string stays empty." + (should (equal (cj/signal--format-notify-body "") ""))) + +(ert-deftest test-signal-config-format-notify-body-exact-limit () + "Boundary: a body exactly at the limit is untouched." + (let ((s (make-string cj/signal--notify-body-max ?x))) + (should (equal (cj/signal--format-notify-body s) s)))) + +(ert-deftest test-signal-config-format-notify-body-truncates-over-limit () + "Boundary: over-limit text truncates to the limit, ending in an ellipsis." + (let* ((s (make-string (1+ cj/signal--notify-body-max) ?x)) + (out (cj/signal--format-notify-body s))) + (should (= (length out) cj/signal--notify-body-max)) + (should (string-suffix-p "…" out)))) + +(ert-deftest test-signal-config-format-notify-body-unicode () + "Boundary: multibyte text truncates by characters, not bytes." + (let* ((s (make-string (+ cj/signal--notify-body-max 10) ?é)) + (out (cj/signal--format-notify-body s))) + (should (= (length out) cj/signal--notify-body-max)) + (should (string-suffix-p "…" out)))) + +;;; cj/signel--notify routing + +(ert-deftest test-signal-config-notify-suppressed-when-viewing () + "Normal: nothing fires when the suppression predicate says no." + (let (script-calls fallback-calls) + (cl-letf (((symbol-function 'cj/signal--should-notify-p) + (lambda (_chat-id) nil)) + ((symbol-function 'start-process) + (lambda (&rest args) (push args script-calls) nil)) + ((symbol-function 'notifications-notify) + (lambda (&rest args) (push args fallback-calls) nil))) + (cj/signel--notify "+15551234567" "Alice" "hi")) + (should-not script-calls) + (should-not fallback-calls))) + +(ert-deftest test-signal-config-notify-script-silent-by-default () + "Normal: with the script present and sound off, runs notify info --silent." + (let (script-calls) + (cl-letf (((symbol-function 'cj/signal--should-notify-p) + (lambda (_chat-id) t)) + ((symbol-function 'executable-find) + (lambda (p &optional _remote) + (when (equal p "notify") "/usr/bin/notify"))) + ((symbol-function 'start-process) + (lambda (&rest args) (push args script-calls) nil)) + ((symbol-function 'notifications-notify) + (lambda (&rest _) + (error "Fallback must not fire when the script is present")))) + (let ((cj/signel-notify-sound nil)) + (cj/signel--notify "+15551234567" "Alice" "hi"))) + (should (= (length script-calls) 1)) + ;; start-process args: (NAME BUFFER PROGRAM &rest PROGRAM-ARGS); + ;; PROGRAM is the path executable-find resolved, not the bare name. + (should (equal (nthcdr 2 (car script-calls)) + '("/usr/bin/notify" "info" "Signal: Alice" "hi" "--silent"))))) + +(ert-deftest test-signal-config-notify-sound-enabled-drops-silent () + "Normal: with `cj/signel-notify-sound' non-nil, --silent is omitted." + (let (script-calls) + (cl-letf (((symbol-function 'cj/signal--should-notify-p) + (lambda (_chat-id) t)) + ((symbol-function 'executable-find) + (lambda (p &optional _remote) + (when (equal p "notify") "/usr/bin/notify"))) + ((symbol-function 'start-process) + (lambda (&rest args) (push args script-calls) nil))) + (let ((cj/signel-notify-sound t)) + (cj/signel--notify "+15551234567" "Alice" "hi"))) + (should (equal (nthcdr 2 (car script-calls)) + '("/usr/bin/notify" "info" "Signal: Alice" "hi"))))) + +(ert-deftest test-signal-config-notify-fallback-when-script-missing () + "Error: without the script on PATH, falls back to notifications-notify." + (let (script-calls fallback-calls) + (cl-letf (((symbol-function 'cj/signal--should-notify-p) + (lambda (_chat-id) t)) + ((symbol-function 'executable-find) + (lambda (_p &optional _remote) nil)) + ((symbol-function 'start-process) + (lambda (&rest args) (push args script-calls) nil)) + ((symbol-function 'notifications-notify) + (lambda (&rest args) (push args fallback-calls) nil))) + (cj/signel--notify "+15551234567" "Alice" "hi")) + (should-not script-calls) + (should (= (length fallback-calls) 1)) + (let ((args (car fallback-calls))) + (should (equal (plist-get args :title) "Signal: Alice")) + (should (equal (plist-get args :body) "hi"))))) + +(ert-deftest test-signal-config-notify-formats-body-before-send () + "Normal: the body runs through the formatter before reaching the script." + (let (script-calls) + (cl-letf (((symbol-function 'cj/signal--should-notify-p) + (lambda (_chat-id) t)) + ((symbol-function 'executable-find) + (lambda (p &optional _remote) + (when (equal p "notify") "/usr/bin/notify"))) + ((symbol-function 'start-process) + (lambda (&rest args) (push args script-calls) nil))) + (let ((cj/signel-notify-sound nil)) + (cj/signel--notify "+15551234567" "Alice" "first line\nsecond line"))) + (should (equal (nth 5 (car script-calls)) "first line second line")))) + +(provide 'test-signal-config-notify) +;;; test-signal-config-notify.el ends here diff --git a/archive/tests/test-signal-config.el b/archive/tests/test-signal-config.el new file mode 100644 index 00000000..f8bf4410 --- /dev/null +++ b/archive/tests/test-signal-config.el @@ -0,0 +1,410 @@ +;;; test-signal-config.el --- Tests for signal-config -*- lexical-binding: t -*- + +;;; Commentary: +;; ERT tests for the pure helper layer of `signal-config': contact-list +;; parsing for the contact picker, and the notify-when-not-viewing +;; predicate. These need neither signal-cli nor a linked account. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'json) + +;; signel is the fork at ~/code/signel; signal-config wires it via +;; use-package but the connection-guard/fetch tests need the symbols +;; available directly. +(eval-and-compile + (add-to-list 'load-path (expand-file-name "~/code/signel"))) +(require 'signel) + +(require 'signal-config) + +;;; cj/signal--jstr + +(ert-deftest test-signal-config-jstr-string () + "Normal: a non-blank string passes through unchanged." + (should (equal (cj/signal--jstr "hi") "hi"))) + +(ert-deftest test-signal-config-jstr-rejects-nonstrings () + "Boundary/Error: nil, empty, whitespace, and non-string sentinels become nil." + (should-not (cj/signal--jstr nil)) + (should-not (cj/signal--jstr "")) + (should-not (cj/signal--jstr " ")) + (should-not (cj/signal--jstr :null)) + (should-not (cj/signal--jstr 42))) + +;;; cj/signal--contact-display-name +;; Field priority: nickName, then nickGiven+nickFamily, then top-level name, +;; then top-level given+family, then profile given+family, then username. + +(ert-deftest test-signal-config-display-name-prefers-name () + "Normal: the top-level combined name wins over the given/family parts." + (should (equal (cj/signal--contact-display-name + '((name . "Alice Anderson") (givenName . "Ali") (familyName . "A"))) + "Alice Anderson"))) + +(ert-deftest test-signal-config-display-name-nickname-wins () + "Normal: a nickName overrides the contact name." + (should (equal (cj/signal--contact-display-name + '((nickName . "Edster") (name . "Eve Edwards") + (givenName . "Eve") (familyName . "Edwards"))) + "Edster"))) + +(ert-deftest test-signal-config-display-name-nickname-parts () + "Boundary: nickGivenName+nickFamilyName combine when nickName is unset." + (should (equal (cj/signal--contact-display-name + '((nickGivenName . "DJ") (nickFamilyName . "Cool") (name . "Daniel"))) + "DJ Cool"))) + +(ert-deftest test-signal-config-display-name-toplevel-given-family () + "Boundary: with no name, top-level givenName+familyName combine." + (should (equal (cj/signal--contact-display-name + '((name) (givenName . "Bob") (familyName . "Brown"))) + "Bob Brown"))) + +(ert-deftest test-signal-config-display-name-profile-fallback () + "Boundary: with no name or top-level parts, profile given/family is the fallback." + (should (equal (cj/signal--contact-display-name + '((name) (givenName) (familyName) + (profile . ((givenName . "Carol") (familyName))))) + "Carol"))) + +(ert-deftest test-signal-config-display-name-username-fallback () + "Boundary: username is the last name source." + (should (equal (cj/signal--contact-display-name + '((name) (username . "dave.42"))) + "dave.42"))) + +(ert-deftest test-signal-config-display-name-none () + "Error: no usable name yields nil." + (should-not (cj/signal--contact-display-name + '((name) (givenName) (familyName) + (profile . ((givenName) (familyName)))))) + (should-not (cj/signal--contact-display-name '((number . "+15551112222"))))) + +;;; cj/signal--parse-contacts + +(defconst test-signal-config--contacts-json + "[ + {\"number\":\"+15551112222\",\"uuid\":\"uuid-a\",\"name\":\"Alice Anderson\",\"givenName\":\"Alice\",\"familyName\":\"Anderson\",\"nickName\":null,\"nickGivenName\":null,\"nickFamilyName\":null,\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}}, + {\"number\":\"+15553334444\",\"uuid\":null,\"name\":null,\"givenName\":\"Bob\",\"familyName\":\"Brown\",\"nickName\":null,\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}}, + {\"number\":\"+15555556666\",\"uuid\":\"uuid-c\",\"name\":null,\"givenName\":null,\"familyName\":null,\"nickName\":null,\"username\":null,\"profile\":{\"givenName\":\"Carol\",\"familyName\":null}}, + {\"number\":null,\"uuid\":\"uuid-d\",\"name\":null,\"givenName\":null,\"familyName\":null,\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}}, + {\"number\":\"+15557778888\",\"uuid\":\"uuid-e\",\"name\":\"Eve Edwards\",\"givenName\":\"Eve\",\"familyName\":\"Edwards\",\"nickName\":\"Edster\",\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}} +]" + "Synthetic fixture mirroring the signal-cli 0.14.4.1 `listContacts' shape: +top-level name/givenName/familyName and nickName fields, with a profile +sub-object whose name fields are usually null. Field layout was confirmed +against a live linked account on 2026-05-26; the values here are fake.") + +(ert-deftest test-signal-config-parse-contacts-normal () + "Normal: top-level name, top-level parts, profile fallback, uuid fallback, nickname." + (let* ((result (json-read-from-string test-signal-config--contacts-json)) + (pairs (cj/signal--parse-contacts result))) + (should (equal pairs + '(("Alice Anderson (+15551112222)" . "+15551112222") + ("Bob Brown (+15553334444)" . "+15553334444") + ("Carol (+15555556666)" . "+15555556666") + ("Edster (+15557778888)" . "+15557778888") + ("uuid-d" . "uuid-d")))))) + +(ert-deftest test-signal-config-parse-contacts-empty () + "Boundary: an empty result yields nil for both vector and nil input." + (should-not (cj/signal--parse-contacts [])) + (should-not (cj/signal--parse-contacts nil))) + +(ert-deftest test-signal-config-parse-contacts-accepts-list-and-vector () + "Boundary: vector and list result sequences parse identically." + (let ((entry '((number . "+15551112222") (name . "Al")))) + (should (equal (cj/signal--parse-contacts (vector entry)) + (cj/signal--parse-contacts (list entry)))))) + +(ert-deftest test-signal-config-parse-contacts-drops-recipientless () + "Error: a contact with neither number nor uuid is dropped." + (should-not (cj/signal--parse-contacts + (list '((name . "Ghost") (number) (uuid)))))) + +;;; cj/signal--suppress-notify-p + +(ert-deftest test-signal-config-suppress-when-viewing-focused () + "Normal: viewing the chat buffer with focus suppresses the notification." + (should (cj/signal--suppress-notify-p + "+15551112222" "*Signel: +15551112222*" t))) + +(ert-deftest test-signal-config-no-suppress-other-buffer () + "Boundary: a different selected buffer does not suppress." + (should-not (cj/signal--suppress-notify-p + "+15551112222" "*scratch*" t))) + +(ert-deftest test-signal-config-no-suppress-unfocused () + "Boundary: viewing the chat but with the frame unfocused still notifies." + (should-not (cj/signal--suppress-notify-p + "+15551112222" "*Signel: +15551112222*" nil))) + +(ert-deftest test-signal-config-no-suppress-nil-viewing () + "Error: a nil viewing-buffer name does not suppress." + (should-not (cj/signal--suppress-notify-p "+15551112222" nil t))) + +;;; cj/signel--ensure-started + +(ert-deftest test-signal-config-ensure-started-live-process-noop () + "Normal: with a live signel process, ensure-started returns without +calling `signel-start' or the pre-warm fetch." + (let ((start-called nil) + (fetch-called nil)) + (cl-letf (((symbol-function 'process-live-p) (lambda (_) t)) + ((symbol-function 'get-process) (lambda (_) 'fake-proc)) + ((symbol-function 'signel-start) + (lambda () (setq start-called t))) + ((symbol-function 'cj/signel--fetch-contacts) + (lambda (&rest _) (setq fetch-called t)))) + (cj/signel--ensure-started) + (should-not start-called) + (should-not fetch-called)))) + +(ert-deftest test-signal-config-ensure-started-starts-when-account-set () + "Normal: with `signel-account' set and no live process, ensure-started +calls `signel-start' to bring the daemon up." + (let ((start-called nil) + (signel-account "+15555550100")) + (cl-letf (((symbol-function 'process-live-p) (lambda (_) nil)) + ((symbol-function 'get-process) (lambda (_) nil)) + ((symbol-function 'signel-start) + (lambda () (setq start-called t))) + ((symbol-function 'cj/signel--fetch-contacts) + (lambda (&rest _) nil))) + (cj/signel--ensure-started) + (should start-called)))) + +(ert-deftest test-signal-config-ensure-started-prewarms-on-start () + "Normal: when ensure-started actually starts the daemon, it triggers a +pre-warm fetch so the picker cache is warm on first invocation." + (let ((fetch-called nil) + (signel-account "+15555550100")) + (cl-letf (((symbol-function 'process-live-p) (lambda (_) nil)) + ((symbol-function 'get-process) (lambda (_) nil)) + ((symbol-function 'signel-start) (lambda () nil)) + ((symbol-function 'cj/signel--fetch-contacts) + (lambda (&rest _) (setq fetch-called t)))) + (cj/signel--ensure-started) + (should fetch-called)))) + +(ert-deftest test-signal-config-ensure-started-errors-when-no-account () + "Error: with `signel-account' nil, ensure-started signals a user-error +naming the remedy (set the account in the private config) instead of +starting an account-less daemon." + (let ((signel-account nil)) + (cl-letf (((symbol-function 'process-live-p) (lambda (_) nil)) + ((symbol-function 'get-process) (lambda (_) nil))) + (should-error (cj/signel--ensure-started) :type 'user-error)))) + +(ert-deftest test-signal-config-ensure-started-requires-signel-first () + "Error: ensure-started must `require' signel BEFORE reading any of +its private variables, so a `void-variable' error cannot fire when +called before signel has been autoloaded. Captures the bug where +`signel--process-name' was forward-declared in signal-config but not +yet bound at runtime because the `use-package' autoload had not fired. + +Asserts ordering, not just presence: a future refactor that moves the +`require' below the `cond' would still execute the require eventually +but the variable read in the cond would fire void-variable first. +The test fails if `require' isn't the first call inside the function." + (let ((call-order nil)) + (cl-letf (((symbol-function 'require) + (lambda (feature &optional _filename _noerror) + (push (list 'require feature) call-order) + t)) + ((symbol-function 'process-live-p) + (lambda (_) (push 'process-live-p call-order) t)) + ((symbol-function 'get-process) + (lambda (_) (push 'get-process call-order) 'fake-proc))) + (cj/signel--ensure-started) + (should (equal (car (reverse call-order)) '(require signel)))))) + +;;; cj/signel--fetch-contacts + cj/signel--contact-cache + +(ert-deftest test-signal-config-fetch-contacts-issues-list-contacts-rpc () + "Normal: fetch-contacts sends a `listContacts' RPC and registers a +success callback so the response routes back." + (let (sent-method sent-callback) + (cl-letf (((symbol-function 'signel--send-rpc) + (lambda (method _params _target callback) + (setq sent-method method + sent-callback callback) + 1))) + (cj/signel--fetch-contacts)) + (should (equal sent-method "listContacts")) + (should (functionp sent-callback)))) + +(ert-deftest test-signal-config-fetch-contacts-callback-populates-cache () + "Normal: on a successful result, the callback parses the contact list +and stores the (LABEL . RECIPIENT) alist in `cj/signel--contact-cache'." + (let (sent-callback) + (cl-letf (((symbol-function 'signel--send-rpc) + (lambda (_method _params _target callback) + (setq sent-callback callback) 1))) + (setq cj/signel--contact-cache nil) + (cj/signel--fetch-contacts) + (funcall sent-callback + [((number . "+15555550100") (givenName . "Alice"))])) + (should (equal cj/signel--contact-cache + '(("Alice (+15555550100)" . "+15555550100")))))) + +(ert-deftest test-signal-config-fetch-contacts-empty-result-caches-sentinel () + "Boundary: an empty listContacts result caches the `empty' sentinel. +nil would read as a cold cache and re-run the picker's blocking fetch on +every open; the sentinel marks warm-but-empty. Still distinct from a +failure path, which never invokes the success callback. (This test +formerly pinned the nil behavior -- the 2026-06 audit's F2 bug.)" + (let (sent-callback) + (cl-letf (((symbol-function 'signel--send-rpc) + (lambda (_method _params _target callback) + (setq sent-callback callback) 1))) + (setq cj/signel--contact-cache '(("stale" . "+10000000000"))) + (cj/signel--fetch-contacts) + (funcall sent-callback [])) + (should (eq cj/signel--contact-cache 'empty)))) + +;;; cj/signel-refresh-contacts + +(ert-deftest test-signal-config-refresh-contacts-clears-and-refetches () + "Normal: `cj/signel-refresh-contacts' clears the cache and triggers a +fresh fetch so a stale entry can't survive a user-driven refresh." + (let ((fetch-called nil)) + (setq cj/signel--contact-cache '(("stale" . "+10000000000"))) + (cl-letf (((symbol-function 'cj/signel--fetch-contacts) + (lambda (&rest _) (setq fetch-called t)))) + (cj/signel-refresh-contacts)) + (should-not cj/signel--contact-cache) + (should fetch-called))) + +;;; cj/signel-message picker + +(ert-deftest test-signal-config-message-warm-cache-picks-contact () + "Normal: with a warm cache, picking a contact label opens that +recipient's chat buffer." + (let ((chosen-recipient nil) + (signel-account "+15555550100")) + (setq cj/signel--contact-cache + '(("Alice (+15555550200)" . "+15555550200"))) + (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil)) + ((symbol-function 'completing-read) + (lambda (&rest _) "Alice (+15555550200)")) + ((symbol-function 'signel-chat) + (lambda (r) (setq chosen-recipient r)))) + (cj/signel-message)) + (should (equal chosen-recipient "+15555550200")))) + +(ert-deftest test-signal-config-message-warm-cache-picks-note-to-self () + "Normal: the pinned `Note to Self' entry resolves to `signel-account' +so a self-message lands in the Signal Note-to-Self thread." + (let ((chosen-recipient nil) + (signel-account "+15555550100")) + (setq cj/signel--contact-cache + '(("Alice (+15555550200)" . "+15555550200"))) + (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil)) + ((symbol-function 'completing-read) + (lambda (&rest _) "Note to Self")) + ((symbol-function 'signel-chat) + (lambda (r) (setq chosen-recipient r)))) + (cj/signel-message)) + (should (equal chosen-recipient "+15555550100")))) + +(ert-deftest test-signal-config-message-cold-cache-fetch-resolves-in-time () + "Normal: cold cache, fetch's after-callback fires inside the bounded +wait, picker proceeds with the now-warm cache." + (let ((chosen-recipient nil) + (signel-account "+15555550100") + (cj/signel-fetch-timeout 1.0)) + (setq cj/signel--contact-cache nil) + (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil)) + ((symbol-function 'cj/signel--fetch-contacts) + (lambda (&optional after-cb) + (setq cj/signel--contact-cache + '(("Bob (+15555550300)" . "+15555550300"))) + (when after-cb (funcall after-cb)))) + ((symbol-function 'completing-read) + (lambda (&rest _) "Bob (+15555550300)")) + ((symbol-function 'signel-chat) + (lambda (r) (setq chosen-recipient r)))) + (cj/signel-message)) + (should (equal chosen-recipient "+15555550300")))) + +(ert-deftest test-signal-config-message-cold-cache-timeout-errors () + "Error: cold cache, fetch never resolves, picker user-errors before +the bounded wait would let Emacs hang on a dead daemon." + (let ((signel-account "+15555550100") + (cj/signel-fetch-timeout 0.1)) + (setq cj/signel--contact-cache nil) + (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil)) + ((symbol-function 'cj/signel--fetch-contacts) + (lambda (&rest _) nil))) + (should-error (cj/signel-message) :type 'user-error)))) + +;;; cj/signel-message-self + +(ert-deftest test-signal-config-message-self-calls-signel-chat-with-account () + "Normal: the direct self-message command opens a chat buffer addressed +to `signel-account', skipping the picker entirely." + (let ((chosen-recipient nil) + (signel-account "+15555550100")) + (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil)) + ((symbol-function 'signel-chat) + (lambda (r) (setq chosen-recipient r)))) + (cj/signel-message-self)) + (should (equal chosen-recipient "+15555550100")))) + +;;; cj/signel-prefix-map (C-; M) + +(ert-deftest test-signal-config-prefix-map-has-expected-bindings () + "Normal: the signel C-; M prefix map binds m / s / d / q / SPC to the +commands the workflow spec names." + (should (eq (keymap-lookup cj/signel-prefix-map "m") + #'cj/signel-message)) + (should (eq (keymap-lookup cj/signel-prefix-map "s") + #'cj/signel-message-self)) + (should (eq (keymap-lookup cj/signel-prefix-map "d") + #'signel-dashboard)) + (should (eq (keymap-lookup cj/signel-prefix-map "q") + #'signel-stop)) + (should (eq (keymap-lookup cj/signel-prefix-map "SPC") + #'cj/signel-connect))) + +(ert-deftest test-signal-config-prefix-map-registered-under-c-semi-m () + "Normal: loading signal-config registers `cj/signel-prefix-map' under +`M' in `cj/custom-keymap', so C-; M reaches the signel prefix. Guards +the wiring contract that the load-order bug broke: signal-config must +register through `cj/register-prefix-map', not a boundp-guarded direct +mutation that silently no-ops when keybindings loaded in a different +order." + (require 'keybindings) + (should (eq (keymap-lookup cj/custom-keymap "M") cj/signel-prefix-map))) + +;;; display-buffer-alist entry for *Signel: ...* chat buffers + +(ert-deftest test-signal-config-chat-buffer-display-rule-uses-bottom-30 () + "Normal: signal-config registers a `display-buffer-alist' entry that +matches `*Signel: *' buffers, routes them through +`display-buffer-at-bottom', and sets `window-height' to 0.3 so the +chat docks to the bottom 30% of the frame." + (let ((entry (seq-find (lambda (e) (equal (car e) "\\`\\*Signel: ")) + display-buffer-alist))) + (should entry) + (should (memq 'display-buffer-at-bottom (cadr entry))) + (should (equal 0.3 (cdr (assq 'window-height (cddr entry))))))) + +(ert-deftest test-signal-config-chat-buffer-display-rule-matches-buffer-name () + "Boundary: the registered regex matches a realistic chat buffer name +\(phone-number id and group-id) and does not match unrelated buffers." + (let* ((entry (seq-find (lambda (e) (equal (car e) "\\`\\*Signel: ")) + display-buffer-alist)) + (regex (car entry))) + (should regex) + (should (string-match-p regex "*Signel: +15555550100*")) + (should (string-match-p regex "*Signel: groupid-abc*")) + (should-not (string-match-p regex "*signel-log*")) + (should-not (string-match-p regex "scratch")))) + +(provide 'test-signal-config) +;;; test-signal-config.el ends here diff --git a/archive/tests/test-signel-cancel-input.el b/archive/tests/test-signel-cancel-input.el new file mode 100644 index 00000000..b2a7ef89 --- /dev/null +++ b/archive/tests/test-signel-cancel-input.el @@ -0,0 +1,74 @@ +;;; test-signel-cancel-input.el --- Cancel-input contract for the signel fork -*- lexical-binding: t; -*- + +;;; Commentary: +;; `signel--cancel-input' is the C-c C-k handler in `signel-chat-mode'. +;; Its contract: clear any in-progress input between `signel--input-marker' +;; and `point-max' (so the prompt is fresh on next visit), then dismiss +;; the window via `quit-window' (the buffer stays alive so chat history +;; survives revisits). These tests lock the contract; the binding test +;; locks the keymap entry. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(eval-and-compile + (add-to-list 'load-path (expand-file-name "~/code/signel"))) +(require 'signel) + +(defmacro test-signel-cancel--with-chat-buffer (&rest body) + "Set up a temp signel-chat-mode buffer with prompt drawn and run BODY." + (declare (indent 0)) + `(with-temp-buffer + (signel-chat-mode) + (setq signel--chat-id "+15555550100") + (signel--draw-prompt) + ,@body)) + +(ert-deftest test-signel-cancel-input-clears-pending-text () + "Normal: pending input from input-marker to point-max is cleared." + (test-signel-cancel--with-chat-buffer + (insert "abandoned-draft") + (cl-letf (((symbol-function 'quit-window) (lambda (&rest _) nil))) + (signel--cancel-input)) + (should-not (signel--pending-input)))) + +(ert-deftest test-signel-cancel-input-empty-input-area-is-a-noop () + "Boundary: cancelling with no in-progress input is harmless." + (test-signel-cancel--with-chat-buffer + (cl-letf (((symbol-function 'quit-window) (lambda (&rest _) nil))) + (signel--cancel-input)) + (should-not (signel--pending-input)))) + +(ert-deftest test-signel-cancel-input-calls-quit-window () + "Normal: cancel dismisses the window via `quit-window'." + (test-signel-cancel--with-chat-buffer + (insert "abandoned-draft") + (let ((called nil)) + (cl-letf (((symbol-function 'quit-window) + (lambda (&rest _) (setq called t)))) + (signel--cancel-input)) + (should called)))) + +(ert-deftest test-signel-cancel-input-preserves-buffer () + "Normal: cancel does not kill the buffer; chat history (prompt + prior +content above the input marker) survives so reopening the contact lands +in the same buffer." + (test-signel-cancel--with-chat-buffer + (insert "abandoned-draft") + (let ((buf (current-buffer))) + (cl-letf (((symbol-function 'quit-window) (lambda (&rest _) nil))) + (signel--cancel-input)) + (should (buffer-live-p buf))))) + +(ert-deftest test-signel-chat-mode-binds-c-c-c-k-to-cancel () + "Normal: `signel-chat-mode' binds C-c C-k to `signel--cancel-input' so +the documented cancel gesture reaches the handler." + (with-temp-buffer + (signel-chat-mode) + (should (eq (lookup-key (current-local-map) (kbd "C-c C-k")) + #'signel--cancel-input)))) + +(provide 'test-signel-cancel-input) +;;; test-signel-cancel-input.el ends here diff --git a/archive/tests/test-signel-input-preservation.el b/archive/tests/test-signel-input-preservation.el new file mode 100644 index 00000000..e8ce4ddb --- /dev/null +++ b/archive/tests/test-signel-input-preservation.el @@ -0,0 +1,68 @@ +;;; test-signel-input-preservation.el --- Regression for signel #2 input clobber -*- lexical-binding: t; -*- + +;;; Commentary: +;; signel-chat-mode buffers have an editable prompt area starting at +;; `signel--input-marker'. Before this fix, both `signel--insert-msg' (the +;; receive path) and `signel--insert-system-msg' (the RPC-error path) +;; called `(delete-region (point) (point-max))' to clear the old prompt +;; before redrawing it, which destroyed any text the user was mid-typing. +;; +;; These tests lock the preservation contract: a small `signel--pending-input' +;; helper captures the in-progress input from the marker to `point-max', and +;; both inserters restore it after the freshly drawn prompt. The chat-mode +;; buffer is constructed in a temp buffer; `signel--insert-msg' is steered +;; to it via a stub on `signel--get-buffer'. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(eval-and-compile + (add-to-list 'load-path (expand-file-name "~/code/signel"))) +(require 'signel) + +(defmacro test-signel--with-chat-buffer (&rest body) + "Set up a temp signel-chat-mode buffer with prompt drawn and run BODY." + (declare (indent 0)) + `(with-temp-buffer + (signel-chat-mode) + (setq signel--chat-id "+15555550100") + (signel--draw-prompt) + ,@body)) + +(ert-deftest test-signel-input-pending-returns-typed-text () + "Normal: with text after the prompt marker, `signel--pending-input' +returns the captured text." + (test-signel--with-chat-buffer + (insert "halfwritten") + (should (equal (signel--pending-input) "halfwritten")))) + +(ert-deftest test-signel-input-pending-returns-nil-when-empty () + "Boundary: an empty input area returns nil so callers don't restore an +empty string after the prompt." + (test-signel--with-chat-buffer + (should-not (signel--pending-input)))) + +(ert-deftest test-signel-input-system-msg-preserves-pending-input () + "Regression for #2: `signel--insert-system-msg' redraws the prompt +without clobbering text the user was mid-typing." + (test-signel--with-chat-buffer + (insert "halfwritten") + (signel--insert-system-msg "An error happened" 'signel-error-face) + (should (string-match-p "An error happened" (buffer-string))) + (should (equal (signel--pending-input) "halfwritten")))) + +(ert-deftest test-signel-input-msg-preserves-pending-input () + "Regression for #2: `signel--insert-msg' (the receive path) redraws the +prompt without clobbering the user's in-progress input." + (test-signel--with-chat-buffer + (insert "halfwritten") + (cl-letf (((symbol-function 'signel--get-buffer) + (lambda (_) (current-buffer)))) + (signel--insert-msg "+15555550100" "Alice" "Hi there" nil nil nil)) + (should (string-match-p "Hi there" (buffer-string))) + (should (equal (signel--pending-input) "halfwritten")))) + +(provide 'test-signel-input-preservation) +;;; test-signel-input-preservation.el ends here diff --git a/archive/tests/test-signel-notify-function.el b/archive/tests/test-signel-notify-function.el new file mode 100644 index 00000000..e3d97af5 --- /dev/null +++ b/archive/tests/test-signel-notify-function.el @@ -0,0 +1,89 @@ +;;; test-signel-notify-function.el --- Tests for signel's notify-function dispatch -*- lexical-binding: t -*- + +;;; Commentary: +;; signel's receive handler (signel.el in the fork at ~/code/signel) +;; raised notifications through a hardwired `notifications-notify' +;; call. The notification slice (docs/specs/signal-client-spec-doing.org, +;; "Notification slice" addendum) replaces that with +;; `signel-notify-function', a customization point called with +;; CHAT-ID, SENDER, and BODY so a config layer can add suppression or +;; route through an external notifier. These tests cover the +;; dispatch: text, sticker, and attachment bodies reach the function +;; with the right arguments, and the default preserves the plain +;; `notifications-notify' behavior. +;; +;; `signel--handle-receive' is exercised directly with synthetic +;; envelope alists; buffer/dashboard side effects are stubbed. No +;; live process needed. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(eval-and-compile + (add-to-list 'load-path (expand-file-name "~/code/signel"))) +(require 'signel) + +(defun test-signel-notify--receive (envelope) + "Run `signel--handle-receive' on ENVELOPE, capturing notify calls. +Returns the list of (CHAT-ID SENDER BODY) argument lists the handler +passed to `signel-notify-function', oldest first. Buffer and +dashboard side effects are stubbed out." + (let (calls) + (cl-letf (((symbol-function 'signel--insert-msg) (lambda (&rest _) nil)) + ((symbol-function 'signel--dashboard-refresh) (lambda () nil)) + ((symbol-function 'signel--get-buffer) + (lambda (_) (current-buffer)))) + (let ((signel-notify-function + (lambda (chat-id sender body) + (push (list chat-id sender body) calls))) + (signel-auto-open-buffer nil)) + (signel--handle-receive `((envelope . ,envelope))))) + (nreverse calls))) + +(ert-deftest test-signel-notify-function-text-message () + "Normal: a text dataMessage calls the function with chat-id, sender, text." + (should (equal (test-signel-notify--receive + '((sourceNumber . "+15551234567") + (sourceName . "Alice") + (dataMessage . ((message . "hi there"))))) + '(("+15551234567" "Alice" "hi there"))))) + +(ert-deftest test-signel-notify-function-sticker-placeholder () + "Boundary: a sticker with no text gets the [Sticker] placeholder body." + (should (equal (test-signel-notify--receive + '((sourceNumber . "+15551234567") + (sourceName . "Alice") + (dataMessage . ((sticker . ((packId . "p1"))))))) + '(("+15551234567" "Alice" "[Sticker]"))))) + +(ert-deftest test-signel-notify-function-attachment-placeholder () + "Boundary: an attachment with no text gets the [Attachment] placeholder." + (should (equal (test-signel-notify--receive + '((sourceNumber . "+15551234567") + (sourceName . "Alice") + (dataMessage . ((attachments . [((id . "a1"))]))))) + '(("+15551234567" "Alice" "[Attachment]"))))) + +(ert-deftest test-signel-notify-function-no-data-no-call () + "Boundary: an envelope with no dataMessage never calls the function." + (should-not (test-signel-notify--receive + '((sourceNumber . "+15551234567") + (sourceName . "Alice") + (typingMessage . ((action . "STARTED"))))))) + +(ert-deftest test-signel-notify-function-default-preserves-behavior () + "Normal: the default value raises a plain notifications-notify toast." + (should (eq signel-notify-function #'signel--notify-default)) + (let (calls) + (cl-letf (((symbol-function 'notifications-notify) + (lambda (&rest args) (push args calls) nil))) + (signel--notify-default "+15551234567" "Alice" "hi")) + (should (= (length calls) 1)) + (let ((args (car calls))) + (should (equal (plist-get args :title) "Signel: Alice")) + (should (equal (plist-get args :body) "hi"))))) + +(provide 'test-signel-notify-function) +;;; test-signel-notify-function.el ends here diff --git a/archive/tests/test-signel-rpc-dispatch.el b/archive/tests/test-signel-rpc-dispatch.el new file mode 100644 index 00000000..5ae023d6 --- /dev/null +++ b/archive/tests/test-signel-rpc-dispatch.el @@ -0,0 +1,94 @@ +;;; test-signel-rpc-dispatch.el --- Tests for signel JSON-RPC success-result dispatch -*- lexical-binding: t; -*- + +;;; Commentary: +;; signel's JSON-RPC dispatch (signel.el in the fork at ~/code/signel) routes +;; incoming `receive' notifications and errors, but successful +;; `((id . N) (result . VALUE))' responses had no path until this work added a +;; request-callback table. These tests cover the new behavior: a registered +;; callback fires with the result and is then removed; an error response +;; also removes the handler so a retry starts clean; an unregistered id is a +;; silent no-op; passing SUCCESS-CALLBACK to `signel--send-rpc' registers it +;; under the returned id. +;; +;; The dispatch tests exercise `signel--dispatch' directly with synthetic JSON +;; alists; no live process is needed. The send-rpc test stubs `get-process' +;; and `process-send-string' so it doesn't require a running signal-cli. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(eval-and-compile + (add-to-list 'load-path (expand-file-name "~/code/signel"))) +(require 'signel) + +(defun test-signel-rpc--reset () + "Reset signel dispatch state to a clean baseline before each test." + (clrhash signel--request-handler-map) + (clrhash signel--request-buffer-map) + (setq signel--rpc-id-counter 0)) + +(ert-deftest test-signel-rpc-dispatch-result-invokes-callback () + "Normal: a result response with a registered id fires the callback with the +result value and removes the handler." + (test-signel-rpc--reset) + (let ((captured nil)) + (puthash 7 (lambda (val) (setq captured val)) signel--request-handler-map) + (signel--dispatch '((jsonrpc . "2.0") (id . 7) + (result . ((contacts . [1 2 3]))))) + (should (equal captured '((contacts . [1 2 3])))) + (should-not (gethash 7 signel--request-handler-map)))) + +(ert-deftest test-signel-rpc-dispatch-unknown-id-is-noop () + "Boundary: a result response with an unregistered id is a silent no-op: +neither receive nor error handler fires, and the handler map stays empty." + (test-signel-rpc--reset) + (let ((called nil)) + (cl-letf (((symbol-function 'signel--handle-error) + (lambda (&rest _) (setq called 'error))) + ((symbol-function 'signel--handle-receive) + (lambda (&rest _) (setq called 'receive)))) + (signel--dispatch '((jsonrpc . "2.0") (id . 99) (result . "anything")))) + (should-not called) + (should (zerop (hash-table-count signel--request-handler-map))))) + +(ert-deftest test-signel-rpc-dispatch-error-cleans-up-handler () + "Error: an error response with a registered id removes the handler without +firing the callback, leaving the map clean for a retry." + (test-signel-rpc--reset) + (let ((fired nil)) + (puthash 11 (lambda (&rest _) (setq fired t)) + signel--request-handler-map) + (cl-letf (((symbol-function 'signel--handle-error) (lambda (&rest _) nil))) + (signel--dispatch '((jsonrpc . "2.0") (id . 11) + (error . ((code . -1) (message . "boom")))))) + (should-not fired) + (should-not (gethash 11 signel--request-handler-map)))) + +(ert-deftest test-signel-rpc-send-rpc-registers-success-callback () + "Normal: passing a SUCCESS-CALLBACK to `signel--send-rpc' stores it under +the returned id so the matching response can route to it." + (test-signel-rpc--reset) + (let ((cb (lambda (_) 'ok)) + (sent nil)) + (cl-letf (((symbol-function 'get-process) (lambda (&rest _) 'fake-proc)) + ((symbol-function 'process-send-string) + (lambda (_ s) (setq sent s))) + ((symbol-function 'signel--log) (lambda (&rest _) nil))) + (let ((id (signel--send-rpc "listContacts" nil nil cb))) + (should (eq cb (gethash id signel--request-handler-map))) + (should (stringp sent)))))) + +(ert-deftest test-signel-rpc-stop-clears-handler-map () + "Normal (reconnect-invalidation): `signel-stop' clears the handler map so a +restart starts with no stale callbacks waiting for responses that will never +arrive." + (test-signel-rpc--reset) + (puthash 13 (lambda (&rest _) nil) signel--request-handler-map) + (cl-letf (((symbol-function 'get-process) (lambda (&rest _) nil))) + (signel-stop)) + (should (zerop (hash-table-count signel--request-handler-map)))) + +(provide 'test-signel-rpc-dispatch) +;;; test-signel-rpc-dispatch.el ends here diff --git a/docs/specs/messenger-unification-spec.org b/docs/specs/messenger-unification-spec.org index c92ba1a7..7847fd04 100644 --- a/docs/specs/messenger-unification-spec.org +++ b/docs/specs/messenger-unification-spec.org @@ -8,6 +8,7 @@ :PROPERTIES: :ID: 4bfc2011-8ffc-4765-8886-91df12141171 :END: +- 2026-07-14 Tue @ 01:10:00 -0500 — premise shift: the signel client was retired to archive/ (agents drive Signal via signal-cli), so the registry example, the "signel remains the running reference" language, and the backend list all need a rewrite before this leaves DRAFT — the live in-Emacs backends are now telega and Slack, with smoke (~/code/smoke) still the future native adopter. Also landed since the 06-11 survey: slack-config gained signel-shape notification hardening (c69f2f56) and telega notifications are on (5bc5ef7a), so the shared cj/messenger-notify extraction has two live call sites ready. - 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DRAFT from existing :STATUS: not-started (held open for more ideas) * Problem diff --git a/docs/specs/signal-client-spec.org b/docs/specs/signal-client-spec.org index c3d54da4..56c9ea1a 100644 --- a/docs/specs/signal-client-spec.org +++ b/docs/specs/signal-client-spec.org @@ -7,6 +7,7 @@ :PROPERTIES: :ID: 0cabd6ee-c458-47b5-a8af-3ee054b25821 :END: +- 2026-07-14 Tue @ 01:10:00 -0500 — RETIRED from the config (Craig's call, ~an hour after the IMPLEMENTED flip): agents drive Signal via signal-cli / signal-mcp, so the interactive in-Emacs client earns no keep. signal-config.el and its seven test files moved to archive/ (see archive/README.org); the C-; M prefix unregistered; the ~/code/signel fork repo untouched. Keyword stays IMPLEMENTED as the honest record of what was built. - 2026-07-14 Tue @ 01:01:32 -0500 — IMPLEMENTED (Craig's call): v1 is shipped and in daily use — forked signel engine, contact picker with cached contacts (lifecycle hardened 703b4841 / 1296cc45), notifications with sound gating and script-with-fallback delivery, bottom-30% window rule. Next-generation client work belongs to the smoke project (~/code/smoke, its own architecture spec), which the messenger-unification spec designates as the ground-up replacement; signal-config.el stays the running reference until smoke reaches parity. - 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DOING from existing :STATUS: doing diff --git a/init.el b/init.el index d1128602..caa2c68e 100644 --- a/init.el +++ b/init.el @@ -78,7 +78,6 @@ (require 'slack-config) ;; slack client via emacs-slack (require 'pearl-config) ;; Linear.app issue tracking via pearl (deepsat + craigjennings) (require 'telega-config) ;; telegram client via telega.el (TDLib in docker) -(require 'signal-config) ;; signal client via forked signel + signal-cli (require 'eshell-config) ;; emacs shell configuration (require 'eat-config) ;; EAT terminal + the F12 dock-and-remember toggle (require 'ai-term) ;; in-Emacs Claude launcher (vertical-split EAT terminal) diff --git a/modules/dashboard-config.el b/modules/dashboard-config.el index 53f19b72..d5aa501d 100644 --- a/modules/dashboard-config.el +++ b/modules/dashboard-config.el @@ -68,7 +68,6 @@ (declare-function cj/erc-switch-to-buffer-with-completion "erc-config") (declare-function cj/telega "telega-config") (declare-function cj/slack-start "slack-config") -(declare-function cj/signel-message "signal-config") (declare-function cj/kill-all-other-buffers-and-windows "undead-buffers") ;; External package commands invoked by launchers. @@ -149,15 +148,15 @@ Adjust this if the title doesn't appear centered under the banner image.") (list "i" #'nerd-icons-faicon "nf-fa-comments" "IRC" "Emacs Relay Chat" (lambda () (cj/erc-switch-to-buffer-with-completion))) (list "G" #'nerd-icons-faicon "nf-fa-telegram" "Telegram" "Telega Telegram Client" (lambda () (cj/telega))) (list "s" #'nerd-icons-faicon "nf-fa-slack" "Slack" "Slack Client" (lambda () (cj/slack-start))) - (list "l" #'nerd-icons-octicon "nf-oct-issue_tracks" "Linear" "Linear Issue Tracker" (lambda () (pearl-list-issues))) - (list "S" #'nerd-icons-mdicon "nf-md-message" "Signal" "Signal Messenger" (lambda () (cj/signel-message)))) + (list "l" #'nerd-icons-octicon "nf-oct-issue_tracks" "Linear" "Linear Issue Tracker" (lambda () (pearl-list-issues)))) "Dashboard launcher table: (KEY ICON-FN ICON-NAME LABEL TOOLTIP ACTION). Drives both `dashboard-navigator-buttons' and the dashboard-mode-map keys.") -(defconst cj/dashboard--row-sizes '(5 4 3 3) +(defconst cj/dashboard--row-sizes '(5 4 3 2) "Navigator row lengths. Must sum to the number of `cj/dashboard--launchers'. -The top row carries Weather alongside the core tools; the last row groups -Slack, Linear, and Signal together.") +The top row carries Weather alongside the core tools; the last row pairs +Slack and Linear. (Signal left the table when the signel client was +retired to archive/ -- agents drive Signal via signal-cli.)") (defun cj/dashboard--navigator-button (l) "Build a `dashboard-navigator-buttons' entry from launcher L." diff --git a/modules/signal-config.el b/modules/signal-config.el deleted file mode 100644 index a1ec7933..00000000 --- a/modules/signal-config.el +++ /dev/null @@ -1,380 +0,0 @@ -;;; signal-config.el --- Signal client (forked signel) configuration -*- lexical-binding: t -*- - -;;; Commentary: -;; cj/-namespaced configuration and helpers layered on the forked `signel' -;; package, a Signal client that drives signal-cli over JSON-RPC. -;; -;; This file currently holds the pure, signal-cli-independent helper layer -;; that the fork edits and `use-package' wiring build on: -;; - contact-list parsing for a completing-read contact picker, and -;; - the predicate that suppresses a notification for the chat the user -;; is actively viewing. -;; Both are unit-tested without a linked account. The use-package wiring, -;; keybindings, and the signel fork edits that call these helpers land once -;; signal-cli is installed and the device is linked. - -;;; Code: - -(require 'seq) -(require 'keybindings) ;; provides cj/custom-keymap + cj/register-prefix-map -(require 'system-lib) ;; for cj/executable-find-or-warn - -(declare-function notifications-notify "notifications") - -(defun cj/signal--jstr (value) - "Return VALUE if it is a non-blank string, else nil. -Normalizes a JSON field that may arrive as nil, the empty string, or a -null sentinel symbol into a plain string-or-nil." - (and (stringp value) - (not (string-empty-p (string-trim value))) - value)) - -(defun cj/signal--combine-name (given family) - "Join GIVEN and FAMILY name parts into a trimmed full name, or nil. -Either part may be nil, the empty string, or a JSON null sentinel." - (let ((parts (delq nil (list (cj/signal--jstr given) (cj/signal--jstr family))))) - (cj/signal--jstr (mapconcat #'identity parts " ")))) - -(defun cj/signal--contact-display-name (contact) - "Return a display name for CONTACT, or nil when none is set. -CONTACT is one entry alist from signal-cli `listContacts'. Picks the -first set source in priority order: the nickname (combined nickName, or -nickGivenName+nickFamilyName), the stored contact name, the top-level -givenName+familyName, the profile givenName+familyName, then username. -signal-cli 0.14 puts givenName/familyName at the top level; the profile -sub-object's name fields are usually null, so it is the deeper fallback." - (let ((profile (alist-get 'profile contact))) - (seq-find - #'cj/signal--jstr - (list (cj/signal--jstr (alist-get 'nickName contact)) - (cj/signal--combine-name (alist-get 'nickGivenName contact) - (alist-get 'nickFamilyName contact)) - (cj/signal--jstr (alist-get 'name contact)) - (cj/signal--combine-name (alist-get 'givenName contact) - (alist-get 'familyName contact)) - (cj/signal--combine-name (alist-get 'givenName profile) - (alist-get 'familyName profile)) - (cj/signal--jstr (alist-get 'username contact)))))) - -(defun cj/signal--parse-contacts (result) - "Parse RESULT from signal-cli `listContacts' into a completing-read alist. -RESULT is the JSON-RPC result value: a sequence (list or vector) of -contact alists. Returns an alist of (LABEL . RECIPIENT) sorted by LABEL, -where RECIPIENT is the contact's phone number (falling back to its UUID) -and LABEL is \"Name (recipient)\" when a name is known, or the bare -recipient otherwise. Contacts with no usable recipient are dropped." - (let (pairs) - (dolist (contact (append result nil)) - (let ((recipient (or (cj/signal--jstr (alist-get 'number contact)) - (cj/signal--jstr (alist-get 'uuid contact)))) - (name (cj/signal--contact-display-name contact))) - (when recipient - (push (cons (if name (format "%s (%s)" name recipient) recipient) - recipient) - pairs)))) - (sort pairs (lambda (a b) (string-lessp (car a) (car b)))))) - -(defun cj/signal--chat-buffer-name (id) - "Return the chat buffer name `signel' uses for chat ID." - (format "*Signel: %s*" id)) - -(defun cj/signal--suppress-notify-p (chat-id viewing-buffer-name frame-focused) - "Return non-nil when a notification for CHAT-ID should be suppressed. -Suppress only while the user is actively viewing that chat: the chat -buffer named by `cj/signal--chat-buffer-name' is VIEWING-BUFFER-NAME and -FRAME-FOCUSED is non-nil. A nil VIEWING-BUFFER-NAME or an unfocused -frame never suppresses." - (and frame-focused - (stringp viewing-buffer-name) - (string= viewing-buffer-name (cj/signal--chat-buffer-name chat-id)))) - -(defun cj/signal--frame-focused-p () - "Return non-nil when the selected frame currently has input focus. -Treats an unknown focus state as focused." - (if (fboundp 'frame-focus-state) - (let ((state (frame-focus-state))) - (if (eq state 'unknown) t state)) - t)) - -(defun cj/signal--should-notify-p (chat-id) - "Return non-nil when an incoming message for CHAT-ID should notify. -Notify unless the user is actively viewing that chat in the selected -window of a focused frame." - (not (cj/signal--suppress-notify-p - chat-id - (buffer-name (window-buffer (selected-window))) - (cj/signal--frame-focused-p)))) - -;;; Notifications - -(defcustom cj/signel-notify-sound nil - "When non-nil, incoming-message notifications play the notify script's sound. -Nil (the default) passes --silent so the toast is visual only." - :type 'boolean - :group 'signel) - -(defconst cj/signal--notify-body-max 120 - "Maximum character length of a desktop-notification body. -Longer message text truncates to this length ending in an ellipsis; -the full text is always in the chat buffer.") - -(defun cj/signal--format-notify-body (text) - "Collapse whitespace in TEXT and truncate it for a notification body. -Whitespace runs (including newlines) become single spaces, the result -is trimmed, and anything over `cj/signal--notify-body-max' characters -truncates to that length with a trailing ellipsis." - (let ((flat (string-trim (replace-regexp-in-string "[ \t\n\r]+" " " text)))) - (if (<= (length flat) cj/signal--notify-body-max) - flat - (concat (substring flat 0 (1- cj/signal--notify-body-max)) "…")))) - -(defun cj/signel--notify (chat-id sender body) - "Raise a desktop notification for an incoming Signal message. -Suppressed via `cj/signal--should-notify-p' when the user is actively -viewing CHAT-ID. Routes through the external notify script when it is -on PATH (type info, sound gated by `cj/signel-notify-sound'), falling -back to `notifications-notify' otherwise. SENDER names the title; -BODY is formatted by `cj/signal--format-notify-body'. Installed as -`signel-notify-function' in the use-package :config below." - (when (cj/signal--should-notify-p chat-id) - (let ((title (format "Signal: %s" sender)) - (text (cj/signal--format-notify-body body)) - (script (executable-find "notify"))) - (if script - (apply #'start-process "signel-notify" nil script "info" title text - (unless cj/signel-notify-sound (list "--silent"))) - ;; notifications.el is not autoloaded; load it on the first fallback. - (unless (fboundp 'notifications-notify) - (require 'notifications)) - (notifications-notify :title title :body text))))) - -;;; signel — fork integration - -(defcustom cj/signal-private-config-file - (expand-file-name "signal-config.local.el" user-emacs-directory) - "Private signal-config file, loaded when readable. -This is the place to set `signel-account' to the linked phone number so -the number stays out of the version-controlled (and publicly mirrored) -config. A phone number is an identifier rather than a credential, so it -lives here rather than in authinfo, which avoids a GPG prompt at connect -time." - :type 'file - :group 'signel) - -(use-package signel - :load-path "~/code/signel" - :ensure nil - :commands (signel-start signel-stop signel-chat signel-dashboard) - :custom - ;; Don't let an incoming message steal a window by auto-popping its chat - ;; buffer; surface arrivals through notifications instead (see child task - ;; "Notify only for the unviewed conversation"). - (signel-auto-open-buffer nil) - :config - (when (file-readable-p cj/signal-private-config-file) - (load cj/signal-private-config-file nil t)) - ;; Route incoming-message notifications through cj/signel--notify - ;; (suppression + notify script + truncation); warn once at load when - ;; the script is missing — the runtime path still falls back to - ;; notifications-notify, so messages are never silently dropped. - (setq signel-notify-function #'cj/signel--notify) - (cj/executable-find-or-warn "notify" "Signal desktop notifications via the notify script (falling back to notifications-notify)" 'signal-config)) - -;; Chat buffers (named `*Signel: *') open in the bottom 30% of the -;; frame rather than wherever display-buffer's fallback rule picks. -;; The fork's `signel-chat' uses `pop-to-buffer', so this entry applies. -(add-to-list - 'display-buffer-alist - '("\\`\\*Signel: " - (display-buffer-reuse-window display-buffer-at-bottom) - (window-height . 0.3) - (reusable-frames . nil))) - -;;; Connection guard, contact fetch, and cache - -;; Forward declarations: signel.el is loaded by the use-package above (with -;; :load-path on the fork), but the byte-compiler doesn't see those symbols -;; statically. Declaring them keeps the compile clean without changing -;; runtime behavior. -(defvar signel-account) -(defvar signel--process-name) -(declare-function signel-start "signel" ()) -(declare-function signel--send-rpc "signel" (method params &optional target-buffer success-callback)) - -(defvar cj/signel--contact-cache nil - "Contact-picker cache: nil (cold), `empty', or a `(LABEL . RECIPIENT)' alist. -Populated by `cj/signel--fetch-contacts' on first invocation (or after a -`cj/signel-refresh-contacts'). A fetched-and-empty account caches the -symbol `empty' rather than nil, so it reads as warm and the picker does -not re-run its blocking fetch on every open -- read through -`cj/signel--cached-contacts'. Cleared back to cold by -`cj/signel--clear-contact-cache', advised onto `signel-stop' so a stale -list can't survive a reconnect. In-memory only.") - -(defun cj/signel--clear-contact-cache (&rest _) - "Return the contact cache to cold (nil) so the next picker refetches. -Advised `:after' `signel-stop': a relink or reconnect may change the -contact list, so a cache from the previous connection must not survive." - (setq cj/signel--contact-cache nil)) - -(advice-add 'signel-stop :after #'cj/signel--clear-contact-cache) - -(defun cj/signel--cached-contacts () - "Return the cached contact alist, treating the `empty' sentinel as none." - (unless (eq cj/signel--contact-cache 'empty) - cj/signel--contact-cache)) - -(defcustom cj/signel-fetch-timeout 3.0 - "Seconds the picker blocks on `accept-process-output' for a cold-cache fetch. -On warm cache the picker opens instantly; on cold cache it kicks off a -fetch and waits up to this many seconds for the RPC result before -reporting a `user-error' so a dead or wedged daemon can't hang Emacs." - :type 'number - :group 'signel) - -(defun cj/signel--ensure-started () - "Ensure the signel daemon is live, starting it if needed. -Three branches: -- The process is already live -- no-op, return nil. -- `signel-account' is set but no live process exists -- call `signel-start' - and pre-warm the contact cache with a background `listContacts' fetch so - the picker is instant on first use. -- `signel-account' is nil -- `user-error' naming the remedy (set the - account in `cj/signal-private-config-file'). - -If startup launches but the RPC handshake exits before the first response, -the subsequent `signel--send-rpc' call (in the pre-warm or any later -fetch) signals through its own error path; check =*signel-log*= and -=*signel-stderr*= for detail and link the account manually. - -Loads the `signel' feature explicitly before reading any of its -private variables: the use-package above autoloads only on -`signel-start' / `signel-stop' / `signel-chat' / `signel-dashboard', -so without this require the first branch's read of `signel--process-name' -fires a void-variable error before the autoload would trigger." - (require 'signel) - (cond - ((process-live-p (get-process signel--process-name)) - nil) - ((null signel-account) - (user-error - "signel-account is unset. Set it in %s (or your private config) and link the device manually with `signal-cli link', then retry" - cj/signal-private-config-file)) - (t - (signel-start) - (cj/signel--fetch-contacts)))) - -(defun cj/signel--fetch-contacts (&optional after-callback) - "Fetch the contact list from signal-cli and populate `cj/signel--contact-cache'. -Issues a `listContacts' RPC and registers a success callback that runs -the result through `cj/signal--parse-contacts' (the verified parser) and -stores the resulting `(LABEL . RECIPIENT)' alist in the cache. An empty -result caches the `empty' sentinel -- nil would read as a cold cache and -re-run the picker's blocking fetch on every open. A failure goes -through the dispatch error path and never invokes the callback, so the -prior cache survives. - -AFTER-CALLBACK, when non-nil, is invoked with no arguments after the -cache has been populated -- the picker uses this to unblock its -bounded-wait on cold caches." - (signel--send-rpc - "listContacts" nil nil - (lambda (result) - (setq cj/signel--contact-cache - (or (cj/signal--parse-contacts result) 'empty)) - (when after-callback (funcall after-callback))))) - -(defun cj/signel-refresh-contacts () - "Clear the picker's contact cache and refetch it from signal-cli. -Use when a contact added or renamed on the phone hasn't shown up in the -picker yet; this forces a fresh `listContacts' rather than reading the -cached snapshot." - (interactive) - (setq cj/signel--contact-cache nil) - (cj/signel--fetch-contacts)) - -;;; Picker, self-message, and connect - -(declare-function signel-chat "signel" (recipient)) -(declare-function signel-dashboard "signel" ()) -(declare-function signel-stop "signel" ()) - -(defun cj/signel-connect () - "Connect to signal-cli, starting the daemon if needed. -Thin interactive wrapper around `cj/signel--ensure-started' so the -keymap has a friendly verb to bind." - (interactive) - (cj/signel--ensure-started) - (message "Signel connected.")) - -(defun cj/signel-message () - "Pick a Signal contact by name and open the chat buffer. -Ensures the daemon is connected first (auto-starts and pre-warms on -cold start, or errors with the remedy if the account isn't set). Uses -the cached contact list when warm; on a cold cache, kicks off a fetch -and waits up to `cj/signel-fetch-timeout' seconds for the result before -raising a `user-error' so a dead daemon can't hang Emacs. The picker -offers a pinned \"Note to Self\" entry plus every Signal contact, and -opens the chosen recipient in `signel-chat'." - (interactive) - (cj/signel--ensure-started) - (unless cj/signel--contact-cache - (let ((done nil) - (deadline (+ (float-time) cj/signel-fetch-timeout))) - (cj/signel--fetch-contacts (lambda () (setq done t))) - (while (and (not done) (< (float-time) deadline)) - (accept-process-output nil 0.1)) - (unless done - (user-error - "Signal contact fetch timed out after %.1fs; try again or run M-x cj/signel-refresh-contacts (see *signel-log* for detail)" - cj/signel-fetch-timeout)))) - (let* ((note-self (cons "Note to Self" signel-account)) - (candidates (cons note-self (cj/signel--cached-contacts))) - (table (lambda (string pred action) - (if (eq action 'metadata) - `(metadata - (category . signal-contact) - (annotation-function - . ,(lambda (cand) - (let ((r (cdr (assoc cand candidates)))) - (when r - (concat " " (propertize r 'face 'completions-annotations)))))) - (display-sort-function . identity) - (cycle-sort-function . identity)) - (complete-with-action action candidates string pred)))) - (label (completing-read "Signal recipient: " table nil t)) - (recipient (cdr (assoc label candidates)))) - (when recipient - (signel-chat recipient)))) - -(defun cj/signel-message-self () - "Open a Signal chat buffer addressed to Note to Self. -Resolves to `signel-account' (the linked phone number). Sending to it -lands in the Signal Note-to-Self thread on the phone; manual-verify -that on first use." - (interactive) - (cj/signel--ensure-started) - (unless signel-account - (user-error "signel-account is unset; cannot send to self")) - (signel-chat signel-account)) - -(defvar cj/signel-prefix-map - (let ((map (make-sparse-keymap))) - (keymap-set map "m" #'cj/signel-message) - (keymap-set map "s" #'cj/signel-message-self) - (keymap-set map "d" #'signel-dashboard) - (keymap-set map "q" #'signel-stop) - (keymap-set map "SPC" #'cj/signel-connect) - map) - "Signel \"Messages\" prefix keymap, bound under `C-; M'. -Leaves =l= unbound for now -- the future =cj/signel-link= command lands -in a later pass. See =docs/specs/signal-client-spec-doing.org= scope summary.") - -;; Register the messages prefix under C-; M via the documented helper. -;; keybindings.el owns cj/custom-keymap; the (require 'keybindings) above -;; guarantees it is loaded before this runs, so no load-order guard is -;; needed. This is the same pattern every other feature module uses. -(cj/register-prefix-map "M" cj/signel-prefix-map "signal messages") - -(provide 'signal-config) -;;; signal-config.el ends here diff --git a/tests/test-dashboard-config-launchers.el b/tests/test-dashboard-config-launchers.el index 53c46caa..76fbcc42 100644 --- a/tests/test-dashboard-config-launchers.el +++ b/tests/test-dashboard-config-launchers.el @@ -28,20 +28,21 @@ ;; Telegram moved from "g" to "G" so "g" is free for dashboard refresh. ;; Signal ("S") added as the 14th launcher. ;; Weather ("w") added after Agenda as the 15th launcher (top-row daily glance). -(defconst test-dash--keys '("c" "d" "t" "a" "w" "r" "b" "f" "m" "e" "i" "G" "s" "l" "S")) +(defconst test-dash--keys '("c" "d" "t" "a" "w" "r" "b" "f" "m" "e" "i" "G" "s" "l")) ;; ----------------------------- launcher table -------------------------------- (ert-deftest test-dashboard-launchers-keys-in-order () - "Normal: 15 launchers with the expected keys in display order." - (should (= 15 (length cj/dashboard--launchers))) + "Normal: 14 launchers with the expected keys in display order. +(Signal left the table when the signel client was retired to archive/.)" + (should (= 14 (length cj/dashboard--launchers))) (should (equal test-dash--keys (mapcar (lambda (l) (nth 0 l)) cj/dashboard--launchers)))) (ert-deftest test-dashboard-launchers-labels-in-order () "Normal: labels in display order (Telegram and Slack reordered so Slack sits next to Linear on the last navigator row)." (should (equal '("Code" "Files" "Terminal" "Agenda" "Weather" "Feeds" "Books" - "Flashcards" "Music" "Email" "IRC" "Telegram" "Slack" "Linear" "Signal") + "Flashcards" "Music" "Email" "IRC" "Telegram" "Slack" "Linear") (mapcar (lambda (l) (nth 3 l)) cj/dashboard--launchers)))) (ert-deftest test-dashboard-row-sizes-cover-all-launchers () @@ -51,9 +52,9 @@ next to Linear on the last navigator row)." ;; --------------------------- navigator rows ---------------------------------- -(ert-deftest test-dashboard-navigator-rows-grouped-5-4-3-3 () - "Normal: navigator derives rows per `cj/dashboard--row-sizes' (5 4 3 3), with -Weather joining the top row and Slack, Linear, and Signal sharing the last row." +(ert-deftest test-dashboard-navigator-rows-grouped-5-4-3-2 () + "Normal: navigator derives rows per `cj/dashboard--row-sizes' (5 4 3 2), with +Weather joining the top row and Slack and Linear pairing on the last row." (cl-letf (((symbol-function 'nerd-icons-faicon) (lambda (n &rest _) (concat "I:" n))) ((symbol-function 'nerd-icons-devicon) (lambda (n &rest _) (concat "I:" n))) ((symbol-function 'nerd-icons-mdicon) (lambda (n &rest _) (concat "I:" n))) @@ -62,10 +63,10 @@ Weather joining the top row and Slack, Linear, and Signal sharing the last row." ((symbol-function 'nerd-icons-wicon) (lambda (n &rest _) (concat "I:" n)))) (let ((rows (cj/dashboard--navigator-rows))) (should (= 4 (length rows))) - (should (equal '(5 4 3 3) (mapcar #'length rows))) + (should (equal '(5 4 3 2) (mapcar #'length rows))) (should (equal '("Code" "Files" "Terminal" "Agenda" "Weather") (mapcar (lambda (b) (nth 1 b)) (nth 0 rows)))) - (should (equal '("Slack" "Linear" "Signal") + (should (equal '("Slack" "Linear") (mapcar (lambda (b) (nth 1 b)) (nth 3 rows)))) (let ((btn (car (car rows)))) ; (icon label tooltip action nil " " "") (should (string= "I:nf-fa-code" (nth 0 btn))) @@ -100,7 +101,6 @@ Weather joining the top row and Slack, Linear, and Signal sharing the last row." ((symbol-function 'cj/slack-start) (lambda (&rest _) (push 'slack calls))) ((symbol-function 'cj/telega) (lambda (&rest _) (push 'tg calls))) ((symbol-function 'pearl-list-issues) (lambda (&rest _) (push 'linear calls))) - ((symbol-function 'cj/signel-message) (lambda (&rest _) (push 'signal calls))) ;; wttrin is invoked via `call-interactively', so the stub must be ;; a command -- a plain variadic lambda masked the real arity bug. ((symbol-function 'wttrin) (lambda (&rest _) (interactive) (push 'weather calls)))) @@ -112,9 +112,8 @@ Weather joining the top row and Slack, Linear, and Signal sharing the last row." (should (memq 'linear calls)) (should (memq 'm-toggle calls)) (should (memq 'm-load calls)) - (should (memq 'signal calls)) (should (memq 'weather calls)) - (should (= 16 (length calls)))))) ; 15 keys, Music fires two + (should (= 15 (length calls)))))) ; 14 keys, Music fires two (provide 'test-dashboard-config-launchers) ;;; test-dashboard-config-launchers.el ends here diff --git a/tests/test-signal-config--contact-cache.el b/tests/test-signal-config--contact-cache.el deleted file mode 100644 index 720e0c3a..00000000 --- a/tests/test-signal-config--contact-cache.el +++ /dev/null @@ -1,61 +0,0 @@ -;;; test-signal-config--contact-cache.el --- Contact-cache lifecycle tests -*- lexical-binding: t; -*- - -;;; Commentary: -;; The picker's contact cache has two lifecycle bugs from the 2026-06 config -;; audit: (1) its docstring promised clearing on signel-stop but nothing -;; cleared it, so a stale list survived a relink/reconnect; (2) a -;; fetched-and-empty list was cached as nil, indistinguishable from a cold -;; cache, so a zero-contact account re-ran the blocking fetch (up to -;; `cj/signel-fetch-timeout') on every picker open. The fix names the empty -;; state with an `empty' sentinel and clears the cache via a named function -;; advised onto `signel-stop'. -;; -;; Boundary mocks only (the RPC send, the prompt); the cache logic runs -;; real. The ensure-started branches the audit called untested were -;; already covered in test-signal-config.el -- that claim was stale. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'signal-config) - -;;; ------------------------- clear-on-stop (F1) ------------------------------ - -(ert-deftest test-signal-config-clear-contact-cache-resets () - "Normal: the clear function empties the cache back to cold (nil)." - (let ((cj/signel--contact-cache '(("Al (+1)" . "+1")))) - (cj/signel--clear-contact-cache) - (should (null cj/signel--contact-cache)))) - -(ert-deftest test-signal-config-clear-contact-cache-advises-stop () - "Normal: `signel-stop' carries the clear advice, so the docstring's -\"cleared on stop/restart\" promise is real." - (should (advice-member-p #'cj/signel--clear-contact-cache 'signel-stop))) - -;;; ------------------------- empty sentinel (F2) ------------------------------ - -(ert-deftest test-signal-config-fetch-empty-caches-sentinel () - "Boundary: a fetched-and-empty list caches the `empty' sentinel, not nil. -nil means cold cache; without the sentinel a zero-contact account re-ran -the blocking fetch on every picker open." - (let ((cj/signel--contact-cache nil) - (captured-callback nil)) - (cl-letf (((symbol-function 'signel--send-rpc) - (lambda (_method _params _buf callback) - (setq captured-callback callback)))) - (cj/signel--fetch-contacts) - (funcall captured-callback '())) - (should (eq cj/signel--contact-cache 'empty)))) - -(ert-deftest test-signal-config-cached-contacts-unwraps-sentinel () - "Normal: the cache reader returns the alist, and nil for the sentinel." - (let ((cj/signel--contact-cache '(("Al (+1)" . "+1")))) - (should (equal (cj/signel--cached-contacts) '(("Al (+1)" . "+1"))))) - (let ((cj/signel--contact-cache 'empty)) - (should (null (cj/signel--cached-contacts))))) - -(provide 'test-signal-config--contact-cache) -;;; test-signal-config--contact-cache.el ends here diff --git a/tests/test-signal-config-notify.el b/tests/test-signal-config-notify.el deleted file mode 100644 index 1a772289..00000000 --- a/tests/test-signal-config-notify.el +++ /dev/null @@ -1,150 +0,0 @@ -;;; test-signal-config-notify.el --- Tests for the signal-config notification slice -*- lexical-binding: t -*- - -;;; Commentary: -;; ERT tests for the notification slice of `signal-config': the pure -;; body formatter (whitespace collapse + truncation to -;; `cj/signal--notify-body-max') and `cj/signel--notify' routing (the -;; suppression gate, the notify-script path with the sound flag, and -;; the `notifications-notify' fallback). Spec: the "Notification -;; slice" addendum in docs/specs/signal-client-spec-doing.org. No signal-cli or -;; linked account needed. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -;; signel is the fork at ~/code/signel; signal-config wires it via -;; use-package but these tests need the symbols available directly. -(eval-and-compile - (add-to-list 'load-path (expand-file-name "~/code/signel"))) -(require 'signel) - -(require 'signal-config) - -;;; cj/signal--format-notify-body - -(ert-deftest test-signal-config-format-notify-body-passthrough () - "Normal: short single-line text passes through unchanged." - (should (equal (cj/signal--format-notify-body "lunch at noon?") - "lunch at noon?"))) - -(ert-deftest test-signal-config-format-notify-body-collapses-whitespace () - "Normal: newlines and whitespace runs collapse to single spaces." - (should (equal (cj/signal--format-notify-body "two\nlines\n\nhere") - "two lines here")) - (should (equal (cj/signal--format-notify-body "tabs\t\tand spaces") - "tabs and spaces"))) - -(ert-deftest test-signal-config-format-notify-body-trims () - "Boundary: leading and trailing whitespace is trimmed." - (should (equal (cj/signal--format-notify-body " hi ") "hi"))) - -(ert-deftest test-signal-config-format-notify-body-empty () - "Boundary: the empty string stays empty." - (should (equal (cj/signal--format-notify-body "") ""))) - -(ert-deftest test-signal-config-format-notify-body-exact-limit () - "Boundary: a body exactly at the limit is untouched." - (let ((s (make-string cj/signal--notify-body-max ?x))) - (should (equal (cj/signal--format-notify-body s) s)))) - -(ert-deftest test-signal-config-format-notify-body-truncates-over-limit () - "Boundary: over-limit text truncates to the limit, ending in an ellipsis." - (let* ((s (make-string (1+ cj/signal--notify-body-max) ?x)) - (out (cj/signal--format-notify-body s))) - (should (= (length out) cj/signal--notify-body-max)) - (should (string-suffix-p "…" out)))) - -(ert-deftest test-signal-config-format-notify-body-unicode () - "Boundary: multibyte text truncates by characters, not bytes." - (let* ((s (make-string (+ cj/signal--notify-body-max 10) ?é)) - (out (cj/signal--format-notify-body s))) - (should (= (length out) cj/signal--notify-body-max)) - (should (string-suffix-p "…" out)))) - -;;; cj/signel--notify routing - -(ert-deftest test-signal-config-notify-suppressed-when-viewing () - "Normal: nothing fires when the suppression predicate says no." - (let (script-calls fallback-calls) - (cl-letf (((symbol-function 'cj/signal--should-notify-p) - (lambda (_chat-id) nil)) - ((symbol-function 'start-process) - (lambda (&rest args) (push args script-calls) nil)) - ((symbol-function 'notifications-notify) - (lambda (&rest args) (push args fallback-calls) nil))) - (cj/signel--notify "+15551234567" "Alice" "hi")) - (should-not script-calls) - (should-not fallback-calls))) - -(ert-deftest test-signal-config-notify-script-silent-by-default () - "Normal: with the script present and sound off, runs notify info --silent." - (let (script-calls) - (cl-letf (((symbol-function 'cj/signal--should-notify-p) - (lambda (_chat-id) t)) - ((symbol-function 'executable-find) - (lambda (p &optional _remote) - (when (equal p "notify") "/usr/bin/notify"))) - ((symbol-function 'start-process) - (lambda (&rest args) (push args script-calls) nil)) - ((symbol-function 'notifications-notify) - (lambda (&rest _) - (error "Fallback must not fire when the script is present")))) - (let ((cj/signel-notify-sound nil)) - (cj/signel--notify "+15551234567" "Alice" "hi"))) - (should (= (length script-calls) 1)) - ;; start-process args: (NAME BUFFER PROGRAM &rest PROGRAM-ARGS); - ;; PROGRAM is the path executable-find resolved, not the bare name. - (should (equal (nthcdr 2 (car script-calls)) - '("/usr/bin/notify" "info" "Signal: Alice" "hi" "--silent"))))) - -(ert-deftest test-signal-config-notify-sound-enabled-drops-silent () - "Normal: with `cj/signel-notify-sound' non-nil, --silent is omitted." - (let (script-calls) - (cl-letf (((symbol-function 'cj/signal--should-notify-p) - (lambda (_chat-id) t)) - ((symbol-function 'executable-find) - (lambda (p &optional _remote) - (when (equal p "notify") "/usr/bin/notify"))) - ((symbol-function 'start-process) - (lambda (&rest args) (push args script-calls) nil))) - (let ((cj/signel-notify-sound t)) - (cj/signel--notify "+15551234567" "Alice" "hi"))) - (should (equal (nthcdr 2 (car script-calls)) - '("/usr/bin/notify" "info" "Signal: Alice" "hi"))))) - -(ert-deftest test-signal-config-notify-fallback-when-script-missing () - "Error: without the script on PATH, falls back to notifications-notify." - (let (script-calls fallback-calls) - (cl-letf (((symbol-function 'cj/signal--should-notify-p) - (lambda (_chat-id) t)) - ((symbol-function 'executable-find) - (lambda (_p &optional _remote) nil)) - ((symbol-function 'start-process) - (lambda (&rest args) (push args script-calls) nil)) - ((symbol-function 'notifications-notify) - (lambda (&rest args) (push args fallback-calls) nil))) - (cj/signel--notify "+15551234567" "Alice" "hi")) - (should-not script-calls) - (should (= (length fallback-calls) 1)) - (let ((args (car fallback-calls))) - (should (equal (plist-get args :title) "Signal: Alice")) - (should (equal (plist-get args :body) "hi"))))) - -(ert-deftest test-signal-config-notify-formats-body-before-send () - "Normal: the body runs through the formatter before reaching the script." - (let (script-calls) - (cl-letf (((symbol-function 'cj/signal--should-notify-p) - (lambda (_chat-id) t)) - ((symbol-function 'executable-find) - (lambda (p &optional _remote) - (when (equal p "notify") "/usr/bin/notify"))) - ((symbol-function 'start-process) - (lambda (&rest args) (push args script-calls) nil))) - (let ((cj/signel-notify-sound nil)) - (cj/signel--notify "+15551234567" "Alice" "first line\nsecond line"))) - (should (equal (nth 5 (car script-calls)) "first line second line")))) - -(provide 'test-signal-config-notify) -;;; test-signal-config-notify.el ends here diff --git a/tests/test-signal-config.el b/tests/test-signal-config.el deleted file mode 100644 index f8bf4410..00000000 --- a/tests/test-signal-config.el +++ /dev/null @@ -1,410 +0,0 @@ -;;; test-signal-config.el --- Tests for signal-config -*- lexical-binding: t -*- - -;;; Commentary: -;; ERT tests for the pure helper layer of `signal-config': contact-list -;; parsing for the contact picker, and the notify-when-not-viewing -;; predicate. These need neither signal-cli nor a linked account. - -;;; Code: - -(require 'ert) -(require 'cl-lib) -(require 'json) - -;; signel is the fork at ~/code/signel; signal-config wires it via -;; use-package but the connection-guard/fetch tests need the symbols -;; available directly. -(eval-and-compile - (add-to-list 'load-path (expand-file-name "~/code/signel"))) -(require 'signel) - -(require 'signal-config) - -;;; cj/signal--jstr - -(ert-deftest test-signal-config-jstr-string () - "Normal: a non-blank string passes through unchanged." - (should (equal (cj/signal--jstr "hi") "hi"))) - -(ert-deftest test-signal-config-jstr-rejects-nonstrings () - "Boundary/Error: nil, empty, whitespace, and non-string sentinels become nil." - (should-not (cj/signal--jstr nil)) - (should-not (cj/signal--jstr "")) - (should-not (cj/signal--jstr " ")) - (should-not (cj/signal--jstr :null)) - (should-not (cj/signal--jstr 42))) - -;;; cj/signal--contact-display-name -;; Field priority: nickName, then nickGiven+nickFamily, then top-level name, -;; then top-level given+family, then profile given+family, then username. - -(ert-deftest test-signal-config-display-name-prefers-name () - "Normal: the top-level combined name wins over the given/family parts." - (should (equal (cj/signal--contact-display-name - '((name . "Alice Anderson") (givenName . "Ali") (familyName . "A"))) - "Alice Anderson"))) - -(ert-deftest test-signal-config-display-name-nickname-wins () - "Normal: a nickName overrides the contact name." - (should (equal (cj/signal--contact-display-name - '((nickName . "Edster") (name . "Eve Edwards") - (givenName . "Eve") (familyName . "Edwards"))) - "Edster"))) - -(ert-deftest test-signal-config-display-name-nickname-parts () - "Boundary: nickGivenName+nickFamilyName combine when nickName is unset." - (should (equal (cj/signal--contact-display-name - '((nickGivenName . "DJ") (nickFamilyName . "Cool") (name . "Daniel"))) - "DJ Cool"))) - -(ert-deftest test-signal-config-display-name-toplevel-given-family () - "Boundary: with no name, top-level givenName+familyName combine." - (should (equal (cj/signal--contact-display-name - '((name) (givenName . "Bob") (familyName . "Brown"))) - "Bob Brown"))) - -(ert-deftest test-signal-config-display-name-profile-fallback () - "Boundary: with no name or top-level parts, profile given/family is the fallback." - (should (equal (cj/signal--contact-display-name - '((name) (givenName) (familyName) - (profile . ((givenName . "Carol") (familyName))))) - "Carol"))) - -(ert-deftest test-signal-config-display-name-username-fallback () - "Boundary: username is the last name source." - (should (equal (cj/signal--contact-display-name - '((name) (username . "dave.42"))) - "dave.42"))) - -(ert-deftest test-signal-config-display-name-none () - "Error: no usable name yields nil." - (should-not (cj/signal--contact-display-name - '((name) (givenName) (familyName) - (profile . ((givenName) (familyName)))))) - (should-not (cj/signal--contact-display-name '((number . "+15551112222"))))) - -;;; cj/signal--parse-contacts - -(defconst test-signal-config--contacts-json - "[ - {\"number\":\"+15551112222\",\"uuid\":\"uuid-a\",\"name\":\"Alice Anderson\",\"givenName\":\"Alice\",\"familyName\":\"Anderson\",\"nickName\":null,\"nickGivenName\":null,\"nickFamilyName\":null,\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}}, - {\"number\":\"+15553334444\",\"uuid\":null,\"name\":null,\"givenName\":\"Bob\",\"familyName\":\"Brown\",\"nickName\":null,\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}}, - {\"number\":\"+15555556666\",\"uuid\":\"uuid-c\",\"name\":null,\"givenName\":null,\"familyName\":null,\"nickName\":null,\"username\":null,\"profile\":{\"givenName\":\"Carol\",\"familyName\":null}}, - {\"number\":null,\"uuid\":\"uuid-d\",\"name\":null,\"givenName\":null,\"familyName\":null,\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}}, - {\"number\":\"+15557778888\",\"uuid\":\"uuid-e\",\"name\":\"Eve Edwards\",\"givenName\":\"Eve\",\"familyName\":\"Edwards\",\"nickName\":\"Edster\",\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}} -]" - "Synthetic fixture mirroring the signal-cli 0.14.4.1 `listContacts' shape: -top-level name/givenName/familyName and nickName fields, with a profile -sub-object whose name fields are usually null. Field layout was confirmed -against a live linked account on 2026-05-26; the values here are fake.") - -(ert-deftest test-signal-config-parse-contacts-normal () - "Normal: top-level name, top-level parts, profile fallback, uuid fallback, nickname." - (let* ((result (json-read-from-string test-signal-config--contacts-json)) - (pairs (cj/signal--parse-contacts result))) - (should (equal pairs - '(("Alice Anderson (+15551112222)" . "+15551112222") - ("Bob Brown (+15553334444)" . "+15553334444") - ("Carol (+15555556666)" . "+15555556666") - ("Edster (+15557778888)" . "+15557778888") - ("uuid-d" . "uuid-d")))))) - -(ert-deftest test-signal-config-parse-contacts-empty () - "Boundary: an empty result yields nil for both vector and nil input." - (should-not (cj/signal--parse-contacts [])) - (should-not (cj/signal--parse-contacts nil))) - -(ert-deftest test-signal-config-parse-contacts-accepts-list-and-vector () - "Boundary: vector and list result sequences parse identically." - (let ((entry '((number . "+15551112222") (name . "Al")))) - (should (equal (cj/signal--parse-contacts (vector entry)) - (cj/signal--parse-contacts (list entry)))))) - -(ert-deftest test-signal-config-parse-contacts-drops-recipientless () - "Error: a contact with neither number nor uuid is dropped." - (should-not (cj/signal--parse-contacts - (list '((name . "Ghost") (number) (uuid)))))) - -;;; cj/signal--suppress-notify-p - -(ert-deftest test-signal-config-suppress-when-viewing-focused () - "Normal: viewing the chat buffer with focus suppresses the notification." - (should (cj/signal--suppress-notify-p - "+15551112222" "*Signel: +15551112222*" t))) - -(ert-deftest test-signal-config-no-suppress-other-buffer () - "Boundary: a different selected buffer does not suppress." - (should-not (cj/signal--suppress-notify-p - "+15551112222" "*scratch*" t))) - -(ert-deftest test-signal-config-no-suppress-unfocused () - "Boundary: viewing the chat but with the frame unfocused still notifies." - (should-not (cj/signal--suppress-notify-p - "+15551112222" "*Signel: +15551112222*" nil))) - -(ert-deftest test-signal-config-no-suppress-nil-viewing () - "Error: a nil viewing-buffer name does not suppress." - (should-not (cj/signal--suppress-notify-p "+15551112222" nil t))) - -;;; cj/signel--ensure-started - -(ert-deftest test-signal-config-ensure-started-live-process-noop () - "Normal: with a live signel process, ensure-started returns without -calling `signel-start' or the pre-warm fetch." - (let ((start-called nil) - (fetch-called nil)) - (cl-letf (((symbol-function 'process-live-p) (lambda (_) t)) - ((symbol-function 'get-process) (lambda (_) 'fake-proc)) - ((symbol-function 'signel-start) - (lambda () (setq start-called t))) - ((symbol-function 'cj/signel--fetch-contacts) - (lambda (&rest _) (setq fetch-called t)))) - (cj/signel--ensure-started) - (should-not start-called) - (should-not fetch-called)))) - -(ert-deftest test-signal-config-ensure-started-starts-when-account-set () - "Normal: with `signel-account' set and no live process, ensure-started -calls `signel-start' to bring the daemon up." - (let ((start-called nil) - (signel-account "+15555550100")) - (cl-letf (((symbol-function 'process-live-p) (lambda (_) nil)) - ((symbol-function 'get-process) (lambda (_) nil)) - ((symbol-function 'signel-start) - (lambda () (setq start-called t))) - ((symbol-function 'cj/signel--fetch-contacts) - (lambda (&rest _) nil))) - (cj/signel--ensure-started) - (should start-called)))) - -(ert-deftest test-signal-config-ensure-started-prewarms-on-start () - "Normal: when ensure-started actually starts the daemon, it triggers a -pre-warm fetch so the picker cache is warm on first invocation." - (let ((fetch-called nil) - (signel-account "+15555550100")) - (cl-letf (((symbol-function 'process-live-p) (lambda (_) nil)) - ((symbol-function 'get-process) (lambda (_) nil)) - ((symbol-function 'signel-start) (lambda () nil)) - ((symbol-function 'cj/signel--fetch-contacts) - (lambda (&rest _) (setq fetch-called t)))) - (cj/signel--ensure-started) - (should fetch-called)))) - -(ert-deftest test-signal-config-ensure-started-errors-when-no-account () - "Error: with `signel-account' nil, ensure-started signals a user-error -naming the remedy (set the account in the private config) instead of -starting an account-less daemon." - (let ((signel-account nil)) - (cl-letf (((symbol-function 'process-live-p) (lambda (_) nil)) - ((symbol-function 'get-process) (lambda (_) nil))) - (should-error (cj/signel--ensure-started) :type 'user-error)))) - -(ert-deftest test-signal-config-ensure-started-requires-signel-first () - "Error: ensure-started must `require' signel BEFORE reading any of -its private variables, so a `void-variable' error cannot fire when -called before signel has been autoloaded. Captures the bug where -`signel--process-name' was forward-declared in signal-config but not -yet bound at runtime because the `use-package' autoload had not fired. - -Asserts ordering, not just presence: a future refactor that moves the -`require' below the `cond' would still execute the require eventually -but the variable read in the cond would fire void-variable first. -The test fails if `require' isn't the first call inside the function." - (let ((call-order nil)) - (cl-letf (((symbol-function 'require) - (lambda (feature &optional _filename _noerror) - (push (list 'require feature) call-order) - t)) - ((symbol-function 'process-live-p) - (lambda (_) (push 'process-live-p call-order) t)) - ((symbol-function 'get-process) - (lambda (_) (push 'get-process call-order) 'fake-proc))) - (cj/signel--ensure-started) - (should (equal (car (reverse call-order)) '(require signel)))))) - -;;; cj/signel--fetch-contacts + cj/signel--contact-cache - -(ert-deftest test-signal-config-fetch-contacts-issues-list-contacts-rpc () - "Normal: fetch-contacts sends a `listContacts' RPC and registers a -success callback so the response routes back." - (let (sent-method sent-callback) - (cl-letf (((symbol-function 'signel--send-rpc) - (lambda (method _params _target callback) - (setq sent-method method - sent-callback callback) - 1))) - (cj/signel--fetch-contacts)) - (should (equal sent-method "listContacts")) - (should (functionp sent-callback)))) - -(ert-deftest test-signal-config-fetch-contacts-callback-populates-cache () - "Normal: on a successful result, the callback parses the contact list -and stores the (LABEL . RECIPIENT) alist in `cj/signel--contact-cache'." - (let (sent-callback) - (cl-letf (((symbol-function 'signel--send-rpc) - (lambda (_method _params _target callback) - (setq sent-callback callback) 1))) - (setq cj/signel--contact-cache nil) - (cj/signel--fetch-contacts) - (funcall sent-callback - [((number . "+15555550100") (givenName . "Alice"))])) - (should (equal cj/signel--contact-cache - '(("Alice (+15555550100)" . "+15555550100")))))) - -(ert-deftest test-signal-config-fetch-contacts-empty-result-caches-sentinel () - "Boundary: an empty listContacts result caches the `empty' sentinel. -nil would read as a cold cache and re-run the picker's blocking fetch on -every open; the sentinel marks warm-but-empty. Still distinct from a -failure path, which never invokes the success callback. (This test -formerly pinned the nil behavior -- the 2026-06 audit's F2 bug.)" - (let (sent-callback) - (cl-letf (((symbol-function 'signel--send-rpc) - (lambda (_method _params _target callback) - (setq sent-callback callback) 1))) - (setq cj/signel--contact-cache '(("stale" . "+10000000000"))) - (cj/signel--fetch-contacts) - (funcall sent-callback [])) - (should (eq cj/signel--contact-cache 'empty)))) - -;;; cj/signel-refresh-contacts - -(ert-deftest test-signal-config-refresh-contacts-clears-and-refetches () - "Normal: `cj/signel-refresh-contacts' clears the cache and triggers a -fresh fetch so a stale entry can't survive a user-driven refresh." - (let ((fetch-called nil)) - (setq cj/signel--contact-cache '(("stale" . "+10000000000"))) - (cl-letf (((symbol-function 'cj/signel--fetch-contacts) - (lambda (&rest _) (setq fetch-called t)))) - (cj/signel-refresh-contacts)) - (should-not cj/signel--contact-cache) - (should fetch-called))) - -;;; cj/signel-message picker - -(ert-deftest test-signal-config-message-warm-cache-picks-contact () - "Normal: with a warm cache, picking a contact label opens that -recipient's chat buffer." - (let ((chosen-recipient nil) - (signel-account "+15555550100")) - (setq cj/signel--contact-cache - '(("Alice (+15555550200)" . "+15555550200"))) - (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil)) - ((symbol-function 'completing-read) - (lambda (&rest _) "Alice (+15555550200)")) - ((symbol-function 'signel-chat) - (lambda (r) (setq chosen-recipient r)))) - (cj/signel-message)) - (should (equal chosen-recipient "+15555550200")))) - -(ert-deftest test-signal-config-message-warm-cache-picks-note-to-self () - "Normal: the pinned `Note to Self' entry resolves to `signel-account' -so a self-message lands in the Signal Note-to-Self thread." - (let ((chosen-recipient nil) - (signel-account "+15555550100")) - (setq cj/signel--contact-cache - '(("Alice (+15555550200)" . "+15555550200"))) - (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil)) - ((symbol-function 'completing-read) - (lambda (&rest _) "Note to Self")) - ((symbol-function 'signel-chat) - (lambda (r) (setq chosen-recipient r)))) - (cj/signel-message)) - (should (equal chosen-recipient "+15555550100")))) - -(ert-deftest test-signal-config-message-cold-cache-fetch-resolves-in-time () - "Normal: cold cache, fetch's after-callback fires inside the bounded -wait, picker proceeds with the now-warm cache." - (let ((chosen-recipient nil) - (signel-account "+15555550100") - (cj/signel-fetch-timeout 1.0)) - (setq cj/signel--contact-cache nil) - (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil)) - ((symbol-function 'cj/signel--fetch-contacts) - (lambda (&optional after-cb) - (setq cj/signel--contact-cache - '(("Bob (+15555550300)" . "+15555550300"))) - (when after-cb (funcall after-cb)))) - ((symbol-function 'completing-read) - (lambda (&rest _) "Bob (+15555550300)")) - ((symbol-function 'signel-chat) - (lambda (r) (setq chosen-recipient r)))) - (cj/signel-message)) - (should (equal chosen-recipient "+15555550300")))) - -(ert-deftest test-signal-config-message-cold-cache-timeout-errors () - "Error: cold cache, fetch never resolves, picker user-errors before -the bounded wait would let Emacs hang on a dead daemon." - (let ((signel-account "+15555550100") - (cj/signel-fetch-timeout 0.1)) - (setq cj/signel--contact-cache nil) - (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil)) - ((symbol-function 'cj/signel--fetch-contacts) - (lambda (&rest _) nil))) - (should-error (cj/signel-message) :type 'user-error)))) - -;;; cj/signel-message-self - -(ert-deftest test-signal-config-message-self-calls-signel-chat-with-account () - "Normal: the direct self-message command opens a chat buffer addressed -to `signel-account', skipping the picker entirely." - (let ((chosen-recipient nil) - (signel-account "+15555550100")) - (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil)) - ((symbol-function 'signel-chat) - (lambda (r) (setq chosen-recipient r)))) - (cj/signel-message-self)) - (should (equal chosen-recipient "+15555550100")))) - -;;; cj/signel-prefix-map (C-; M) - -(ert-deftest test-signal-config-prefix-map-has-expected-bindings () - "Normal: the signel C-; M prefix map binds m / s / d / q / SPC to the -commands the workflow spec names." - (should (eq (keymap-lookup cj/signel-prefix-map "m") - #'cj/signel-message)) - (should (eq (keymap-lookup cj/signel-prefix-map "s") - #'cj/signel-message-self)) - (should (eq (keymap-lookup cj/signel-prefix-map "d") - #'signel-dashboard)) - (should (eq (keymap-lookup cj/signel-prefix-map "q") - #'signel-stop)) - (should (eq (keymap-lookup cj/signel-prefix-map "SPC") - #'cj/signel-connect))) - -(ert-deftest test-signal-config-prefix-map-registered-under-c-semi-m () - "Normal: loading signal-config registers `cj/signel-prefix-map' under -`M' in `cj/custom-keymap', so C-; M reaches the signel prefix. Guards -the wiring contract that the load-order bug broke: signal-config must -register through `cj/register-prefix-map', not a boundp-guarded direct -mutation that silently no-ops when keybindings loaded in a different -order." - (require 'keybindings) - (should (eq (keymap-lookup cj/custom-keymap "M") cj/signel-prefix-map))) - -;;; display-buffer-alist entry for *Signel: ...* chat buffers - -(ert-deftest test-signal-config-chat-buffer-display-rule-uses-bottom-30 () - "Normal: signal-config registers a `display-buffer-alist' entry that -matches `*Signel: *' buffers, routes them through -`display-buffer-at-bottom', and sets `window-height' to 0.3 so the -chat docks to the bottom 30% of the frame." - (let ((entry (seq-find (lambda (e) (equal (car e) "\\`\\*Signel: ")) - display-buffer-alist))) - (should entry) - (should (memq 'display-buffer-at-bottom (cadr entry))) - (should (equal 0.3 (cdr (assq 'window-height (cddr entry))))))) - -(ert-deftest test-signal-config-chat-buffer-display-rule-matches-buffer-name () - "Boundary: the registered regex matches a realistic chat buffer name -\(phone-number id and group-id) and does not match unrelated buffers." - (let* ((entry (seq-find (lambda (e) (equal (car e) "\\`\\*Signel: ")) - display-buffer-alist)) - (regex (car entry))) - (should regex) - (should (string-match-p regex "*Signel: +15555550100*")) - (should (string-match-p regex "*Signel: groupid-abc*")) - (should-not (string-match-p regex "*signel-log*")) - (should-not (string-match-p regex "scratch")))) - -(provide 'test-signal-config) -;;; test-signal-config.el ends here diff --git a/tests/test-signel-cancel-input.el b/tests/test-signel-cancel-input.el deleted file mode 100644 index b2a7ef89..00000000 --- a/tests/test-signel-cancel-input.el +++ /dev/null @@ -1,74 +0,0 @@ -;;; test-signel-cancel-input.el --- Cancel-input contract for the signel fork -*- lexical-binding: t; -*- - -;;; Commentary: -;; `signel--cancel-input' is the C-c C-k handler in `signel-chat-mode'. -;; Its contract: clear any in-progress input between `signel--input-marker' -;; and `point-max' (so the prompt is fresh on next visit), then dismiss -;; the window via `quit-window' (the buffer stays alive so chat history -;; survives revisits). These tests lock the contract; the binding test -;; locks the keymap entry. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "~/code/signel"))) -(require 'signel) - -(defmacro test-signel-cancel--with-chat-buffer (&rest body) - "Set up a temp signel-chat-mode buffer with prompt drawn and run BODY." - (declare (indent 0)) - `(with-temp-buffer - (signel-chat-mode) - (setq signel--chat-id "+15555550100") - (signel--draw-prompt) - ,@body)) - -(ert-deftest test-signel-cancel-input-clears-pending-text () - "Normal: pending input from input-marker to point-max is cleared." - (test-signel-cancel--with-chat-buffer - (insert "abandoned-draft") - (cl-letf (((symbol-function 'quit-window) (lambda (&rest _) nil))) - (signel--cancel-input)) - (should-not (signel--pending-input)))) - -(ert-deftest test-signel-cancel-input-empty-input-area-is-a-noop () - "Boundary: cancelling with no in-progress input is harmless." - (test-signel-cancel--with-chat-buffer - (cl-letf (((symbol-function 'quit-window) (lambda (&rest _) nil))) - (signel--cancel-input)) - (should-not (signel--pending-input)))) - -(ert-deftest test-signel-cancel-input-calls-quit-window () - "Normal: cancel dismisses the window via `quit-window'." - (test-signel-cancel--with-chat-buffer - (insert "abandoned-draft") - (let ((called nil)) - (cl-letf (((symbol-function 'quit-window) - (lambda (&rest _) (setq called t)))) - (signel--cancel-input)) - (should called)))) - -(ert-deftest test-signel-cancel-input-preserves-buffer () - "Normal: cancel does not kill the buffer; chat history (prompt + prior -content above the input marker) survives so reopening the contact lands -in the same buffer." - (test-signel-cancel--with-chat-buffer - (insert "abandoned-draft") - (let ((buf (current-buffer))) - (cl-letf (((symbol-function 'quit-window) (lambda (&rest _) nil))) - (signel--cancel-input)) - (should (buffer-live-p buf))))) - -(ert-deftest test-signel-chat-mode-binds-c-c-c-k-to-cancel () - "Normal: `signel-chat-mode' binds C-c C-k to `signel--cancel-input' so -the documented cancel gesture reaches the handler." - (with-temp-buffer - (signel-chat-mode) - (should (eq (lookup-key (current-local-map) (kbd "C-c C-k")) - #'signel--cancel-input)))) - -(provide 'test-signel-cancel-input) -;;; test-signel-cancel-input.el ends here diff --git a/tests/test-signel-input-preservation.el b/tests/test-signel-input-preservation.el deleted file mode 100644 index e8ce4ddb..00000000 --- a/tests/test-signel-input-preservation.el +++ /dev/null @@ -1,68 +0,0 @@ -;;; test-signel-input-preservation.el --- Regression for signel #2 input clobber -*- lexical-binding: t; -*- - -;;; Commentary: -;; signel-chat-mode buffers have an editable prompt area starting at -;; `signel--input-marker'. Before this fix, both `signel--insert-msg' (the -;; receive path) and `signel--insert-system-msg' (the RPC-error path) -;; called `(delete-region (point) (point-max))' to clear the old prompt -;; before redrawing it, which destroyed any text the user was mid-typing. -;; -;; These tests lock the preservation contract: a small `signel--pending-input' -;; helper captures the in-progress input from the marker to `point-max', and -;; both inserters restore it after the freshly drawn prompt. The chat-mode -;; buffer is constructed in a temp buffer; `signel--insert-msg' is steered -;; to it via a stub on `signel--get-buffer'. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "~/code/signel"))) -(require 'signel) - -(defmacro test-signel--with-chat-buffer (&rest body) - "Set up a temp signel-chat-mode buffer with prompt drawn and run BODY." - (declare (indent 0)) - `(with-temp-buffer - (signel-chat-mode) - (setq signel--chat-id "+15555550100") - (signel--draw-prompt) - ,@body)) - -(ert-deftest test-signel-input-pending-returns-typed-text () - "Normal: with text after the prompt marker, `signel--pending-input' -returns the captured text." - (test-signel--with-chat-buffer - (insert "halfwritten") - (should (equal (signel--pending-input) "halfwritten")))) - -(ert-deftest test-signel-input-pending-returns-nil-when-empty () - "Boundary: an empty input area returns nil so callers don't restore an -empty string after the prompt." - (test-signel--with-chat-buffer - (should-not (signel--pending-input)))) - -(ert-deftest test-signel-input-system-msg-preserves-pending-input () - "Regression for #2: `signel--insert-system-msg' redraws the prompt -without clobbering text the user was mid-typing." - (test-signel--with-chat-buffer - (insert "halfwritten") - (signel--insert-system-msg "An error happened" 'signel-error-face) - (should (string-match-p "An error happened" (buffer-string))) - (should (equal (signel--pending-input) "halfwritten")))) - -(ert-deftest test-signel-input-msg-preserves-pending-input () - "Regression for #2: `signel--insert-msg' (the receive path) redraws the -prompt without clobbering the user's in-progress input." - (test-signel--with-chat-buffer - (insert "halfwritten") - (cl-letf (((symbol-function 'signel--get-buffer) - (lambda (_) (current-buffer)))) - (signel--insert-msg "+15555550100" "Alice" "Hi there" nil nil nil)) - (should (string-match-p "Hi there" (buffer-string))) - (should (equal (signel--pending-input) "halfwritten")))) - -(provide 'test-signel-input-preservation) -;;; test-signel-input-preservation.el ends here diff --git a/tests/test-signel-notify-function.el b/tests/test-signel-notify-function.el deleted file mode 100644 index e3d97af5..00000000 --- a/tests/test-signel-notify-function.el +++ /dev/null @@ -1,89 +0,0 @@ -;;; test-signel-notify-function.el --- Tests for signel's notify-function dispatch -*- lexical-binding: t -*- - -;;; Commentary: -;; signel's receive handler (signel.el in the fork at ~/code/signel) -;; raised notifications through a hardwired `notifications-notify' -;; call. The notification slice (docs/specs/signal-client-spec-doing.org, -;; "Notification slice" addendum) replaces that with -;; `signel-notify-function', a customization point called with -;; CHAT-ID, SENDER, and BODY so a config layer can add suppression or -;; route through an external notifier. These tests cover the -;; dispatch: text, sticker, and attachment bodies reach the function -;; with the right arguments, and the default preserves the plain -;; `notifications-notify' behavior. -;; -;; `signel--handle-receive' is exercised directly with synthetic -;; envelope alists; buffer/dashboard side effects are stubbed. No -;; live process needed. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "~/code/signel"))) -(require 'signel) - -(defun test-signel-notify--receive (envelope) - "Run `signel--handle-receive' on ENVELOPE, capturing notify calls. -Returns the list of (CHAT-ID SENDER BODY) argument lists the handler -passed to `signel-notify-function', oldest first. Buffer and -dashboard side effects are stubbed out." - (let (calls) - (cl-letf (((symbol-function 'signel--insert-msg) (lambda (&rest _) nil)) - ((symbol-function 'signel--dashboard-refresh) (lambda () nil)) - ((symbol-function 'signel--get-buffer) - (lambda (_) (current-buffer)))) - (let ((signel-notify-function - (lambda (chat-id sender body) - (push (list chat-id sender body) calls))) - (signel-auto-open-buffer nil)) - (signel--handle-receive `((envelope . ,envelope))))) - (nreverse calls))) - -(ert-deftest test-signel-notify-function-text-message () - "Normal: a text dataMessage calls the function with chat-id, sender, text." - (should (equal (test-signel-notify--receive - '((sourceNumber . "+15551234567") - (sourceName . "Alice") - (dataMessage . ((message . "hi there"))))) - '(("+15551234567" "Alice" "hi there"))))) - -(ert-deftest test-signel-notify-function-sticker-placeholder () - "Boundary: a sticker with no text gets the [Sticker] placeholder body." - (should (equal (test-signel-notify--receive - '((sourceNumber . "+15551234567") - (sourceName . "Alice") - (dataMessage . ((sticker . ((packId . "p1"))))))) - '(("+15551234567" "Alice" "[Sticker]"))))) - -(ert-deftest test-signel-notify-function-attachment-placeholder () - "Boundary: an attachment with no text gets the [Attachment] placeholder." - (should (equal (test-signel-notify--receive - '((sourceNumber . "+15551234567") - (sourceName . "Alice") - (dataMessage . ((attachments . [((id . "a1"))]))))) - '(("+15551234567" "Alice" "[Attachment]"))))) - -(ert-deftest test-signel-notify-function-no-data-no-call () - "Boundary: an envelope with no dataMessage never calls the function." - (should-not (test-signel-notify--receive - '((sourceNumber . "+15551234567") - (sourceName . "Alice") - (typingMessage . ((action . "STARTED"))))))) - -(ert-deftest test-signel-notify-function-default-preserves-behavior () - "Normal: the default value raises a plain notifications-notify toast." - (should (eq signel-notify-function #'signel--notify-default)) - (let (calls) - (cl-letf (((symbol-function 'notifications-notify) - (lambda (&rest args) (push args calls) nil))) - (signel--notify-default "+15551234567" "Alice" "hi")) - (should (= (length calls) 1)) - (let ((args (car calls))) - (should (equal (plist-get args :title) "Signel: Alice")) - (should (equal (plist-get args :body) "hi"))))) - -(provide 'test-signel-notify-function) -;;; test-signel-notify-function.el ends here diff --git a/tests/test-signel-rpc-dispatch.el b/tests/test-signel-rpc-dispatch.el deleted file mode 100644 index 5ae023d6..00000000 --- a/tests/test-signel-rpc-dispatch.el +++ /dev/null @@ -1,94 +0,0 @@ -;;; test-signel-rpc-dispatch.el --- Tests for signel JSON-RPC success-result dispatch -*- lexical-binding: t; -*- - -;;; Commentary: -;; signel's JSON-RPC dispatch (signel.el in the fork at ~/code/signel) routes -;; incoming `receive' notifications and errors, but successful -;; `((id . N) (result . VALUE))' responses had no path until this work added a -;; request-callback table. These tests cover the new behavior: a registered -;; callback fires with the result and is then removed; an error response -;; also removes the handler so a retry starts clean; an unregistered id is a -;; silent no-op; passing SUCCESS-CALLBACK to `signel--send-rpc' registers it -;; under the returned id. -;; -;; The dispatch tests exercise `signel--dispatch' directly with synthetic JSON -;; alists; no live process is needed. The send-rpc test stubs `get-process' -;; and `process-send-string' so it doesn't require a running signal-cli. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "~/code/signel"))) -(require 'signel) - -(defun test-signel-rpc--reset () - "Reset signel dispatch state to a clean baseline before each test." - (clrhash signel--request-handler-map) - (clrhash signel--request-buffer-map) - (setq signel--rpc-id-counter 0)) - -(ert-deftest test-signel-rpc-dispatch-result-invokes-callback () - "Normal: a result response with a registered id fires the callback with the -result value and removes the handler." - (test-signel-rpc--reset) - (let ((captured nil)) - (puthash 7 (lambda (val) (setq captured val)) signel--request-handler-map) - (signel--dispatch '((jsonrpc . "2.0") (id . 7) - (result . ((contacts . [1 2 3]))))) - (should (equal captured '((contacts . [1 2 3])))) - (should-not (gethash 7 signel--request-handler-map)))) - -(ert-deftest test-signel-rpc-dispatch-unknown-id-is-noop () - "Boundary: a result response with an unregistered id is a silent no-op: -neither receive nor error handler fires, and the handler map stays empty." - (test-signel-rpc--reset) - (let ((called nil)) - (cl-letf (((symbol-function 'signel--handle-error) - (lambda (&rest _) (setq called 'error))) - ((symbol-function 'signel--handle-receive) - (lambda (&rest _) (setq called 'receive)))) - (signel--dispatch '((jsonrpc . "2.0") (id . 99) (result . "anything")))) - (should-not called) - (should (zerop (hash-table-count signel--request-handler-map))))) - -(ert-deftest test-signel-rpc-dispatch-error-cleans-up-handler () - "Error: an error response with a registered id removes the handler without -firing the callback, leaving the map clean for a retry." - (test-signel-rpc--reset) - (let ((fired nil)) - (puthash 11 (lambda (&rest _) (setq fired t)) - signel--request-handler-map) - (cl-letf (((symbol-function 'signel--handle-error) (lambda (&rest _) nil))) - (signel--dispatch '((jsonrpc . "2.0") (id . 11) - (error . ((code . -1) (message . "boom")))))) - (should-not fired) - (should-not (gethash 11 signel--request-handler-map)))) - -(ert-deftest test-signel-rpc-send-rpc-registers-success-callback () - "Normal: passing a SUCCESS-CALLBACK to `signel--send-rpc' stores it under -the returned id so the matching response can route to it." - (test-signel-rpc--reset) - (let ((cb (lambda (_) 'ok)) - (sent nil)) - (cl-letf (((symbol-function 'get-process) (lambda (&rest _) 'fake-proc)) - ((symbol-function 'process-send-string) - (lambda (_ s) (setq sent s))) - ((symbol-function 'signel--log) (lambda (&rest _) nil))) - (let ((id (signel--send-rpc "listContacts" nil nil cb))) - (should (eq cb (gethash id signel--request-handler-map))) - (should (stringp sent)))))) - -(ert-deftest test-signel-rpc-stop-clears-handler-map () - "Normal (reconnect-invalidation): `signel-stop' clears the handler map so a -restart starts with no stale callbacks waiting for responses that will never -arrive." - (test-signel-rpc--reset) - (puthash 13 (lambda (&rest _) nil) signel--request-handler-map) - (cl-letf (((symbol-function 'get-process) (lambda (&rest _) nil))) - (signel-stop)) - (should (zerop (hash-table-count signel--request-handler-map)))) - -(provide 'test-signel-rpc-dispatch) -;;; test-signel-rpc-dispatch.el ends here -- cgit v1.2.3