aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test-custom-buffer-file-move-buffer-and-file.el27
-rw-r--r--tests/test-custom-buffer-file-rename-buffer-and-file.el14
-rw-r--r--tests/test-system-commands-resolve-and-run.el45
-rw-r--r--tests/test-system-lib-confirm-destructive.el141
-rw-r--r--tests/test-system-lib-confirm-strong.el37
5 files changed, 207 insertions, 57 deletions
diff --git a/tests/test-custom-buffer-file-move-buffer-and-file.el b/tests/test-custom-buffer-file-move-buffer-and-file.el
index 8331db5c..b3d78ccf 100644
--- a/tests/test-custom-buffer-file-move-buffer-and-file.el
+++ b/tests/test-custom-buffer-file-move-buffer-and-file.el
@@ -884,11 +884,17 @@
(with-temp-file source-file
(insert "new"))
(find-file source-file)
- ;; Mock yes-or-no-p to capture that it was called
- (cl-letf (((symbol-function 'yes-or-no-p)
- (lambda (prompt)
+ ;; The overwrite confirm goes through `cj/confirm-destructive', which
+ ;; reads a single key rather than a typed yes. Mock the key read, not
+ ;; `yes-or-no-p' -- mocking the latter would pass whether or not the
+ ;; prompt happened at all.
+ (cl-letf (((symbol-function 'read-char-choice)
+ (lambda (&rest _)
(setq prompted t)
- t))
+ ?y))
+ ((symbol-function 'yes-or-no-p)
+ (lambda (&rest _)
+ (error "overwrite confirm must not demand a typed yes")))
((symbol-function 'read-directory-name)
(lambda (&rest _) target-dir)))
(call-interactively #'cj/move-buffer-and-file)
@@ -907,11 +913,14 @@
(with-temp-file source-file
(insert "new"))
(find-file source-file)
- ;; Mock yes-or-no-p to capture if it was called
- (cl-letf (((symbol-function 'yes-or-no-p)
- (lambda (prompt)
- (setq prompted t)
- t))
+ ;; Both prompt paths are mocked, not just the old one. Watching
+ ;; `yes-or-no-p' alone would make this assertion unfalsifiable now
+ ;; that the confirm reads a key instead: it would pass whether the
+ ;; prompt was correctly skipped or merely moved.
+ (cl-letf (((symbol-function 'read-char-choice)
+ (lambda (&rest _) (setq prompted t) ?y))
+ ((symbol-function 'yes-or-no-p)
+ (lambda (&rest _) (setq prompted t) t))
((symbol-function 'read-directory-name)
(lambda (&rest _) target-dir)))
(call-interactively #'cj/move-buffer-and-file)
diff --git a/tests/test-custom-buffer-file-rename-buffer-and-file.el b/tests/test-custom-buffer-file-rename-buffer-and-file.el
index 1eb61f1b..019fad8c 100644
--- a/tests/test-custom-buffer-file-rename-buffer-and-file.el
+++ b/tests/test-custom-buffer-file-rename-buffer-and-file.el
@@ -923,11 +923,17 @@
(with-temp-file new-file
(insert "existing"))
(find-file old-file)
- ;; Mock yes-or-no-p to capture that it was called
- (cl-letf (((symbol-function 'yes-or-no-p)
- (lambda (prompt)
+ ;; The overwrite confirm goes through `cj/confirm-destructive', which
+ ;; reads a single key rather than a typed yes. Mock the key read, not
+ ;; `yes-or-no-p' -- mocking the latter would pass whether or not the
+ ;; prompt happened at all.
+ (cl-letf (((symbol-function 'read-char-choice)
+ (lambda (&rest _)
(setq prompted t)
- t))
+ ?y))
+ ((symbol-function 'yes-or-no-p)
+ (lambda (&rest _)
+ (error "overwrite confirm must not demand a typed yes")))
((symbol-function 'read-string)
(lambda (&rest _) "new.txt")))
(call-interactively #'cj/rename-buffer-and-file)
diff --git a/tests/test-system-commands-resolve-and-run.el b/tests/test-system-commands-resolve-and-run.el
index 7e5146b1..3dae8cda 100644
--- a/tests/test-system-commands-resolve-and-run.el
+++ b/tests/test-system-commands-resolve-and-run.el
@@ -85,26 +85,34 @@
(put 'test-sc-confirm-cmd 'cj/system-confirm nil)))
(ert-deftest test-system-cmd-strong-confirm-decline-aborts ()
- "Boundary: a strong-confirm var uses yes-or-no-p; declining aborts and
-does not run the command."
+ "Boundary: a strong-confirm var asks a single y/n; declining aborts and
+does not run the command.
+
+The strong path used to demand a typed \"yes\", and this test used to assert
+that by erroring if `read-char-choice' was called at all. It now asserts the
+opposite, because a long-form prompt is only as safe as it is answerable: on
+2026-07-31 one became unanswerable when a second agent session held the
+selected window, and the Emacs session had to be killed with buffers unsaved.
+What survives the change is the part that mattered -- the prompt still has no
+default, so RET and space re-prompt rather than confirming a shutdown."
(defvar test-sc-strong-cmd "test-strong-cmd")
(put 'test-sc-strong-cmd 'cj/system-confirm 'strong)
(unwind-protect
- (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) nil))
- ((symbol-function 'read-char-choice)
- (lambda (&rest _) (error "strong confirm must not use read-char-choice")))
+ (cl-letf (((symbol-function 'read-char-choice) (lambda (&rest _) ?n))
+ ((symbol-function 'yes-or-no-p)
+ (lambda (&rest _) (error "strong confirm must not demand a typed yes")))
((symbol-function 'start-process-shell-command)
(lambda (&rest _) (error "shouldn't run"))))
(should-error (cj/system-cmd 'test-sc-strong-cmd) :type 'user-error))
(put 'test-sc-strong-cmd 'cj/system-confirm nil)))
(ert-deftest test-system-cmd-strong-confirm-accept-runs ()
- "Normal: a strong-confirm var runs the command when yes-or-no-p returns t."
+ "Normal: a strong-confirm var runs the command on a single y."
(defvar test-sc-strong-cmd-2 "echo strong")
(put 'test-sc-strong-cmd-2 'cj/system-confirm 'strong)
(let (cmd-line)
(unwind-protect
- (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
+ (cl-letf (((symbol-function 'read-char-choice) (lambda (&rest _) ?y))
((symbol-function 'start-process-shell-command)
(lambda (_name _buf c) (setq cmd-line c) 'fake-proc))
((symbol-function 'set-process-query-on-exit-flag) #'ignore)
@@ -114,6 +122,29 @@ does not run the command."
(put 'test-sc-strong-cmd-2 'cj/system-confirm nil))
(should (string-match-p "echo strong" cmd-line))))
+(ert-deftest test-system-cmd-strong-confirm-rejects-stray-keys ()
+ "Boundary: the strong prompt offers only y and n, so a stray RET or space
+cannot confirm an irreversible command.
+
+This is the protection the typed-\"yes\" form existed for, kept while the
+answer became a single keystroke. Asserted on the accepted-character set
+handed to `read-char-choice' rather than on the prompt text, because the set
+is what actually decides."
+ (defvar test-sc-strong-cmd-3 "echo strong")
+ (put 'test-sc-strong-cmd-3 'cj/system-confirm 'strong)
+ (let (chars)
+ (unwind-protect
+ (cl-letf (((symbol-function 'read-char-choice)
+ (lambda (_prompt cs &rest _) (setq chars cs) ?n))
+ ((symbol-function 'start-process-shell-command)
+ (lambda (&rest _) (error "shouldn't run"))))
+ (should-error (cj/system-cmd 'test-sc-strong-cmd-3) :type 'user-error))
+ (put 'test-sc-strong-cmd-3 'cj/system-confirm nil))
+ (should (equal (sort (copy-sequence chars) #'<) '(?N ?Y ?n ?y)))
+ (should-not (memq ?\r chars))
+ (should-not (memq ?\n chars))
+ (should-not (memq ?\s chars))))
+
;;; cj/system-cmd--emacs-service-available-p
(ert-deftest test-system-cmd-service-available-true-on-zero-exit ()
diff --git a/tests/test-system-lib-confirm-destructive.el b/tests/test-system-lib-confirm-destructive.el
new file mode 100644
index 00000000..4fc96e21
--- /dev/null
+++ b/tests/test-system-lib-confirm-destructive.el
@@ -0,0 +1,141 @@
+;;; test-system-lib-confirm-destructive.el --- Tests for cj/confirm-destructive -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; ERT tests for `cj/confirm-destructive', the confirmation used for
+;; irreversible actions: file destruction, overwrites, power-off.
+;;
+;; The contract has two halves, and they pull against each other:
+;;
+;; 1. One keystroke. This replaced a typed-"yes" prompt on 2026-07-31, after
+;; one became unanswerable -- a second agent session held the selected
+;; window while the prompt waited in another frame, so keystrokes went to a
+;; terminal and the Emacs session had to be killed with buffers unsaved. A
+;; long-form prompt is only as safe as it is answerable.
+;;
+;; 2. No default. Only y and n answer. A stray RET or space re-prompts
+;; instead of confirming, which is the accidental-confirm protection the
+;; long form existed for, and it survives the change.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'system-lib)
+
+;;; Normal Cases
+
+(ert-deftest test-system-lib-confirm-destructive-returns-t-on-y ()
+ "Normal: a y answer confirms."
+ (cl-letf (((symbol-function 'read-char-choice) (lambda (&rest _) ?y)))
+ (should (eq (cj/confirm-destructive "Really? ") t))))
+
+(ert-deftest test-system-lib-confirm-destructive-returns-nil-on-n ()
+ "Normal: an n answer declines."
+ (cl-letf (((symbol-function 'read-char-choice) (lambda (&rest _) ?n)))
+ (should (eq (cj/confirm-destructive "Really? ") nil))))
+
+;;; Boundary Cases
+
+(ert-deftest test-system-lib-confirm-destructive-accepts-uppercase ()
+ "Boundary: a capital Y confirms and a capital N declines, so the answer
+does not depend on the shift key or caps lock."
+ (cl-letf (((symbol-function 'read-char-choice) (lambda (&rest _) ?Y)))
+ (should (eq (cj/confirm-destructive "Really? ") t)))
+ (cl-letf (((symbol-function 'read-char-choice) (lambda (&rest _) ?N)))
+ (should (eq (cj/confirm-destructive "Really? ") nil))))
+
+(ert-deftest test-system-lib-confirm-destructive-offers-only-y-and-n ()
+ "Boundary: RET, space and every other key are refused.
+
+This is the protection that justified the old typed-\"yes\" form, kept now
+that the answer is a single keystroke. Asserted on the character set handed
+to `read-char-choice', which is what actually decides, rather than on the
+prompt text, which only describes."
+ (let (chars)
+ (cl-letf (((symbol-function 'read-char-choice)
+ (lambda (_prompt cs &rest _) (setq chars cs) ?n)))
+ (cj/confirm-destructive "Really? "))
+ (should (equal (sort (copy-sequence chars) #'<) '(?N ?Y ?n ?y)))
+ (should-not (memq ?\r chars))
+ (should-not (memq ?\n chars))
+ (should-not (memq ?\s chars))))
+
+(ert-deftest test-system-lib-confirm-destructive-is-single-keystroke ()
+ "Boundary: it never routes through `yes-or-no-p'.
+
+The regression this file guards. Binding `use-short-answers' to nil for the
+call, which is what the old implementation did, is exactly the shape that
+produced an unanswerable prompt."
+ (cl-letf (((symbol-function 'read-char-choice) (lambda (&rest _) ?y))
+ ((symbol-function 'yes-or-no-p)
+ (lambda (&rest _) (error "must not demand a typed yes"))))
+ (should (eq (cj/confirm-destructive "Really? ") t))))
+
+(ert-deftest test-system-lib-confirm-destructive-ignores-short-answer-setting ()
+ "Boundary: the answer is one key whether or not `use-short-answers' is set.
+The global default is t, but nothing about this prompt should depend on it."
+ (dolist (setting '(t nil))
+ (let ((use-short-answers setting))
+ (cl-letf (((symbol-function 'read-char-choice) (lambda (&rest _) ?y))
+ ((symbol-function 'yes-or-no-p)
+ (lambda (&rest _) (error "must not demand a typed yes"))))
+ (should (eq (cj/confirm-destructive "Really? ") t))))))
+
+(ert-deftest test-system-lib-confirm-destructive-discards-type-ahead ()
+ "Boundary: pending input is discarded before the key is read.
+
+The regression that made this necessary. `read-char-choice' reads the input
+queue, so a key typed before the prompt appeared would answer it: a queued y
+would confirm a shutdown or a file deletion instantly. The typed-\"yes\"
+form absorbed such a key harmlessly, so dropping it without this guard would
+have traded a rare annoyance for a rare catastrophe.
+
+Asserted on ordering, since that is the whole property: the discard has to
+happen before the read, not merely somewhere in the function."
+ (let ((events '()))
+ (cl-letf (((symbol-function 'discard-input)
+ (lambda (&rest _) (push 'discard events) nil))
+ ((symbol-function 'read-char-choice)
+ (lambda (&rest _) (push 'read events) ?y)))
+ (should (eq (cj/confirm-destructive "Really? ") t)))
+ (should (equal (nreverse events) '(discard read)))))
+
+;;; Error Cases
+
+(ert-deftest test-system-lib-confirm-destructive-declines-on-other-char ()
+ "Error: anything that is not y or n declines rather than confirming.
+
+`read-char-choice' normally loops until it gets a listed character, but it
+can return outside the set -- `read-char-from-minibuffer' yields RET when the
+minibuffer result is empty. On an irreversible action the safe reading of an
+unexpected answer is no."
+ (dolist (ch (list ?\r ?\n ?\s ?q ?\C-m))
+ (cl-letf (((symbol-function 'read-char-choice) (lambda (&rest _) ch)))
+ (should-not (cj/confirm-destructive "Really? ")))))
+
+(ert-deftest test-system-lib-confirm-destructive-propagates-quit ()
+ "Error: C-g aborts the caller rather than reading as a confirmation.
+Swallowing the quit here would turn an abort into a yes on an irreversible
+action."
+ (cl-letf (((symbol-function 'read-char-choice)
+ (lambda (&rest _) (signal 'quit nil))))
+ ;; `should-error' cannot express this: quit is not an error, so ERT lets
+ ;; it through and reports the test as QUIT rather than passed. Catching
+ ;; it here is what makes the assertion a real pass or fail.
+ (should (eq 'quit (condition-case nil
+ (progn (cj/confirm-destructive "Really? ")
+ 'returned-normally)
+ (quit 'quit))))))
+
+(ert-deftest test-system-lib-confirm-destructive-prompt-shows-choices ()
+ "Error: the prompt names the keys that answer, so an unfamiliar prompt is
+not a guessing game."
+ (let (prompt)
+ (cl-letf (((symbol-function 'read-char-choice)
+ (lambda (p &rest _) (setq prompt p) ?n)))
+ (cj/confirm-destructive "Delete everything? "))
+ (should (string-match-p "Delete everything\\?" prompt))
+ (should (string-match-p "y or n" prompt))))
+
+(provide 'test-system-lib-confirm-destructive)
+;;; test-system-lib-confirm-destructive.el ends here
diff --git a/tests/test-system-lib-confirm-strong.el b/tests/test-system-lib-confirm-strong.el
deleted file mode 100644
index 26c00822..00000000
--- a/tests/test-system-lib-confirm-strong.el
+++ /dev/null
@@ -1,37 +0,0 @@
-;;; test-system-lib-confirm-strong.el --- Tests for cj/confirm-strong -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; ERT tests for `cj/confirm-strong', the typed-"yes" confirmation used for
-;; irreversible actions. The behavior under test is the long-form guarantee:
-;; the prompt demands a typed yes/no even when the global single-key default
-;; (`use-short-answers') is in effect.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-(require 'system-lib)
-
-(ert-deftest test-system-lib-confirm-strong-returns-t-on-yes ()
- "Normal: passes a t answer through from `yes-or-no-p'."
- (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t)))
- (should (eq (cj/confirm-strong "Really? ") t))))
-
-(ert-deftest test-system-lib-confirm-strong-returns-nil-on-no ()
- "Normal: passes a nil answer through from `yes-or-no-p'."
- (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) nil)))
- (should (eq (cj/confirm-strong "Really? ") nil))))
-
-(ert-deftest test-system-lib-confirm-strong-forces-long-form ()
- "Boundary: binds `use-short-answers' to nil for the call even when it is
-globally t, so the irreversible prompt requires a typed yes/no regardless of
-the single-key default."
- (let ((use-short-answers t)
- (seen 'unset))
- (cl-letf (((symbol-function 'yes-or-no-p)
- (lambda (&rest _) (setq seen use-short-answers) t)))
- (cj/confirm-strong "Really? ")
- (should (eq seen nil)))))
-
-(provide 'test-system-lib-confirm-strong)
-;;; test-system-lib-confirm-strong.el ends here