From 703b4841253ca610e9b50a8e8519863eaf22a6b2 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 14 Jul 2026 00:07:06 -0500 Subject: fix(signal): honor the cache-clear promise and mark empty fetches warm Two picker-cache bugs from the config audit, verified against current code first. The cache docstring promised clearing on signel-stop, but nothing cleared it, so a stale contact list survived a relink or reconnect. I advised a named clear function :after signel-stop, which makes the docstring true. An empty listContacts result cached nil, indistinguishable from a cold cache, so a zero-contact account re-ran the picker's blocking fetch (up to the 3s timeout) on every open. The fetch now caches an empty sentinel and the picker reads through cj/signel--cached-contacts. One existing test pinned the nil behavior and now asserts the sentinel instead. --- tests/test-signal-config--contact-cache.el | 61 ++++++++++++++++++++++++++++++ tests/test-signal-config.el | 11 ++++-- 2 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 tests/test-signal-config--contact-cache.el (limited to 'tests') diff --git a/tests/test-signal-config--contact-cache.el b/tests/test-signal-config--contact-cache.el new file mode 100644 index 00000000..720e0c3a --- /dev/null +++ b/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/tests/test-signal-config.el b/tests/test-signal-config.el index 7556efdb..f8bf4410 100644 --- a/tests/test-signal-config.el +++ b/tests/test-signal-config.el @@ -251,9 +251,12 @@ and stores the (LABEL . RECIPIENT) alist in `cj/signel--contact-cache'." (should (equal cj/signel--contact-cache '(("Alice (+15555550100)" . "+15555550100")))))) -(ert-deftest test-signal-config-fetch-contacts-empty-result-clears-cache () - "Boundary: an empty listContacts result populates the cache as nil, -distinct from a failure path (which never invokes the success callback)." +(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) @@ -261,7 +264,7 @@ distinct from a failure path (which never invokes the success callback)." (setq cj/signel--contact-cache '(("stale" . "+10000000000"))) (cj/signel--fetch-contacts) (funcall sent-callback [])) - (should-not cj/signel--contact-cache))) + (should (eq cj/signel--contact-cache 'empty)))) ;;; cj/signel-refresh-contacts -- cgit v1.2.3