From 8357eed1e4753b142cdda0e57e00260f2341443e Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 2 Jul 2026 06:32:16 -0400 Subject: feat(buffer-file): make the disk-changed diff review navigable Pressing d in the C-x C-s conflict menu (and the save-some loop) now enters a modal review instead of a peek-and-return toggle: point lands on the first hunk, arrows and TAB move through the changes, and the menu keys act from inside the diff. difftastic gets --context 1 and an explicit --width, since as a subprocess it can't detect the terminal and wrapped at 80 columns. A new m choice resolves the conflict in ediff. I kept the post-merge save re-asking once, so an abandoned merge can't silently overwrite the disk version. --- modules/custom-buffer-file.el | 196 ++++++++++++++++--- tests/test-custom-buffer-file--diff-review.el | 261 ++++++++++++++++++++++++++ 2 files changed, 427 insertions(+), 30 deletions(-) create mode 100644 tests/test-custom-buffer-file--diff-review.el diff --git a/modules/custom-buffer-file.el b/modules/custom-buffer-file.el index 606188af..25555b53 100644 --- a/modules/custom-buffer-file.el +++ b/modules/custom-buffer-file.el @@ -397,34 +397,64 @@ stock `revert-buffer' prompt-every-time behavior on this map." (declare-function ansi-color-apply-on-region "ansi-color") +(defvar cj/diff-context-lines 1 + "Lines of unchanged context shown around each change in buffer/file diffs. +Passed to difftastic's --context and diff's -U, so the diff view shows what +differs rather than pages of identical text.") + +(defun cj/--difft-args (file1 file2 context width) + "Build the difftastic argument list for FILE1 vs FILE2. +CONTEXT is the number of unchanged lines shown around each change; WIDTH is the +column budget for the side-by-side layout (difftastic cannot detect a terminal +when run as a subprocess and would otherwise wrap at 80)." + (list "--color" "always" + "--display" "side-by-side-show-both" + "--context" (number-to-string context) + "--width" (number-to-string width) + file1 file2)) + +(defun cj/--unified-diff-args (file1 file2 context) + "Build the diff(1) argument list for a unified diff of FILE1 vs FILE2. +CONTEXT is the number of unchanged lines shown around each change (-U)." + (list (format "-U%d" context) file1 file2)) + (defun cj/--diff-with-difftastic (file1 file2 buffer) "Run difftastic on FILE1 and FILE2, output to BUFFER. -Applies ANSI color and sets up special-mode for navigation." +Applies ANSI color, sets up special-mode for navigation, and leaves point on +the first hunk's content (past this header and difftastic's file header) so the +window opens on the first difference." (with-current-buffer buffer (let ((inhibit-read-only t)) (erase-buffer) (insert (format "Difftastic diff: %s (saved) vs buffer (modified)\n\n" (file-name-nondirectory file1))) - (call-process "difft" nil t nil - "--color" "always" - "--display" "side-by-side-show-both" - file1 file2) + (apply #'call-process "difft" nil t nil + (cj/--difft-args file1 file2 cj/diff-context-lines (frame-width))) (require 'ansi-color) (ansi-color-apply-on-region (point-min) (point-max)) (special-mode) - (goto-char (point-min))))) + (goto-char (point-min)) + (forward-line 2) ; past this function's header + (when (looking-at-p ".* --- ") ; difftastic's own file header + (forward-line 1)) + (while (and (eolp) (not (eobp))) ; any blank separator lines + (forward-line 1))))) (defun cj/--diff-with-regular-diff (file1 file2 buffer) "Run regular unified diff on FILE1 and FILE2, output to BUFFER. -Sets up diff-mode for navigation." +Sets up diff-mode for navigation and leaves point on the first @@ hunk so the +window opens on the first difference." (with-current-buffer buffer (let ((inhibit-read-only t)) (erase-buffer) (insert (format "Unified diff: %s (saved) vs buffer (modified)\n\n" (file-name-nondirectory file1))) - (call-process "diff" nil t nil "-u" file1 file2) + (apply #'call-process "diff" nil t nil + (cj/--unified-diff-args file1 file2 cj/diff-context-lines)) (diff-mode) - (goto-char (point-min))))) + (goto-char (point-min)) + (when (re-search-forward "^@@" nil t) + (goto-char (match-beginning 0)))))) (defun cj/--diff-buffer-renderer (ws-only difft-available) "Choose the diff renderer symbol from WS-ONLY and DIFFT-AVAILABLE. @@ -454,11 +484,13 @@ the question so the reader knows the difference is whitespace before choosing." "Return the terse `read-multiple-choice' menu for the disk-changed save prompt. Inline names are single words so the menu fits at a glance; the full meaning is in each description (the ? help). s overwrites the file with the buffer; r -discards the buffer's edits and rereads from disk." +discards the buffer's edits and rereads from disk; m resolves the two versions +side-by-side in ediff." '((?s "save" "overwrite the file with this buffer") - (?d "diff" "show what changed, then ask again") + (?d "diff" "review the diff; navigate and act from there") (?w "clean" "clean whitespace and save") (?r "revert" "discard edits and reread from disk") + (?m "merge" "resolve side-by-side in ediff, then save from there") (?c "cancel" "leave the buffer as is"))) (defun cj/--buffer-changed-on-disk-p (buffer) @@ -475,43 +507,143 @@ underneath has moved, so a plain save would silently overwrite the disk version. (defun cj/--buffer-differs-action (key) "Map a disk-changed-prompt KEY to an action symbol, or nil when unmapped. `save' overwrites the file, `clean-save' cleans whitespace then saves, `revert' -rereads from disk, `cancel' does nothing, and `diff' peeks (the caller re-prompts)." +rereads from disk, `merge' resolves in ediff, `cancel' does nothing, and `diff' +peeks (the caller re-prompts)." (pcase key (?s 'save) (?w 'clean-save) (?r 'revert) + (?m 'merge) (?d 'diff) (?c 'cancel))) +(declare-function ediff-current-file "ediff") + (defun cj/--buffer-differs-dispatch (buffer action) "Carry out ACTION for BUFFER after a disk-changed prompt. `save' overwrites the file with the buffer; `clean-save' strips trailing whitespace first; `revert' discards the buffer's edits and rereads the disk; -`cancel' leaves the buffer untouched. Save updates the recorded modtime first so -the stock `save-buffer' does not re-ask its own \"changed on disk\" question." +`merge' launches `ediff-current-file' to resolve the buffer against the disk +version hunk-by-hunk (the file's modtime is deliberately NOT marked as seen, so +the save after merging re-asks once -- an abandoned merge can never silently +overwrite the disk); `cancel' leaves the buffer untouched. Save updates the +recorded modtime first so the stock `save-buffer' does not re-ask its own +\"changed on disk\" question." (with-current-buffer buffer (pcase action ('save (set-visited-file-modtime) (save-buffer)) ('clean-save (delete-trailing-whitespace) (set-visited-file-modtime) (save-buffer)) ('revert (revert-buffer t t)) + ('merge (ediff-current-file) + (message "Merge in ediff, quit it, then C-x C-s and choose save")) ('cancel (message "Save cancelled; buffer left as is")) (_ nil)))) +(defvar cj/--diff-review-choice nil + "Key chosen inside the diff review, or nil when the review went back to the menu.") + +(declare-function diff-hunk-next "diff-mode") +(declare-function diff-hunk-prev "diff-mode") + +(defun cj/--diff-section-next () + "Move to the next hunk in the diff buffer. +Uses diff-mode's hunk motion when available; otherwise the next +blank-line-separated section (difftastic output)." + (interactive) + (if (derived-mode-p 'diff-mode) + (diff-hunk-next) + (forward-paragraph) + (skip-chars-forward "\n"))) + +(defun cj/--diff-section-prev () + "Move to the previous hunk in the diff buffer. +Uses diff-mode's hunk motion when available; otherwise the previous +blank-line-separated section (difftastic output)." + (interactive) + (if (derived-mode-p 'diff-mode) + (diff-hunk-prev) + (skip-chars-backward "\n") + (backward-paragraph) + (skip-chars-forward "\n"))) + +(defun cj/--diff-review-choose (key) + "Record KEY as the review's choice and exit the review." + (setq cj/--diff-review-choice key) + (exit-recursive-edit)) + +(defun cj/--diff-review-back () + "Leave the diff review without a choice; the menu prompt returns." + (interactive) + (exit-recursive-edit)) + +(defun cj/--diff-review-keymap (choices) + "Build the diff-review keymap from CHOICES (a `read-multiple-choice' list). +Every menu key except d acts directly from the review. TAB / S-TAB (and n / p +when the menu doesn't claim them) move between hunks. ESC always goes back to +the menu prompt; q does too unless the menu claims q (the save-some loop)." + (let ((map (make-sparse-keymap))) + (dolist (entry choices) + (let ((key (car entry))) + (unless (eq key ?d) + (define-key map (vector key) + (lambda () (interactive) (cj/--diff-review-choose key)))))) + (unless (assq ?q choices) + (define-key map "q" #'cj/--diff-review-back)) + (unless (assq ?n choices) + (define-key map "n" #'cj/--diff-section-next)) + (unless (assq ?p choices) + (define-key map "p" #'cj/--diff-section-prev)) + (define-key map (kbd "") #'cj/--diff-review-back) + (define-key map (kbd "TAB") #'cj/--diff-section-next) + (define-key map (kbd "") #'cj/--diff-section-prev) + map)) + +(defun cj/--diff-review-hint (choices) + "One-line echo hint for the review built from CHOICES: actions, motion, exit." + (concat + (mapconcat (lambda (e) (format "%c:%s" (car e) (nth 1 e))) + (seq-remove (lambda (e) (eq (car e) ?d)) choices) + " ") + " TAB:hunks" + (if (assq ?q choices) " ESC:menu" " q:menu"))) + +(defun cj/--diff-review (diff-buffer choices) + "Modal review of DIFF-BUFFER: navigate the diff, act with the menu keys. +Selects DIFF-BUFFER's window and enters a recursive edit with the keymap from +CHOICES layered on top, so point motion (arrows, scrolling, search) works while +every menu key acts immediately. Returns the chosen key, or nil when the user +went back to the menu (q, ESC, or an aborted recursive edit). Returns nil +untouched when the buffer has no window to review in." + (let ((win (and (buffer-live-p diff-buffer) (get-buffer-window diff-buffer)))) + (when win + (setq cj/--diff-review-choice nil) + (with-selected-window win + (let ((exit-fn (set-transient-map (cj/--diff-review-keymap choices) + (lambda () t)))) + (unwind-protect + (progn + (message "%s" (cj/--diff-review-hint choices)) + (condition-case nil (recursive-edit) (quit nil))) + (funcall exit-fn)))) + cj/--diff-review-choice))) + (defun cj/--read-choice-with-diff (prompt choices show-diff-fn) - "Read a `read-multiple-choice' key for PROMPT and CHOICES; d toggles a diff. -SHOW-DIFF-FN displays the buffer/file diff and returns its buffer. The d key -shows the diff, or hides it when it is already shown (a toggle). Any other key --- a terminating choice -- closes a still-open diff window before returning that -key, so the diff never lingers after the decision is made." + "Read a `read-multiple-choice' key for PROMPT and CHOICES; d reviews the diff. +SHOW-DIFF-FN displays the buffer/file diff and returns its buffer. d shows the +diff and enters a navigable review (`cj/--diff-review') where every other menu +key acts directly; leaving the review (q, ESC) returns to this prompt. A +terminating choice -- made at the prompt or inside the review -- closes a +still-open diff window before it is returned, so the diff never lingers after +the decision is made." (let ((key nil) (diff-buf nil)) (while (not key) (let ((k (car (read-multiple-choice prompt choices)))) - (if (eq k ?d) - (let ((win (and (buffer-live-p diff-buf) (get-buffer-window diff-buf)))) - (if win - (progn (quit-window nil win) (setq diff-buf nil)) - (setq diff-buf (funcall show-diff-fn)))) - (setq key k)))) + (if (not (eq k ?d)) + (setq key k) + (unless (and (buffer-live-p diff-buf) (get-buffer-window diff-buf)) + (setq diff-buf (funcall show-diff-fn))) + (when diff-buf + (setq key (cj/--diff-review diff-buf choices)))))) (let ((win (and (buffer-live-p diff-buf) (get-buffer-window diff-buf)))) (when win (quit-window nil win))) key)) @@ -519,7 +651,8 @@ key, so the diff never lingers after the decision is made." (defun cj/--buffer-differs-read-key (buffer ws-only) "Read a disk-changed-prompt key for BUFFER via `read-multiple-choice'. WS-ONLY non-nil folds a terse \"(whitespace only)\" note into the prompt. d -toggles the buffer/file diff; a terminating choice closes a still-open diff." +opens the buffer/file diff in a navigable review where the menu keys act +directly; a terminating choice closes a still-open diff." (cj/--read-choice-with-diff (cj/--buffer-differs-prompt-string (buffer-name buffer) ws-only) (cj/--buffer-differs-choices) @@ -530,8 +663,10 @@ toggles the buffer/file diff; a terminating choice closes a still-open diff." A normal save falls straight through to `save-buffer' (ARG, the prefix argument, is passed along so \\[universal-argument] \\[save-buffer] still marks for backup). When the buffer has unsaved edits AND the file changed on disk since it was -visited, offer a terse labeled menu -- save / diff / clean / revert / cancel -- -instead of the stock yes/no \"Save anyway?\" prompt. Bound to \\`C-x C-s'." +visited, offer a terse labeled menu -- save / diff / clean / revert / merge / +cancel -- instead of the stock yes/no \"Save anyway?\" prompt. d opens a +navigable diff review (arrows and TAB move, the menu keys act from inside); m +resolves the two versions in ediff. Bound to \\`C-x C-s'." (interactive "P") (if (not (cj/--buffer-changed-on-disk-p (current-buffer))) (save-buffer arg) @@ -562,7 +697,7 @@ in each description (the ? help)." '((?y "save" "save this buffer") (?n "skip" "do not save this buffer") (?w "clean" "clean whitespace and save this buffer") - (?d "diff" "show what changed, then ask again") + (?d "diff" "review the diff; navigate and act from there") (?! "all" "save this and all remaining buffers") (?. "only" "save this buffer, then skip the rest") (?q "none" "stop; save no more buffers"))) @@ -607,7 +742,8 @@ skip the rest). KEY-FN is not consulted once a buffer triggers save-rest or sto (defun cj/--save-some-buffers-read-key (buffer ws-only) "Read a save-loop key for BUFFER via `read-multiple-choice'. WS-ONLY non-nil folds a terse \"(whitespace only)\" note into the prompt. d -toggles the buffer/file diff; a terminating choice closes a still-open diff." +opens the buffer/file diff in a navigable review where the menu keys act +directly; a terminating choice closes a still-open diff." (cj/--read-choice-with-diff (format "Save %s%s" (if (buffer-file-name buffer) diff --git a/tests/test-custom-buffer-file--diff-review.el b/tests/test-custom-buffer-file--diff-review.el new file mode 100644 index 00000000..5418638d --- /dev/null +++ b/tests/test-custom-buffer-file--diff-review.el @@ -0,0 +1,261 @@ +;;; test-custom-buffer-file--diff-review.el --- navigable diff review pieces -*- lexical-binding: t; -*- + +;;; Commentary: +;; Pure-logic tests for the navigable diff review added to the disk-changed save +;; prompts: the diff command argument builders (context + width), first-hunk +;; positioning, the generated review keymap (menu keys act inside the diff, q +;; goes back, TAB/S-TAB navigate), the merge menu entry, and the reworked +;; read-choice loop (d enters the review; a review choice terminates the +;; prompt). The recursive-edit itself and the live window interaction are +;; exercised manually, not here. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'custom-buffer-file) + +(declare-function cj/--difft-args "custom-buffer-file" (file1 file2 context width)) +(declare-function cj/--unified-diff-args "custom-buffer-file" (file1 file2 context)) +(declare-function cj/--diff-review-keymap "custom-buffer-file" (choices)) +(declare-function cj/--diff-review-back "custom-buffer-file" ()) +(declare-function cj/--diff-section-next "custom-buffer-file" ()) +(declare-function cj/--diff-section-prev "custom-buffer-file" ()) +(declare-function cj/--diff-review "custom-buffer-file" (diff-buffer choices)) +(declare-function cj/--diff-with-regular-diff "custom-buffer-file" (file1 file2 buffer)) +(declare-function cj/--diff-with-difftastic "custom-buffer-file" (file1 file2 buffer)) +(declare-function cj/--buffer-differs-choices "custom-buffer-file" ()) +(declare-function cj/--buffer-differs-action "custom-buffer-file" (key)) +(declare-function cj/--buffer-differs-dispatch "custom-buffer-file" (buffer action)) +(declare-function cj/--save-some-buffers-choices "custom-buffer-file" ()) +(declare-function cj/--read-choice-with-diff "custom-buffer-file" (prompt choices show-diff-fn)) + +(defvar cj/--diff-review-choice) +(defvar cj/diff-context-lines) + +;;; ------------------------------ arg builders -------------------------------- + +(ert-deftest test-cbf-difft-args-context-and-width () + "Normal: difft args carry the context and width knobs plus both files." + (let ((args (cj/--difft-args "/a.el" "/b.el" 1 143))) + (should (member "--context" args)) + (should (member "1" args)) + (should (member "--width" args)) + (should (member "143" args)) + (should (member "/a.el" args)) + (should (member "/b.el" args)))) + +(ert-deftest test-cbf-difft-args-color-and-display () + "Normal: difft args keep forced color and the side-by-side display." + (let ((args (cj/--difft-args "/a" "/b" 3 80))) + (should (member "--color" args)) + (should (member "always" args)) + (should (member "side-by-side-show-both" args)))) + +(ert-deftest test-cbf-difft-args-zero-context () + "Boundary: context 0 is passed through, not dropped." + (let ((args (cj/--difft-args "/a" "/b" 0 80))) + (should (member "--context" args)) + (should (member "0" args)))) + +(ert-deftest test-cbf-unified-diff-args-context () + "Normal: unified diff args use -U and both files, in order." + (should (equal (cj/--unified-diff-args "/a" "/b" 1) '("-U1" "/a" "/b")))) + +(ert-deftest test-cbf-unified-diff-args-zero-context () + "Boundary: context 0 produces -U0." + (should (equal (car (cj/--unified-diff-args "/a" "/b" 0)) "-U0"))) + +;;; ------------------------- first-hunk positioning --------------------------- + +(defun test-cbf-review--two-files (line-changed body-fn) + "Make two 60-line temp files differing at LINE-CHANGED; call BODY-FN with both." + (let ((f1 (make-temp-file "cbf-rev-a-" nil ".txt")) + (f2 (make-temp-file "cbf-rev-b-" nil ".txt"))) + (unwind-protect + (progn + (with-temp-file f1 + (dotimes (i 60) (insert (format "line %d stays the same\n" (1+ i))))) + (with-temp-file f2 + (dotimes (i 60) + (insert (if (= (1+ i) line-changed) + (format "line %d CHANGED in buffer\n" (1+ i)) + (format "line %d stays the same\n" (1+ i)))))) + (funcall body-fn f1 f2)) + (when (file-exists-p f1) (delete-file f1)) + (when (file-exists-p f2) (delete-file f2))))) + +(ert-deftest test-cbf-regular-diff-lands-on-first-hunk () + "Normal: after rendering a unified diff, point sits on the first @@ hunk line." + (test-cbf-review--two-files + 30 + (lambda (f1 f2) + (with-temp-buffer + (cj/--diff-with-regular-diff f1 f2 (current-buffer)) + (should (looking-at "@@")))))) + +(ert-deftest test-cbf-difftastic-lands-past-headers () + "Normal: after rendering difftastic output, point sits on hunk content, not the headers." + (skip-unless (executable-find "difft")) + (test-cbf-review--two-files + 30 + (lambda (f1 f2) + (with-temp-buffer + (cj/--diff-with-difftastic f1 f2 (current-buffer)) + (should (> (line-number-at-pos) 2)) ; past our two-line header + (should-not (looking-at-p ".* --- ")) ; past difft's file header + (should-not (eolp)))))) ; on a content line, not a blank + +;;; ------------------------------ review keymap ------------------------------- + +(ert-deftest test-cbf-review-keymap-binds-menu-keys () + "Normal: every buffer-differs menu key except d becomes a review binding." + (let ((map (cj/--diff-review-keymap (cj/--buffer-differs-choices)))) + (dolist (key '(?s ?w ?r ?m ?c)) + (should (commandp (lookup-key map (vector key))))) + (should-not (lookup-key map (vector ?d))))) + +(ert-deftest test-cbf-review-keymap-q-goes-back-when-free () + "Normal: q maps to back-to-menu when the menu does not claim q." + (let ((map (cj/--diff-review-keymap (cj/--buffer-differs-choices)))) + (should (eq (lookup-key map "q") #'cj/--diff-review-back)))) + +(ert-deftest test-cbf-review-keymap-q-stays-menu-key-when-claimed () + "Boundary: in the save-some loop q is a menu action, not back-to-menu." + (let ((map (cj/--diff-review-keymap (cj/--save-some-buffers-choices)))) + (should (commandp (lookup-key map "q"))) + (should-not (eq (lookup-key map "q") #'cj/--diff-review-back)))) + +(ert-deftest test-cbf-review-keymap-navigation-bound () + "Normal: TAB and S-TAB navigate hunks in every review." + (let ((map (cj/--diff-review-keymap (cj/--buffer-differs-choices)))) + (should (eq (lookup-key map (kbd "TAB")) #'cj/--diff-section-next)) + (should (eq (lookup-key map (kbd "")) #'cj/--diff-section-prev)))) + +(ert-deftest test-cbf-review-keymap-np-navigate-when-free () + "Normal: n/p navigate hunks when the menu does not claim them." + (let ((map (cj/--diff-review-keymap (cj/--buffer-differs-choices)))) + (should (eq (lookup-key map "n") #'cj/--diff-section-next)) + (should (eq (lookup-key map "p") #'cj/--diff-section-prev)))) + +(ert-deftest test-cbf-review-keymap-n-stays-menu-key-when-claimed () + "Boundary: in the save-some loop n is the skip action, not navigation." + (let ((map (cj/--diff-review-keymap (cj/--save-some-buffers-choices)))) + (should (commandp (lookup-key map "n"))) + (should-not (eq (lookup-key map "n") #'cj/--diff-section-next)))) + +(ert-deftest test-cbf-review-keymap-escape-always-back () + "Normal: ESC goes back to the menu even when the menu claims q (save-some loop)." + (let ((map (cj/--diff-review-keymap (cj/--save-some-buffers-choices)))) + (should (eq (lookup-key map (kbd "")) #'cj/--diff-review-back)))) + +(ert-deftest test-cbf-review-choice-command-records-key () + "Normal: a review action command records its key and exits the review." + (let ((map (cj/--diff-review-keymap (cj/--buffer-differs-choices))) + (cj/--diff-review-choice nil) + (exited nil)) + (cl-letf (((symbol-function 'exit-recursive-edit) + (lambda (&rest _) (setq exited t)))) + (funcall (lookup-key map "s")) + (should (eq cj/--diff-review-choice ?s)) + (should exited)))) + +(ert-deftest test-cbf-review-back-leaves-choice-nil () + "Normal: back-to-menu exits the review without recording a choice." + (let ((cj/--diff-review-choice nil) + (exited nil)) + (cl-letf (((symbol-function 'exit-recursive-edit) + (lambda (&rest _) (setq exited t)))) + (cj/--diff-review-back) + (should-not cj/--diff-review-choice) + (should exited)))) + +;;; ---------------------------- merge menu entry ------------------------------ + +(ert-deftest test-cbf-buffer-differs-choices-include-merge () + "Normal: the disk-changed menu offers m for merge, described via ediff." + (let ((entry (assq ?m (cj/--buffer-differs-choices)))) + (should entry) + (should (string-match-p "ediff" (or (nth 2 entry) ""))))) + +(ert-deftest test-cbf-buffer-differs-action-merge () + "Normal: m maps to the merge action." + (should (eq (cj/--buffer-differs-action ?m) 'merge))) + +(ert-deftest test-cbf-buffer-differs-dispatch-merge-launches-ediff () + "Normal: dispatching merge launches ediff-current-file in the conflicted buffer." + (let ((launched-in nil)) + (cl-letf (((symbol-function 'ediff-current-file) + (lambda (&rest _) (setq launched-in (current-buffer))))) + (with-temp-buffer + (cj/--buffer-differs-dispatch (current-buffer) 'merge) + (should (eq launched-in (current-buffer))))))) + +(ert-deftest test-cbf-buffer-differs-dispatch-merge-keeps-buffer-modified () + "Boundary: merge neither saves nor reverts -- the buffer's state is untouched." + (cl-letf (((symbol-function 'ediff-current-file) (lambda (&rest _) nil))) + (with-temp-buffer + (insert "content") + (set-buffer-modified-p t) + (cj/--buffer-differs-dispatch (current-buffer) 'merge) + (should (buffer-modified-p))))) + +;;; ------------------------- read-choice loop rework -------------------------- + +(defun test-cbf-review--run-loop (rmc-keys review-results &optional diff-buffer) + "Drive cj/--read-choice-with-diff with scripted inputs. +RMC-KEYS are successive read-multiple-choice answers; REVIEW-RESULTS are +successive cj/--diff-review returns. DIFF-BUFFER (or a fresh one) is what the +show-diff-fn yields. Returns (RESULT RMC-CALLS REVIEW-CALLS)." + (let ((rmc-calls 0) (review-calls 0) (keys rmc-keys) (reviews review-results)) + (cl-letf (((symbol-function 'read-multiple-choice) + (lambda (&rest _) + (setq rmc-calls (1+ rmc-calls)) + (list (pop keys)))) + ((symbol-function 'cj/--diff-review) + (lambda (&rest _) + (setq review-calls (1+ review-calls)) + (pop reviews)))) + (let ((result (cj/--read-choice-with-diff + "Prompt" (cj/--buffer-differs-choices) + (lambda () diff-buffer)))) + (list result rmc-calls review-calls))))) + +(ert-deftest test-cbf-read-choice-review-choice-terminates () + "Normal: a key chosen inside the diff review terminates the prompt directly." + (with-temp-buffer + (pcase-let ((`(,result ,rmc-calls ,review-calls) + (test-cbf-review--run-loop '(?d) '(?s) (current-buffer)))) + (should (eq result ?s)) + (should (= rmc-calls 1)) + (should (= review-calls 1))))) + +(ert-deftest test-cbf-read-choice-review-back-reprompts () + "Normal: leaving the review without a choice re-shows the menu prompt." + (with-temp-buffer + (pcase-let ((`(,result ,rmc-calls ,review-calls) + (test-cbf-review--run-loop '(?d ?c) '(nil) (current-buffer)))) + (should (eq result ?c)) + (should (= rmc-calls 2)) + (should (= review-calls 1))))) + +(ert-deftest test-cbf-read-choice-no-diff-skips-review () + "Error: when the diff cannot render (no differences), d re-prompts without a review." + (pcase-let ((`(,result ,rmc-calls ,review-calls) + (test-cbf-review--run-loop '(?d ?c) '(?s) nil))) + (should (eq result ?c)) + (should (= rmc-calls 2)) + (should (= review-calls 0)))) + +(ert-deftest test-cbf-read-choice-plain-key-never-reviews () + "Boundary: a terminating menu key never enters the review." + (with-temp-buffer + (pcase-let ((`(,result ,rmc-calls ,review-calls) + (test-cbf-review--run-loop '(?s) '() (current-buffer)))) + (should (eq result ?s)) + (should (= rmc-calls 1)) + (should (= review-calls 0))))) + +(provide 'test-custom-buffer-file--diff-review) +;;; test-custom-buffer-file--diff-review.el ends here -- cgit v1.2.3