aboutsummaryrefslogtreecommitdiff
path: root/archive
diff options
context:
space:
mode:
Diffstat (limited to 'archive')
-rw-r--r--archive/README.org7
-rw-r--r--archive/modules/signal-config.el380
-rw-r--r--archive/tests/test-signal-config--contact-cache.el61
-rw-r--r--archive/tests/test-signal-config-notify.el150
-rw-r--r--archive/tests/test-signal-config.el410
-rw-r--r--archive/tests/test-signel-cancel-input.el74
-rw-r--r--archive/tests/test-signel-input-preservation.el68
-rw-r--r--archive/tests/test-signel-notify-function.el89
-rw-r--r--archive/tests/test-signel-rpc-dispatch.el94
9 files changed, 1333 insertions, 0 deletions
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: <id>*') 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: <id>*' 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