diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-28 01:55:00 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-28 01:55:00 -0500 |
| commit | 505e7070bfbff9812823c09e94c9c87ed8c9ca0d (patch) | |
| tree | a763a256a0b0a358f1a2d14c843b5c291852a06b /tests | |
| parent | 00f218a13add7fce072879da0749ee2afe6d4a6f (diff) | |
| download | pearl-505e7070bfbff9812823c09e94c9c87ed8c9ca0d.tar.gz pearl-505e7070bfbff9812823c09e94c9c87ed8c9ca0d.zip | |
feat(prompts): split sentinel + default-yes for safe yes/no
Two UX refinements on the just-shipped prompt surface, both following the principle "the prompt should describe what it's doing and the default should be the most-common choice."
First, the sentinel that was uniformly "[ None. ]" really meant two different things at two kinds of prompt, and the label only matched one of them. Filter-dimension prompts (team, state, project, labels, assignee) treat picking the sentinel as "no constraint on this dimension." Every value matches, which is *any*, not *none*. The saved-query prompts (delete, run) treat picking the sentinel as "don't act." That one is *cancel*, not *none* or *any*. Renamed accordingly: `pearl--filter-any` ("[ Any. ]") for the five filter dimensions and `pearl--filter-cancel` ("[ Cancel. ]") for the two saved-query prompts. The generic helper became `pearl--with-sentinel SENTINEL CANDIDATES` so each call site picks the label that fits its case. The predicate became `pearl--filter-sentinel-value-p` (recognizes either sentinel or empty/nil) so the cancellation logic is unchanged.
Second, three non-destructive yes/no prompts ("Open issues only?", "Save this filter locally...", "Save N fields across M issues?") moved from `y-or-n-p` to a new `pearl--read-yes-no` helper. The helper renders a completing-read over ("yes" "no") with the most-common choice as the default and topmost candidate, so RET takes it without typing. Default is "yes" for all three (each is a do-the-thing-I-asked confirmation), but the helper takes a DEFAULT arg so a future prompt where "no" is more common can opt in. The destructive prompts (delete issue, delete saved query, delete comment) stay as `yes-or-no-p`. Typing "yes" there is a deliberate safety affordance, not friction worth removing.
Tests cover the sentinel-value predicate across both sentinels + empty/nil + real values, the `pearl--with-sentinel` helper, the `pearl--read-yes-no` t/nil return and default-ordering behavior, and the three save-test stubs swapped to mock the new helper.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test-pearl-adhoc.el | 114 | ||||
| -rw-r--r-- | tests/test-pearl-save.el | 8 |
2 files changed, 87 insertions, 35 deletions
diff --git a/tests/test-pearl-adhoc.el b/tests/test-pearl-adhoc.el index 2dec1a3..558578a 100644 --- a/tests/test-pearl-adhoc.el +++ b/tests/test-pearl-adhoc.el @@ -28,34 +28,43 @@ (require 'test-bootstrap (expand-file-name "test-bootstrap.el")) (require 'cl-lib) -;;; --filter-none sentinel helpers - -(ert-deftest test-pearl-filter-none-value-p-recognizes-sentinel () - "The sentinel string reads as a `no constraint' value." - (should (pearl--filter-none-value-p pearl--filter-none))) - -(ert-deftest test-pearl-filter-none-value-p-recognizes-empty-and-nil () - "Empty input and nil also read as `no constraint' (for back-compat)." - (should (pearl--filter-none-value-p "")) - (should (pearl--filter-none-value-p nil))) - -(ert-deftest test-pearl-filter-none-value-p-rejects-real-values () - "An actual picked value does not read as `no constraint'." - (should-not (pearl--filter-none-value-p "In Progress")) - (should-not (pearl--filter-none-value-p "Platform")) - (should-not (pearl--filter-none-value-p "bug"))) - -(ert-deftest test-pearl-with-none-prepends-sentinel () - "`pearl--with-none' puts the sentinel first so it sits at the top of the -completing-read candidate list." - (let ((wrapped (pearl--with-none '("alpha" "beta")))) - (should (string= pearl--filter-none (car wrapped))) - (should (equal '("alpha" "beta") (cdr wrapped))))) - -(ert-deftest test-pearl-with-none-empty-list-yields-just-the-sentinel () +;;; sentinel helpers + +(ert-deftest test-pearl-filter-sentinel-value-p-recognizes-any-and-cancel () + "Both opt-out sentinels read as sentinel values." + (should (pearl--filter-sentinel-value-p pearl--filter-any)) + (should (pearl--filter-sentinel-value-p pearl--filter-cancel))) + +(ert-deftest test-pearl-filter-sentinel-value-p-recognizes-empty-and-nil () + "Empty input and nil also read as opt-out (back-compat for the legacy +empty-input idiom and for callers that pass nil)." + (should (pearl--filter-sentinel-value-p "")) + (should (pearl--filter-sentinel-value-p nil))) + +(ert-deftest test-pearl-filter-sentinel-value-p-rejects-real-values () + "An actual picked value does not read as opt-out." + (should-not (pearl--filter-sentinel-value-p "In Progress")) + (should-not (pearl--filter-sentinel-value-p "Platform")) + (should-not (pearl--filter-sentinel-value-p "bug"))) + +(ert-deftest test-pearl-filter-any-and-cancel-are-distinct-strings () + "The two sentinels carry different labels so the prompt reads accurately +for its case: \"any\" for filter dimensions, \"cancel\" for pick-an-existing." + (should-not (string= pearl--filter-any pearl--filter-cancel))) + +(ert-deftest test-pearl-with-sentinel-prepends-the-given-sentinel () + "`pearl--with-sentinel' puts SENTINEL first so it sits at the top of the +candidate list." + (let ((wrapped-any (pearl--with-sentinel pearl--filter-any '("alpha" "beta")))) + (should (string= pearl--filter-any (car wrapped-any))) + (should (equal '("alpha" "beta") (cdr wrapped-any)))) + (let ((wrapped-cancel (pearl--with-sentinel pearl--filter-cancel '("a" "b")))) + (should (string= pearl--filter-cancel (car wrapped-cancel))))) + +(ert-deftest test-pearl-with-sentinel-empty-list-yields-just-the-sentinel () "An empty candidate list still yields a one-element list with the sentinel." - (let ((wrapped (pearl--with-none '()))) - (should (equal (list pearl--filter-none) wrapped)))) + (let ((wrapped (pearl--with-sentinel pearl--filter-any '()))) + (should (equal (list pearl--filter-any) wrapped)))) (ert-deftest test-pearl-completion-table-keep-order-metadata-pins-identity () "Action `metadata' returns an alist with `display-sort-function' bound to @@ -66,6 +75,49 @@ completing-read candidate list." (should (eq #'identity (alist-get 'display-sort-function (cdr meta)))) (should (eq #'identity (alist-get 'cycle-sort-function (cdr meta)))))) +(ert-deftest test-pearl-read-yes-no-returns-t-for-yes () + "A `yes' choice returns t." + (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "yes"))) + (should (eq t (pearl--read-yes-no "Anything? "))))) + +(ert-deftest test-pearl-read-yes-no-returns-nil-for-no () + "A `no' choice returns nil." + (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "no"))) + (should-not (pearl--read-yes-no "Anything? ")))) + +(ert-deftest test-pearl-read-yes-no-default-passed-as-default-arg () + "The DEFAULT argument is the value RET takes without typing; the helper +forwards it as completing-read's default so the framework highlights it." + (let (forwarded-default) + (cl-letf (((symbol-function 'completing-read) + (lambda (&rest args) (setq forwarded-default (nth 6 args)) "no"))) + (pearl--read-yes-no "?" "no") + (should (string= "no" forwarded-default))))) + +(ert-deftest test-pearl-read-yes-no-default-yes-orders-yes-first () + "With the default `yes' (the omitted-arg case), the candidate list is +ordered yes-then-no so the most-common choice sits at the top." + (let (captured-collection) + (cl-letf (((symbol-function 'completing-read) + (lambda (_p coll &rest _) + (setq captured-collection + (when (functionp coll) (all-completions "" coll))) + "yes"))) + (pearl--read-yes-no "?") + (should (equal '("yes" "no") captured-collection))))) + +(ert-deftest test-pearl-read-yes-no-default-no-orders-no-first () + "With DEFAULT \"no\", the candidate list is ordered no-then-yes so a +non-affirmative default is what the user lands on." + (let (captured-collection) + (cl-letf (((symbol-function 'completing-read) + (lambda (_p coll &rest _) + (setq captured-collection + (when (functionp coll) (all-completions "" coll))) + "no"))) + (pearl--read-yes-no "?" "no") + (should (equal '("no" "yes") captured-collection))))) + (ert-deftest test-pearl-completion-table-keep-order-delegates-all-completions () "Non-metadata actions delegate to `complete-with-action' over the original candidate list, so completion still works the way the framework expects." @@ -151,14 +203,14 @@ candidate list, so completion still works the way the framework expects." (should-not persisted)))) (ert-deftest test-pearl-delete-saved-query-sentinel-cancels () - "Picking the `pearl--filter-none' sentinel cancels without touching anything." + "Picking the `pearl--filter-cancel' sentinel cancels without touching anything." (let ((pearl-saved-queries '(("Keep" :filter (:open t)))) (confirmed nil)) (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) (setq confirmed t) t)) ((symbol-function 'customize-save-variable) (lambda (&rest _) nil))) - (pearl-delete-saved-query pearl--filter-none) + (pearl-delete-saved-query pearl--filter-cancel) (should (assoc "Keep" pearl-saved-queries)) (should-not confirmed)))) @@ -177,12 +229,12 @@ rather than offering an empty picker." ;;; pearl-run-saved-query sentinel cancel (ert-deftest test-pearl-run-saved-query-sentinel-cancels () - "Picking the `pearl--filter-none' sentinel cancels the run without fetching." + "Picking the `pearl--filter-cancel' sentinel cancels the run without fetching." (let ((fetched nil) (pearl-saved-queries '(("Open" :filter (:open t))))) (cl-letf (((symbol-function 'pearl--query-issues-async) (lambda (&rest _) (setq fetched t)))) - (should-error (pearl-run-saved-query pearl--filter-none) :type 'user-error) + (should-error (pearl-run-saved-query pearl--filter-cancel) :type 'user-error) (should-not fetched)))) ;;; pearl-list-issues-filtered diff --git a/tests/test-pearl-save.el b/tests/test-pearl-save.el index 47b48c2..b82ca04 100644 --- a/tests/test-pearl-save.el +++ b/tests/test-pearl-save.el @@ -755,7 +755,7 @@ description hash is left untouched and the title still saved." (test-pearl-save--in-two-issues (let ((prompted nil) (fetched nil)) (test-pearl-save--with-net - (cl-letf (((symbol-function 'y-or-n-p) (lambda (&rest _) (setq prompted t) t)) + (cl-letf (((symbol-function 'pearl--read-yes-no) (lambda (&rest _) (setq prompted t) t)) ((symbol-function 'pearl--fetch-issue-description-async) (lambda (&rest _) (setq fetched t)))) (pearl-save-all) @@ -770,7 +770,7 @@ description hash is left untouched and the title still saved." (end-of-line) (insert " EDITED")) (goto-char (point-min)) (test-pearl-save--with-net - (cl-letf (((symbol-function 'y-or-n-p) (lambda (&rest _) t)) + (cl-letf (((symbol-function 'pearl--read-yes-no) (lambda (&rest _) t)) ((symbol-function 'pearl--fetch-issue-description-async) (lambda (_id cb) (funcall cb '(:description "Body." :updated-at "t0"))))) (pearl-save-all) @@ -783,7 +783,7 @@ description hash is left untouched and the title still saved." (end-of-line) (insert " EDITED")) (goto-char (point-min)) (test-pearl-save--with-net - (cl-letf (((symbol-function 'y-or-n-p) (lambda (&rest _) nil)) + (cl-letf (((symbol-function 'pearl--read-yes-no) (lambda (&rest _) nil)) ((symbol-function 'pearl--fetch-issue-description-async) (lambda (_id cb) (funcall cb '(:description "Body." :updated-at "t0"))))) (pearl-save-all) @@ -803,7 +803,7 @@ description hash is left untouched and the title still saved." (end-of-line) (insert " EDITED")) (goto-char (point-min)) (test-pearl-save--with-net - (cl-letf (((symbol-function 'y-or-n-p) (lambda (&rest _) t)) + (cl-letf (((symbol-function 'pearl--read-yes-no) (lambda (&rest _) t)) ((symbol-function 'pearl--fetch-issue-description-async) (lambda (_id cb) (funcall cb '(:description "Body." :updated-at "t0"))))) (pearl-save-all) |
