aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--docs/subr-mock-migration-spec.org158
-rw-r--r--modules/custom-buffer-file.el280
-rw-r--r--scripts/theme-studio/WIP.json136
-rw-r--r--tests/test-custom-buffer-file--buffer-differs-prompt.el197
-rw-r--r--tests/test-custom-buffer-file--diff-whitespace-only.el146
-rw-r--r--tests/test-custom-buffer-file--save-some-buffers.el123
-rw-r--r--themes/WIP-theme.el15
8 files changed, 1031 insertions, 27 deletions
diff --git a/.gitignore b/.gitignore
index 274ac4b0a..af0d2ca0c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -100,3 +100,6 @@ __pycache__/
# editor/image backup files
*.bak
smoke/
+
+# personal task list — kept local, not tracked
+todo.org
diff --git a/docs/subr-mock-migration-spec.org b/docs/subr-mock-migration-spec.org
new file mode 100644
index 000000000..26f1dd576
--- /dev/null
+++ b/docs/subr-mock-migration-spec.org
@@ -0,0 +1,158 @@
+#+TITLE: Spec: Migrate Tests Off Mocking C Primitives
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-30
+#+STATUS: Draft — for discussion
+
+* Status
+
+Draft. Pulled out of =todo.org= (=** TODO [#C] Migrate tests off mocking
+primitives (native-comp robustness) :test:refactor:solo:=) so the scope and
+approach can be settled before any code moves. Execution is deferred; this
+document is the discussion vehicle.
+
+Companion reference: [[file:native-comp-subr-mocking.org][native-comp-subr-mocking.org]] holds the full mechanism, the
+upstream research, and the 2026-06-21 decision. This spec does not restate the
+mechanism; it plans the remaining work that decision deferred.
+
+* Background — how we hit this
+
+We re-enabled native compilation config-wide (early-init.el, commit 3fd28987,
+2026-06-20). Tests that had been green for months immediately started failing
+with no change to their source — the first 8 were window-primitive mocks in
+=test-dirvish-config-wrappers.el= and =test-calibredb-epub-config.el=
+(=window-body-width=, =window-margins=, =current-window-configuration=,
+=get-buffer-window=), throwing =wrong-number-of-arguments= for a zero-arg mock
+lambda called with one argument.
+
+** What we struggled with (the consequences)
+
+- *Intermittent, non-deterministic failure.* The same test passed, then
+ crashed, then passed again within a session. Native-comp generates a
+ per-primitive trampoline =.eln= lazily and caches it on disk; whether a mock
+ "works" depends on whether that trampoline has been built yet. The
+ non-determinism was the tell, and it made the failures hard to trust or
+ reproduce.
+- *Three distinct failure modes from one cause* (full detail in the companion
+ doc): (1) trampoline generation failure under =--batch=; (2) silent bypass —
+ natively-compiled callers ignore the mock and run the real primitive, so a
+ test passes for the wrong reason; (3) arity mismatch — the trampoline calls
+ the mock with the primitive's *maximum* arity, so a fixed-arity mock narrower
+ than the primitive throws. Mode 3 is the one that bit us; modes 1 and 2 sit
+ latent.
+- *The tempting quick fix is the dangerous one.* Disabling subr trampolines
+ (=native-comp-enable-subr-trampolines nil=) is the most-cited workaround, but
+ in our native-comp-heavy setup it produces mode 2 — tests that pass while
+ asserting against the real primitive. A quiet false pass is worse than a loud
+ crash.
+- *Scale of the latent surface.* The suite mocks subrs in hundreds of places.
+ The variadic sweep touched 188 arity-narrow mocks.
+
+** What is already done (the stopgap, 2026-06-21)
+
+Not currently broken — two things shipped (commits 571da499, b62c3c88):
+
+1. *Variadic sweep.* Every arity-narrow subr mock got =&rest _= appended, which
+ tolerates the trampoline's full-arity call. Deterministic, keeps trampolines
+ on, so no silent bypass. Fixes mode 3.
+2. *Meta-test gate.* =tests/test-meta-subr-mock-arity.el= statically scans every
+ test file for =symbol-function= / =fset= / =setf= subr redefinitions and
+ fails =make test= if any mock can't accept the primitive's maximum arity. A
+ new arity-narrow mock can't merge silently.
+
+The stopgap fixes the mode we actually suffered. It leaves modes 1 and 2 latent.
+The durable fix the ecosystem (and our own =elisp-testing.md=) points to is to
+*not redefine primitives at all*. That is the work this spec scopes.
+
+* The real scope — most mocks should NOT move
+
+The raw inventory is large, but the headline number is misleading. =testing.md=
+says to mock external boundaries; converting those to "drive real state" would
+mean running real shells and touching the real filesystem in unit tests — the
+opposite of what we want. So the actual migration target is narrow.
+
+Current subr-mock sites across 261 test files (=cl-letf= / =fset= / =advice-add=
+on the named primitive):
+
+| Primitive | Sites | Classification | Disposition |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| shell-command-to-string | 62 | external boundary | keep mocked (variadic) |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| executable-find | 60 | external boundary | keep mocked (variadic) |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| shell-command | 29 | external boundary | keep mocked (variadic) |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| call-process | 17 | external boundary | keep mocked (variadic) |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| current-time | 11 | time boundary | keep mocked (variadic) |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| save-buffer | 10 | file I/O boundary | keep, or real temp-file fixture |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| write-region | 4 | file I/O boundary | keep, or real temp-file fixture |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| message | 69 | output-silencing | keep mocked (variadic) |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| completing-read | 25 | UI prompt | MIGRATE — extract pure internal |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| read-string | 16 | UI prompt | MIGRATE — extract pure internal |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| yes-or-no-p | 14 | UI prompt | MIGRATE — extract pure internal |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| read-from-minibuffer | 6 | UI prompt | MIGRATE — extract pure internal |
+|-------------------------+-------+-------------------------+-----------------------------------|
+
+The genuine migration target is the UI-prompt bucket: ~61 sites. Per
+=elisp-testing.md='s Interactive-vs-Internal rule, the fix is to extract a pure
+internal that takes the value as an argument and test that directly, leaving the
+interactive wrapper a thin un-tested (or smoke-tested) shell. That removes the
+prompt mock entirely — immune to all three failure modes — and improves the
+production code's testability as a side effect.
+
+Boundary mocks (shell, file I/O, time, =executable-find=, =call-process=) stay
+mocked: that is correct unit-test practice, and the variadic form already
+handles native-comp. =message= is output-silencing, not logic — keep it.
+
+* Proposed approach
+
+Not a single sweep. The migration touches production code (each extraction is a
+small design change), so it is incremental and reviewable, not mechanical.
+
+1. *Scoped exemplar pass first.* Pick one module with a few prompt-mocks, do the
+ extract-internal conversion there, set the pattern, and measure the per-case
+ effort. This calibrates the rest.
+2. *Batch by module afterward.* Convert remaining UI-prompt sites a module at a
+ time, each its own commit, with the suite green between.
+3. *Leave boundaries alone.* No conversion of shell / file / time / process
+ mocks. The meta-test keeps them arity-safe.
+
+* Open decisions (resolve in discussion)
+
+** TODO Confirm the scope: UI-prompt mocks only, boundaries stay
+Is the migration scoped to the ~61 UI-prompt sites (completing-read, read-string,
+yes-or-no-p, read-from-minibuffer), with all boundary mocks explicitly out of
+scope? Or is there an appetite to also convert the file-I/O mocks
+(=save-buffer=, =write-region=) to real temp-file fixtures where it reads
+cleaner?
+
+** TODO Reframe the todo.org task title to match the real scope
+The current title — "Migrate tests off mocking primitives" — reads as all 300+
+sites. If we agree on UI-prompt-only, retitle to something like "Extract pure
+internals for UI-prompt-mocked tests" so a future session does not re-scope it
+as a wholesale sweep.
+
+** TODO Pick the exemplar module for the first pass
+Which module gets the calibrating conversion? A small one with 2-4 prompt-mocks
+is ideal. Candidate selection needs a per-module breakdown of the ~61 sites
+(not yet collected).
+
+** TODO Decide priority and timing
+Currently =[#C]=, =:solo:=. The suite is not broken (stopgap holds), so this is
+test-quality debt, not urgent. Confirm it stays low and gets done in batches
+between other work, rather than as a dedicated push.
+
+* Non-goals
+
+- Re-deriving or re-documenting the native-comp trampoline mechanism (see the
+ companion doc).
+- Converting boundary mocks (shell, file I/O, time, process, executable-find).
+- Removing the variadic-mock convention or the meta-test gate — both stay; they
+ are the standing protection for every mock that legitimately remains.
diff --git a/modules/custom-buffer-file.el b/modules/custom-buffer-file.el
index 261956f37..38ae0bae1 100644
--- a/modules/custom-buffer-file.el
+++ b/modules/custom-buffer-file.el
@@ -370,6 +370,262 @@ Sets up diff-mode for navigation."
(diff-mode)
(goto-char (point-min)))))
+(defun cj/--diff-buffer-renderer (ws-only difft-available)
+ "Choose the diff renderer symbol from WS-ONLY and DIFFT-AVAILABLE.
+`whitespace' for a whitespace-only diff (a plain unified diff with trailing
+whitespace highlighted, because difftastic treats it as no change and renders it
+blank); otherwise `difftastic' when available, else `regular'."
+ (cond (ws-only 'whitespace)
+ (difft-available 'difftastic)
+ (t 'regular)))
+
+(defun cj/--diff-whitespace-only-p (file-a file-b)
+ "Return non-nil if FILE-A and FILE-B differ ONLY in whitespace.
+Route-1 detection via diff(1): true when a plain `diff' reports a difference but
+`diff -w' (ignore all whitespace) reports none. Identical files differ in
+nothing, so they are not whitespace-only."
+ (and (not (zerop (call-process "diff" nil nil nil "-q" file-a file-b)))
+ (zerop (call-process "diff" nil nil nil "-q" "-w" file-a file-b))))
+
+(defun cj/--buffer-differs-prompt-string (name ws-only-p)
+ "Build the buffer-differs prompt question for buffer NAME.
+When WS-ONLY-P is non-nil, fold a terse \"(whitespace only)\" parenthetical into
+the question so the reader knows the difference is whitespace before choosing."
+ (format "%s changed on disk%s"
+ name (if ws-only-p " (whitespace only)" "")))
+
+(defun cj/--buffer-differs-choices ()
+ "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."
+ '((?s "save" "overwrite the file with this buffer")
+ (?d "diff" "show what changed, then ask again")
+ (?w "clean" "clean whitespace and save")
+ (?r "revert" "discard edits and reread from disk")
+ (?c "cancel" "leave the buffer as is")))
+
+(defun cj/--buffer-changed-on-disk-p (buffer)
+ "Return non-nil if BUFFER is modified AND its file changed on disk since visited.
+This is the disk-changed conflict: there are unsaved edits to lose AND the file
+underneath has moved, so a plain save would silently overwrite the disk version."
+ (when (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (and (buffer-modified-p)
+ buffer-file-name
+ (file-exists-p buffer-file-name)
+ (not (verify-visited-file-modtime buffer))))))
+
+(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)."
+ (pcase key
+ (?s 'save)
+ (?w 'clean-save)
+ (?r 'revert)
+ (?d 'diff)
+ (?c 'cancel)))
+
+(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."
+ (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))
+ ('cancel (message "Save cancelled; buffer left as is"))
+ (_ nil))))
+
+(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."
+ (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))))
+ (let ((win (and (buffer-live-p diff-buf) (get-buffer-window diff-buf))))
+ (when win (quit-window nil win)))
+ key))
+
+(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."
+ (cj/--read-choice-with-diff
+ (cj/--buffer-differs-prompt-string (buffer-name buffer) ws-only)
+ (cj/--buffer-differs-choices)
+ (lambda () (with-current-buffer buffer (cj/diff-buffer-with-file)))))
+
+(defun cj/save-buffer (&optional arg)
+ "Save the current buffer; show a legible menu when the file changed on disk.
+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'."
+ (interactive "P")
+ (if (not (cj/--buffer-changed-on-disk-p (current-buffer)))
+ (save-buffer arg)
+ (let* ((buf (current-buffer))
+ (ws-only (cj/--buffer-file-whitespace-only-p buf))
+ (key (cj/--buffer-differs-read-key buf ws-only)))
+ (cj/--buffer-differs-dispatch buf (cj/--buffer-differs-action key)))))
+
+(defun cj/--save-some-buffers-action (key)
+ "Map a save-loop KEY to (THIS-ACTION . LOOP-EFFECT), or nil when unmapped.
+THIS-ACTION is `save', `clean-save', `skip', or `diff'. LOOP-EFFECT is
+`continue' (keep prompting), `save-rest' (save this and all remaining without
+asking), `stop' (act on this, skip the rest), or `reprompt' (peek, then ask the
+same buffer again)."
+ (pcase key
+ (?y '(save . continue))
+ (?n '(skip . continue))
+ (?w '(clean-save . continue))
+ (?! '(save . save-rest))
+ (?. '(save . stop))
+ (?q '(skip . stop))
+ (?d '(diff . reprompt))))
+
+(defun cj/--save-some-buffers-choices ()
+ "Return the terse `read-multiple-choice' choices for the save loop.
+Single-word inline names keep the menu to the minimum space; the full meaning is
+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")
+ (?! "all" "save this and all remaining buffers")
+ (?. "only" "save this buffer, then skip the rest")
+ (?q "none" "stop; save no more buffers")))
+
+(defun cj/--buffer-file-whitespace-only-p (buffer)
+ "Return non-nil if BUFFER's text differs from its visited file ONLY in whitespace.
+Writes the buffer to a temp file and reuses `cj/--diff-whitespace-only-p'. Nil
+when BUFFER visits no file or the file is gone."
+ (when (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (let ((file (buffer-file-name)))
+ (when (and file (file-exists-p file))
+ (let ((temp (make-temp-file "cbf-ws-buf-" nil
+ (or (file-name-extension file t) "")))
+ (content (buffer-string)))
+ (unwind-protect
+ (progn (with-temp-file temp (insert content))
+ (cj/--diff-whitespace-only-p file temp))
+ (when (file-exists-p temp) (delete-file temp)))))))))
+
+(defun cj/--save-some-buffers-plan (buffers key-fn)
+ "Resolve each buffer in BUFFERS to a per-buffer action using KEY-FN.
+KEY-FN is called with a buffer and returns a `read-multiple-choice' key; the diff
+re-prompt is the caller's concern, so KEY-FN never returns ?d. Returns a list of
+\(BUFFER . ACTION) where ACTION is `save', `clean-save', or `skip', honoring
+`save-rest' (! saves this and all remaining) and `stop' (./q act on this, then
+skip the rest). KEY-FN is not consulted once a buffer triggers save-rest or stop."
+ (let ((plan nil) (mode 'ask))
+ (dolist (buf buffers (nreverse plan))
+ (pcase mode
+ ('save-all (push (cons buf 'save) plan))
+ ('done (push (cons buf 'skip) plan))
+ ('ask
+ (pcase (cj/--save-some-buffers-action (funcall key-fn buf))
+ (`(,act . save-rest) (push (cons buf act) plan) (setq mode 'save-all))
+ (`(,act . stop) (push (cons buf act) plan) (setq mode 'done))
+ (`(,act . ,_) (push (cons buf act) plan))
+ (_ (push (cons buf 'skip) plan))))))))
+
+(declare-function files--buffers-needing-to-be-saved "files" (pred))
+
+(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."
+ (cj/--read-choice-with-diff
+ (format "Save %s%s"
+ (if (buffer-file-name buffer)
+ (file-name-nondirectory (buffer-file-name buffer))
+ (buffer-name buffer))
+ (if ws-only " (whitespace only)" ""))
+ (cj/--save-some-buffers-choices)
+ (lambda () (with-current-buffer buffer (cj/diff-buffer-with-file)))))
+
+(defun cj/--save-some-buffers-execute (plan)
+ "Carry out PLAN, a list of (BUFFER . ACTION); return the number saved.
+ACTION `clean-save' deletes trailing whitespace before saving; `save' saves as-is;
+`skip' leaves the buffer alone."
+ (let ((n 0))
+ (dolist (entry plan n)
+ (let ((buffer (car entry)))
+ (when (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (pcase (cdr entry)
+ ('clean-save (delete-trailing-whitespace) (save-buffer) (setq n (1+ n)))
+ ('save (save-buffer) (setq n (1+ n)))
+ (_ nil))))))))
+
+(defun cj/save-some-buffers (&optional arg pred)
+ "Save modified buffers with a legible, labeled prompt per buffer.
+A `read-multiple-choice' replacement for `save-some-buffers': the options are
+shown on screen rather than recalled as keys, with an added clean-whitespace-and-
+save action and a per-buffer \"(whitespace only)\" note. ARG and PRED match
+`save-some-buffers' -- ARG non-nil saves all without asking; PRED selects which
+buffers are considered. Installed over `save-some-buffers' by advice, so \\[save-some-buffers]
+and the save-on-exit prompt both use it."
+ (interactive "P")
+ (unless pred
+ (setq pred
+ (if (and (symbolp save-some-buffers-default-predicate)
+ (get save-some-buffers-default-predicate
+ 'save-some-buffers-function))
+ (funcall save-some-buffers-default-predicate)
+ save-some-buffers-default-predicate)))
+ (let (queried autosaved-buffers files-done inhibit-message)
+ (save-window-excursion
+ ;; Save buffers flagged for unconditional save first (mirrors the original).
+ (dolist (buffer (buffer-list))
+ (with-current-buffer buffer
+ (when (and buffer-save-without-query (buffer-modified-p))
+ (push (buffer-name) autosaved-buffers)
+ (save-buffer))))
+ (let* ((candidates (files--buffers-needing-to-be-saved pred))
+ (plan (if arg
+ (mapcar (lambda (b) (cons b 'save)) candidates)
+ (when candidates (setq queried t))
+ (cj/--save-some-buffers-plan
+ candidates
+ (lambda (b)
+ (cj/--save-some-buffers-read-key
+ b (cj/--buffer-file-whitespace-only-p b)))))))
+ (setq files-done (cj/--save-some-buffers-execute plan)))
+ ;; Let other things (abbrevs, etc.) save at this point.
+ (dolist (func save-some-buffers-functions)
+ (setq inhibit-message (or (funcall func nil arg) inhibit-message)))
+ (or queried (> files-done 0) inhibit-message
+ (cond
+ ((null autosaved-buffers)
+ (when (called-interactively-p 'any)
+ (message "(No files need saving)")))
+ ((= (length autosaved-buffers) 1)
+ (message "(Saved %s)" (car autosaved-buffers)))
+ (t (message "(Saved %d files: %s)" (length autosaved-buffers)
+ (mapconcat #'identity autosaved-buffers ", "))))))
+ files-done))
+
+(advice-add 'save-some-buffers :override #'cj/save-some-buffers)
+(keymap-global-set "C-x C-s" #'cj/save-buffer)
+
(defun cj/diff-buffer-with-file ()
"Compare the current modified buffer with the saved version.
Uses difftastic if available for syntax-aware diffing, otherwise
@@ -389,17 +645,27 @@ Signals an error if the buffer is not visiting a file."
(insert buffer-content))
;; Check if there are any differences first
(if (zerop (call-process "diff" nil nil nil "-q" file temp-file))
- (message "No differences between buffer and file")
- ;; Run diff/difftastic and display in buffer
- (let* ((using-difftastic (cj/executable-exists-p "difft"))
- (buffer-name (if using-difftastic
+ (progn (message "No differences between buffer and file") nil)
+ ;; Pick a renderer: difftastic for content diffs, but a plain unified
+ ;; diff with trailing whitespace highlighted for whitespace-only ones
+ ;; (difftastic treats trailing whitespace as no change and hides it).
+ (let* ((renderer (cj/--diff-buffer-renderer
+ (cj/--diff-whitespace-only-p file temp-file)
+ (cj/executable-exists-p "difft")))
+ (buffer-name (if (eq renderer 'difftastic)
"*Diff (difftastic)*"
"*Diff (unified)*"))
(diff-buffer (get-buffer-create buffer-name)))
- (if using-difftastic
+ (if (eq renderer 'difftastic)
(cj/--diff-with-difftastic file temp-file diff-buffer)
- (cj/--diff-with-regular-diff file temp-file diff-buffer))
- (display-buffer diff-buffer))))
+ (cj/--diff-with-regular-diff file temp-file diff-buffer)
+ (when (eq renderer 'whitespace)
+ (with-current-buffer diff-buffer
+ (setq-local show-trailing-whitespace t))))
+ (display-buffer diff-buffer)
+ ;; Return the diff buffer so callers (the save prompts) can toggle
+ ;; and auto-close its window.
+ diff-buffer)))
;; Clean up temp file
(when (file-exists-p temp-file)
(delete-file temp-file)))))
diff --git a/scripts/theme-studio/WIP.json b/scripts/theme-studio/WIP.json
index f30294dbc..5a54729bb 100644
--- a/scripts/theme-studio/WIP.json
+++ b/scripts/theme-studio/WIP.json
@@ -437,7 +437,7 @@
"sky"
],
[
- "#5f8bf9",
+ "#5178db",
"link",
"link"
]
@@ -1046,7 +1046,7 @@
"height": null
},
"link": {
- "fg": "#5f8bf9",
+ "fg": "#5178db",
"bg": null,
"distant-fg": null,
"family": null,
@@ -1571,7 +1571,16 @@
"pkg:nerd-icons:nerd-icons-red",
"pkg:nerd-icons:nerd-icons-red-alt",
"pkg:nerd-icons:nerd-icons-silver",
- "pkg:nerd-icons:nerd-icons-yellow"
+ "pkg:nerd-icons:nerd-icons-yellow",
+ "pkg:nov-reading:cj/nov-reading-light",
+ "pkg:nov-reading:cj/nov-reading-dark",
+ "pkg:nov-reading:cj/nov-reading-sepia",
+ "pkg:nov-reading:cj/nov-reading-sepia-heading",
+ "pkg:nov-reading:cj/nov-reading-sepia-link",
+ "pkg:nov-reading:cj/nov-reading-dark-heading",
+ "pkg:nov-reading:cj/nov-reading-dark-link",
+ "pkg:nov-reading:cj/nov-reading-light-heading",
+ "pkg:nov-reading:cj/nov-reading-light-link"
],
"packages": {
"org-mode": {
@@ -3564,18 +3573,6 @@
"inherit": null,
"source": "user"
},
- "eat-term-color-22": {
- "fg": "#002f00",
- "bg": null,
- "inherit": null,
- "source": "user"
- },
- "eat-term-color-52": {
- "fg": "#2f0000",
- "bg": null,
- "inherit": null,
- "source": "user"
- },
"eat-term-color-bright-black": {
"fg": "#8e919a",
"bg": null,
@@ -3681,6 +3678,18 @@
"bg": null,
"inherit": null,
"source": "user"
+ },
+ "eat-term-color-22": {
+ "fg": "#002f00",
+ "bg": null,
+ "inherit": null,
+ "source": "user"
+ },
+ "eat-term-color-52": {
+ "fg": "#2f0000",
+ "bg": null,
+ "inherit": null,
+ "source": "user"
}
},
"auto-dim-other-buffers": {
@@ -4503,6 +4512,75 @@
"source": "user"
}
},
+ "nov-reading": {
+ "cj/nov-reading-sepia": {
+ "fg": "#ab8d2e",
+ "bg": null,
+ "inherit": null,
+ "source": "user"
+ },
+ "cj/nov-reading-dark": {
+ "fg": "#7c838a",
+ "bg": null,
+ "inherit": null,
+ "source": "user"
+ },
+ "cj/nov-reading-light": {
+ "fg": "#100f0f",
+ "bg": "#7c838a",
+ "inherit": null,
+ "source": "user"
+ },
+ "cj/nov-reading-sepia-heading": {
+ "fg": "#54677d",
+ "bg": null,
+ "inherit": "cj/nov-reading-sepia",
+ "height": 1.2,
+ "source": "user"
+ },
+ "cj/nov-reading-sepia-link": {
+ "fg": "#5178db",
+ "bg": null,
+ "underline": {
+ "color": null,
+ "style": "line"
+ },
+ "inherit": null,
+ "source": "user"
+ },
+ "cj/nov-reading-dark-heading": {
+ "fg": null,
+ "bg": null,
+ "inherit": null,
+ "source": "default"
+ },
+ "cj/nov-reading-dark-link": {
+ "fg": "#5178db",
+ "bg": null,
+ "underline": {
+ "color": null,
+ "style": "line"
+ },
+ "inherit": "cj/nov-reading-dark",
+ "source": "user"
+ },
+ "cj/nov-reading-light-heading": {
+ "fg": null,
+ "bg": null,
+ "inherit": null,
+ "source": "default"
+ },
+ "cj/nov-reading-light-link": {
+ "fg": "#5178db",
+ "bg": null,
+ "underline": {
+ "color": null,
+ "style": "line"
+ },
+ "inherit": null,
+ "source": "user"
+ }
+ },
"erc": {
"erc-header-line": {
"fg": "#e4eaf8",
@@ -5815,7 +5893,7 @@
"source": "user"
},
"shr-link": {
- "fg": "#5f8bf9",
+ "fg": "#5178db",
"bg": null,
"underline": {
"style": "line",
@@ -8678,6 +8756,32 @@
"source": "default"
}
},
+ "wttrin": {
+ "wttrin-instructions": {
+ "fg": null,
+ "bg": null,
+ "inherit": null,
+ "source": "default"
+ },
+ "wttrin-key": {
+ "fg": null,
+ "bg": null,
+ "inherit": null,
+ "source": "default"
+ },
+ "wttrin-mode-line-stale": {
+ "fg": null,
+ "bg": null,
+ "inherit": null,
+ "source": "default"
+ },
+ "wttrin-staleness-header": {
+ "fg": null,
+ "bg": null,
+ "inherit": null,
+ "source": "default"
+ }
+ },
"yasnippet": {
"yas--field-debug-face": {
"fg": null,
diff --git a/tests/test-custom-buffer-file--buffer-differs-prompt.el b/tests/test-custom-buffer-file--buffer-differs-prompt.el
new file mode 100644
index 000000000..109ca121f
--- /dev/null
+++ b/tests/test-custom-buffer-file--buffer-differs-prompt.el
@@ -0,0 +1,197 @@
+;;; test-custom-buffer-file--buffer-differs-prompt.el --- disk-changed save prompt pieces -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Pure-logic tests for the disk-changed save prompt (the C-x C-s case where the
+;; buffer is modified AND the file changed on disk): the question string (with a
+;; terse whitespace-only parenthetical), the labeled-but-terse choice list, and
+;; the key->action mapping. The interactive read loop, the diff display, and the
+;; save/revert dispatch are exercised live, not here.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'custom-buffer-file)
+
+(declare-function cj/--buffer-differs-prompt-string "custom-buffer-file" (name ws-only-p))
+(declare-function cj/--buffer-differs-choices "custom-buffer-file" ())
+(declare-function cj/--buffer-differs-action "custom-buffer-file" (key))
+
+;;; ------------------- cj/--buffer-differs-prompt-string ----------------------
+
+(ert-deftest test-cbf-buffer-differs-prompt-plain ()
+ "Normal: without whitespace-only, the prompt names the buffer, no parenthetical."
+ (let ((s (cj/--buffer-differs-prompt-string "todo.org" nil)))
+ (should (string-match-p "todo\\.org" s))
+ (should-not (string-match-p "whitespace" s))))
+
+(ert-deftest test-cbf-buffer-differs-prompt-whitespace-only ()
+ "Normal: whitespace-only folds in a terse \"(whitespace only)\" parenthetical."
+ (let ((s (cj/--buffer-differs-prompt-string "todo.org" t)))
+ (should (string-match-p "todo\\.org" s))
+ (should (string-match-p "(whitespace only)" s))))
+
+;;; ---------------------- cj/--buffer-differs-choices -------------------------
+
+(ert-deftest test-cbf-buffer-differs-choices-keys ()
+ "Normal: the menu offers save, diff, clean, revert, and cancel."
+ (let ((c (cj/--buffer-differs-choices)))
+ (dolist (key '(?s ?d ?w ?r ?c))
+ (should (assq key c)))))
+
+(ert-deftest test-cbf-buffer-differs-choices-terse-names ()
+ "Boundary: inline names stay terse (one word) so the menu fits at a glance."
+ (dolist (entry (cj/--buffer-differs-choices))
+ (let ((name (nth 1 entry)))
+ (should (stringp name))
+ (should-not (string-match-p " " name)))))
+
+(ert-deftest test-cbf-buffer-differs-choices-clean-help-mentions-whitespace ()
+ "Normal: the clean option's description (the ? help) names whitespace."
+ (let ((entry (assq ?w (cj/--buffer-differs-choices))))
+ (should (string-match-p "whitespace" (or (nth 2 entry) "")))))
+
+(ert-deftest test-cbf-buffer-differs-choices-revert-help-mentions-disk ()
+ "Normal: the revert option's description makes clear it rereads from disk."
+ (let ((entry (assq ?r (cj/--buffer-differs-choices))))
+ (should (string-match-p "disk" (or (nth 2 entry) "")))))
+
+;;; ---------------------- cj/--buffer-differs-action --------------------------
+
+(ert-deftest test-cbf-buffer-differs-action-save ()
+ "Normal: s overwrites the file with the buffer."
+ (should (eq (cj/--buffer-differs-action ?s) 'save)))
+
+(ert-deftest test-cbf-buffer-differs-action-clean ()
+ "Normal: w cleans whitespace, then saves."
+ (should (eq (cj/--buffer-differs-action ?w) 'clean-save)))
+
+(ert-deftest test-cbf-buffer-differs-action-revert ()
+ "Normal: r discards edits and rereads from disk."
+ (should (eq (cj/--buffer-differs-action ?r) 'revert)))
+
+(ert-deftest test-cbf-buffer-differs-action-diff ()
+ "Normal: d peeks at the diff (re-prompt is the caller's concern)."
+ (should (eq (cj/--buffer-differs-action ?d) 'diff)))
+
+(ert-deftest test-cbf-buffer-differs-action-cancel ()
+ "Boundary: c cancels, leaving the buffer untouched."
+ (should (eq (cj/--buffer-differs-action ?c) 'cancel)))
+
+(ert-deftest test-cbf-buffer-differs-action-unknown ()
+ "Error: an unmapped key returns nil."
+ (should-not (cj/--buffer-differs-action ?z)))
+
+;;; ------------------- cj/--buffer-changed-on-disk-p --------------------------
+;; Real visited-file buffers; modtime state is driven (set-visited-file-modtime),
+;; not mocked. The trigger is the disk-changed conflict: modified AND the file
+;; changed on disk since visited.
+
+(declare-function cj/--buffer-changed-on-disk-p "custom-buffer-file" (buffer))
+
+(defun test-cbf-cod--with-visited (edit-fn body-fn)
+ "Visit a temp file, run EDIT-FN in its buffer, call BODY-FN with the buffer."
+ (let ((f (make-temp-file "cbf-cod-" nil ".txt")))
+ (unwind-protect
+ (progn
+ (with-temp-file f (insert "original\n"))
+ (let ((buf (find-file-noselect f)))
+ (unwind-protect
+ (with-current-buffer buf (funcall edit-fn) (funcall body-fn buf))
+ (with-current-buffer buf (set-buffer-modified-p nil))
+ (kill-buffer buf))))
+ (when (file-exists-p f) (delete-file f)))))
+
+(ert-deftest test-cbf-changed-on-disk-detects ()
+ "Normal: modified buffer whose recorded modtime no longer matches is changed-on-disk."
+ (test-cbf-cod--with-visited
+ (lambda () (goto-char (point-max)) (insert "edit\n") (set-visited-file-modtime '(0 0)))
+ (lambda (buf) (should (cj/--buffer-changed-on-disk-p buf)))))
+
+(ert-deftest test-cbf-changed-on-disk-clean-modtime ()
+ "Boundary: modified buffer whose modtime still matches is not changed-on-disk."
+ (test-cbf-cod--with-visited
+ (lambda () (goto-char (point-max)) (insert "edit\n"))
+ (lambda (buf) (should-not (cj/--buffer-changed-on-disk-p buf)))))
+
+(ert-deftest test-cbf-changed-on-disk-unmodified ()
+ "Boundary: an unmodified buffer is never changed-on-disk (nothing of mine to lose)."
+ (test-cbf-cod--with-visited
+ (lambda () (set-visited-file-modtime '(0 0)))
+ (lambda (buf) (should-not (cj/--buffer-changed-on-disk-p buf)))))
+
+;;; -------------- cj/--buffer-differs-dispatch (data direction) ---------------
+;; The destructive directions, driven against real files: `save' overwrites the
+;; disk with the buffer (buffer wins); `revert' discards the buffer's edits and
+;; rereads the disk (disk wins); `clean-save' strips trailing whitespace first.
+
+(declare-function cj/--buffer-differs-dispatch "custom-buffer-file" (buffer action))
+(declare-function cj/save-buffer "custom-buffer-file" ())
+
+(defun test-cbf-disp--with-conflict (buffer-insert disk-content body-fn)
+ "Visit a temp file, BUFFER-INSERT into the buffer, overwrite the file with
+DISK-CONTENT underneath, then call BODY-FN with the buffer and file path."
+ (let ((f (make-temp-file "cbf-disp-" nil ".txt")))
+ (unwind-protect
+ (progn
+ (with-temp-file f (insert "original\n"))
+ (let ((buf (find-file-noselect f)))
+ (unwind-protect
+ (with-current-buffer buf
+ (goto-char (point-max)) (insert buffer-insert)
+ (with-temp-file f (insert disk-content))
+ (funcall body-fn buf f))
+ (with-current-buffer buf (set-buffer-modified-p nil))
+ (kill-buffer buf))))
+ (when (file-exists-p f) (delete-file f)))))
+
+(defun test-cbf-disp--disk (f)
+ "Return the on-disk contents of F."
+ (with-temp-buffer (insert-file-contents f) (buffer-string)))
+
+(ert-deftest test-cbf-buffer-differs-dispatch-save-overwrites-disk ()
+ "Normal: save writes the buffer over the disk version (buffer wins)."
+ (test-cbf-disp--with-conflict
+ "my edit\n" "disk changed underneath\n"
+ (lambda (buf f)
+ (cj/--buffer-differs-dispatch buf 'save)
+ (should-not (buffer-modified-p buf))
+ (should (string= (test-cbf-disp--disk f) "original\nmy edit\n")))))
+
+(ert-deftest test-cbf-buffer-differs-dispatch-revert-discards-edits ()
+ "Normal: revert discards the buffer's edits and rereads the disk (disk wins)."
+ (test-cbf-disp--with-conflict
+ "my edit\n" "disk version\n"
+ (lambda (buf f)
+ (cj/--buffer-differs-dispatch buf 'revert)
+ (should-not (buffer-modified-p buf))
+ (should (string= (with-current-buffer buf (buffer-string)) "disk version\n")))))
+
+(ert-deftest test-cbf-buffer-differs-dispatch-clean-save-strips-whitespace ()
+ "Normal: clean-save strips trailing whitespace, then overwrites the disk."
+ (test-cbf-disp--with-conflict
+ "edit \n" "disk changed\n"
+ (lambda (buf f)
+ (cj/--buffer-differs-dispatch buf 'clean-save)
+ (should-not (buffer-modified-p buf))
+ (should (string= (test-cbf-disp--disk f) "original\nedit\n")))))
+
+(ert-deftest test-cbf-save-buffer-fast-path-no-conflict ()
+ "Boundary: with no disk conflict, cj/save-buffer just saves (no prompt path)."
+ (let ((f (make-temp-file "cbf-fast-" nil ".txt")))
+ (unwind-protect
+ (progn
+ (with-temp-file f (insert "base\n"))
+ (let ((buf (find-file-noselect f)))
+ (unwind-protect
+ (with-current-buffer buf
+ (goto-char (point-max)) (insert "added\n")
+ (cj/save-buffer) ; modtime matches -> fast path
+ (should-not (buffer-modified-p))
+ (should (string= (test-cbf-disp--disk f) "base\nadded\n")))
+ (with-current-buffer buf (set-buffer-modified-p nil))
+ (kill-buffer buf))))
+ (when (file-exists-p f) (delete-file f)))))
+
+(provide 'test-custom-buffer-file--buffer-differs-prompt)
+;;; test-custom-buffer-file--buffer-differs-prompt.el ends here
diff --git a/tests/test-custom-buffer-file--diff-whitespace-only.el b/tests/test-custom-buffer-file--diff-whitespace-only.el
new file mode 100644
index 000000000..e792637b5
--- /dev/null
+++ b/tests/test-custom-buffer-file--diff-whitespace-only.el
@@ -0,0 +1,146 @@
+;;; test-custom-buffer-file--diff-whitespace-only.el --- whitespace-only diff detection -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/--diff-whitespace-only-p, the route-1 detector behind the
+;; buffer-differs save prompt: two files differ ONLY in whitespace when a plain
+;; diff finds changes but `diff -w' (ignore all whitespace) finds none. Uses
+;; real temp files and the real diff(1) binary (a system boundary we keep), so
+;; nothing is mocked.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'custom-buffer-file)
+
+(declare-function cj/--diff-whitespace-only-p "custom-buffer-file" (file-a file-b))
+
+(defun test-cbf-ws--two-files (content-a content-b fn)
+ "Write CONTENT-A and CONTENT-B to temp files, call FN with their paths, clean up."
+ (let ((a (make-temp-file "cbf-ws-a-"))
+ (b (make-temp-file "cbf-ws-b-")))
+ (unwind-protect
+ (progn
+ (with-temp-file a (insert content-a))
+ (with-temp-file b (insert content-b))
+ (funcall fn a b))
+ (delete-file a)
+ (delete-file b))))
+
+(ert-deftest test-cbf-diff-whitespace-only-trailing ()
+ "Normal: files differing only by trailing whitespace are whitespace-only."
+ (test-cbf-ws--two-files
+ "alpha\nbeta\n" "alpha \nbeta\n"
+ (lambda (a b) (should (cj/--diff-whitespace-only-p a b)))))
+
+(ert-deftest test-cbf-diff-whitespace-only-indentation ()
+ "Normal: files differing only by leading indentation are whitespace-only."
+ (test-cbf-ws--two-files
+ "(foo)\n(bar)\n" "(foo)\n (bar)\n"
+ (lambda (a b) (should (cj/--diff-whitespace-only-p a b)))))
+
+(ert-deftest test-cbf-diff-whitespace-only-real-content ()
+ "Normal: files differing in actual content are NOT whitespace-only."
+ (test-cbf-ws--two-files
+ "alpha\nbeta\n" "alpha\nGAMMA\n"
+ (lambda (a b) (should-not (cj/--diff-whitespace-only-p a b)))))
+
+(ert-deftest test-cbf-diff-whitespace-only-identical ()
+ "Boundary: identical files do not differ at all, so not whitespace-only."
+ (test-cbf-ws--two-files
+ "alpha\nbeta\n" "alpha\nbeta\n"
+ (lambda (a b) (should-not (cj/--diff-whitespace-only-p a b)))))
+
+(ert-deftest test-cbf-diff-whitespace-only-mixed ()
+ "Boundary: whitespace change plus a real content change is NOT whitespace-only."
+ (test-cbf-ws--two-files
+ "alpha\nbeta\n" "alpha \nGAMMA\n"
+ (lambda (a b) (should-not (cj/--diff-whitespace-only-p a b)))))
+
+;;; -------------------- cj/--diff-buffer-renderer -----------------------------
+;; Which renderer the diff command uses: whitespace-only diffs go to a plain
+;; unified diff (trailing whitespace highlighted, so it is actually visible)
+;; because difftastic treats trailing-whitespace as no change and renders it
+;; blank. Real content diffs use difftastic when available, else plain diff.
+
+(declare-function cj/--diff-buffer-renderer "custom-buffer-file" (ws-only difft-available))
+
+(ert-deftest test-cbf-diff-renderer-whitespace-over-difftastic ()
+ "Normal: a whitespace-only diff uses the whitespace renderer even when difft is present."
+ (should (eq (cj/--diff-buffer-renderer t t) 'whitespace)))
+
+(ert-deftest test-cbf-diff-renderer-whitespace-no-difft ()
+ "Boundary: whitespace-only still uses the whitespace renderer without difft."
+ (should (eq (cj/--diff-buffer-renderer t nil) 'whitespace)))
+
+(ert-deftest test-cbf-diff-renderer-content-uses-difftastic ()
+ "Normal: a content diff uses difftastic when it is available."
+ (should (eq (cj/--diff-buffer-renderer nil t) 'difftastic)))
+
+(ert-deftest test-cbf-diff-renderer-content-no-difft-regular ()
+ "Boundary: a content diff falls back to the regular renderer without difft."
+ (should (eq (cj/--diff-buffer-renderer nil nil) 'regular)))
+
+;;; --------------- cj/--buffer-file-whitespace-only-p (buffer) ----------------
+;; Buffer-vs-its-file variant: writes the buffer to a temp file and reuses the
+;; detector against the buffer's visited file. Uses a real visited-file buffer.
+
+(declare-function cj/--buffer-file-whitespace-only-p "custom-buffer-file" (buffer))
+
+(defun test-cbf-ws--with-visited (disk-content edit-fn body-fn)
+ "Visit a temp file holding DISK-CONTENT, apply EDIT-FN in it, call BODY-FN with the buffer."
+ (let ((f (make-temp-file "cbf-bws-" nil ".txt")))
+ (unwind-protect
+ (progn
+ (with-temp-file f (insert disk-content))
+ (let ((buf (find-file-noselect f)))
+ (unwind-protect
+ (with-current-buffer buf
+ (funcall edit-fn)
+ (funcall body-fn buf))
+ (with-current-buffer buf (set-buffer-modified-p nil))
+ (kill-buffer buf))))
+ (when (file-exists-p f) (delete-file f)))))
+
+(ert-deftest test-cbf-buffer-file-ws-only-trailing ()
+ "Normal: an unsaved trailing-whitespace edit is whitespace-only vs the file."
+ (test-cbf-ws--with-visited
+ "alpha\nbeta\n"
+ (lambda () (goto-char (point-min)) (end-of-line) (insert " "))
+ (lambda (buf) (should (cj/--buffer-file-whitespace-only-p buf)))))
+
+(ert-deftest test-cbf-buffer-file-ws-only-content ()
+ "Normal: an unsaved content edit is NOT whitespace-only vs the file."
+ (test-cbf-ws--with-visited
+ "alpha\nbeta\n"
+ (lambda () (goto-char (point-max)) (insert "gamma\n"))
+ (lambda (buf) (should-not (cj/--buffer-file-whitespace-only-p buf)))))
+
+;;; --------- cj/diff-buffer-with-file return value (for the toggle) -----------
+
+(ert-deftest test-cbf-diff-returns-buffer-when-differs ()
+ "Normal: cj/diff-buffer-with-file returns the live diff buffer when the buffer differs."
+ (test-cbf-ws--with-visited
+ "x\ny\n"
+ (lambda () (goto-char (point-max)) (insert "ADDED\n"))
+ (lambda (buf)
+ (let ((db (with-current-buffer buf (cj/diff-buffer-with-file))))
+ (should (bufferp db))
+ (should (buffer-live-p db))))))
+
+(ert-deftest test-cbf-diff-returns-nil-when-identical ()
+ "Boundary: with no differences, cj/diff-buffer-with-file returns nil."
+ (test-cbf-ws--with-visited
+ "x\ny\n"
+ (lambda () nil)
+ (lambda (buf)
+ (should-not (with-current-buffer buf (cj/diff-buffer-with-file))))))
+
+(ert-deftest test-cbf-buffer-file-ws-only-non-file ()
+ "Boundary: a buffer not visiting a file is not whitespace-only."
+ (with-temp-buffer
+ (insert "scratch")
+ (should-not (cj/--buffer-file-whitespace-only-p (current-buffer)))))
+
+(provide 'test-custom-buffer-file--diff-whitespace-only)
+;;; test-custom-buffer-file--diff-whitespace-only.el ends here
diff --git a/tests/test-custom-buffer-file--save-some-buffers.el b/tests/test-custom-buffer-file--save-some-buffers.el
new file mode 100644
index 000000000..d4ecd318d
--- /dev/null
+++ b/tests/test-custom-buffer-file--save-some-buffers.el
@@ -0,0 +1,123 @@
+;;; test-custom-buffer-file--save-some-buffers.el --- save-loop prompt pieces -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Pure-logic tests for the read-multiple-choice save loop that replaces
+;; save-some-buffers' terse map-y-or-n-p prompt: the key->action mapping (what
+;; to do with this buffer, and how the choice steers the rest of the loop) and
+;; the labeled choice list. The interactive loop, the file saves, and the
+;; override wiring are exercised live, not here.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'custom-buffer-file)
+
+(declare-function cj/--save-some-buffers-action "custom-buffer-file" (key))
+(declare-function cj/--save-some-buffers-choices "custom-buffer-file" ())
+
+;;; --------------------- cj/--save-some-buffers-action ------------------------
+;; Each result is (THIS-ACTION . LOOP-EFFECT):
+;; THIS-ACTION ∈ save | clean-save | skip | diff
+;; LOOP-EFFECT ∈ continue | save-rest | stop | reprompt
+
+(ert-deftest test-cbf-ssb-action-save ()
+ "Normal: y saves this buffer and continues prompting."
+ (should (equal (cj/--save-some-buffers-action ?y) '(save . continue))))
+
+(ert-deftest test-cbf-ssb-action-skip ()
+ "Normal: n skips this buffer and continues."
+ (should (equal (cj/--save-some-buffers-action ?n) '(skip . continue))))
+
+(ert-deftest test-cbf-ssb-action-clean-save ()
+ "Normal: w cleans whitespace, saves this buffer, and continues."
+ (should (equal (cj/--save-some-buffers-action ?w) '(clean-save . continue))))
+
+(ert-deftest test-cbf-ssb-action-save-rest ()
+ "Boundary: ! saves this buffer and all remaining without asking."
+ (should (equal (cj/--save-some-buffers-action ?!) '(save . save-rest))))
+
+(ert-deftest test-cbf-ssb-action-save-this-stop ()
+ "Boundary: . saves this buffer and skips the rest."
+ (should (equal (cj/--save-some-buffers-action ?.) '(save . stop))))
+
+(ert-deftest test-cbf-ssb-action-quit ()
+ "Boundary: q saves no more buffers (skips this and the rest)."
+ (should (equal (cj/--save-some-buffers-action ?q) '(skip . stop))))
+
+(ert-deftest test-cbf-ssb-action-diff ()
+ "Normal: d views the diff and re-prompts rather than resolving."
+ (should (equal (cj/--save-some-buffers-action ?d) '(diff . reprompt))))
+
+(ert-deftest test-cbf-ssb-action-unknown ()
+ "Error: an unmapped key returns nil."
+ (should-not (cj/--save-some-buffers-action ?z)))
+
+;;; --------------------- cj/--save-some-buffers-choices -----------------------
+
+(ert-deftest test-cbf-ssb-choices-cover-all-keys ()
+ "Normal: the choice list offers every save-loop key, each labeled."
+ (let ((choices (cj/--save-some-buffers-choices)))
+ (dolist (key '(?y ?n ?w ?d ?! ?. ?q))
+ (let ((entry (assq key choices)))
+ (should entry)
+ ;; entry is (KEY NAME &optional DESC); NAME must be a non-empty label.
+ (should (stringp (nth 1 entry)))
+ (should (> (length (nth 1 entry)) 0))))))
+
+(ert-deftest test-cbf-ssb-choices-terse-names ()
+ "Boundary: inline names are single words so the menu takes minimum space."
+ (dolist (entry (cj/--save-some-buffers-choices))
+ (let ((name (nth 1 entry)))
+ (should (stringp name))
+ (should-not (string-match-p " " name)))))
+
+(ert-deftest test-cbf-ssb-choices-clean-mentions-whitespace ()
+ "Normal: the clean-and-save choice is labeled with whitespace."
+ (let ((entry (assq ?w (cj/--save-some-buffers-choices))))
+ (should (string-match-p "whitespace"
+ (mapconcat #'identity (cdr entry) " ")))))
+
+;;; ---------------------- cj/--save-some-buffers-plan -------------------------
+;; The pure planner: given the candidate BUFFERS and a KEY-FN that yields a
+;; (non-diff) key per buffer, resolve each to `save' / `clean-save' / `skip',
+;; honoring ! (save the rest) and . / q (stop after this). Buffers are opaque
+;; here (symbols stand in), so the planner is testable without real buffers.
+
+(declare-function cj/--save-some-buffers-plan "custom-buffer-file" (buffers key-fn))
+
+(ert-deftest test-cbf-ssb-plan-mixed ()
+ "Normal: y / n / w resolve per-buffer to save / skip / clean-save."
+ (let* ((keys '((a . ?y) (b . ?n) (c . ?w)))
+ (plan (cj/--save-some-buffers-plan
+ '(a b c) (lambda (buf) (cdr (assq buf keys))))))
+ (should (equal plan '((a . save) (b . skip) (c . clean-save))))))
+
+(ert-deftest test-cbf-ssb-plan-save-rest ()
+ "Boundary: ! saves this and all remaining without consulting KEY-FN again."
+ (let* ((asked nil)
+ (plan (cj/--save-some-buffers-plan
+ '(a b c)
+ (lambda (buf) (push buf asked) (if (eq buf 'a) ?! ?n)))))
+ (should (equal plan '((a . save) (b . save) (c . save))))
+ ;; key-fn consulted only for the first buffer; the rest ride save-all.
+ (should (equal asked '(a)))))
+
+(ert-deftest test-cbf-ssb-plan-save-this-stop ()
+ "Boundary: . saves this buffer and skips the rest."
+ (let ((plan (cj/--save-some-buffers-plan
+ '(a b c) (lambda (buf) (if (eq buf 'a) ?. ?y)))))
+ (should (equal plan '((a . save) (b . skip) (c . skip))))))
+
+(ert-deftest test-cbf-ssb-plan-quit-skips-all ()
+ "Boundary: q skips this buffer and all remaining."
+ (let ((plan (cj/--save-some-buffers-plan
+ '(a b c) (lambda (_) ?q))))
+ (should (equal plan '((a . skip) (b . skip) (c . skip))))))
+
+(ert-deftest test-cbf-ssb-plan-empty ()
+ "Boundary: no candidate buffers yields an empty plan."
+ (should-not (cj/--save-some-buffers-plan '() (lambda (_) ?y))))
+
+(provide 'test-custom-buffer-file--save-some-buffers)
+;;; test-custom-buffer-file--save-some-buffers.el ends here
diff --git a/themes/WIP-theme.el b/themes/WIP-theme.el
index 843537d20..5bb25be8b 100644
--- a/themes/WIP-theme.el
+++ b/themes/WIP-theme.el
@@ -51,7 +51,7 @@
'(isearch-fail ((t (:foreground "#cb6b4d" :weight bold))))
'(show-paren-match ((t (:foreground "#100f0f" :background "#74932f"))))
'(show-paren-mismatch ((t (:foreground "#edeff1" :background "#cb6b4d"))))
- '(link ((t (:foreground "#5f8bf9" :underline t))))
+ '(link ((t (:foreground "#5178db" :underline t))))
'(error ((t (:foreground "#cb6b4d" :weight bold))))
'(warning ((t (:foreground "#ab8d2e" :weight bold))))
'(success ((t (:foreground "#74932f" :weight bold))))
@@ -265,8 +265,6 @@
'(eat-term-color-magenta ((t (:foreground "#8255b5"))))
'(eat-term-color-cyan ((t (:foreground "#88b2c3"))))
'(eat-term-color-white ((t (:foreground "#bfc4d0"))))
- '(eat-term-color-22 ((t (:foreground "#002f00"))))
- '(eat-term-color-52 ((t (:foreground "#2f0000"))))
'(eat-term-color-bright-black ((t (:foreground "#8e919a" :weight bold))))
'(eat-term-color-bright-red ((t (:foreground "#cb6b4d" :weight bold))))
'(eat-term-color-bright-green ((t (:foreground "#74932f" :weight bold))))
@@ -281,6 +279,8 @@
'(eat-shell-prompt-annotation-success ((t (:foreground "#74932f"))))
'(eat-shell-prompt-annotation-running ((t (:foreground "#dab53d"))))
'(eat-shell-prompt-annotation-failure ((t (:foreground "#a85b42"))))
+ '(eat-term-color-22 ((t (:foreground "#002f00"))))
+ '(eat-term-color-52 ((t (:foreground "#2f0000"))))
'(auto-dim-other-buffers ((t (:foreground "#777980"))))
'(auto-dim-other-buffers-hide ((t (:foreground "#0a0c0d"))))
'(dashboard-banner-logo-title ((t (:inherit default :foreground "#dab53d" :background "#100f0f" :weight bold :slant italic :height 1.25))))
@@ -406,6 +406,13 @@
'(calibredb-mouse-face ((t (:background "#363638"))))
'(calibredb-title-detailed-view-face ((t (:foreground "#dab53d" :weight bold))))
'(calibredb-edit-annotation-header-title-face ((t (:foreground "#bfc4d0" :weight bold))))
+ '(cj/nov-reading-sepia ((t (:foreground "#ab8d2e"))))
+ '(cj/nov-reading-dark ((t (:foreground "#7c838a"))))
+ '(cj/nov-reading-light ((t (:foreground "#100f0f" :background "#7c838a"))))
+ '(cj/nov-reading-sepia-heading ((t (:inherit cj/nov-reading-sepia :foreground "#54677d" :height 1.2))))
+ '(cj/nov-reading-sepia-link ((t (:foreground "#5178db" :underline t))))
+ '(cj/nov-reading-dark-link ((t (:inherit cj/nov-reading-dark :foreground "#5178db" :underline t))))
+ '(cj/nov-reading-light-link ((t (:foreground "#5178db" :underline t))))
'(erc-header-line ((t (:foreground "#e4eaf8" :background "#2f343a" :weight bold))))
'(erc-timestamp-face ((t (:foreground "#5e6770"))))
'(erc-notice-face ((t (:foreground "#838d97"))))
@@ -607,7 +614,7 @@
'(shr-h5 ((t (:foreground "#777980" :weight bold))))
'(shr-h6 ((t (:foreground "#606267" :weight bold))))
'(shr-text ((t (:foreground "#a9b2bb"))))
- '(shr-link ((t (:foreground "#5f8bf9" :underline t))))
+ '(shr-link ((t (:foreground "#5178db" :underline t))))
'(shr-selected-link ((t (:foreground "#777980" :weight bold :underline t))))
'(shr-code ((t (:foreground "#cb6b4d"))))
'(shr-mark ((t (:foreground "#100f0f" :background "#dab53d"))))